1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-17 06:52:27 +01:00

Merge branch 'monthly-payment' into dev

This commit is contained in:
Sylvain 2021-02-23 09:26:11 +01:00
commit b9dc16c0c2
273 changed files with 9305 additions and 2621 deletions

3
.gitignore vendored
View File

@ -34,6 +34,9 @@
# PDF invoices
/invoices/*
# PDF Payment Schedules
/payment_schedules/*
# XLSX exports
/exports/*

View File

@ -16,6 +16,7 @@ Metrics/BlockLength:
- 'lib/tasks/**/*.rake'
- 'config/routes.rb'
- 'app/pdfs/pdf/*.rb'
- 'test/**/*.rb'
Metrics/ParameterLists:
CountKeywordArgs: false
Style/BracesAroundHashParameters:

View File

@ -1,6 +1,25 @@
# Changelog Fab-manager
## Next release
- Payment schedules on subscriptions
- Refactored theme builder to use scss files
- Updated stripe gem to 5.29.0
- Architecture documentation
- Improved coupon creation/deletion workflow
- Default texts for the login modal
- Updated caniuse to 1.0.30001191
- Fix a bug: unable to access embedded plan views
- Fix a bug: warning message overflow in credit wallet modal
- Fix a bug: when using a cash coupon, the amount shown in the statistics is invalid
- Fix a bug: unable to create a coupon on stripe
- Fix a bug: no notifications for refunds generated on wallet credit
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
- [TODO DEPLOY] `rails fablab:setup:add_schedule_reference`
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] add the `INTL_LOCALE` environment variable (see [doc/environment.md](doc/environment.md#INTL_LOCALE) for configuration details)
- [TODO DEPLOY] add the `INTL_CURRENCY` environment variable (see [doc/environment.md](doc/environment.md#INTL_CURRENCY) for configuration details)
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-payment-schedules.sh | bash`
- Fix a bug: unable to configure the app to use a german locale

View File

@ -58,6 +58,7 @@ RUN apk del .build-deps && \
RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/config && \
mkdir -p /usr/src/app/invoices && \
mkdir -p /usr/src/app/payment_schedules && \
mkdir -p /usr/src/app/exports && \
mkdir -p /usr/src/app/imports && \
mkdir -p /usr/src/app/log && \
@ -72,6 +73,7 @@ COPY . /usr/src/app
# Volumes
VOLUME /usr/src/app/invoices
VOLUME /usr/src/app/payment_schedules
VOLUME /usr/src/app/exports
VOLUME /usr/src/app/imports
VOLUME /usr/src/app/public

View File

@ -90,7 +90,7 @@ gem 'sidekiq', '>= 6.0.7'
gem 'sidekiq-cron'
gem 'sidekiq-unique-jobs', '~> 6.0.22'
gem 'stripe', '5.1.1'
gem 'stripe', '5.29.0'
gem 'recurrence'

View File

@ -373,7 +373,7 @@ GEM
actionpack (>= 4.0)
activesupport (>= 4.0)
sprockets (>= 3.0.0)
stripe (5.1.1)
stripe (5.29.0)
sync (0.5.0)
sys-filesystem (1.3.3)
ffi
@ -488,7 +488,7 @@ DEPENDENCIES
sidekiq-unique-jobs (~> 6.0.22)
spring
spring-watcher-listen (~> 2.0.0)
stripe (= 5.1.1)
stripe (= 5.29.0)
sys-filesystem
tzinfo-data
vcr (= 3.0.1)

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require File.expand_path('../config/application', __FILE__)
require_relative 'config/application'
Rails.application.load_tasks

View File

@ -23,7 +23,6 @@ class API::InvoicesController < API::ApiController
p = params.require(:query).permit(:number, :customer, :date, :order_by, :page, :size)
render json: { error: 'page must be an integer' }, status: :unprocessable_entity and return unless p[:page].is_a? Integer
render json: { error: 'size must be an integer' }, status: :unprocessable_entity and return unless p[:size].is_a? Integer
order = InvoicesService.parse_order(p[:order_by])

View File

@ -0,0 +1,86 @@
# frozen_string_literal: true
# API Controller for resources of PaymentSchedule
class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user!
before_action :set_payment_schedule, only: %i[download cancel]
before_action :set_payment_schedule_item, only: %i[cash_check refresh_item pay_item]
def index
@payment_schedules = PaymentSchedule.where('invoicing_profile_id = ?', current_user.invoicing_profile.id)
.includes(:invoicing_profile, :payment_schedule_items, :subscription)
.joins(:invoicing_profile)
.order('payment_schedules.created_at DESC')
.page(params[:page])
.per(params[:size])
end
def list
authorize PaymentSchedule
p = params.require(:query).permit(:reference, :customer, :date, :page, :size)
render json: { error: 'page must be an integer' }, status: :unprocessable_entity and return unless p[:page].is_a? Integer
render json: { error: 'size must be an integer' }, status: :unprocessable_entity and return unless p[:size].is_a? Integer
@payment_schedules = PaymentScheduleService.list(
p[:page],
p[:size],
reference: p[:reference], customer: p[:customer], date: p[:date]
)
end
def download
authorize @payment_schedule
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
end
def cash_check
authorize @payment_schedule_item.payment_schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item)
attrs = { state: 'paid', payment_method: 'check' }
@payment_schedule_item.update_attributes(attrs)
render json: attrs, status: :ok
end
def refresh_item
authorize @payment_schedule_item.payment_schedule
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
render json: { state: 'refreshed' }, status: :ok
end
def pay_item
authorize @payment_schedule_item.payment_schedule
stripe_key = Setting.get('stripe_secret_key')
stp_invoice = Stripe::Invoice.pay(@payment_schedule_item.stp_invoice_id, {}, { api_key: stripe_key })
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
render json: { status: stp_invoice.status }, status: :ok
rescue Stripe::StripeError => e
stripe_key = Setting.get('stripe_secret_key')
stp_invoice = Stripe::Invoice.retrieve(@payment_schedule_item.stp_invoice_id, api_key: stripe_key)
PaymentScheduleItemWorker.new.perform(@payment_schedule_item.id)
render json: { status: stp_invoice.status, error: e }, status: :unprocessable_entity
end
def cancel
authorize @payment_schedule
canceled_at = PaymentScheduleService.cancel(@payment_schedule)
render json: { canceled_at: canceled_at }, status: :ok
end
private
def set_payment_schedule
@payment_schedule = PaymentSchedule.find(params[:id])
end
def set_payment_schedule_item
@payment_schedule_item = PaymentScheduleItem.find(params[:id])
end
end

View File

@ -49,7 +49,7 @@ class API::PaymentsController < API::ApiController
if params[:cart_items][:reservation]
res = on_reservation_success(intent, amount[:details])
elsif params[:cart_items][:subscription]
res = on_subscription_success(intent)
res = on_subscription_success(intent, amount[:details])
end
end
@ -68,17 +68,65 @@ class API::PaymentsController < API::ApiController
render json: { status: false }
end
def setup_intent
user = User.find(params[:user_id])
key = Setting.get('stripe_secret_key')
@intent = Stripe::SetupIntent.create({ customer: user.stp_customer_id }, { api_key: key })
render json: { id: @intent.id, client_secret: @intent.client_secret }
end
def confirm_payment_schedule
key = Setting.get('stripe_secret_key')
intent = Stripe::SetupIntent.retrieve(params[:setup_intent_id], api_key: key)
amount = card_amount
if intent&.status == 'succeeded'
if params[:cart_items][:reservation]
res = on_reservation_success(intent, amount[:details])
elsif params[:cart_items][:subscription]
res = on_subscription_success(intent, amount[:details])
end
end
render generate_payment_response(intent, res)
rescue Stripe::InvalidRequestError => e
render json: e, status: :unprocessable_entity
end
def update_card
user = User.find(params[:user_id])
key = Setting.get('stripe_secret_key')
Stripe::Customer.update(user.stp_customer_id,
{ invoice_settings: { default_payment_method: params[:payment_method_id] } },
{ api_key: key })
render json: { updated: true }, status: :ok
rescue Stripe::StripeError => e
render json: { updated: false, error: e }, status: :unprocessable_entity
end
private
def on_reservation_success(intent, details)
@reservation = Reservation.new(reservation_params)
is_reserve = Reservations::Reserve.new(current_user.id, current_user.invoicing_profile.id)
.pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id)
Stripe::PaymentIntent.update(
intent.id,
{ description: "Invoice reference: #{@reservation.invoice.reference}" },
{ api_key: Setting.get('stripe_secret_key') }
)
payment_method = params[:cart_items][:reservation][:payment_method] || 'stripe'
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:reservation][:user_id]
else
current_user.id
end
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation,
payment_details: details,
intent_id: intent.id,
schedule: params[:cart_items][:reservation][:payment_schedule],
payment_method: payment_method)
if intent.class == Stripe::PaymentIntent
Stripe::PaymentIntent.update(
intent.id,
{ description: "Invoice reference: #{@reservation.invoice.reference}" },
{ api_key: Setting.get('stripe_secret_key') }
)
end
if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
@ -89,16 +137,26 @@ class API::PaymentsController < API::ApiController
end
end
def on_subscription_success(intent)
def on_subscription_success(intent, details)
@subscription = Subscription.new(subscription_params)
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, current_user.id)
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code], invoice: true, payment_intent_id: intent.id)
Stripe::PaymentIntent.update(
intent.id,
{ description: "Invoice reference: #{@subscription.invoices.first.reference}" },
{ api_key: Setting.get('stripe_secret_key') }
)
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:subscription][:user_id]
else
current_user.id
end
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
.pay_and_save(@subscription,
payment_details: details,
intent_id: intent.id,
schedule: params[:cart_items][:subscription][:payment_schedule],
payment_method: 'stripe')
if intent.class == Stripe::PaymentIntent
Stripe::PaymentIntent.update(
intent.id,
{ description: "Invoice reference: #{@subscription.invoices.first.reference}" },
{ api_key: Setting.get('stripe_secret_key') }
)
end
if is_subscribe
{ template: 'api/subscriptions/show', status: :created, location: @subscription }
@ -141,6 +199,11 @@ class API::PaymentsController < API::ApiController
slots = cart_items_params[:slots_attributes] || []
nb_places = cart_items_params[:nb_reserve_places]
tickets = cart_items_params[:tickets_attributes]
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:reservation][:user_id]
else
current_user.id
end
else
raise NotImplementedError unless params[:cart_items][:subscription]
@ -149,16 +212,21 @@ class API::PaymentsController < API::ApiController
slots = []
nb_places = nil
tickets = nil
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:subscription][:user_id]
else
current_user.id
end
end
price_details = Price.compute(false,
current_user,
User.find(user_id),
reservable,
slots,
plan_id,
nb_places,
tickets,
coupon_params[:coupon_code])
plan_id: plan_id,
nb_places: nb_places,
tickets: tickets,
coupon_code: coupon_params[:coupon_code])
# Subtract wallet amount from total
total = price_details[:total]

View File

@ -68,7 +68,7 @@ class API::PlansController < API::ApiController
@parameters = @parameters.require(:plan)
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
:training_credit_nb, :ui_weight, :disabled,
:training_credit_nb, :ui_weight, :disabled, :monthly_payment,
plan_file_attributes: %i[id attachment _destroy],
prices_attributes: %i[id amount])
end

View File

@ -14,7 +14,7 @@ class API::PricesController < API::ApiController
@prices = @prices.where(priceable_id: params[:priceable_id]) if params[:priceable_id]
end
if params[:plan_id]
plan_id = if params[:plan_id] =~ /no|nil|null|undefined/i
plan_id = if /no|nil|null|undefined/i.match?(params[:plan_id])
nil
else
params[:plan_id]
@ -37,22 +37,31 @@ class API::PricesController < API::ApiController
end
def compute
price_parameters = compute_price_params
price_parameters = if params[:reservation]
compute_reservation_price_params
elsif params[:subscription]
compute_subscription_price_params
end
# user
user = User.find(price_parameters[:user_id])
# reservable
if [nil, ''].include? price_parameters[:reservable_id]
if [nil, ''].include?(price_parameters[:reservable_id]) && ['', nil].include?(price_parameters[:plan_id])
@amount = { elements: nil, total: 0, before_coupon: 0 }
else
reservable = price_parameters[:reservable_type].constantize.find(price_parameters[:reservable_id])
reservable = if [nil, ''].include?(price_parameters[:reservable_id])
nil
else
price_parameters[:reservable_type].constantize.find(price_parameters[:reservable_id])
end
@amount = Price.compute(current_user.admin? || (current_user.manager? && current_user.id != user.id),
user,
reservable,
price_parameters[:slots_attributes] || [],
price_parameters[:plan_id],
price_parameters[:nb_reserve_places],
price_parameters[:tickets_attributes],
coupon_params[:coupon_code])
plan_id: price_parameters[:plan_id],
nb_places: price_parameters[:nb_reserve_places],
tickets: price_parameters[:tickets_attributes],
coupon_code: coupon_params[:coupon_code],
payment_schedule: price_parameters[:payment_schedule])
end
@ -69,12 +78,16 @@ class API::PricesController < API::ApiController
params.require(:price).permit(:amount)
end
def compute_price_params
params.require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :user_id, :nb_reserve_places,
def compute_reservation_price_params
params.require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :user_id, :nb_reserve_places, :payment_schedule,
tickets_attributes: %i[event_price_category_id booked],
slots_attributes: %i[id start_at end_at availability_id offered])
end
def compute_subscription_price_params
params.require(:subscription).permit(:plan_id, :user_id, :payment_schedule)
end
def coupon_params
params.permit(:coupon_code)
end

View File

@ -35,7 +35,10 @@ class API::ReservationsController < API::ApiController
@reservation = Reservation.new(reservation_params)
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation, payment_details: price[:price_details])
.pay_and_save(@reservation,
payment_details: price[:price_details],
schedule: params[:reservation][:payment_schedule],
payment_method: params[:reservation][:payment_method])
if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
@ -65,10 +68,10 @@ class API::ReservationsController < API::ApiController
user,
reservation_params[:reservable_type].constantize.find(reservation_params[:reservable_id]),
reservation_params[:slots_attributes] || [],
reservation_params[:plan_id],
reservation_params[:nb_reserve_places],
reservation_params[:tickets_attributes],
coupon_params[:coupon_code])
plan_id: reservation_params[:plan_id],
nb_places: reservation_params[:nb_reserve_places],
tickets: reservation_params[:tickets_attributes],
coupon_code: coupon_params[:coupon_code])
# Subtract wallet amount from total
total = price_details[:total]

View File

@ -14,13 +14,15 @@ class API::SubscriptionsController < API::ApiController
# Managers can create subscriptions for other users
def create
user_id = current_user.admin? || current_user.manager? ? params[:subscription][:user_id] : current_user.id
amount = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
transaction = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
authorize SubscriptionContext.new(Subscription, amount, user_id)
authorize SubscriptionContext.new(Subscription, transaction[:amount], user_id)
@subscription = Subscription.new(subscription_params)
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
.pay_and_save(@subscription, coupon: coupon_params[:coupon_code], invoice: true)
.pay_and_save(@subscription, payment_details: transaction[:details],
schedule: params[:subscription][:payment_schedule],
payment_method: params[:subscription][:payment_method])
if is_subscribe
render :show, status: :created, location: @subscription
@ -54,15 +56,15 @@ class API::SubscriptionsController < API::ApiController
user,
nil,
[],
subscription_params[:plan_id],
nil,
nil,
coupon_params[:coupon_code])
plan_id: subscription_params[:plan_id],
nb_places: nil,
tickets: nil,
coupon_code: coupon_params[:coupon_code])
# Subtract wallet amount from total
total = price_details[:total]
wallet_debit = get_wallet_debit(user, total)
total - wallet_debit
{ amount: total - wallet_debit, details: price_details }
end
def get_wallet_debit(user, total_amount)

View File

@ -17,5 +17,4 @@ class SocialBotController < ActionController::Base
puts "unknown bot request : #{request.original_url}"
end
end
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Raised when the Avoir cannot be generated from an existing Invoice
class CannotRefundError < StandardError
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when trying to create something based on a subscription but it does not exists or is expired
class InvalidSubscriptionError < StandardError
end

View File

@ -91,6 +91,7 @@ importAll(require.context('../src/javascript/controllers/', true, /.*/));
importAll(require.context('../src/javascript/services/', true, /.*/));
importAll(require.context('../src/javascript/directives/', true, /.*/));
importAll(require.context('../src/javascript/filters/', true, /.*/));
importAll(require.context('../src/javascript/typings/', true, /.*/));
importAll(require.context('../images', true));
importAll(require.context('../templates', true));

View File

@ -0,0 +1,62 @@
import axios, { AxiosInstance } from 'axios'
const token: HTMLMetaElement = document.querySelector('[name="csrf-token"]');
const client: AxiosInstance = axios.create({
headers: {
common: {
'X-CSRF-Token': token?.content || 'no-csrf-token'
}
}
});
client.interceptors.response.use(function (response) {
// Any status code that lie within the range of 2xx cause this function to trigger
return response;
}, function (error) {
// Any status codes that falls outside the range of 2xx cause this function to trigger
const message = error.response?.data || error.message || error;
return Promise.reject(extractHumanReadableMessage(message));
});
function extractHumanReadableMessage(error: any): string {
if (typeof error === 'string') {
if (error.match(/^<!DOCTYPE html>/)) {
// parse ruby error pages
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(error, 'text/html');
if (htmlDoc.querySelectorAll('h2').length > 2) {
return htmlDoc.querySelector('h2').textContent;
} else {
return htmlDoc.querySelector('h1').textContent;
}
}
return error;
}
// parse Rails errors (as JSON) or API errors
let message = '';
if (error instanceof Object) {
// API errors
if (error.hasOwnProperty('error') && typeof error.error === 'string') {
return error.error;
}
// iterate through all the keys to build the message
for (const key in error) {
if (Object.prototype.hasOwnProperty.call(error, key)) {
message += `${key} : `;
if (error[key] instanceof Array) {
// standard rails messages are stored as {field: [error1, error2]}
// we rebuild them as "field: error1, error2"
message += error[key].join(', ');
} else {
message += error[key];
}
}
}
return message;
}
return JSON.stringify(error);
}
export default client;

View File

@ -0,0 +1,17 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { CustomAsset, CustomAssetName } from '../models/custom-asset';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
export default class CustomAssetAPI {
async get (name: CustomAssetName): Promise<CustomAsset> {
const res: AxiosResponse = await apiClient.get(`/api/custom_assets/${name}`);
return res?.data?.custom_asset;
}
static get (name: CustomAssetName): IWrapPromise<CustomAsset> {
const api = new CustomAssetAPI();
return wrapPromise(api.get(name));
}
}

View File

@ -0,0 +1,42 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import {
CancelScheduleResponse,
CashCheckResponse, PayItemResponse,
PaymentSchedule,
PaymentScheduleIndexRequest, RefreshItemResponse
} from '../models/payment-schedule';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
export default class PaymentScheduleAPI {
async list (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/list`, query);
return res?.data;
}
async index (query: PaymentScheduleIndexRequest): Promise<Array<PaymentSchedule>> {
const res: AxiosResponse = await apiClient.get(`/api/payment_schedules?page=${query.query.page}&size=${query.query.size}`);
return res?.data;
}
async cashCheck(paymentScheduleItemId: number): Promise<CashCheckResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/cash_check`);
return res?.data;
}
async refreshItem(paymentScheduleItemId: number): Promise<RefreshItemResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/refresh_item`);
return res?.data;
}
async payItem(paymentScheduleItemId: number): Promise<PayItemResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payment_schedules/items/${paymentScheduleItemId}/pay_item`);
return res?.data;
}
async cancel (paymentScheduleId: number): Promise<CancelScheduleResponse> {
const res: AxiosResponse = await apiClient.put(`/api/payment_schedules/${paymentScheduleId}/cancel`);
return res?.data;
}
}

View File

@ -0,0 +1,36 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { CartItems, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
export default class PaymentAPI {
static async confirm (stp_payment_method_id: string, cart_items: CartItems): Promise<PaymentConfirmation> {
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment`, {
payment_method_id: stp_payment_method_id,
cart_items
});
return res?.data;
}
static async setupIntent (user_id: number): Promise<IntentConfirmation> {
const res: AxiosResponse = await apiClient.get(`/api/payments/setup_intent/${user_id}`);
return res?.data;
}
// TODO, type the response
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: CartItems): Promise<any> {
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment_schedule`, {
setup_intent_id,
cart_items
});
return res?.data;
}
static async updateCard (user_id: number, stp_payment_method_id: string): Promise<UpdateCardResponse> {
const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, {
user_id,
payment_method_id: stp_payment_method_id,
});
return res?.data;
}
}

View File

@ -0,0 +1,12 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { CartItems } from '../models/payment';
import { ComputePriceResult } from '../models/price';
export default class PriceAPI {
static async compute (cartItems: CartItems): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cartItems);
return res?.data;
}
}

View File

@ -0,0 +1,27 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import { Setting, SettingName } from '../models/setting';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
export default class SettingAPI {
async get (name: SettingName): Promise<Setting> {
const res: AxiosResponse = await apiClient.get(`/api/settings/${name}`);
return res?.data?.setting;
}
async query (names: Array<SettingName>): Promise<Map<SettingName, any>> {
const res: AxiosResponse = await apiClient.get(`/api/settings/?names=[${names.join(',')}]`);
return res?.data;
}
static get (name: SettingName): IWrapPromise<Setting> {
const api = new SettingAPI();
return wrapPromise(api.get(name));
}
static query(names: Array<SettingName>): IWrapPromise<Map<SettingName, any>> {
const api = new SettingAPI();
return wrapPromise(api.query(names));
}
}

View File

@ -0,0 +1,12 @@
import apiClient from './api-client';
import { AxiosResponse } from 'axios';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
import { Wallet } from '../models/wallet';
export default class WalletAPI {
static async getByUser (user_id: number): Promise<Wallet> {
const res: AxiosResponse = await apiClient.get(`/api/wallet/by_user/${user_id}`);
return res?.data;
}
}

View File

@ -0,0 +1,10 @@
/**
* This is a compatibility wrapper to allow usage of react-switch inside of the angular.js app
*/
import Switch from 'react-switch';
import { react2angular } from 'react2angular';
import { IApplication } from '../../models/application';
declare var Application: IApplication;
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className', 'disabled']));

View File

@ -0,0 +1,57 @@
/**
* This component shows 3 input fields for filtering invoices/payment-schedules by reference, customer name and date
*/
import React, { useEffect, useState } from 'react';
import { LabelledInput } from './labelled-input';
import { useTranslation } from 'react-i18next';
interface DocumentFiltersProps {
onFilterChange: (value: { reference: string, customer: string, date: Date }) => void
}
export const DocumentFilters: React.FC<DocumentFiltersProps> = ({ onFilterChange }) => {
const { t } = useTranslation('admin');
const [referenceFilter, setReferenceFilter] = useState('');
const [customerFilter, setCustomerFilter] = useState('');
const [dateFilter, setDateFilter] = useState(null);
useEffect(() => {
onFilterChange({ reference: referenceFilter, customer: customerFilter, date: dateFilter });
}, [referenceFilter, customerFilter, dateFilter])
const handleReferenceUpdate = (e) => {
setReferenceFilter(e.target.value);
}
const handleCustomerUpdate = (e) => {
setCustomerFilter(e.target.value);
}
const handleDateUpdate = (e) => {
let date = e.target.value;
if (e.target.value === '') date = null;
setDateFilter(date);
}
return (
<div className="document-filters">
<LabelledInput id="reference"
label={t('app.admin.invoices.document_filters.reference')}
type="text"
onChange={handleReferenceUpdate}
value={referenceFilter} />
<LabelledInput id="customer"
label={t('app.admin.invoices.document_filters.customer')}
type="text"
onChange={handleCustomerUpdate}
value={customerFilter} />
<LabelledInput id="reference"
label={t('app.admin.invoices.document_filters.date')}
type="date"
onChange={handleDateUpdate}
value={dateFilter ? dateFilter : ''} />
</div>
);
}

View File

@ -0,0 +1,43 @@
/**
* This component is a template for a clickable button that wraps the application style
*/
import React, { ReactNode, SyntheticEvent } from 'react';
interface FabButtonProps {
onClick?: (event: SyntheticEvent) => void,
icon?: ReactNode,
className?: string,
disabled?: boolean,
type?: 'submit' | 'reset' | 'button',
form?: string,
}
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, children }) => {
/**
* Check if the current component was provided an icon to display
*/
const hasIcon = (): boolean => {
return !!icon;
}
/**
* Handle the action of the button
*/
const handleClick = (e: SyntheticEvent): void => {
if (typeof onClick === 'function') {
onClick(e);
}
}
return (
<button type={type} form={form} onClick={handleClick} disabled={disabled} className={`fab-button ${className ? className : ''}`}>
{hasIcon() && <span className="fab-button--icon">{icon}</span>}
{children}
</button>
);
}
FabButton.defaultProps = { type: 'button' };

View File

@ -0,0 +1,87 @@
/**
* This component is a template for a modal dialog that wraps the application style
*/
import React, { ReactNode, SyntheticEvent } from 'react';
import Modal from 'react-modal';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import CustomAssetAPI from '../api/custom-asset';
import { CustomAssetName } from '../models/custom-asset';
import { FabButton } from './fab-button';
Modal.setAppElement('body');
export enum ModalSize {
small = 'sm',
medium = 'md',
large = 'lg'
}
interface FabModalProps {
title: string,
isOpen: boolean,
toggleModal: () => void,
confirmButton?: ReactNode,
closeButton?: boolean,
className?: string,
width?: ModalSize,
customFooter?: ReactNode,
onConfirm?: (event: SyntheticEvent) => void,
preventConfirm?: boolean
}
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => {
const { t } = useTranslation('shared');
const blackLogo = blackLogoFile.read();
/**
* Check if the confirm button should be present
*/
const hasConfirmButton = (): boolean => {
return confirmButton !== undefined;
}
/**
* Should we display the close button?
*/
const hasCloseButton = (): boolean => {
return closeButton;
}
/**
* Check if there's a custom footer
*/
const hasCustomFooter = (): boolean => {
return customFooter !== undefined;
}
return (
<Modal isOpen={isOpen}
className={`fab-modal fab-modal-${width} ${className}`}
overlayClassName="fab-modal-overlay"
onRequestClose={toggleModal}>
<div className="fab-modal-header">
<Loader>
<img src={blackLogo.custom_asset_file_attributes.attachment_url}
alt={blackLogo.custom_asset_file_attributes.attachment}
className="modal-logo" />
</Loader>
<h1>{ title }</h1>
</div>
<div className="fab-modal-content">
{children}
</div>
<div className="fab-modal-footer">
<Loader>
{hasCloseButton() &&<FabButton className="modal-btn--close" onClick={toggleModal}>{t('app.shared.buttons.close')}</FabButton>}
{hasConfirmButton() && <FabButton className="modal-btn--confirm" disabled={preventConfirm} onClick={onConfirm}>{confirmButton}</FabButton>}
{hasCustomFooter() && customFooter}
</Loader>
</div>
</Modal>
);
}

View File

@ -0,0 +1,18 @@
/**
* This component renders a translation with some HTML content.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
interface HtmlTranslateProps {
trKey: string,
options?: any
}
export const HtmlTranslate: React.FC<HtmlTranslateProps> = ({ trKey, options }) => {
const { t } = useTranslation(trKey?.split('.')[1]);
return (
<span dangerouslySetInnerHTML={{__html: t(trKey, options)}} />
);
}

View File

@ -0,0 +1,22 @@
/**
* This component shows input field with its label, styled
*/
import React from 'react';
interface LabelledInputProps {
id: string,
type: string,
label: string,
value: any,
onChange: (value: any) => void
}
export const LabelledInput: React.FC<LabelledInputProps> = ({ id, type, label, value, onChange }) => {
return (
<div className="input-with-label">
<label className="label" htmlFor={id}>{label}</label>
<input className="input" id={id} type={type} value={value} onChange={onChange} />
</div>
);
}

View File

@ -0,0 +1,20 @@
/**
* This component is a wrapper that display a loader while the children components have their rendering suspended
*/
import React, { Suspense } from 'react';
export const Loader: React.FC = ({children }) => {
const loading = (
<div className="fa-3x">
<i className="fas fa-circle-notch fa-spin" />
</div>
);
return (
<Suspense fallback={loading}>
{children}
</Suspense>
);
}

View File

@ -1,24 +0,0 @@
// This is a demonstration of using react components inside an angular.js 1.x app
// TODO remove this
import { IApplication } from "./application";
declare var Application: IApplication;
import React from 'react';
import { react2angular } from 'react2angular';
interface MyComponentProps {
fooBar: number,
baz: string
}
const MyComponent: React.FC<MyComponentProps> = ({ fooBar, baz }) => {
return (
<div>
<p>FooBar: {fooBar}</p>
<p>Baz: {baz}</p>
</div>
);
}
Application.Components.component('myComponent', react2angular(MyComponent, ['fooBar', 'baz']));

View File

@ -0,0 +1,101 @@
/**
* This component displays a summary of the monthly payment schedule for the current cart, with a subscription
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import moment from 'moment';
import { IApplication } from '../models/application';
import '../lib/i18n';
import { PaymentSchedule } from '../models/payment-schedule';
import { Loader } from './loader';
import { FabModal } from './fab-modal';
import { IFablab } from '../models/fablab';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface PaymentScheduleSummaryProps {
schedule: PaymentSchedule
}
const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
const { t } = useTranslation('shared');
const [modal, setModal] = useState(false);
/**
* Return the formatted localized date for the given date
*/
const formatDate = (date: Date): string => {
return Intl.DateTimeFormat().format(moment(date).toDate());
}
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/
const formatPrice = (price: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
}
/**
* Test if all payment deadlines have the same amount
*/
const hasEqualDeadlines = (): boolean => {
const prices = schedule.items.map(i => i.amount);
return prices.every(p => p === prices[0]);
}
/**
* Open or closes the modal dialog showing the full payment schedule
*/
const toggleFullScheduleModal = (): void => {
setModal(!modal);
}
return (
<div className="payment-schedule-summary">
<div>
<h4>{ t('app.shared.cart.your_payment_schedule') }</h4>
{hasEqualDeadlines() && <ul>
<li>
<span className="schedule-item-info">
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length, AMOUNT: formatPrice(schedule.items[0].amount) })}
</span>
<span className="schedule-item-date">{t('app.shared.cart.first_debit')}</span>
</li>
</ul>}
{!hasEqualDeadlines() && <ul>
<li>
<span className="schedule-item-info">{t('app.shared.cart.monthly_payment_NUMBER', { NUMBER: 1 })}</span>
<span className="schedule-item-price">{formatPrice(schedule.items[0].amount)}</span>
<span className="schedule-item-date">{t('app.shared.cart.debit')}</span>
</li>
<li>
<span className="schedule-item-info">
{t('app.shared.cart.NUMBER_monthly_payment_of_AMOUNT', { NUMBER: schedule.items.length - 1, AMOUNT: formatPrice(schedule.items[1].amount) })}
</span>
</li>
</ul>}
<button className="view-full-schedule" onClick={toggleFullScheduleModal}>{t('app.shared.cart.view_full_schedule')}</button>
<FabModal title={t('app.shared.cart.your_payment_schedule')} isOpen={modal} toggleModal={toggleFullScheduleModal}>
<ul className="full-schedule">
{schedule.items.map(item => (
<li key={String(item.due_date)}>
<span className="schedule-item-date">{formatDate(item.due_date)}</span>
<span> </span>
<span className="schedule-item-price">{formatPrice(item.amount)}</span>
</li>
))}
</ul>
</FabModal>
</div>
</div>
);
}
const PaymentScheduleSummaryWrapper: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
return (
<Loader>
<PaymentScheduleSummary schedule={schedule} />
</Loader>
);
}
Application.Components.component('paymentScheduleSummary', react2angular(PaymentScheduleSummaryWrapper, ['schedule']));

View File

@ -0,0 +1,94 @@
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
* for the currentUser
*/
import React, { useEffect, useState } from 'react';
import { IApplication } from '../models/application';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import { react2angular } from 'react2angular';
import PaymentScheduleAPI from '../api/payment-schedule';
import { PaymentSchedulesTable } from './payment-schedules-table';
import { FabButton } from './fab-button';
import { User } from '../models/user';
import { PaymentSchedule } from '../models/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesDashboardProps {
currentUser: User
}
const PAGE_SIZE = 20;
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
const { t } = useTranslation('logged');
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
const [pageNumber, setPageNumber] = useState<number>(1);
useEffect(() => {
handleRefreshList();
}, []);
/**
* Fetch from the API the next payment schedules to display, for the current filters, and append them to the current results table.
*/
const handleLoadMore = (): void => {
setPageNumber(pageNumber + 1);
const api = new PaymentScheduleAPI();
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const api = new PaymentScheduleAPI();
api.index({ query: { page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
if (typeof onError === 'function') { onError(err.message); }
});
}
/**
* Check if the current collection of payment schedules is empty or not.
*/
const hasSchedules = (): boolean => {
return paymentSchedules.length > 0;
}
/**
* Check if there are some results for the current filters that aren't currently shown.
*/
const hasMoreSchedules = (): boolean => {
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
}
return (
<div className="payment-schedules-dashboard">
{!hasSchedules() && <div>{t('app.logged.dashboard.payment_schedules.no_payment_schedules')}</div>}
{hasSchedules() && <div className="schedules-list">
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={false} refreshList={handleRefreshList} operator={currentUser} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.logged.dashboard.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
);
}
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
return (
<Loader>
<PaymentSchedulesDashboard currentUser={currentUser} />
</Loader>
);
}
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser']));

View File

@ -0,0 +1,118 @@
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
import React, { useEffect, useState } from 'react';
import { IApplication } from '../models/application';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import { react2angular } from 'react2angular';
import PaymentScheduleAPI from '../api/payment-schedule';
import { DocumentFilters } from './document-filters';
import { PaymentSchedulesTable } from './payment-schedules-table';
import { FabButton } from './fab-button';
import { User } from '../models/user';
import { PaymentSchedule } from '../models/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesListProps {
currentUser: User
}
const PAGE_SIZE = 20;
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
const { t } = useTranslation('admin');
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
const [pageNumber, setPageNumber] = useState<number>(1);
const [referenceFilter, setReferenceFilter] = useState<string>(null);
const [customerFilter, setCustomerFilter] = useState<string>(null);
const [dateFilter, setDateFilter] = useState<Date>(null);
useEffect(() => {
handleRefreshList();
}, []);
/**
* Fetch from the API the payments schedules matching the given filters and reset the results table with the new schedules.
*/
const handleFiltersChange = ({ reference, customer, date }): void => {
setReferenceFilter(reference);
setCustomerFilter(customer);
setDateFilter(date);
const api = new PaymentScheduleAPI();
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
setPaymentSchedules(res);
});
};
/**
* Fetch from the API the next payment schedules to display, for the current filters, and append them to the current results table.
*/
const handleLoadMore = (): void => {
setPageNumber(pageNumber + 1);
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const api = new PaymentScheduleAPI();
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: 1, size: PAGE_SIZE * pageNumber }}).then((res) => {
setPaymentSchedules(res);
}).catch((err) => {
if (typeof onError === 'function') { onError(err.message); }
});
}
/**
* Check if the current collection of payment schedules is empty or not.
*/
const hasSchedules = (): boolean => {
return paymentSchedules.length > 0;
}
/**
* Check if there are some results for the current filters that aren't currently shown.
*/
const hasMoreSchedules = (): boolean => {
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
}
return (
<div className="payment-schedules-list">
<h3>
<i className="fas fa-filter" />
{t('app.admin.invoices.payment_schedules.filter_schedules')}
</h3>
<div className="schedules-filters">
<DocumentFilters onFilterChange={handleFiltersChange} />
</div>
{!hasSchedules() && <div>{t('app.admin.invoices.payment_schedules.no_payment_schedules')}</div>}
{hasSchedules() && <div className="schedules-list">
<PaymentSchedulesTable paymentSchedules={paymentSchedules} showCustomer={true} refreshList={handleRefreshList} operator={currentUser} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
);
}
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
return (
<Loader>
<PaymentSchedulesList currentUser={currentUser} />
</Loader>
);
}
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));

View File

@ -0,0 +1,477 @@
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
import React, { ReactEventHandler, ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader } from './loader';
import moment from 'moment';
import { IFablab } from '../models/fablab';
import _ from 'lodash';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../models/payment-schedule';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
import PaymentScheduleAPI from '../api/payment-schedule';
import { StripeElements } from './stripe-elements';
import { StripeConfirm } from './stripe-confirm';
import stripeLogo from '../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../images/mastercard.png';
import visaLogo from '../../../images/visa.png';
import { StripeCardUpdate } from './stripe-card-update';
import { User, UserRole } from '../models/user';
declare var Fablab: IFablab;
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
showCustomer?: boolean,
refreshList: (onError: (msg: any) => void) => void,
operator: User,
}
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
const { t } = useTranslation('shared');
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
const [tempSchedule, setTempSchedule] = useState<PaymentSchedule>(null);
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
const [errors, setErrors] = useState<string>(null);
const [showCancelSubscription, setShowCancelSubscription] = useState<boolean>(false);
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
*/
const isExpanded = (paymentScheduleId: number): boolean => {
return showExpanded.get(paymentScheduleId);
}
/**
* Return the formatted localized date for the given date
*/
const formatDate = (date: Date): string => {
return Intl.DateTimeFormat().format(moment(date).toDate());
}
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/
const formatPrice = (price: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(price);
}
/**
* Return the value for the CSS property 'display', for the payment schedule deadlines
*/
const statusDisplay = (paymentScheduleId: number): string => {
if (isExpanded(paymentScheduleId)) {
return 'table-row'
} else {
return 'none';
}
}
/**
* Return the action icon for showing/hiding the deadlines
*/
const expandCollapseIcon = (paymentScheduleId: number): JSX.Element => {
if (isExpanded(paymentScheduleId)) {
return <i className="fas fa-minus-square" />;
} else {
return <i className="fas fa-plus-square" />
}
}
/**
* Show or hide the deadlines for the provided payment schedule, inverting their current status
*/
const togglePaymentScheduleDetails = (paymentScheduleId: number): ReactEventHandler => {
return (): void => {
if (isExpanded(paymentScheduleId)) {
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, false));
} else {
setShowExpanded((prev) => new Map(prev).set(paymentScheduleId, true));
}
}
}
/**
* For use with downloadButton()
*/
enum TargetType {
Invoice = 'invoices',
PaymentSchedule = 'payment_schedules'
}
/**
* Return a button to download a PDF file, may be an invoice, or a payment schedule, depending or the provided parameters
*/
const downloadButton = (target: TargetType, id: number): JSX.Element => {
const link = `api/${target}/${id}/download`;
return (
<a href={link} target="_blank" className="download-button">
<i className="fas fa-download" />
{t('app.shared.schedules_table.download')}
</a>
);
}
/**
* Return the human-readable string for the status of the provided deadline.
*/
const formatState = (item: PaymentScheduleItem): JSX.Element => {
let res = t(`app.shared.schedules_table.state_${item.state}`);
if (item.state === PaymentScheduleItemState.Paid) {
const key = `app.shared.schedules_table.method_${item.payment_method}`
res += ` (${t(key)})`;
}
return <span className={`state-${item.state}`}>{res}</span>;
}
/**
* Check if the current operator has administrative rights or is a normal member
*/
const isPrivileged = (): boolean => {
return (operator.role === UserRole.Admin || operator.role == UserRole.Manager);
}
/**
* Return the action button(s) for the given deadline
*/
const itemButtons = (item: PaymentScheduleItem, schedule: PaymentSchedule): JSX.Element => {
switch (item.state) {
case PaymentScheduleItemState.Paid:
return downloadButton(TargetType.Invoice, item.invoice_id);
case PaymentScheduleItemState.Pending:
if (isPrivileged()) {
return (
<FabButton onClick={handleConfirmCheckPayment(item)}
icon={<i className="fas fa-money-check" />}>
{t('app.shared.schedules_table.confirm_payment')}
</FabButton>
);
} else {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
}
case PaymentScheduleItemState.RequireAction:
return (
<FabButton onClick={handleSolveAction(item)}
icon={<i className="fas fa-wrench" />}>
{t('app.shared.schedules_table.solve')}
</FabButton>
);
case PaymentScheduleItemState.RequirePaymentMethod:
return (
<FabButton onClick={handleUpdateCard(item, schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
);
case PaymentScheduleItemState.Error:
if (isPrivileged()) {
return (
<FabButton onClick={handleCancelSubscription(schedule)}
icon={<i className="fas fa-times" />}>
{t('app.shared.schedules_table.cancel_subscription')}
</FabButton>
)
} else {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
}
default:
return <span />
}
}
/**
* Callback triggered when the user's clicks on the "cash check" button: show a confirmation modal
*/
const handleConfirmCheckPayment = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
toggleConfirmCashingModal();
}
}
/**
* After the user has confirmed that he wants to cash the check, update the API, refresh the list and close the modal.
*/
const onCheckCashingConfirmed = (): void => {
const api = new PaymentScheduleAPI();
api.cashCheck(tempDeadline.id).then((res) => {
if (res.state === PaymentScheduleItemState.Paid) {
refreshSchedulesTable();
toggleConfirmCashingModal();
}
});
}
/**
* Refresh all payment schedules in the table
*/
const refreshSchedulesTable = (): void => {
refreshList(setErrors);
}
/**
* Show/hide the modal dialog that enable to confirm the cashing of the check for a given deadline.
*/
const toggleConfirmCashingModal = (): void => {
setShowConfirmCashing(!showConfirmCashing);
}
/**
* Show/hide the modal dialog that trigger the card "action".
*/
const toggleResolveActionModal = (): void => {
setShowResolveAction(!showResolveAction);
}
/**
* Callback triggered when the user's clicks on the "resolve" button: show a modal that will trigger the action
*/
const handleSolveAction = (item: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
toggleResolveActionModal();
}
}
/**
* After the action was done (successfully or not), ask the API to refresh the item status, then refresh the list and close the modal
*/
const afterAction = (): void => {
toggleConfirmActionButton();
const api = new PaymentScheduleAPI();
api.refreshItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
toggleResolveActionModal();
});
}
/**
* Enable/disable the confirm button of the "action" modal
*/
const toggleConfirmActionButton = (): void => {
setConfirmActionDisabled(!isConfirmActionDisabled);
}
/**
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
*/
const handleUpdateCard = (item: PaymentScheduleItem, paymentSchedule: PaymentSchedule): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
setTempSchedule(paymentSchedule);
toggleUpdateCardModal();
}
}
/**
* Show/hide the modal dialog to update the bank card details
*/
const toggleUpdateCardModal = (): void => {
setShowUpdateCard(!showUpdateCard);
}
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="stripe-modal-icons">
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
<img src={stripeLogo} alt="powered by stripe" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* When the submit button is pushed, disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was successfully updated, pay the invoice (using the new payment method) and close the modal
*/
const handleCardUpdateSuccess = (): void => {
const api = new PaymentScheduleAPI();
api.payItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
toggleUpdateCardModal();
}).catch((err) => {
handleCardUpdateError(err);
});
}
/**
* When the card was not updated, show the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
}
/**
* Callback triggered when the user clicks on the "cancel subscription" button
*/
const handleCancelSubscription = (schedule: PaymentSchedule): ReactEventHandler => {
return (): void => {
setTempSchedule(schedule);
toggleCancelSubscriptionModal();
}
}
/**
* Show/hide the modal dialog to cancel the current subscription
*/
const toggleCancelSubscriptionModal = (): void => {
setShowCancelSubscription(!showCancelSubscription);
}
/**
* When the user has confirmed the cancellation, we transfer the request to the API
*/
const onCancelSubscriptionConfirmed = (): void => {
const api = new PaymentScheduleAPI();
api.cancel(tempSchedule.id).then(() => {
refreshSchedulesTable();
toggleCancelSubscriptionModal();
});
}
return (
<div>
<table className="schedules-table">
<thead>
<tr>
<th className="w-35" />
<th className="w-200">{t('app.shared.schedules_table.schedule_num')}</th>
<th className="w-200">{t('app.shared.schedules_table.date')}</th>
<th className="w-120">{t('app.shared.schedules_table.price')}</th>
{showCustomer && <th className="w-200">{t('app.shared.schedules_table.customer')}</th>}
<th className="w-200"/>
</tr>
</thead>
<tbody>
{paymentSchedules.map(p => <tr key={p.id}>
<td colSpan={showCustomer ? 6 : 5}>
<table className="schedules-table-body">
<tbody>
<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">{formatDate(p.created_at)}</td>
<td className="w-120">{formatPrice(p.total)}</td>
{showCustomer && <td className="w-200">{p.user.name}</td>}
<td className="w-200">{downloadButton(TargetType.PaymentSchedule, p.id)}</td>
</tr>
<tr style={{ display: statusDisplay(p.id) }}>
<td className="w-35" />
<td colSpan={showCustomer ? 5 : 4}>
<div>
<table className="schedule-items-table">
<thead>
<tr>
<th className="w-120">{t('app.shared.schedules_table.deadline')}</th>
<th className="w-120">{t('app.shared.schedules_table.amount')}</th>
<th className="w-200">{t('app.shared.schedules_table.state')}</th>
<th className="w-200" />
</tr>
</thead>
<tbody>
{_.orderBy(p.items, 'due_date').map(item => <tr key={item.id}>
<td>{formatDate(item.due_date)}</td>
<td>{formatPrice(item.amount)}</td>
<td>{formatState(item)}</td>
<td>{itemButtons(item, p)}</td>
</tr>)}
</tbody>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</td>
</tr>)}
</tbody>
</table>
<div className="modals">
<FabModal title={t('app.shared.schedules_table.confirm_check_cashing')}
isOpen={showConfirmCashing}
toggleModal={toggleConfirmCashingModal}
onConfirm={onCheckCashingConfirmed}
closeButton={true}
confirmButton={t('app.shared.schedules_table.confirm_button')}>
{tempDeadline && <span>
{t('app.shared.schedules_table.confirm_check_cashing_body', {
AMOUNT: formatPrice(tempDeadline.amount),
DATE: formatDate(tempDeadline.due_date)
})}
</span>}
</FabModal>
<FabModal title={t('app.shared.schedules_table.cancel_subscription')}
isOpen={showCancelSubscription}
toggleModal={toggleCancelSubscriptionModal}
onConfirm={onCancelSubscriptionConfirmed}
closeButton={true}
confirmButton={t('app.shared.schedules_table.confirm_button')}>
{t('app.shared.schedules_table.confirm_cancel_subscription')}
</FabModal>
<StripeElements>
<FabModal title={t('app.shared.schedules_table.resolve_action')}
isOpen={showResolveAction}
toggleModal={toggleResolveActionModal}
onConfirm={afterAction}
confirmButton={t('app.shared.schedules_table.ok_button')}
preventConfirm={isConfirmActionDisabled}>
{tempDeadline && <StripeConfirm clientSecret={tempDeadline.client_secret} onResponse={toggleConfirmActionButton} />}
</FabModal>
<FabModal title={t('app.shared.schedules_table.update_card')}
isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
closeButton={false}
customFooter={logoFooter()}
className="update-card-modal">
{tempDeadline && tempSchedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
onSuccess={handleCardUpdateSuccess}
onError={handleCardUpdateError}
customerId={tempSchedule.user.id}
operator={operator}
className="card-form" >
{errors && <div className="stripe-errors">
{errors}
</div>}
</StripeCardUpdate>}
<div className="submit-card">
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form="stripe-card" className="submit-card-btn">{t('app.shared.schedules_table.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
</StripeElements>
</div>
</div>
);
};
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
return (
<Loader>
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
</Loader>
);
}

View File

@ -0,0 +1,134 @@
/**
* This component is a "card" publicly presenting the details of a plan
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import moment from 'moment';
import _ from 'lodash'
import { IApplication } from '../models/application';
import { Plan } from '../models/plan';
import { User, UserRole } from '../models/user';
import { Loader } from './loader';
import '../lib/i18n';
import { IFablab } from '../models/fablab';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface PlanCardProps {
plan: Plan,
userId?: number,
subscribedPlanId?: number,
operator: User,
isSelected: boolean,
onSelectPlan: (plan: Plan) => void,
}
const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
const { t } = useTranslation('public');
/**
* 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 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 the formatted localized duration of te given plan (eg. Month/3 => "3 mois")
*/
const duration = (): string => {
return moment.duration(plan.interval_count, plan.interval).humanize();
}
/**
* Check if the user can subscribe to the current plan, for himself
*/
const canSubscribeForMe = (): boolean => {
return operator?.role === UserRole.Member || (operator?.role === UserRole.Manager && userId === operator?.id)
}
/**
* Check if the user can subscribe to the current plan, for someone else
*/
const canSubscribeForOther = (): boolean => {
return operator?.role === UserRole.Admin || (operator?.role === UserRole.Manager && userId !== operator?.id)
}
/**
* Check it the user has subscribed to this plan or not
*/
const hasSubscribedToThisPlan = (): boolean => {
return subscribedPlanId === plan.id;
}
/**
* Check if the plan has an attached file
*/
const hasAttachment = (): boolean => {
return !!plan.plan_file_url;
}
/**
* Check if the plan is allowing a monthly payment schedule
*/
const canBeScheduled = (): boolean => {
return plan.monthly_payment;
}
/**
* Callback triggered when the user select the plan
*/
const handleSelectPlan = (): void => {
onSelectPlan(plan);
}
return (
<div className="plan-card">
<h3 className="title">{plan.base_name}</h3>
<div className="content">
{canBeScheduled() && <div className="wrap-monthly">
<div className="price">
<div className="amount">{t('app.public.plans.AMOUNT_per_month', {AMOUNT: monthlyAmount()})}</div>
<span className="period">{duration()}</span>
</div>
</div>}
{!canBeScheduled() && <div className="wrap">
<div className="price">
<div className="amount">{amount()}</div>
<span className="period">{duration()}</span>
</div>
</div>}
</div>
{canSubscribeForMe() && <div className="cta-button">
{!hasSubscribedToThisPlan() && <button className={`subscribe-button ${isSelected ? 'selected-card' : ''}`}
onClick={handleSelectPlan}
disabled={!_.isNil(subscribedPlanId)}>
{userId && <span>{t('app.public.plans.i_choose_that_plan')}</span>}
{!userId && <span>{t('app.public.plans.i_subscribe_online')}</span>}
</button>}
{hasSubscribedToThisPlan() && <button className="subscribe-button selected-card" disabled>
{ t('app.public.plans.i_already_subscribed') }
</button>}
</div>}
{canSubscribeForOther() && <div className="cta-button">
<button className={`subscribe-button ${isSelected ? 'selected-card' : ''}`}
onClick={handleSelectPlan}
disabled={_.isNil(userId)}>
<span>{ t('app.public.plans.i_choose_that_plan') }</span>
</button>
</div>}
{hasAttachment() && <a className="info-link" href={ plan.plan_file_url } target="_blank">{ t('app.public.plans.more_information') }</a>}
</div>
);
}
const PlanCardWrapper: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
return (
<Loader>
<PlanCard plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan}/>
</Loader>
);
}
Application.Components.component('planCard', react2angular(PlanCardWrapper, ['plan', 'userId', 'subscribedPlanId', 'operator', 'onSelectPlan', 'isSelected']));

View File

@ -0,0 +1,44 @@
/**
* This component is a switch enabling the users to choose if they want to pay by monthly schedule
* or with a one time payment
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { react2angular } from 'react2angular';
import Switch from 'react-switch';
import { IApplication } from '../models/application';
import { Loader } from './loader';
import '../lib/i18n';
declare var Application: IApplication;
interface SelectScheduleProps {
show: boolean,
selected: boolean,
onChange: (selected: boolean) => void,
className: string,
}
const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
const { t } = useTranslation('shared');
return (
<div className="select-schedule">
{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>}
</div>
);
}
const SelectScheduleWrapper: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
return (
<Loader>
<SelectSchedule show={show} selected={selected} onChange={onChange} className={className} />
</Loader>
);
}
Application.Components.component('selectSchedule', react2angular(SelectScheduleWrapper, ['show', 'selected', 'onChange', 'className']));

View File

@ -0,0 +1,101 @@
import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { SetupIntent } from "@stripe/stripe-js";
import PaymentAPI from '../api/payment';
import { PaymentConfirmation } from '../models/payment';
import { User } from '../models/user';
interface StripeCardUpdateProps {
onSubmit: () => void,
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onError: (message: string) => void,
customerId: number,
operator: User,
className?: string,
}
/**
* A simple form component to collect and update the credit card details, for Stripe.
*
* The form validation button must be created elsewhere, using the attribute form="stripe-card".
*/
export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, onSuccess, onError, className, customerId, operator, children }) => {
const stripe = useStripe();
const elements = useElements();
/**
* Handle the submission of the form. Depending on the configuration, it will create the payment method on Stripe,
* or it will process a payment with the inputted card.
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
onSubmit();
// Stripe.js has not loaded yet
if (!stripe || !elements) { return; }
const cardElement = elements.getElement(CardElement);
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
// stripe error
onError(error.message);
} else {
try {
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customerId);
const { error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id,
mandate_data: {
customer_acceptance: {
type: 'online',
online: {
ip_address: operator.ip_address,
user_agent: navigator.userAgent
}
}
}
})
if (error) {
onError(error.message);
} else {
// then we update the default payment method
const res = await PaymentAPI.updateCard(customerId, paymentMethod.id);
onSuccess(res);
}
} catch (err) {
// catch api errors
onError(err);
}
}
}
/**
* Options for the Stripe's card input
*/
const cardOptions = {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': { color: '#aab7c4' }
},
invalid: {
color: '#9e2146',
iconColor: '#9e2146'
},
},
hidePostalCode: true
};
return (
<form onSubmit={handleSubmit} id="stripe-card" className={className}>
<CardElement options={cardOptions} />
{children}
</form>
);
}

View File

@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import { useStripe } from '@stripe/react-stripe-js';
import { useTranslation } from 'react-i18next';
interface StripeConfirmProps {
clientSecret: string,
onResponse: () => void,
}
export const StripeConfirm: React.FC<StripeConfirmProps> = ({ clientSecret, onResponse }) => {
const stripe = useStripe();
const { t } = useTranslation('shared');
const [message, setMessage] = useState<string>(t('app.shared.stripe_confirm.pending'));
const [type, setType] = useState<string>('info');
useEffect(() => {
stripe.confirmCardPayment(clientSecret).then(function(result) {
onResponse();
if (result.error) {
// Display error.message in your UI.
setType('error');
setMessage(result.error.message);
} else {
// The setup has succeeded. Display a success message.
setType('success');
setMessage(t('app.shared.stripe_confirm.success'));
}
});
}, [])
return <div className="stripe-confirm">
<div className={`message--${type}`}><span className="message-text">{message}</span></div>
</div>;
}

View File

@ -0,0 +1,29 @@
/**
* This component initializes the stripe's Elements tag with the API key
*/
import React, { memo, useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from "@stripe/stripe-js";
import SettingAPI from '../api/setting';
import { SettingName } from '../models/setting';
const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey);
export const StripeElements: React.FC = memo(({ children }) => {
const [stripe, setStripe] = useState(undefined);
useEffect(() => {
const key = stripePublicKey.read();
const promise = loadStripe(key.value);
setStripe(promise);
}, [])
return (
<div>
{stripe && <Elements stripe={stripe}>
{children}
</Elements>}
</div>
);
})

View File

@ -0,0 +1,146 @@
import React, { FormEvent } from 'react';
import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js';
import { SetupIntent } from "@stripe/stripe-js";
import PaymentAPI from '../api/payment';
import { CartItems, PaymentConfirmation } from '../models/payment';
import { useTranslation } from 'react-i18next';
import { User } from '../models/user';
interface StripeFormProps {
onSubmit: () => void,
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: boolean,
cartItems?: CartItems
}
/**
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form="stripe-form".
*/
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => {
const { t } = useTranslation('shared');
const stripe = useStripe();
const elements = useElements();
/**
* Handle the submission of the form. Depending on the configuration, it will create the payment method on Stripe,
* or it will process a payment with the inputted card.
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
onSubmit();
// Stripe.js has not loaded yet
if (!stripe || !elements) { return; }
const cardElement = elements.getElement(CardElement);
const { error, paymentMethod } = await stripe.createPaymentMethod({
type: 'card',
card: cardElement,
});
if (error) {
// stripe error
onError(error.message);
} else {
try {
if (!paymentSchedule) {
// process the normal payment pipeline, including SCA validation
const res = await PaymentAPI.confirm(paymentMethod.id, cartItems);
await handleServerConfirmation(res);
} else {
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customer.id);
const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id,
mandate_data: {
customer_acceptance: {
type: 'online',
online: {
ip_address: operator.ip_address,
user_agent: navigator.userAgent
}
}
}
})
if (error) {
onError(error.message);
} else {
// then we confirm the payment schedule
const res = await PaymentAPI.confirmPaymentSchedule(setupIntent.id, cartItems);
onSuccess(res);
}
}
} catch (err) {
// catch api errors
onError(err);
}
}
}
/**
* Process the server response about the Strong-customer authentication (SCA)
* @param response can be a PaymentConfirmation, or a Reservation (if the reservation succeeded), or a Subscription (if the subscription succeeded)
* @see app/controllers/api/payments_controller.rb#on_reservation_success
* @see app/controllers/api/payments_controller.rb#on_subscription_success
* @see app/controllers/api/payments_controller.rb#generate_payment_response
*/
const handleServerConfirmation = async (response: PaymentConfirmation|any) => {
if (response.error) {
if (response.error.statusText) {
onError(response.error.statusText);
} else {
onError(`${t('app.shared.messages.payment_card_error')} ${response.error}`);
}
} else if (response.requires_action) {
// Use Stripe.js to handle required card action
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
if (result.error) {
onError(result.error.message);
} else {
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
try {
const confirmation = await PaymentAPI.confirm(result.paymentIntent.id, cartItems);
await handleServerConfirmation(confirmation);
} catch (e) {
onError(e);
}
}
} else {
onSuccess(response);
}
}
/**
* Options for the Stripe's card input
*/
const cardOptions = {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': { color: '#aab7c4' }
},
invalid: {
color: '#9e2146',
iconColor: '#9e2146'
},
},
hidePostalCode: true
};
return (
<form onSubmit={handleSubmit} id="stripe-form" className={className}>
<CardElement options={cardOptions} />
{children}
</form>
);
}

View File

@ -0,0 +1,225 @@
/**
* This component enables the user to input his card data or process payments.
* Supports Strong-Customer Authentication (SCA).
*/
import React, { ReactNode, useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { Loader } from './loader';
import { IApplication } from '../models/application';
import { StripeElements } from './stripe-elements';
import { useTranslation } from 'react-i18next';
import { FabModal, ModalSize } from './fab-modal';
import { SetupIntent } from '@stripe/stripe-js';
import { WalletInfo } from './wallet-info';
import { User } from '../models/user';
import CustomAssetAPI from '../api/custom-asset';
import { CustomAssetName } from '../models/custom-asset';
import { PaymentSchedule } from '../models/payment-schedule';
import { IFablab } from '../models/fablab';
import WalletLib from '../lib/wallet';
import { StripeForm } from './stripe-form';
import stripeLogo from '../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../images/mastercard.png';
import visaLogo from '../../../images/visa.png';
import { CartItems, PaymentConfirmation } from '../models/payment';
import WalletAPI from '../api/wallet';
import PriceAPI from '../api/price';
import { HtmlTranslate } from './html-translate';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: SetupIntent|PaymentConfirmation) => void,
cartItems: CartItems,
currentUser: User,
schedule: PaymentSchedule,
customer: User
}
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cartItems, currentUser, schedule, customer }) => {
// customer's wallet
const [wallet, setWallet] = useState(null);
// server-computed price with all details
const [price, setPrice] = useState(null);
// remaining price = total price - wallet amount
const [remainingPrice, setRemainingPrice] = useState(0);
// is the component ready to display?
const [ready, setReady] = useState(false);
// errors to display in the UI (stripe errors mainly)
const [errors, setErrors] = useState(null);
// are we currently processing the payment (ie. the form was submit, but the process is still running)?
const [submitState, setSubmitState] = useState(false);
// did the user accepts the terms of services (CGV)?
const [tos, setTos] = useState(false);
const { t } = useTranslation('shared');
const cgv = cgvFile.read();
/**
* On each display:
* - Refresh the wallet
* - Refresh the price
* - Refresh the remaining price
*/
useEffect(() => {
if (!cartItems) return;
WalletAPI.getByUser(cartItems.reservation?.user_id || cartItems.subscription?.user_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cartItems).then((res) => {
setPrice(res);
const wLib = new WalletLib(wallet);
setRemainingPrice(wLib.computeRemainingPrice(res.price));
setReady(true);
})
})
}, [cartItems]);
/**
* Check if there is currently an error to display
*/
const hasErrors = (): boolean => {
return errors !== null;
}
/**
* Check if the user accepts the Terms of Sales document
*/
const hasCgv = (): boolean => {
return cgv != null;
}
/**
* Triggered when the user accepts or declines the Terms of Sales
*/
const toggleTos = (): void => {
setTos(!tos);
}
/**
* Check if we are currently creating a payment schedule
*/
const isPaymentSchedule = (): boolean => {
return schedule !== undefined;
}
/**
* Return the formatted localized amount for the given price (eg. 20.5 => "20,50 €")
*/
const formatPrice = (amount: number): string => {
return new Intl.NumberFormat(Fablab.intl_locale, {style: 'currency', currency: Fablab.intl_currency}).format(amount);
}
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="stripe-modal-icons">
<i className="fa fa-lock fa-2x m-r-sm pos-rlt" />
<img src={stripeLogo} alt="powered by stripe" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* Set the component as 'currently submitting'
*/
const handleSubmit = (): void => {
setSubmitState(true);
}
/**
* After sending the form with success, process the resulting payment method
*/
const handleFormSuccess = async (result: SetupIntent|PaymentConfirmation|any): Promise<void> => {
setSubmitState(false);
afterSuccess(result);
}
/**
* When stripe-form raise an error, it is handled by this callback which display it in the modal.
*/
const handleFormError = (message: string): void => {
setSubmitState(false);
setErrors(message);
}
/**
* Check the form can be submitted.
* => We're not currently already submitting the form, and, if the terms of service are enabled, the user agrees with them.
*/
const canSubmit = (): boolean => {
let terms = true;
if (hasCgv()) { terms = tos; }
return !submitState && terms;
}
return (
<FabModal title={t('app.shared.stripe.online_payment')}
isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.medium}
closeButton={false}
customFooter={logoFooter()}
className="stripe-modal">
{ready && <StripeElements>
<WalletInfo cartItems={cartItems} currentUser={currentUser} wallet={wallet} price={price?.price} />
<StripeForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess}
onError={handleFormError}
operator={currentUser}
className="stripe-form"
cartItems={cartItems}
customer={customer}
paymentSchedule={isPaymentSchedule()}>
{hasErrors() && <div className="stripe-errors">
{errors}
</div>}
{isPaymentSchedule() && <div className="payment-schedule-info">
<HtmlTranslate trKey="app.shared.stripe.payment_schedule_html" options={{ DEADLINES: schedule.items.length }} />
</div>}
{hasCgv() && <div className="terms-of-sales">
<input type="checkbox" id="acceptToS" name="acceptCondition" checked={tos} onChange={toggleTos} required />
<label htmlFor="acceptToS">{ t('app.shared.stripe.i_have_read_and_accept_') }
<a href={cgv.custom_asset_file_attributes.attachment_url} target="_blank">
{ t('app.shared.stripe._the_general_terms_and_conditions') }
</a>
</label>
</div>}
</StripeForm>
{!submitState && <button type="submit"
disabled={!canSubmit()}
form="stripe-form"
className="validate-btn">
{t('app.shared.stripe.confirm_payment_of_', { AMOUNT: formatPrice(remainingPrice) })}
</button>}
{submitState && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</StripeElements>}
</FabModal>
);
}
const StripeModalWrapper: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, customer }) => {
return (
<Loader>
<StripeModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} currentUser={currentUser} schedule={schedule} cartItems={cartItems} customer={customer} />
</Loader>
);
}
Application.Components.component('stripeModal', react2angular(StripeModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cartItems', 'customer']));

View File

@ -1,4 +0,0 @@
import Switch from 'react-switch';
import { react2angular } from 'react2angular';
Application.Components.component('switch', react2angular(Switch, ['checked', 'onChange', 'id', 'className']));

View File

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

View File

@ -260,6 +260,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
sample = sample.replace(/W\[([^\]]+)\]/g, '');
// information about refunds (R[text]) - does not apply here
sample = sample.replace(/R\[([^\]]+)\]/g, '');
// information about payment schedules (S[text]) -does not apply here
sample = sample.replace(/S\[([^\]]+)\]/g, '');
}
return sample;
};
@ -733,11 +735,21 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
placement: 'left'
});
}
if (settings.invoicing_module === 'true') {
uitour.createStep({
selector: '.invoices-management .payment-schedules-list',
stepId: 'payment-schedules',
order: 5,
title: _t('app.admin.tour.invoices.payment-schedules.title'),
content: _t('app.admin.tour.invoices.payment-schedules.content'),
placement: 'bottom'
});
}
if (AuthService.isAuthorized('admin')) {
uitour.createStep({
selector: '.invoices-management .invoices-settings',
stepId: 'settings',
order: 5,
order: 6,
title: _t('app.admin.tour.invoices.settings.title'),
content: _t('app.admin.tour.invoices.settings.content'),
placement: 'bottom'
@ -745,7 +757,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
uitour.createStep({
selector: '.invoices-management .accounting-codes-tab',
stepId: 'codes',
order: 6,
order: 7,
title: _t('app.admin.tour.invoices.codes.title'),
content: _t('app.admin.tour.invoices.codes.content'),
placement: 'bottom'
@ -753,7 +765,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
uitour.createStep({
selector: '.heading .export-accounting-button',
stepId: 'export',
order: 7,
order: 8,
title: _t('app.admin.tour.invoices.export.title'),
content: _t('app.admin.tour.invoices.export.content'),
placement: 'bottom'
@ -761,7 +773,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
uitour.createStep({
selector: '.invoices-management .payment-settings',
stepId: 'payment',
order: 8,
order: 9,
title: _t('app.admin.tour.invoices.payment.title'),
content: _t('app.admin.tour.invoices.payment.content'),
placement: 'bottom',
@ -770,7 +782,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
uitour.createStep({
selector: '.heading .close-accounting-periods-button',
stepId: 'periods',
order: 9,
order: 10,
title: _t('app.admin.tour.invoices.periods.title'),
content: _t('app.admin.tour.invoices.periods.content'),
placement: 'bottom',
@ -780,7 +792,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
uitour.createStep({
selector: 'body',
stepId: 'conclusion',
order: 10,
order: 11,
title: _t('app.admin.tour.conclusion.title'),
content: _t('app.admin.tour.conclusion.content'),
placement: 'bottom',
@ -788,7 +800,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
});
// on step change, change the active tab if needed
uitour.on('stepChanged', function (nextStep) {
if (nextStep.stepId === 'list' || nextStep.stepId === 'settings') {
if (nextStep.stepId === 'list' || nextStep.stepId === 'refund') {
$scope.tabs.active = 0;
}
if (nextStep.stepId === 'settings') {
@ -800,6 +812,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
if (nextStep.stepId === 'payment') {
$scope.tabs.active = 3;
}
if (nextStep.stepId === 'payment-schedules') {
$scope.tabs.active = 4;
}
});
// on tour end, save the status in database
uitour.on('ended', function () {

View File

@ -37,7 +37,7 @@
*/
class MembersController {
constructor ($scope, $state, Group, Training) {
// Retrieve the profiles groups (eg. students ...)
// Retrieve the profiles groups (e.g. students ...)
Group.query(function (groups) { $scope.groups = groups.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; }); });
// Retrieve the list of available trainings
@ -62,7 +62,7 @@ class MembersController {
};
/**
* Shows the birth day datepicker
* Shows the birthday datepicker
* @param $event {Object} jQuery event object
*/
$scope.openDatePicker = function ($event) {
@ -85,7 +85,7 @@ class MembersController {
* For use with ngUpload (https://github.com/twilson63/ngUpload).
* Intended to be the callback when an upload is done: any raised error will be stacked in the
* $scope.alerts array. If everything goes fine, the user is redirected to the members listing page.
* @param content {Object} JSON - The upload's result
* @param content {Object} JSON - The result of the upload
*/
$scope.submited = function (content) {
if ((content.id == null)) {
@ -110,7 +110,7 @@ class MembersController {
/**
* For use with 'ng-class', returns the CSS class name for the uploads previews.
* The preview may show a placeholder or the content of the file depending on the upload state.
* The preview may show a placeholder, or the content of the file depending on the upload state.
* @param v {*} any attribute, will be tested for truthiness (see JS evaluation rules)
*/
$scope.fileinputClass = function (v) {
@ -143,7 +143,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
searchText: '',
// Members ordering/sorting. Default: not sorted
order: 'id',
// currently displayed page of members
// the currently displayed page of members
page: 1,
// true when all members where loaded
noMore: false,
@ -158,7 +158,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
};
// admins list
$scope.admins = adminsPromise.admins.filter(function(m) { return m.id != Fablab.superadminId; });
$scope.admins = adminsPromise.admins.filter(function (m) { return m.id !== Fablab.superadminId; });
// Admins ordering/sorting. Default: not sorted
$scope.orderAdmin = null;
@ -210,7 +210,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
* @param orderPartner {string} ordering criterion
*/
$scope.setOrderPartner = function (orderPartner) {
if ($scope.orderPartner === orderPartner) {
if ($scope.orderPartner === orderPartner) {
return $scope.orderPartner = `-${orderPartner}`;
} else {
return $scope.orderPartner = orderPartner;
@ -229,7 +229,6 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
}
};
/**
* Open a modal dialog allowing the admin to create a new partner user
*/
@ -265,12 +264,11 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
});
};
/**
* Ask for confirmation then delete the specified user
* @param memberId {number} identifier of the user to delete
*/
$scope.deleteMember = function(memberId) {
$scope.deleteMember = function (memberId) {
dialogs.confirm(
{
resolve: {
@ -289,11 +287,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
$scope.members.splice(findItemIdxById($scope.members, memberId), 1);
return growl.success(_t('app.admin.members.member_successfully_deleted'));
},
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_member')); }
function (error) {
growl.error(_t('app.admin.members.unable_to_delete_the_member'));
console.error(error);
}
);
}
);
}
};
/**
* Ask for confirmation then delete the specified administrator
@ -319,7 +320,10 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
admins.splice(findItemIdxById(admins, admin.id), 1);
return growl.success(_t('app.admin.members.administrator_successfully_deleted'));
},
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_administrator')); }
function (error) {
growl.error(_t('app.admin.members.unable_to_delete_the_administrator'));
console.error(error);
}
);
}
);
@ -349,11 +353,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
partners.splice(findItemIdxById(partners, partner.id), 1);
return growl.success(_t('app.admin.members.partner_successfully_deleted'));
},
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_partner')); }
function (error) {
growl.error(_t('app.admin.members.unable_to_delete_the_partner'));
console.error(error);
}
);
}
);
}
};
/**
* Ask for confirmation then delete the specified manager
@ -379,11 +386,14 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
managers.splice(findItemIdxById(managers, manager.id), 1);
return growl.success(_t('app.admin.members.manager_successfully_deleted'));
},
function (error) { growl.error(_t('app.admin.members.unable_to_delete_the_manager')); }
function (error) {
growl.error(_t('app.admin.members.unable_to_delete_the_manager'));
console.error(error);
}
);
}
);
}
};
/**
* Callback for the 'load more' button.
@ -399,7 +409,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
*/
$scope.updateTextSearch = function () {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
searchTimeout = setTimeout(function () {
resetSearchMember();
memberSearch();
}, 300);
@ -425,9 +435,8 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
});
};
/**
* Setup the feature-tour for the admin/members page.
* Set up the feature-tour for the admin/members page.
* This is intended as a contextual help (when pressing F1)
*/
$scope.setupMembersTour = function () {
@ -570,7 +579,7 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
if (settingsPromise.feature_tour_display !== 'manual' && $scope.currentUser.profile.tours.indexOf('members') < 0) {
uitour.start();
}
}
};
/* PRIVATE SCOPE */
@ -586,22 +595,22 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
/**
* Will temporize the search query to prevent overloading the API
*/
var searchTimeout = null;
let searchTimeout = null;
/**
* Iterate through the provided array and return the index of the requested item
* @param items {Array} full list of users with role 'admin'
* @param items {Array} full list of users with the 'admin' role
* @param id {Number} id of the item to retrieve in the list
* @returns {Number} index of the requested item, in the provided array
*/
var findItemIdxById = function (items, id) {
const findItemIdxById = function (items, id) {
return (items.map(function (item) { return item.id; })).indexOf(id);
};
/**
* Reinitialize the context of members's search to display new results set
* Reinitialize the context of the search to display new results set
*/
var resetSearchMember = function () {
const resetSearchMember = function () {
$scope.member.noMore = false;
$scope.member.page = 1;
};
@ -609,9 +618,9 @@ Application.Controllers.controller('AdminMembersController', ['$scope', '$sce',
/**
* Run a search query with the current parameters set ($scope.member[searchText,order,page])
* and affect or append the result in $scope.members, depending on the concat parameter
* @param [concat] {boolean} if true, the result will be append to $scope.members instead of being affected
* @param [concat] {boolean} if true, the result will be appended to $scope.members instead of being replaced
*/
var memberSearch = function (concat) {
const memberSearch = function (concat) {
Member.list({
query: {
search: $scope.member.searchText,
@ -666,7 +675,6 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
// the user subscription
if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) {
$scope.subscription = $scope.user.subscription;
$scope.subscription.expired_at = $scope.subscription.expired_at;
} else {
Plan.query({ group_id: $scope.user.group_id }, function (plans) {
$scope.plans = plans;
@ -696,16 +704,15 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
/**
* Open a modal dialog asking for confirmation to change the role of the given user
* @param userId {number} id of the user to "promote"
* @returns {*}
*/
$scope.changeUserRole = function() {
$scope.changeUserRole = function () {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/admin/members/change_role_modal.html',
size: 'lg',
resolve: {
user() { return $scope.user; }
user () { return $scope.user; }
},
controller: ['$scope', '$uibModalInstance', 'Member', 'user', '_t', function ($scope, $uibModalInstance, Member, user, _t) {
$scope.user = user;
@ -715,7 +722,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
$scope.roles = [
{ key: 'admin', label: _t('app.admin.members_edit.admin') },
{ key: 'manager', label: _t('app.admin.members_edit.manager'), notAnOption: (user.role === 'admin') },
{ key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') },
{ key: 'member', label: _t('app.admin.members_edit.member'), notAnOption: (user.role === 'admin' || user.role === 'manager') }
];
$scope.ok = function () {
@ -740,7 +747,7 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
return modalInstance.result.then(function (user) {
// remove the user for the old list add to the new
});
}
};
/**
* Open a modal dialog, allowing the admin to extend the current user's subscription (freely or not)
@ -778,7 +785,10 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
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')); }
function (error) {
growl.error(_t('app.admin.members_edit.a_problem_occurred_while_saving_the_date'));
console.error(error);
}
);
};
@ -792,30 +802,59 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
/**
* 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, availables for the currently reviewed user, as recovered from GET /api/plans
* @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', 'Group', function ($scope, $uibModalInstance, Subscription, Group) {
// selected user
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 contains the group slug, otherwise the group full name
* @param short {boolean} If true, the generated name will contain the group slug, otherwise the group full name
* will be included.
* @returns {String}
*/
$scope.humanReadablePlanName = function (plan, groups, short) { return `${$filter('humanReadablePlanName')(plan, groups, short)}`; };
/**
* Check if the currently selected plan can be paid with a payment schedule or not
* @return {boolean}
*/
$scope.allowMonthlySchedule = function () {
if (!$scope.subscription) return false;
const plan = plans.find(p => p.id === $scope.subscription.plan_id);
return plan && plan.monthly_payment;
};
/**
* Triggered by the <switch> component.
* We must use a setTimeout to workaround the react integration.
* @param checked {Boolean}
*/
$scope.toggleSchedule = function (checked) {
setTimeout(() => {
$scope.subscription.payment_schedule = checked;
$scope.$apply();
}, 50);
};
/**
* Modal dialog validation callback
*/
@ -902,8 +941,9 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}
] });
// once the form was validated succesfully ...
]
});
// once the form was validated successfully...
return modalInstance.result.then(function (wallet) {
$scope.wallet = wallet;
return Wallet.transactions({ id: wallet.id }, function (transactions) { $scope.transactions = transactions; });
@ -923,13 +963,12 @@ Application.Controllers.controller('EditMemberController', ['$scope', '$state',
const initialize = function () {
CSRF.setMetaTags();
// init the birth date to JS object
// init the birthdate to JS object
$scope.user.statistic_profile.birthday = moment($scope.user.statistic_profile.birthday).toDate();
// the user subscription
if (($scope.user.subscribed_plan != null) && ($scope.user.subscription != null)) {
$scope.subscription = $scope.user.subscription;
$scope.subscription.expired_at = $scope.subscription.expired_at;
} else {
Plan.query({ group_id: $scope.user.group_id }, function (plans) {
$scope.plans = plans;
@ -996,7 +1035,7 @@ Application.Controllers.controller('NewMemberController', ['$scope', '$state', '
* Controller used in the member's import page: import from CSV (admin view)
*/
Application.Controllers.controller('ImportMembersController', ['$scope', '$state', 'Group', 'Training', 'CSRF', 'tags', 'growl',
function($scope, $state, Group, Training, CSRF, tags, growl) {
function ($scope, $state, Group, Training, CSRF, tags, growl) {
CSRF.setMetaTags();
/* PUBLIC SCOPE */
@ -1008,19 +1047,19 @@ Application.Controllers.controller('ImportMembersController', ['$scope', '$state
$scope.method = 'post';
// List of all tags
$scope.tags = tags
$scope.tags = tags;
/*
* Callback run after the form was submitted
* @param content {*} The result provided by the server, may be an Import object or an error message
* @param content {*} The result provided by the server, may be an Import object, or an error message
*/
$scope.onImportResult = function(content) {
$scope.onImportResult = function (content) {
if (content.id) {
$state.go('app.admin.members_import_result', { id: content.id });
} else {
growl.error(JSON.stringify(content));
}
}
};
// Using the MembersController
return new MembersController($scope, $state, Group, Training);
@ -1041,7 +1080,7 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
$scope.results = null;
/**
* Changes the admin's view to the members import page
* Changes the view of the admin to the members import page
*/
$scope.cancel = function () { $state.go('app.admin.members_import'); };
@ -1053,8 +1092,8 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
const initialize = function () {
$scope.results = JSON.parse($scope.import.results);
if (!$scope.results) {
setTimeout(function() {
Import.get({ id: $scope.import.id }, function(data) {
setTimeout(function () {
Import.get({ id: $scope.import.id }, function (data) {
$scope.import = data;
initialize();
});
@ -1068,69 +1107,68 @@ Application.Controllers.controller('ImportMembersResultController', ['$scope', '
]);
/**
* Controller used in the admin's creation page (admin view)
* Controller used in the admin creation page (admin view)
*/
Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'Admin', 'growl', '_t', 'phoneRequiredPromise',
function ($state, $scope, Admin, growl, _t, phoneRequiredPromise) {
// default admin profile
let getGender;
$scope.admin = {
statistic_profile_attributes: {
gender: true
},
profile_attributes: {},
invoicing_profile_attributes: {}
};
let getGender;
$scope.admin = {
statistic_profile_attributes: {
gender: true
},
profile_attributes: {},
invoicing_profile_attributes: {}
};
// Default parameters for AngularUI-Bootstrap datepicker
$scope.datePicker = {
format: Fablab.uibDateFormat,
opened: false,
options: {
startingDay: Fablab.weekStartingDay
}
};
// Default parameters for AngularUI-Bootstrap datepicker
$scope.datePicker = {
format: Fablab.uibDateFormat,
opened: false,
options: {
startingDay: Fablab.weekStartingDay
}
};
// is the phone number required in _admin_form?
$scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true');
// is the phone number required in _admin_form?
$scope.phoneRequired = (phoneRequiredPromise.setting.value === 'true');
/**
* Shows the birth day datepicker
* @param $event {Object} jQuery event object
/**
* Shows the birthday datepicker
*/
$scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; };
$scope.openDatePicker = function () { $scope.datePicker.opened = true; };
/**
/**
* Send the new admin, currently stored in $scope.admin, to the server for database saving
*/
$scope.saveAdmin = function () {
Admin.save(
{},
{ admin: $scope.admin },
function () {
growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) }));
return $state.go('app.admin.members');
}
, function (error) {
growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error));
console.error(error);
}
);
};
$scope.saveAdmin = function () {
Admin.save(
{},
{ admin: $scope.admin },
function () {
growl.success(_t('app.admin.admins_new.administrator_successfully_created_he_will_receive_his_connection_directives_by_email', { GENDER: getGender($scope.admin) }));
return $state.go('app.admin.members');
}
, function (error) {
growl.error(_t('app.admin.admins_new.failed_to_create_admin') + JSON.stringify(error.data ? error.data : error));
console.error(error);
}
);
};
/* PRIVATE SCOPE */
/* PRIVATE SCOPE */
/**
/**
* Return an enumerable meaningful string for the gender of the provider user
* @param user {Object} Database user record
* @return {string} 'male' or 'female'
*/
return getGender = function (user) {
if (user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};
}
return getGender = function (user) {
if (user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};
}
]);
@ -1140,65 +1178,64 @@ Application.Controllers.controller('NewAdminController', ['$state', '$scope', 'A
Application.Controllers.controller('NewManagerController', ['$state', '$scope', 'User', 'groupsPromise', 'tagsPromise', 'growl', '_t',
function ($state, $scope, User, groupsPromise, tagsPromise, growl, _t) {
// default admin profile
$scope.manager = {
statistic_profile_attributes: {
gender: true
},
profile_attributes: {},
invoicing_profile_attributes: {}
};
$scope.manager = {
statistic_profile_attributes: {
gender: true
},
profile_attributes: {},
invoicing_profile_attributes: {}
};
// Default parameters for AngularUI-Bootstrap datepicker
$scope.datePicker = {
format: Fablab.uibDateFormat,
opened: false,
options: {
startingDay: Fablab.weekStartingDay
}
};
// Default parameters for AngularUI-Bootstrap datepicker
$scope.datePicker = {
format: Fablab.uibDateFormat,
opened: false,
options: {
startingDay: Fablab.weekStartingDay
}
};
// list of all groups
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
// list of all groups
$scope.groups = groupsPromise.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; });
// list of all tags
$scope.tags = tagsPromise;
// list of all tags
$scope.tags = tagsPromise;
/**
* Shows the birth day datepicker
* @param $event {Object} jQuery event object
/**
* Shows the birthday datepicker
*/
$scope.openDatePicker = function ($event) { $scope.datePicker.opened = true; };
$scope.openDatePicker = function () { $scope.datePicker.opened = true; };
/**
/**
* Send the new manager, currently stored in $scope.manager, to the server for database saving
*/
$scope.saveManager = function () {
User.save(
{},
{ manager: $scope.manager },
function () {
growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) }));
return $state.go('app.admin.members');
}
, function (error) {
growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error));
console.error(error);
}
);
};
$scope.saveManager = function () {
User.save(
{},
{ manager: $scope.manager },
function () {
growl.success(_t('app.admin.manager_new.manager_successfully_created', { GENDER: getGender($scope.manager) }));
return $state.go('app.admin.members');
}
, function (error) {
growl.error(_t('app.admin.admins_new.failed_to_create_manager') + JSON.stringify(error.data ? error.data : error));
console.error(error);
}
);
};
/* PRIVATE SCOPE */
/* PRIVATE SCOPE */
/**
/**
* Return an enumerable meaningful string for the gender of the provider user
* @param user {Object} Database user record
* @return {string} 'male' or 'female'
*/
const getGender = function (user) {
if (user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};
}
const getGender = function (user) {
if (user.statistic_profile_attributes) {
if (user.statistic_profile_attributes.gender) { return 'male'; } else { return 'female'; }
} else { return 'other'; }
};
}
]);

View File

@ -28,15 +28,15 @@ class PlanController {
// groups list
$scope.groups = groups
.filter(function (g) { return (g.slug !== 'admins') && !g.disabled; })
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }))
.map(e => Object.assign({}, e, { category: 'app.shared.plan.groups', id: `${e.id}` }));
$scope.groups.push({ id: 'all', name: 'app.shared.plan.transversal_all_groups', category: 'app.shared.plan.all' });
// dynamically translate a label if needed
$scope.translateLabel = function (group, prop) {
return group[prop] && group[prop].match(/^app\./) ? _t(group[prop]) : group[prop];
}
};
// users with role 'partner', notifiables for a partner plan
// users with role 'partner', notifiable for a partner plan
$scope.partners = partners.users;
// Subscriptions prices, machines prices and training prices, per groups
@ -93,7 +93,8 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
is_rolling: false,
partnerId: null,
partnerContact: null,
ui_weight: 0
ui_weight: 0,
monthly_payment: false
};
// API URL where the form will be posted
@ -144,6 +145,22 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
});
};
/**
* This will update the monthly_payment value when the user toggles the switch button
* @param checked {Boolean}
*/
$scope.toggleMonthlyPayment = function (checked) {
toggle('monthly_payment', checked);
};
/**
* This will update the is_rolling value when the user toggles the switch button
* @param checked {Boolean}
*/
$scope.toggleIsRolling = function (checked) {
toggle('is_rolling', checked);
};
/**
* Display some messages and redirect the user, once the form was submitted, depending on the result status
* (failed/succeeded).
@ -164,6 +181,28 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
}
};
/* PRIVATE SCOPE */
const initialize = function () {
$scope.$watch(scope => scope.plan.interval,
(newValue, oldValue) => {
if (newValue === 'week') { $scope.plan.monthly_payment = false; }
}
);
};
/**
* Asynchronously updates the given property with the new provided value
* @param property {string}
* @param value {*}
*/
const toggle = function (property, value) {
setTimeout(() => {
$scope.plan[property] = value;
$scope.$apply();
}, 50);
};
initialize();
return new PlanController($scope, groups, prices, partners, CSRF, _t);
}
]);
@ -204,7 +243,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
$scope.selectedGroup = function () {
const group = $scope.groups.filter(g => g.id === $scope.plan.group_id);
return $scope.translateLabel(group[0], 'name');
}
};
/**
* If a parent plan was set ($scope.plan.parent), the prices will be copied from this parent plan into
@ -216,7 +255,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
Array.from(parentPlan.prices).map(function (parentPrice) {
return (function () {
const result = [];
for (let childKey in $scope.plan.prices) {
for (const childKey in $scope.plan.prices) {
const childPrice = $scope.plan.prices[childKey];
if ((childPrice.priceable_type === parentPrice.priceable_type) && (childPrice.priceable_id === parentPrice.priceable_id)) {
$scope.plan.prices[childKey].amount = parentPrice.amount;
@ -235,7 +274,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
} else {
return (function () {
const result = [];
for (let key in $scope.plan.prices) {
for (const key in $scope.plan.prices) {
const price = $scope.plan.prices[key];
result.push($scope.plan.prices[key].amount = 0);
}
@ -273,7 +312,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
* @returns {Object} Machine
*/
$scope.getMachine = function (machine_id) {
for (let machine of Array.from($scope.machines)) {
for (const machine of Array.from($scope.machines)) {
if (machine.id === machine_id) {
return machine;
}
@ -286,7 +325,7 @@ Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'p
* @returns {Object} Space
*/
$scope.getSpace = function (space_id) {
for (let space of Array.from($scope.spaces)) {
for (const space of Array.from($scope.spaces)) {
if (space.id === space_id) {
return space;
}

View File

@ -30,13 +30,13 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
const initialize = () => $scope.social.networks = filterNetworks();
/**
* Filter social network or website that are associated with the profile of the user provided in promise
* Filter the social networks or websites that are associated with the profile of the user provided in promise
* and return the filtered networks
* @return {Array}
*/
var filterNetworks = function () {
const filterNetworks = function () {
const networks = [];
for (let network of Array.from(SocialNetworks)) {
for (const network of Array.from(SocialNetworks)) {
if ($scope.user.profile[network] && ($scope.user.profile[network].length > 0)) {
networks.push(network);
}

View File

@ -704,9 +704,12 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'cgv', 'Auth', 'Reservation', 'growl', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'stripeKey',
function ($scope, $uibModalInstance, $state, reservation, price, cgv, Auth, Reservation, growl, wallet, helpers, $filter, coupon, cartItems, stripeKey) {
// User's wallet amount
$scope.walletAmount = wallet.amount;
$scope.wallet = wallet;
// Price
$scope.price = price.price;
// Amount to pay
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Cart items
@ -753,16 +756,22 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
},
coupon () {
return $scope.coupon.applied;
}
},
cartItems () {
return mkRequestParams(reservation, $scope.coupon.applied);
},
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon) {
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon, cartItems) {
// User's wallet amount
$scope.walletAmount = wallet.amount;
$scope.wallet = wallet;
// Price
$scope.price = price.price;
// Cart items
$scope.cartItems = cartItems;
// price to pay
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);

View File

@ -620,13 +620,24 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
* @param plan {Object} the plan to subscribe
*/
$scope.selectPlan = function (plan) {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
return $scope.planSelectionTime = new Date();
setTimeout(() => {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
$scope.planSelectionTime = new Date();
$scope.$apply();
}, 50);
};
/**
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan
*/
$scope.isSelected = function (plan) {
return $scope.selectedPlan === plan;
};
/**
@ -654,7 +665,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
}
$scope.plansAreShown = false;
$scope.selectedPlan = null;
}
@ -745,6 +758,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -12,8 +12,8 @@
*/
'use strict';
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise',
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise) {
Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScope', '$state', '$uibModal', 'Auth', 'AuthService', 'dialogs', 'growl', 'plansPromise', 'groupsPromise', 'Subscription', 'Member', 'subscriptionExplicationsPromise', '_t', 'Wallet', 'helpers', 'settingsPromise', 'Price',
function ($scope, $rootScope, $state, $uibModal, Auth, AuthService, dialogs, growl, plansPromise, groupsPromise, Subscription, Member, subscriptionExplicationsPromise, _t, Wallet, helpers, settingsPromise, Price) {
/* PUBLIC SCOPE */
// list of groups
@ -42,14 +42,16 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
// plan to subscribe (shopping cart)
$scope.selectedPlan = null;
// the moment when the plan selection changed for the last time, used to trigger changes in the cart
$scope.planSelectionTime = null;
// the application global settings
$scope.settings = settingsPromise;
// Discount coupon to apply to the basket, if any
$scope.coupon =
{ applied: null };
// Storage for the total price (plan price + coupon, if any)
$scope.cart =
{ total: null };
// text that appears in the bottom-right box of the page (subscriptions rules details)
$scope.subscriptionExplicationsAlert = subscriptionExplicationsPromise.setting.value;
@ -72,39 +74,27 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
* @param plan {Object} The plan to subscribe to
*/
$scope.selectPlan = function (plan) {
if ($scope.isAuthenticated()) {
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
updateCartPrice();
setTimeout(() => {
if ($scope.isAuthenticated()) {
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
$scope.planSelectionTime = new Date();
} else {
$scope.selectedPlan = null;
}
} else {
$scope.selectedPlan = null;
$scope.login();
}
} else {
$scope.login();
}
$scope.$apply();
}, 50);
};
/**
* Callback to trigger the payment process of the subscription
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan
*/
$scope.openSubscribePlanModal = function () {
Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }, function (wallet) {
const amountToPay = helpers.getAmountToPay($scope.cart.total, wallet.amount);
if ((AuthService.isAuthorized('member') && amountToPay > 0)
|| (AuthService.isAuthorized('manager') && $scope.ctrl.member.id === $rootScope.currentUser.id && amountToPay > 0)) {
if (settingsPromise.online_payment_module !== 'true') {
growl.error(_t('app.public.plans.online_payment_disabled'));
} else {
return payByStripe();
}
} else {
if (AuthService.isAuthorized('admin')
|| (AuthService.isAuthorized('manager') && $scope.ctrl.member.id !== $rootScope.currentUser.id)
|| amountToPay === 0) {
return payOnSite();
}
}
});
$scope.isSelected = function (plan) {
return $scope.selectedPlan === plan;
};
/**
@ -171,6 +161,20 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
*/
$scope.filterDisabledPlans = function (plan) { return !plan.disabled; };
/**
* Once the subscription has been confirmed (payment process successfully completed), mark the plan as subscribed,
* and update the user's subscription
*/
$scope.afterPayment = function () {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
}
$scope.paid.plan = angular.copy($scope.selectedPlan);
$scope.selectedPlan = null;
$scope.coupon.applied = null;
};
/* PRIVATE SCOPE */
/**
@ -180,7 +184,7 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
// group all plans by Group
for (const group of $scope.groups) {
const groupObj = { id: group.id, name: group.name, plans: [], actives: 0 };
for (let plan of plansPromise) {
for (const plan of plansPromise) {
if (plan.group_id === group.id) {
groupObj.plans.push(plan);
if (!plan.disabled) { groupObj.actives++; }
@ -198,188 +202,6 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
}
$scope.$on('devise:new-session', function (event, user) { if (user.role !== 'admin') { $scope.ctrl.member = user; } });
// watch when a coupon is applied to re-compute the total price
$scope.$watch('coupon.applied', function (newValue, oldValue) {
if ((newValue !== null) || (oldValue !== null)) {
return updateCartPrice();
}
});
};
/**
* Compute the total amount for the current reservation according to the previously set parameters
* and assign the result in $scope.reserve.amountTotal
*/
const updateCartPrice = function () {
// first we check the selection of a user
if (Object.keys($scope.ctrl.member).length > 0 && $scope.selectedPlan) {
$scope.cart.total = $scope.selectedPlan.amount;
// apply the coupon if any
if ($scope.coupon.applied) {
let discount;
if ($scope.coupon.applied.type === 'percent_off') {
discount = ($scope.cart.total * $scope.coupon.applied.percent_off) / 100;
} else if ($scope.coupon.applied.type === 'amount_off') {
discount = $scope.coupon.applied.amount_off;
}
return $scope.cart.total -= discount;
}
} else {
return $scope.reserve.amountTotal = null;
}
};
/**
* Open a modal window which trigger the stripe payment process
*/
const payByStripe = function () {
$uibModal.open({
templateUrl: '/stripe/payment_modal.html',
size: 'md',
resolve: {
selectedPlan () { return $scope.selectedPlan; },
member () { return $scope.ctrl.member; },
price () { return $scope.cart.total; },
wallet () {
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }).$promise;
},
coupon () { return $scope.coupon.applied; },
stripeKey: ['Setting', function (Setting) { return Setting.get({ name: 'stripe_public_key' }).$promise; }]
},
controller: ['$scope', '$uibModalInstance', '$state', 'selectedPlan', 'member', 'price', 'Subscription', 'CustomAsset', 'wallet', 'helpers', '$filter', 'coupon', 'stripeKey',
function ($scope, $uibModalInstance, $state, selectedPlan, member, price, Subscription, CustomAsset, wallet, helpers, $filter, coupon, stripeKey) {
// User's wallet amount
$scope.walletAmount = wallet.amount;
// Final price to pay by the user
$scope.amount = helpers.getAmountToPay(price, wallet.amount);
// The plan that the user is about to subscribe
$scope.selectedPlan = selectedPlan;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// Cart items
$scope.cartItems = {
coupon_code: ((coupon ? coupon.code : undefined)),
subscription: {
plan_id: selectedPlan.id
}
};
// stripe publishable key
$scope.stripeKey = stripeKey.setting.value;
// retrieve the CGV
CustomAsset.get({ name: 'cgv-file' }, function (cgv) { $scope.cgv = cgv.custom_asset; });
/**
* Callback for a click on the 'proceed' button.
* Handle the stripe's card tokenization process response and save the subscription to the API with the
* card token just created.
*/
$scope.onPaymentSuccess = function (response) {
$uibModalInstance.close(response);
};
}
]
}).result['finally'](null).then(function (subscription) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
$scope.paid.plan = angular.copy($scope.selectedPlan);
$scope.selectedPlan = null;
$scope.coupon.applied = null;
});
};
/**
* Open a modal window which trigger the local payment process
*/
const payOnSite = function () {
$uibModal.open({
templateUrl: '/plans/payment_modal.html',
size: 'sm',
resolve: {
selectedPlan () { return $scope.selectedPlan; },
member () { return $scope.ctrl.member; },
price () { return $scope.cart.total; },
wallet () {
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }).$promise;
},
coupon () { return $scope.coupon.applied; }
},
controller: ['$scope', '$uibModalInstance', '$state', 'selectedPlan', 'member', 'price', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon',
function ($scope, $uibModalInstance, $state, selectedPlan, member, price, Subscription, wallet, helpers, $filter, coupon) {
// user wallet amount
$scope.walletAmount = wallet.amount;
// subscription price, coupon subtracted if any
$scope.price = price;
// price to pay
$scope.amount = helpers.getAmountToPay($scope.price, wallet.amount);
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// The plan that the user is about to subscribe
$scope.plan = selectedPlan;
// The member who is subscribing a plan
$scope.member = member;
// Button label
if ($scope.amount > 0) {
$scope.validButtonName = _t('app.public.plans.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
} else {
if ((price.price > 0) && ($scope.walletAmount === 0)) {
$scope.validButtonName = _t('app.public.plans.confirm_payment_of_html', { ROLE: $scope.currentUser.role, AMOUNT: $filter('currency')(price.price) });
} else {
$scope.validButtonName = _t('app.shared.buttons.confirm');
}
}
/**
* Callback for the 'proceed' button.
* Save the subscription to the API
*/
$scope.ok = function () {
$scope.attempting = true;
Subscription.save({
coupon_code: ((coupon ? coupon.code : undefined)),
subscription: {
plan_id: selectedPlan.id,
user_id: member.id
}
}
, function (data) { // success
$uibModalInstance.close(data);
}
, function (data, status) { // failed
$scope.alerts = [];
$scope.alerts.push({ msg: _t('app.public.plans.an_error_occured_during_the_payment_process_please_try_again_later'), type: 'danger' });
$scope.attempting = false;
}
);
};
/**
* Callback for the 'cancel' button.
* Close the modal box.
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
}
]
}).result['finally'](null).then(function (subscription) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
$scope.ctrl.member = null;
$scope.paid.plan = angular.copy($scope.selectedPlan);
$scope.selectedPlan = null;
return $scope.coupon.applied = null;
});
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -353,13 +353,13 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
// the moment when the slot selection changed for the last time, used to trigger changes in the cart
$scope.selectionTime = null;
// the last clicked event in the calender
// the last clicked event in the calendar
$scope.selectedEvent = null;
// indicates the state of the current view : calendar or plans information
$scope.plansAreShown = false;
// will store the user's plan if he choosed to buy one
// will store the user's plan if he chose to buy one
$scope.selectedPlan = null;
// the moment when the plan selection changed for the last time, used to trigger changes in the cart
@ -390,7 +390,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
$scope.spaceExplicationsAlert = settingsPromise.space_explications_alert;
/**
* Change the last selected slot's appearence to looks like 'added to cart'
* Change the last selected slot's appearance to looks like 'added to cart'
*/
$scope.markSlotAsAdded = function () {
$scope.selectedEvent.backgroundColor = SELECTED_EVENT_BG_COLOR;
@ -398,7 +398,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Change the last selected slot's appearence to looks like 'never added to cart'
* Change the last selected slot's appearance to looks like 'never added to cart'
*/
$scope.markSlotAsRemoved = function (slot) {
slot.backgroundColor = 'white';
@ -419,7 +419,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
$scope.slotCancelled = function () { $scope.markSlotAsRemoved($scope.selectedEvent); };
/**
* Change the last selected slot's appearence to looks like 'currently looking for a new destination to exchange'
* Change the last selected slot's appearance to looks like 'currently looking for a new destination to exchange'
*/
$scope.markSlotAsModifying = function () {
$scope.selectedEvent.backgroundColor = '#eee';
@ -428,7 +428,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Change the last selected slot's appearence to looks like 'the slot being exchanged will take this place'
* Change the last selected slot's appearance to looks like 'the slot being exchanged will take this place'
*/
$scope.changeModifySpaceSlot = function () {
if ($scope.events.placable) {
@ -517,17 +517,28 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
* @param plan {Object} the plan to subscribe
*/
$scope.selectPlan = function (plan) {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
return $scope.planSelectionTime = new Date();
setTimeout(() => {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
$scope.planSelectionTime = new Date();
$scope.$apply();
}, 50);
};
/**
* Changes the user current view from the plan subsription screen to the machine reservation agenda
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan
*/
$scope.isSelected = function (plan) {
return $scope.selectedPlan === plan;
};
/**
* Changes the user current view from the plan subscription screen to the machine reservation agenda
* @param e {Object} see https://docs.angularjs.org/guide/expression#-event-
*/
$scope.doNotSubscribePlan = function (e) {
@ -560,14 +571,18 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
}
$scope.plansAreShown = false;
$scope.selectedPlan = null;
}
$scope.ctrl.member.training_credits = angular.copy(reservation.user.training_credits);
$scope.ctrl.member.machine_credits = angular.copy(reservation.user.machine_credits);
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
}
refetchCalendar();
};
@ -601,7 +616,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
/**
* Triggered when the user clicks on a reservation slot in the agenda.
* Defines the behavior to adopt depending on the slot status (already booked, free, ready to be reserved ...),
* the user's subscription (current or about to be took) and the time (the user cannot modify a booked reservation
* the user's subscription (current or about to be took), and the time (the user cannot modify a booked reservation
* if it's too late).
* @see http://fullcalendar.io/docs/mouse/eventClick/
*/
@ -611,7 +626,7 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
};
/**
* Triggered when fullCalendar tries to graphicaly render an event block.
* Triggered when fullCalendar tries to graphically render an event block.
* Append the event tag into the block, just after the event title.
* @see http://fullcalendar.io/docs/event_rendering/eventRender/
*/
@ -654,6 +669,9 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -307,13 +307,24 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
* @param plan {Object} the plan to subscribe
*/
$scope.selectPlan = function (plan) {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
return $scope.planSelectionTime = new Date();
setTimeout(() => {
// toggle selected plan
if ($scope.selectedPlan !== plan) {
$scope.selectedPlan = plan;
} else {
$scope.selectedPlan = null;
}
$scope.planSelectionTime = new Date();
$scope.$apply();
}, 50);
};
/**
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan
*/
$scope.isSelected = function (plan) {
return $scope.selectedPlan === plan;
};
/**
@ -350,14 +361,18 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
if ($scope.selectedPlan) {
$scope.ctrl.member.subscribed_plan = angular.copy($scope.selectedPlan);
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.subscribed_plan = angular.copy($scope.selectedPlan);
}
$scope.plansAreShown = false;
$scope.selectedPlan = null;
}
$scope.ctrl.member.training_credits = angular.copy(reservation.user.training_credits);
$scope.ctrl.member.machine_credits = angular.copy(reservation.user.machine_credits);
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
if ($scope.ctrl.member.id === Auth._currentUser.id) {
Auth._currentUser.training_credits = angular.copy(reservation.user.training_credits);
Auth._currentUser.machine_credits = angular.copy(reservation.user.machine_credits);
}
refetchCalendar();
};
@ -447,6 +462,9 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -10,8 +10,8 @@
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'helpers', '_t',
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, helpers, _t) {
Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'Payment', 'helpers', '_t',
function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, Payment, helpers, _t) {
return ({
restrict: 'E',
scope: {
@ -40,7 +40,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
},
templateUrl: '/shared/_cart.html',
link ($scope, element, attributes) {
// will store the user's plan if he choosed to buy one
// will store the user's plan if he chose to buy one
$scope.selectedPlan = null;
// total amount of the bill to pay
@ -67,6 +67,21 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// Global config: delay in hours before a booking while the cancellation is forbidden
$scope.cancelBookingDelay = parseInt($scope.settings.booking_cancel_delay);
// Payment schedule
$scope.schedule = {
requested_schedule: false, // does the user requests a payment schedule for his subscription
payment_schedule: undefined // the effective computed payment schedule
};
// online payments (stripe)
$scope.stripe = {
showModal: false,
cartItems: undefined
};
// currently logged-in user
$scope.currentUser = $rootScope.currentUser;
/**
* Add the provided slot to the shopping cart (state transition from free to 'about to be reserved')
* and increment the total amount of the cart if needed.
@ -107,9 +122,13 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
*/
$scope.isSlotsValid = function () {
let isValid = true;
angular.forEach($scope.events.reserved, function (m) {
if (!m.isValid) { return isValid = false; }
});
if ($scope.events) {
angular.forEach($scope.events.reserved, function (m) {
if (!m.isValid) {
return isValid = false;
}
});
}
return isValid;
};
@ -143,48 +162,58 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const slotValidations = [];
let slotNotValid;
let slotNotValidError;
$scope.events.reserved.forEach(function (slot) {
if (slot.plan_ids.length > 0) {
if (
($scope.selectedPlan && _.includes(slot.plan_ids, $scope.selectedPlan.id)) ||
($scope.user.subscribed_plan && _.includes(slot.plan_ids, $scope.user.subscribed_plan.id))
) {
slotValidations.push(true);
} else {
slotNotValid = slot;
if ($scope.selectedPlan && !_.includes(slot.plan_ids, $scope.selectedPlan.id)) {
slotNotValidError = 'selectedPlanError';
if ($scope.events.reserved) {
$scope.events.reserved.forEach(function (slot) {
if (slot.plan_ids.length > 0) {
if (
($scope.selectedPlan && _.includes(slot.plan_ids, $scope.selectedPlan.id)) ||
($scope.user.subscribed_plan && _.includes(slot.plan_ids, $scope.user.subscribed_plan.id))
) {
slotValidations.push(true);
} else {
slotNotValid = slot;
if ($scope.selectedPlan && !_.includes(slot.plan_ids, $scope.selectedPlan.id)) {
slotNotValidError = 'selectedPlanError';
}
if ($scope.user.subscribed_plan && !_.includes(slot.plan_ids, $scope.user.subscribed_plan.id)) {
slotNotValidError = 'userPlanError';
}
if (!$scope.selectedPlan || !$scope.user.subscribed_plan) {
slotNotValidError = 'noPlanError';
}
slotValidations.push(false);
}
if ($scope.user.subscribed_plan && !_.includes(slot.plan_ids, $scope.user.subscribed_plan.id)) {
slotNotValidError = 'userPlanError';
}
if (!$scope.selectedPlan || !$scope.user.subscribed_plan) {
slotNotValidError = 'noPlanError';
}
slotValidations.push(false);
}
}
});
const hasPlanForSlot = slotValidations.every(function (a) { return a; });
if (!hasPlanForSlot) {
if (!AuthService.isAuthorized(['admin', 'manager'])) {
return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan'));
});
const hasPlanForSlot = slotValidations.every(function (a) {
return a;
});
if (!hasPlanForSlot) {
if (!AuthService.isAuthorized(['admin', 'manager'])) {
return growl.error(_t('app.shared.cart.slot_restrict_subscriptions_must_select_plan'));
} else {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/shared/_reserve_slot_without_plan.html',
size: 'md',
controller: 'ReserveSlotWithoutPlanController',
resolve: {
slot: function () {
return slotNotValid;
},
slotNotValidError: function () {
return slotNotValidError;
}
}
});
modalInstance.result.then(function (res) {
return paySlots();
});
}
} else {
const modalInstance = $uibModal.open({
animation: true,
templateUrl: '/shared/_reserve_slot_without_plan.html',
size: 'md',
controller: 'ReserveSlotWithoutPlanController',
resolve: {
slot: function () { return slotNotValid; },
slotNotValidError: function () { return slotNotValidError; }
}
});
modalInstance.result.then(function (res) {
return paySlots();
});
return paySlots();
}
} else {
} else if ($scope.selectedPlan) {
return paySlots();
}
} else {
@ -271,6 +300,40 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return false;
};
/**
* This will update the payment_schedule setting when the user toggles the switch button
* @param checked {Boolean}
*/
$scope.togglePaymentSchedule = (checked) => {
setTimeout(() => {
$scope.schedule.requested_schedule = checked;
updateCartPrice();
$scope.$apply();
}, 50);
};
/**
* This will open/close the stripe payment modal
*/
$scope.toggleStripeModal = (beforeApply) => {
setTimeout(() => {
$scope.stripe.showModal = !$scope.stripe.showModal;
if (typeof beforeApply === 'function') {
beforeApply();
}
$scope.$apply();
}, 50);
};
/**
* Invoked atfer a successful Stripe payment
* @param result {*} may be a reservation or a subscription
*/
$scope.afterStripeSuccess = (result) => {
$scope.toggleStripeModal();
afterPayment(result);
};
/* PRIVATE SCOPE */
/**
@ -280,24 +343,24 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// What the bound slot
$scope.$watch('slotSelectionTime', function (newValue, oldValue) {
if (newValue !== oldValue) {
return slotSelectionChanged();
slotSelectionChanged();
}
});
$scope.$watch('user', function (newValue, oldValue) {
if (newValue !== oldValue) {
resetCartState();
return updateCartPrice();
updateCartPrice();
}
});
$scope.$watch('planSelectionTime', function (newValue, oldValue) {
if (newValue !== oldValue) {
return planSelectionChanged();
planSelectionChanged();
}
});
// watch when a coupon is applied to re-compute the total price
$scope.$watch('coupon.applied', function (newValue, oldValue) {
if (newValue !== oldValue) {
return updateCartPrice();
updateCartPrice();
}
});
};
@ -492,11 +555,14 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
*/
const resetCartState = function () {
$scope.selectedPlan = null;
$scope.paidPlan = null;
$scope.coupon.applied = null;
$scope.events.moved = null;
$scope.events.paid = [];
$scope.events.modifiable = null;
$scope.events.placable = null;
$scope.schedule.requested_schedule = false;
$scope.schedule.payment_schedule = null;
};
/**
@ -528,6 +594,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
if (Auth.isAuthenticated()) {
if ($scope.selectedPlan !== $scope.plan) {
$scope.selectedPlan = $scope.plan;
$scope.schedule.requested_schedule = $scope.plan.monthly_payment;
} else {
$scope.selectedPlan = null;
}
@ -546,8 +613,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const updateCartPrice = function () {
if (Object.keys($scope.user).length > 0) {
const r = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) {
return Price.compute(mkRequestParams({ reservation: r }, $scope.coupon.applied), function (res) {
$scope.amountTotal = res.price;
$scope.schedule.payment_schedule = res.schedule;
$scope.totalNoCoupon = res.price_without_coupon;
setSlotsDetails(res.details);
});
@ -571,23 +639,22 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
/**
* Format the parameters expected by /api/prices/compute or /api/reservations and return the resulting object
* @param reservation {Object} as returned by mkReservation()
* @param coupon {Object} Coupon as returned from the API
* @return {{reservation:Object, coupon_code:string}}
* @param request {{reservation: *}|{subscription: *}} as returned by mkReservation()
* @param coupon {{code: string}} Coupon as returned from the API
* @return {CartItems}
*/
const mkRequestParams = function (reservation, coupon) {
return {
reservation,
const mkRequestParams = function (request, coupon) {
return Object.assign({
coupon_code: ((coupon ? coupon.code : undefined))
};
}, request);
};
/**
* Create an hash map implementing the Reservation specs
* Create a hash map implementing the Reservation specs
* @param member {Object} User as retrieved from the API: current user / selected user if current is admin
* @param slots {Array<Object>} Array of fullCalendar events: slots selected on the calendar
* @param [plan] {Object} Plan as retrieved from the API: plan to buy with the current reservation
* @return {{user_id:Number, reservable_id:Number, reservable_type:String, slots_attributes:Array<Object>, plan_id:Number|null}}
* @return {{reservable_type: string, payment_schedule: boolean, user_id: *, reservable_id: string, slots_attributes: [], plan_id: (*|undefined)}}
*/
const mkReservation = function (member, slots, plan) {
const reservation = {
@ -595,7 +662,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
reservable_id: $scope.reservableId,
reservable_type: $scope.reservableType,
slots_attributes: [],
plan_id: ((plan ? plan.id : undefined))
plan_id: ((plan ? plan.id : undefined)),
payment_schedule: $scope.schedule.requested_schedule
};
angular.forEach(slots, function (slot) {
reservation.slots_attributes.push({
@ -609,66 +677,53 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return reservation;
};
/**
* Create a hash map implementing the Subscription specs
* @param planId {number}
* @param userId {number}
* @param schedule {boolean}
* @param method {String} 'stripe' | ''
* @return {{subscription: {payment_schedule: boolean, user_id: number, plan_id: number}}}
*/
const mkSubscription = function (planId, userId, schedule, method) {
return {
subscription: {
plan_id: planId,
user_id: userId,
payment_schedule: schedule,
payment_method: method
}
};
};
/**
* Build the CartItems object, from the current reservation
* @param reservation {*}
* @param paymentMethod {string}
* @return {CartItems}
*/
const mkCartItems = function (reservation, paymentMethod) {
let request = { reservation };
if (reservation.slots_attributes.length === 0 && reservation.plan_id) {
request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule, paymentMethod);
} else {
request.reservation.payment_method = paymentMethod;
}
return mkRequestParams(request, $scope.coupon.applied);
};
/**
* Open a modal window that allows the user to process a credit card payment for his current shopping cart.
*/
const payByStripe = function (reservation) {
$uibModal.open({
templateUrl: '/stripe/payment_modal.html',
size: 'md',
resolve: {
reservation () {
return reservation;
},
price () {
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
cgv () {
return CustomAsset.get({ name: 'cgv-file' }).$promise;
},
coupon () {
return $scope.coupon.applied;
},
cartItems () {
return mkRequestParams(reservation, $scope.coupon.applied);
},
stripeKey: ['Setting', function (Setting) { return Setting.get({ name: 'stripe_public_key' }).$promise; }]
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'cgv', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems', 'stripeKey',
function ($scope, $uibModalInstance, $state, reservation, price, cgv, Auth, Reservation, wallet, helpers, $filter, coupon, cartItems, stripeKey) {
// user wallet amount
$scope.walletAmount = wallet.amount;
// Price
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Cart items
$scope.cartItems = cartItems;
// CGV
$scope.cgv = cgv.custom_asset;
// Reservation
$scope.reservation = reservation;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// stripe publishable key
$scope.stripeKey = stripeKey.setting.value;
/**
* Callback to handle the post-payment and reservation
*/
$scope.onPaymentSuccess = function (response) {
$uibModalInstance.close(response);
};
}
]
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
// check that the online payment is enabled
if ($scope.settings.online_payment_module !== 'true') {
growl.error(_t('app.shared.cart.online_payment_disabled'));
} else {
$scope.toggleStripeModal(() => {
$scope.stripe.cartItems = mkCartItems(reservation, 'stripe');
});
}
};
/**
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
@ -676,25 +731,40 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const payOnSite = function (reservation) {
$uibModal.open({
templateUrl: '/shared/valid_reservation_modal.html',
size: 'sm',
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
resolve: {
reservation () {
return reservation;
},
price () {
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
},
cartItems () {
return mkCartItems(reservation, 'stripe');
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
},
coupon () {
return $scope.coupon.applied;
},
selectedPlan () {
return $scope.selectedPlan;
},
schedule () {
return $scope.schedule;
},
user () {
return $scope.user;
},
settings () {
return $scope.settings;
}
},
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'wallet', 'helpers', '$filter', 'coupon',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, wallet, helpers, $filter, coupon) {
// user wallet amount
$scope.walletAmount = wallet.amount;
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'Reservation', 'Subscription', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, Reservation, Subscription, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user, settings) {
// user wallet amount
$scope.wallet = wallet;
// Global price (total of all items)
$scope.price = price.price;
@ -702,57 +772,162 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// Price to pay (wallet deducted)
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation
// Reservation (simple & cartItems format)
$scope.reservation = reservation;
$scope.cartItems = cartItems;
// Subscription
$scope.plan = selectedPlan;
// Used in wallet info template to interpolate some translations
$scope.numberFilter = $filter('number');
// Button label
if ($scope.amount > 0) {
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')($scope.amount) });
} else {
if ((price.price > 0) && ($scope.walletAmount === 0)) {
$scope.validButtonName = _t('app.shared.cart.confirm_payment_of_html', { ROLE: $rootScope.currentUser.role, AMOUNT: $filter('currency')(price.price) });
} else {
$scope.validButtonName = _t('app.shared.buttons.confirm');
}
}
// Shows the schedule info in the modal
$scope.schedule = schedule.payment_schedule;
// how should we collect payments for the payment schedule
$scope.method = {
payment_method: 'stripe'
};
// "valid" Button label
$scope.validButtonName = '';
// stripe modal state
// this is used to collect card data when a payment-schedule was selected, and paid with a card
$scope.isOpenStripeModal = false;
// the customer
$scope.user = user;
/**
* Callback to process the local payment, triggered on button click
*/
$scope.ok = function () {
$scope.attempting = true;
return Reservation.save(mkRequestParams($scope.reservation, coupon), function (reservation) {
$uibModalInstance.close(reservation);
return $scope.attempting = true;
if ($scope.schedule && $scope.method.payment_method === 'stripe') {
// check that the online payment is enabled
if (settings.online_payment_module !== 'true') {
return growl.error(_t('app.shared.cart.online_payment_disabled'));
} else {
return $scope.toggleStripeModal();
}
}
, function (response) {
$scope.attempting = true;
// save subscription (if there's only a subscription selected)
if ($scope.reservation.slots_attributes.length === 0 && selectedPlan) {
const sub = mkSubscription(selectedPlan.id, $scope.reservation.user_id, schedule.requested_schedule, $scope.method.payment_method);
return Subscription.save(mkRequestParams(sub, coupon),
function (subscription) {
$uibModalInstance.close(subscription);
$scope.attempting = true;
}, function (response) {
$scope.alerts = [];
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
$scope.attempting = false;
});
}
// otherwise, save the reservation (may include a subscription)
const rsrv = Object.assign({}, $scope.reservation, { payment_method: $scope.method.payment_method });
Reservation.save(mkRequestParams({ reservation: rsrv }, coupon), function (reservation) {
$uibModalInstance.close(reservation);
$scope.attempting = true;
}, function (response) {
$scope.alerts = [];
$scope.alerts.push({ msg: _t('app.shared.cart.a_problem_occurred_during_the_payment_process_please_try_again_later'), type: 'danger' });
return $scope.attempting = false;
$scope.attempting = false;
});
};
/**
* Callback to close the modal without processing the payment
*/
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
/**
* Asynchronously updates the status of the stripe modal
*/
$scope.toggleStripeModal = function () {
setTimeout(() => {
$scope.isOpenStripeModal = !$scope.isOpenStripeModal;
$scope.$apply();
}, 50);
};
/**
* After creating a payment schedule by card, from an administrator.
* @param result {*} Reservation or Subscription
*/
$scope.afterCreatePaymentSchedule = function (result) {
$scope.toggleStripeModal();
$uibModalInstance.close(result);
};
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the directive is loaded
*/
const initialize = function () {
$scope.$watch('method.payment_method', function (newValue) {
$scope.validButtonName = computeValidButtonName();
$scope.cartItems = mkCartItems($scope.reservation, newValue);
});
};
/**
* Compute the Label of the confirmation button
*/
const computeValidButtonName = function () {
let method = '';
if ($scope.schedule) {
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== reservation.user_id) {
method = $scope.method.payment_method;
} else {
method = 'stripe';
}
}
if ($scope.amount > 0) {
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')($scope.amount) });
} else {
if ((price.price > 0) && ($scope.wallet.amount === 0)) {
return _t('app.shared.cart.confirm_payment_of_html', { METHOD: method, AMOUNT: $filter('currency')(price.price) });
} else {
return _t('app.shared.buttons.confirm');
}
}
};
// # !!! MUST BE CALLED AT THE END of the controller
initialize();
}
]
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
};
/**
* Actions to run after the payment was successful
* @param paymentResult {*} may be a reservation or a subscription
*/
const afterPayment = function (reservation) {
const afterPayment = function (paymentResult) {
// we set the cart content as 'paid' to display a summary of the transaction
$scope.events.paid = $scope.events.reserved;
$scope.amountPaid = $scope.amountTotal;
// we call the external callback if present
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(reservation); }
// we reset the coupon and the cart content and we unselect the slot
$scope.events.reserved = [];
$scope.coupon.applied = null;
$scope.slot = null;
return $scope.selectedPlan = null;
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(paymentResult); }
// we reset the coupon, and the cart content, and we unselect the slot
$scope.coupon.applied = undefined;
if ($scope.slot) {
// reservation (+ subscription)
$scope.slot = undefined;
$scope.events.reserved = [];
} else {
// subscription only
$scope.events = {};
}
$scope.paidPlan = $scope.selectedPlan;
$scope.selectedPlan = undefined;
$scope.schedule.requested_schedule = false;
$scope.schedule.payment_schedule = undefined;
};
/**
@ -763,23 +938,29 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
if ((AuthService.isAuthorized(['member']) && amountToPay > 0) ||
if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) ||
(AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
if ($scope.settings.online_payment_module !== 'true') {
growl.error(_t('app.shared.cart.online_payment_disabled'));
} else {
return payByStripe(reservation);
}
return payByStripe(reservation);
} else {
if (AuthService.isAuthorized(['admin']) ||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
amountToPay === 0) {
(amountToPay === 0 && !hasOtherDeadlines())) {
return payOnSite(reservation);
}
}
});
};
/**
* Check if the later deadlines of the payment schedule exists and are not equal to zero
* @return {boolean}
*/
const hasOtherDeadlines = function () {
if (!$scope.schedule.payment_schedule) return false;
if ($scope.schedule.payment_schedule.items.length < 2) return false;
return $scope.schedule.payment_schedule.items[1].amount !== 0;
};
// !!! MUST BE CALLED AT THE END of the directive
return initialize();
}

View File

@ -47,15 +47,18 @@ Application.Directives.directive('coupon', [ '$rootScope', 'Coupon', '_t', funct
$scope.messages = [];
if ($scope.couponCode === '') {
$scope.status = 'pending';
return $scope.coupon = null;
$scope.coupon = null;
} else {
return Coupon.validate({ code: $scope.couponCode, user_id: $scope.userId, amount: $scope.total }, function (res) {
Coupon.validate({ code: $scope.couponCode, user_id: $scope.userId, amount: $scope.total }, function (res) {
$scope.status = 'valid';
$scope.coupon = res;
if (res.type === 'percent_off') {
return $scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
$scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_PERCENT_discount', { PERCENT: res.percent_off }) });
} else {
return $scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: $rootScope.currencySymbol }) });
$scope.messages.push({ type: 'success', message: _t('app.shared.coupon_input.the_coupon_has_been_applied_you_get_AMOUNT_CURRENCY', { AMOUNT: res.amount_off, CURRENCY: $rootScope.currencySymbol }) });
}
if (res.validity_per_user === 'once') {
$scope.messages.push({ type: 'warning', message: _t('app.shared.coupon_input.coupon_validity_once') });
}
}
, function (err) {

View File

@ -23,10 +23,8 @@ Application.Directives.directive('booleanSetting', ['Setting', 'growl', '_t',
/**
* This will update the value when the user toggles the switch button
* @param checked {Boolean}
* @param event {string}
* @param id {string}
*/
$scope.toggleSetting = (checked, event, id) => {
$scope.toggleSetting = (checked) => {
setTimeout(() => {
$scope.setting.value = checked;
$scope.$apply();

View File

@ -15,7 +15,7 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
onPaymentSuccess: '=',
stripeKey: '@'
},
link: function($scope, element, attributes) {
link: function ($scope, element, attributes) {
const stripe = Stripe($scope.stripeKey);
const elements = stripe.elements();
@ -51,11 +51,11 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
const cardElement = form.find('#card-element');
card.mount(cardElement[0]);
form.bind('submit', function() {
form.bind('submit', function () {
const button = form.find('button');
button.prop('disabled', true);
stripe.createPaymentMethod('card', card).then(function({ paymentMethod, error }) {
stripe.createPaymentMethod('card', card).then(function ({ paymentMethod, error }) {
if (error) {
growl.error(error.message);
button.prop('disabled', false);
@ -64,12 +64,12 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
Payment.confirm({ payment_method_id: paymentMethod.id, cart_items: $scope.cartItems }, function (response) {
// Handle server response (see Step 3)
handleServerResponse(response, button);
}, function(error) { handleServerResponse({ error }, button) });
}, function (error) { handleServerResponse({ error }, button); });
}
});
});
function handleServerResponse(response, confirmButton) {
function handleServerResponse (response, confirmButton) {
if (response.error) {
if (response.error.statusText) {
growl.error(response.error.statusText);
@ -81,16 +81,16 @@ Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
// Use Stripe.js to handle required card action
stripe.handleCardAction(
response.payment_intent_client_secret
).then(function(result) {
).then(function (result) {
if (result.error) {
growl.error(result.error.message);
confirmButton.prop('disabled', false);
} else {
// The card action has been handled
// The PaymentIntent can be confirmed again on the server
Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function(confirmResult) {
Payment.confirm({ payment_intent_id: result.paymentIntent.id, cart_items: $scope.cartItems }, function (confirmResult) {
handleServerResponse(confirmResult, confirmButton);
}, function(error) { handleServerResponse({ error }, confirmButton) });
}, function (error) { handleServerResponse({ error }, confirmButton); });
}
});
} else {

View File

@ -45,7 +45,7 @@ Application.Filters.filter('machineFilter', [function () {
};
}]);
Application.Filters.filter('projectMemberFilter', [ 'Auth', function (Auth) {
Application.Filters.filter('projectMemberFilter', ['Auth', function (Auth) {
return function (projects, selectedMember) {
if (!angular.isUndefined(projects) && angular.isDefined(selectedMember) && (projects != null) && (selectedMember != null) && (selectedMember !== '')) {
const filteredProject = [];
@ -165,7 +165,7 @@ Application.Filters.filter('simpleText', [function () {
};
}]);
Application.Filters.filter('toTrusted', [ '$sce', function ($sce) {
Application.Filters.filter('toTrusted', ['$sce', function ($sce) {
return text => $sce.trustAsHtml(text);
}]);
@ -178,7 +178,7 @@ Application.Filters.filter('humanReadablePlanName', ['$filter', function ($filte
if (plan != null) {
let result = plan.base_name;
if (groups != null) {
for (let group of Array.from(groups)) {
for (const group of Array.from(groups)) {
if (group.id === plan.group_id) {
if (short != null) {
result += ` - ${group.slug}`;
@ -318,7 +318,7 @@ Application.Filters.filter('toIsoDate', [function () {
};
}]);
Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
Application.Filters.filter('booleanFormat', ['_t', function (_t) {
return function (boolean) {
if (((typeof boolean === 'boolean') && boolean) || ((typeof boolean === 'string') && (boolean === 'true'))) {
return _t('app.shared.buttons.yes');
@ -328,7 +328,7 @@ Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
};
}]);
Application.Filters.filter('maxCount', [ '_t', function (_t) {
Application.Filters.filter('maxCount', ['_t', function (_t) {
return function (max) {
if ((typeof max === 'undefined') || (max === null) || ((typeof max === 'number') && (max === 0))) {
return _t('app.admin.pricing.unlimited');

View File

@ -0,0 +1,25 @@
import i18n from 'i18next';
import ICU from 'i18next-icu';
import HttpApi from 'i18next-http-backend';
import { initReactI18next } from 'react-i18next';
declare var Fablab: any;
i18n
.use(ICU)
.use(HttpApi)
.use(initReactI18next)
.init({
lng: Fablab.locale,
fallbackLng: 'en',
ns: ['admin', 'logged', 'public', 'shared'],
defaultNS: 'shared',
backend: {
loadPath: '/api/translations/{{lng}}/app.{{ns}}'
},
interpolation: {
escapeValue: false
}
});
export default i18n;

View File

@ -0,0 +1,20 @@
import { Wallet } from '../models/wallet';
export default class WalletLib {
private wallet: Wallet;
constructor (wallet: Wallet) {
this.wallet = wallet;
}
/**
* Return the price remaining to pay, after we have used the maximum possible amount in the wallet
*/
computeRemainingPrice = (price: number): number => {
if (this.wallet.amount > price) {
return 0;
} else {
return price - this.wallet.amount;
}
}
}

View File

@ -0,0 +1,37 @@
/**
* This function wraps a Promise to make it compatible with react Suspense
*/
export interface IWrapPromise<T> {
read: () => T
}
function wrapPromise(promise: Promise<any>): IWrapPromise<any> {
let status: string = 'pending';
let response: any;
const suspender: Promise<any> = promise.then(
(res) => {
status = 'success'
response = res
},
(err) => {
status = 'error'
response = err
},
);
const read = (): any => {
switch (status) {
case 'pending':
throw suspender
case 'error':
throw response
default:
return response
}
};
return { read };
}
export default wrapPromise;

View File

@ -7,4 +7,3 @@ export interface IApplication {
Filters: IModule,
Directives: IModule
}

View File

@ -0,0 +1,18 @@
export enum CustomAssetName {
LogoFile = 'logo-file',
LogoBlackFile = 'logo-black-file',
CguFile = 'cgu-file',
CgvFile = 'cgv-file',
ProfileImageFile = 'profile-image-file',
FaviconFile = 'favicon-file'
}
export interface CustomAsset {
id: number,
name: CustomAssetName,
custom_asset_file_attributes: {
id: number,
attachment: string
attachment_url: string
}
}

View File

@ -0,0 +1,29 @@
export interface IFablab {
plansModule: boolean,
spacesModule: boolean,
walletModule: boolean,
statisticsModule: boolean,
defaultHost: string,
trackingId: string,
superadminId: number,
baseHostUrl: string,
locale: string,
moment_locale: string,
summernote_locale: string,
fullcalendar_locale: string,
intl_locale: string,
intl_currency: string,
timezone: string,
weekStartingDay: string,
d3DateFormat: string,
uibDateFormat: string,
sessionTours: Array<string>,
translations: {
app: {
shared: {
buttons: Object,
messages: Object,
}
}
}
}

View File

@ -0,0 +1,9 @@
export interface HistoryValue {
id: number,
value: string,
created_at: Date
user: {
id: number,
name: string
}
}

View File

@ -0,0 +1,83 @@
import { StripeIbanElement } from '@stripe/stripe-js';
export enum PaymentScheduleItemState {
New = 'new',
Pending = 'pending',
RequirePaymentMethod = 'requires_payment_method',
RequireAction = 'requires_action',
Paid = 'paid',
Error = 'error'
}
export enum PaymentMethod {
Stripe = 'stripe',
Check = 'check'
}
export interface PaymentScheduleItem {
id: number,
amount: number,
due_date: Date,
state: PaymentScheduleItemState,
invoice_id: number,
payment_method: PaymentMethod,
client_secret?: string,
details: {
recurring: number,
adjustment?: number,
other_items?: number,
without_coupon?: number,
subscription_id: number
}
}
export interface PaymentSchedule {
max_length: number;
id: number,
scheduled_type: string,
scheduled_id: number,
total: number,
stp_subscription_id: string,
reference: string,
payment_method: string,
wallet_amount: number,
items: Array<PaymentScheduleItem>,
created_at: Date,
chained_footprint: boolean,
user: {
id: number,
name: string
},
operator: {
id: number,
first_name: string,
last_name: string,
}
}
export interface PaymentScheduleIndexRequest {
query: {
reference?: string,
customer?: string,
date?: Date,
page: number,
size: number
}
}
export interface CashCheckResponse {
state: PaymentScheduleItemState,
payment_method: PaymentMethod
}
export interface RefreshItemResponse {
state: 'refreshed'
}
export interface PayItemResponse {
status: 'draft' | 'open' | 'paid' | 'uncollectible' | 'void',
error?: string
}
export interface CancelScheduleResponse {
canceled_at: Date
}

View File

@ -0,0 +1,31 @@
import { Reservation } from './reservation';
import { SubscriptionRequest } from './subscription';
export interface PaymentConfirmation {
requires_action?: boolean,
payment_intent_client_secret?: string,
success?: boolean,
error?: {
statusText: string
}
}
export interface IntentConfirmation {
client_secret: string
}
export enum PaymentMethod {
Stripe = 'stripe',
Other = ''
}
export interface CartItems {
reservation?: Reservation,
subscription?: SubscriptionRequest,
coupon_code?: string
}
export interface UpdateCardResponse {
updated: boolean,
error?: string
}

View File

@ -0,0 +1,42 @@
import { Price } from './price';
export enum Interval {
Year = 'year',
Month = 'month',
Week = 'week'
}
export enum PlanType {
Plan = 'Plan',
PartnerPlan = 'PartnerPlan'
}
export interface Partner {
first_name: string,
last_name: string,
email: string
}
export interface Plan {
id: number,
base_name: string,
name: string,
interval: Interval,
interval_count: number,
group_id: number,
training_credit_nb: number,
is_rolling: boolean,
description: string,
type: PlanType,
ui_weight: number,
disabled: boolean,
monthly_payment: boolean
amount: number
prices: Array<Price>,
plan_file_attributes: {
id: number,
attachment_identifier: string
},
plan_file_url: string,
partners: Array<Partner>
}

View File

@ -0,0 +1,27 @@
export interface Price {
id: number,
group_id: number,
plan_id: number,
priceable_type: string,
priceable_id: number,
amount: number
}
export interface ComputePriceResult {
price: number,
price_without_coupon: number,
details?: {
slots: Array<{
start_at: Date,
price: number,
promo: boolean
}>
plan?: number
},
schedule?: {
items: Array<{
amount: number,
due_date: Date
}>
}
}

View File

@ -0,0 +1,21 @@
export interface ReservationSlot {
id?: number,
start_at: Date,
end_at: Date,
availability_id: number,
offered: boolean
}
export interface Reservation {
user_id: number,
reservable_id: number,
reservable_type: string,
slots_attributes: Array<ReservationSlot>,
plan_id?: number,
nb_reserve_places?: number,
payment_schedule?: boolean,
tickets_attributes?: {
event_price_category_id: number,
booked: boolean,
},
}

View File

@ -0,0 +1,109 @@
import { HistoryValue } from './history-value';
export enum SettingName {
AboutTitle = 'about_title',
AboutBody = 'about_body',
AboutContacts = 'about_contacts',
PrivacyDraft = 'privacy_draft',
PrivacyBody = 'privacy_body',
PrivacyDpo = 'privacy_dpo',
TwitterName = 'twitter_name',
HomeBlogpost = 'home_blogpost',
MachineExplicationsAlert = 'machine_explications_alert',
TrainingExplicationsAlert = 'training_explications_alert',
TrainingInformationMessage = 'training_information_message',
SubscriptionExplicationsAlert = 'subscription_explications_alert',
InvoiceLogo = 'invoice_logo',
InvoiceReference = 'invoice_reference',
InvoiceCodeActive = 'invoice_code-active',
InvoiceCodeValue = 'invoice_code-value',
InvoiceOrderNb = 'invoice_order-nb',
InvoiceVATActive = 'invoice_VAT-active',
InvoiceVATRate = 'invoice_VAT-rate',
InvoiceText = 'invoice_text',
InvoiceLegals = 'invoice_legals',
BookingWindowStart = 'booking_window_start',
BookingWindowEnd = 'booking_window_end',
BookingSlotDuration = 'booking_slot_duration',
BookingMoveEnable = 'booking_move_enable',
BookingMoveDelay = 'booking_move_delay',
BookingCancelEnable = 'booking_cancel_enable',
BookingCancelDelay = 'booking_cancel_delay',
MainColor = 'main_color',
SecondaryColor = 'secondary_color',
FablabName = 'fablab_name',
NameGenre = 'name_genre',
ReminderEnable = 'reminder_enable',
ReminderDelay = 'reminder_delay',
EventExplicationsAlert = 'event_explications_alert',
SpaceExplicationsAlert = 'space_explications_alert',
VisibilityYearly = 'visibility_yearly',
VisibilityOthers = 'visibility_others',
DisplayNameEnable = 'display_name_enable',
MachinesSortBy = 'machines_sort_by',
AccountingJournalCode = 'accounting_journal_code',
AccountingCardClientCode = 'accounting_card_client_code',
AccountingCardClientLabel = 'accounting_card_client_label',
AccountingWalletClientCode = 'accounting_wallet_client_code',
AccountingWalletClientLabel = 'accounting_wallet_client_label',
AccountingOtherClientCode = 'accounting_other_client_code',
AccountingOtherClientLabel = 'accounting_other_client_label',
AccountingWalletCode = 'accounting_wallet_code',
AccountingWalletLabel = 'accounting_wallet_label',
AccountingVATCode = 'accounting_VAT_code',
AccountingVATLabel = 'accounting_VAT_label',
AccountingSubscriptionCode = 'accounting_subscription_code',
AccountingSubscriptionLabel = 'accounting_subscription_label',
AccountingMachineCode = 'accounting_Machine_code',
AccountingMachineLabel = 'accounting_Machine_label',
AccountingTrainingCode = 'accounting_Training_code',
AccountingTrainingLabel = 'accounting_Training_label',
AccountingEventCode = 'accounting_Event_code',
AccountingEventLabel = 'accounting_Event_label',
AccountingSpaceCode = 'accounting_Space_code',
AccountingSpaceLabel = 'accounting_Space_label',
HubLastVersion = 'hub_last_version',
HubPublicKey = 'hub_public_key',
FabAnalytics = 'fab_analytics',
LinkName = 'link_name',
HomeContent = 'home_content',
HomeCss = 'home_css',
Origin = 'origin',
Uuid = 'uuid',
PhoneRequired = 'phone_required',
TrackingId = 'tracking_id',
BookOverlappingSlots = 'book_overlapping_slots',
SlotDuration = 'slot_duration',
EventsInCalendar = 'events_in_calendar',
SpacesModule = 'spaces_module',
PlansModule = 'plans_module',
InvoicingModule = 'invoicing_module',
FacebookAppId = 'facebook_app_id',
TwitterAnalytics = 'twitter_analytics',
RecaptchaSiteKey = 'recaptcha_site_key',
RecaptchaSecretKey = 'recaptcha_secret_key',
FeatureTourDisplay = 'feature_tour_display',
EmailFrom = 'email_from',
DisqusShortname = 'disqus_shortname',
AllowedCadExtensions = 'allowed_cad_extensions',
AllowedCadMimeTypes = 'allowed_cad_mime_types',
OpenlabAppId = 'openlab_app_id',
OpenlabAppSecret = 'openlab_app_secret',
OpenlabDefault = 'openlab_default',
OnlinePaymentModule = 'online_payment_module',
StripePublicKey = 'stripe_public_key',
StripeSecretKey = 'stripe_secret_key',
StripeCurrency = 'stripe_currency',
InvoicePrefix = 'invoice_prefix',
ConfirmationRequired = 'confirmation_required',
WalletModule = 'wallet_module',
StatisticsModule = 'statistics_module',
UpcomingEventsShown = 'upcoming_events_shown'
}
export interface Setting {
name: SettingName,
value: string,
last_update: Date,
history: Array<HistoryValue>
}

View File

@ -0,0 +1,18 @@
import { Plan } from './plan';
import { PaymentMethod } from './payment';
export interface Subscription {
id: number,
plan_id: number,
expired_at: Date,
canceled_at?: Date,
stripe: boolean,
plan: Plan
}
export interface SubscriptionRequest {
plan_id: number,
user_id: number,
payment_schedule: boolean,
payment_method: PaymentMethod
}

View File

@ -0,0 +1,86 @@
import { Plan } from './plan';
export enum UserRole {
Member = 'member',
Manager = 'manager',
Admin = 'admin'
}
export interface User {
id: number,
username: string,
email: string,
group_id: number,
role: UserRole
name: string,
need_completion: boolean,
ip_address: string,
profile: {
id: number,
first_name: string,
last_name: string,
interest: string,
software_mastered: string,
phone: string,
website: string,
job: string,
tours: Array<string>,
facebook: string,
twitter: string,
google_plus: string,
viadeo: string,
linkedin: string,
instagram: string,
youtube: string,
vimeo: string,
dailymotion: string,
github: string,
echosciences: string,
pinterest: string,
lastfm: string,
flickr: string,
user_avatar: {
id: number,
attachment_url: string
}
},
invoicing_profile: {
id: number,
address: {
id: number,
address: string
},
organization: {
id: number,
name: string,
address: {
id: number,
address: string
}
}
},
statistic_profile: {
id: number,
gender: string,
birthday: Date
},
subscribed_plan: Plan,
subscription: {
id: number,
expired_at: Date,
canceled_at: Date,
stripe: boolean,
plan: {
id: number,
base_name: string,
name: string,
interval: string,
interval_count: number,
amount: number
}
},
training_credits: Array<number>,
machine_credits: Array<{machine_id: number, hours_used: number}>,
last_sign_in_at: Date
}

View File

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

View File

@ -205,6 +205,15 @@ angular.module('application.router', ['ui.router'])
}
}
})
.state('app.logged.dashboard.payment_schedules', {
url: '/payment_schedules',
views: {
'main@': {
templateUrl: '/dashboard/payment_schedules.html',
controller: 'DashboardController'
}
}
})
.state('app.logged.dashboard.wallet', {
url: '/wallet',
abstract: !Fablab.walletModule,

View File

@ -28,7 +28,7 @@ Application.Services.factory('Help', ['$rootScope', '$uibModal', '$state', 'Auth
// if no tour, just open the guide
if (tourName === undefined) {
return window.open('https://github.com/sleede/fab-manager/raw/master/doc/fr/guide_utilisation_fab_manager_v4.5.pdf', '_blank');
return window.open('https://github.com/sleede/fab-manager/raw/master/doc/fr/guide_utilisation_fab_manager_v4.7.pdf', '_blank');
}
$uibModal.open({

View File

@ -0,0 +1,4 @@
declare module "*.png" {
const value: any;
export default value;
}

View File

@ -291,33 +291,50 @@
padding: 15px 0;
background-color: $bg-gray;
.wrap {
width: 100px;
height: 100px;
.wrap, .wrap-monthly {
width: 130px;
height: 130px;
display: inline-block;
background: white;
@include border-radius(50%, 50%, 50%, 50%);
border: 3px solid;
.price {
width: 114px;
display: flex;
flex-direction: column;
justify-content: center;
@include border-radius(50%, 50%, 50%, 50%);
}
}
.wrap-monthly {
& > .price {
& > .amount {
padding-top: 4px;
line-height: 1.2em;
}
& > .period {
padding-top: 4px;
}
}
}
.price {
position: relative;
top: 5px;
left: 5px;
height: 84px;
width: 84px;
height: 114px;
background-color: black;
@include border-radius(50%, 50%, 50%, 50%);
.amount {
padding-top: 16px;
padding-left: 4px;
padding-right: 4px;
font-weight: bold;
font-size: rem-calc(18);
font-size: rem-calc(17);
color: white;
}
@ -333,7 +350,10 @@
.cta-button {
margin: 20px 0;
.btn {
.subscribe-button {
@extend .btn;
@extend .rounded;
outline: 0;
font-weight: 600;
font-size: rem-calc(16);
@ -341,6 +361,12 @@
padding-left: 30px;
padding-right: 30px;
}
button.subscribe-button:focus, button.subscribe-button:hover {
outline: 0;
}
}
.info-link {
margin-top: 1em;
}
}
@ -766,3 +792,11 @@
input[type=date].form-control {
line-height: 25px;
}
.select-schedule {
margin-top: 0.5em;
.schedule-switch {
vertical-align: middle;
margin-left: 1em;
}
}

View File

@ -21,5 +21,16 @@
@import "modules/signup";
@import "modules/stripe";
@import "modules/tour";
@import "modules/fab-modal";
@import "modules/fab-button";
@import "modules/payment-schedule-summary";
@import "modules/wallet-info";
@import "modules/stripe-modal";
@import "modules/labelled-input";
@import "modules/document-filters";
@import "modules/payment-schedules-table";
@import "modules/payment-schedules-list";
@import "modules/stripe-confirm";
@import "modules/payment-schedule-dashboard";
@import "app.responsive";

View File

@ -0,0 +1,8 @@
.document-filters {
display: flex;
justify-content: space-between;
& > * {
width: 31%;
}
}

View File

@ -0,0 +1,48 @@
.fab-button {
color: black;
background-color: #fbfbfb;
display: inline-block;
margin-bottom: 0;
font-weight: normal;
text-align: center;
white-space: nowrap;
vertical-align: middle;
touch-action: manipulation;
cursor: pointer;
background-image: none;
border: 1px solid #c9c9c9;
padding: 6px 12px;
font-size: 16px;
line-height: 1.5;
border-radius: 4px;
user-select: none;
text-decoration: none;
&:hover {
background-color: #f2f2f2;
color: black;
border-color: #aaaaaa;
text-decoration: none;
}
&:active {
color: black;
background-color: #f2f2f2;
border-color: #aaaaaa;
outline: 0;
box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125);
}
&[disabled] {
color: #3a3a3a;
}
&[disabled]:hover {
color: #3a3a3a;
}
&--icon {
margin-right: 0.5em;
}
}

View File

@ -0,0 +1,87 @@
@keyframes slideInFromTop {
0% { transform: translate(0, -25%); }
100% { transform: translate(0, 0); }
}
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 0.9; }
}
.fab-modal-overlay {
z-index: 1050;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
animation: 0.15s linear fadeIn;
overflow-x: hidden;
overflow-y: auto;
}
.fab-modal-sm { width: 340px; }
.fab-modal-md { width: 440px; }
.fab-modal-lg { width: 600px; }
.fab-modal {
animation: 0.3s ease-out slideInFromTop;
position: relative;
top: 90px;
margin: auto;
opacity: 1;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
outline: 0;
.fab-modal-header {
padding: 8px;
border-bottom: 1px solid #e5e5e5;
.modal-logo {
position: absolute;
top: -70px;
left: 0;
right: 0;
margin: 0 auto;
max-height: 44px;
}
h1 {
margin: 25px 0 20px 0;
font-weight: bold;
text-transform: uppercase;
text-align: center;
}
}
.fab-modal-content {
position: relative;
padding: 15px;
}
.fab-modal-footer {
position: relative;
padding: 15px;
text-align: right;
border-top: 1px solid #e5e5e5;
.modal-btn {
&--close {
color: black;
background-color: #fbfbfb;
border: 1px solid #c9c9c9;
&:hover {
background-color: #f2f2f2;
}
}
&--confirm {
margin-left: 0.5em;
}
}
}
}

View File

@ -0,0 +1,48 @@
.input-with-label {
position: relative;
display: inline-table;
border-collapse: separate;
box-sizing: border-box;
label.label {
padding: 6px 12px;
font-size: 16px;
font-weight: 400;
line-height: 1;
color: #555555;
text-align: center;
background-color: #eeeeee;
border: 1px solid #c4c4c4;
border-radius: 4px 0 0 4px;
width: 1%;
white-space: nowrap;
vertical-align: middle;
display: table-cell;
box-sizing: border-box;
border-right: 0;
}
input.input {
padding: 6px 12px;
height: 38px;
font-size: 16px;
line-height: 1.5;
border: 1px solid #c4c4c4;
border-radius: 0 4px 4px 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
display: table-cell;
position: relative;
z-index: 2;
float: left;
width: 100%;
margin: 0;
box-sizing: border-box;
&:focus {
border-color: #fdde3f;
outline: 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 8px rgba(253, 222, 63, .6);
}
}
}

View File

@ -0,0 +1,12 @@
.payment-schedules-dashboard {
margin: 30px 15px 15px;
.schedules-list {
text-align: center;
.load-more {
margin-top: 2em;
}
}
}

View File

@ -0,0 +1,56 @@
.payment-schedule-summary {
h4 {
margin-left: 2em;
}
ul {
list-style: none;
padding-left: 2em;
li {
position: relative;
margin-bottom: 0.75em;
.schedule-item-info {
display: inline;
border-bottom: 1px solid #ddd;
}
.schedule-item-price {
display: inline;
border-bottom: 1px solid #ddd;
}
.schedule-item-date {
display: block;
font-size: 0.8em;
}
}
}
.view-full-schedule {
margin-left: 1em;
font-size: 0.8em;
border: 0;
background: transparent;
margin-bottom: 2em;
&:before {
content: '\f06e';
font-family: 'Font Awesome 5 Free';
font-weight: 900;
margin-right: 1em;
}
}
}
.full-schedule {
list-style: none;
li {
border-bottom: 1px solid #ddd;
margin-right: 3em;
}
.schedule-item-price {
color: #5a5a5a;
float: right;
}
}

View File

@ -0,0 +1,11 @@
.schedules-filters {
margin-bottom: 2em;
}
.schedules-list {
text-align: center;
.load-more {
margin-top: 2em;
}
}

View File

@ -0,0 +1,181 @@
.schedules-table {
table-layout: fixed;
border: 1px solid #e9e9e9;
border-top: 0;
margin-bottom: 0;
width: 100%;
max-width: 100%;
background-color: transparent;
border-collapse: collapse;
border-spacing: 0;
& > thead {
border-top: 1px solid #e9e9e9;
& > tr > th {
font-weight: 600;
vertical-align: middle;
text-align: center;
padding: 2rem 1rem;
line-height: 1.5;
border: 1px solid #f0f0f0;
border-top: 0;
}
}
.w-35 { width: 35px; }
.w-120 { width: 120px; }
.w-200 { width: 200px; }
.schedules-table-body {
table-layout: fixed;
background-color: #fff;
border: 1px solid #e9e9e9;
border-top: 0;
margin-bottom: 0;
width: 100%;
max-width: 100%;
border-collapse: collapse;
border-spacing: 0;
& > tbody {
background: #f7f7f9;
border-collapse: collapse;
border-spacing: 0;
line-height: 1.5;
& > tr > td {
padding: 12px 10px;
border: 1px solid #f0f0f0;
border-top: 0;
vertical-align: middle;
font-size: 1.4rem;
line-height: 1.5;
&.row-header {
text-align: center;
cursor: pointer;
}
}
.schedule-items-table {
table-layout: fixed;
background-color: #fff;
width: 100%;
max-width: 100%;
margin-bottom: 1rem;
border-collapse: collapse;
border-spacing: 0;
border: 1px solid #e9e9e9;
border-top: 0;
& > thead {
border-top: 1px solid #e9e9e9;
font-size: 1.4rem;
line-height: 1.5;
& > tr > th {
border: 1px solid #f0f0f0;
border-top: 0;
border-bottom: 1px solid #e9e9e9;
font-weight: 600;
vertical-align: middle;
text-align: center;
font-size: 1.1rem;
padding: 2rem 1rem;
text-transform: uppercase;
}
}
& > tbody > tr > td {
vertical-align: middle;
border: 1px solid #f0f0f0;
border-top: 0;
padding: 12px 10px;
font-size: 1.4rem;
line-height: 1.5;
}
}
}
}
.download-button {
@extend .fab-button;
& > i {
margin-right: 0.5em;
}
}
// The color classes above are automatically generated from PaymentScheduleItem.state
.state-new {
color: #3a3a3a;
}
.state-pending,
.state-requires_payment_method,
.state-requires_action {
color: #d43333;
}
.state-paid,
.state-error {
color: black;
}
}
.fab-modal.update-card-modal {
.fab-modal-content {
.card-form {
background-color: #f4f3f3;
border: 1px solid #ddd;
border-radius: 6px 6px 0 0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 15px;
.stripe-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
}
}
.submit-card {
.submit-card-btn {
width: 100%;
border: 1px solid #ddd;
border-radius: 0 0 6px 6px;
border-top: 0;
padding: 16px;
color: #fff;
background-color: #1d98ec;
margin-bottom: 15px;
&[disabled] {
background-color: lighten(#1d98ec, 20%);
}
}
.payment-pending {
@extend .submit-card-btn;
@extend .submit-card-btn[disabled];
text-align: center;
padding: 4px;
}
}
}
.fab-modal-footer {
.stripe-modal-icons {
& {
text-align: center;
}
.fa.fa-lock {
top: 7px;
color: #9edd78;
}
img {
margin-right: 10px;
}
}
}
}

View File

@ -0,0 +1,41 @@
@keyframes spin { 100% { transform:rotate(360deg); } }
.stripe-confirm {
.message {
&--success:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f00c";
color: #3c763d;
margin-right: 0.5em;
}
&--error:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f00d";
color: #840b0f;
margin-right: 0.5em;
}
&--info:before {
font-family: 'Font Awesome 5 Free';
font-weight: 900;
content: "\f1ce";
color: #a0a0a0;
margin-right: 2em;
animation:spin 2s linear infinite;
position: absolute;
}
&--info {
.message-text {
margin-left: 1.5em;
}
}
}
.message-text {
margin-left: 0.5em;
}
}

View File

@ -0,0 +1,79 @@
.stripe-modal {
.fab-modal-content {
padding-bottom: 0;
}
.stripe-form {
background-color: #f4f3f3;
border: 1px solid #ddd;
border-radius: 6px 6px 0 0;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
padding: 15px;
.stripe-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
margin-bottom: 1.2em;
}
}
.terms-of-sales {
margin-top: 1em;
margin-bottom: 1em;
font-size: 1.4rem;
font-weight: 600;
input {
display: inline;
margin-right: 0.5em;
}
label {
display: inline;
}
}
.stripe-modal-icons {
text-align: center;
.fa.fa-lock {
top: 7px;
color: #9edd78;
}
img {
margin-right: 10px;
}
}
.payment-schedule-info {
border: 1px solid #faebcc;
border-radius: 4px;
padding: 15px;
background-color: #fcf8e3;
color: #8a6d3b;
margin-top: 1em;
p {
font-size: small;
margin-bottom: 0.5em;
}
}
.validate-btn {
width: 100%;
border: 1px solid #ddd;
border-radius: 0 0 6px 6px;
border-top: 0;
padding: 16px;
color: #fff;
background-color: #1d98ec;
margin-bottom: 15px;
&[disabled] {
background-color: lighten(#1d98ec, 20%);
}
}
.payment-pending {
@extend .validate-btn;
@extend .validate-btn[disabled];
text-align: center;
padding: 4px;
}
}

View File

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

View File

@ -38,7 +38,7 @@
<div class="legends">
<span class="calendar-legend text-sm border-formation" translate>{{ 'app.admin.calendar.trainings' }}</span><br>
<span class="calendar-legend text-sm border-machine" translate>{{ 'app.admin.calendar.machines' }}</span><br>
<span class="calendar-legend text-sm border-space" ng-show="modules.spaces" translate>{{ 'app.admin.calendar.spaces' }}</span>
<span class="calendar-legend text-sm border-space" ng-show="$root.modules.spaces" translate>{{ 'app.admin.calendar.spaces' }}</span>
<span class="calendar-legend text-sm border-event" ng-show="eventsInCalendar" translate>{{ 'app.admin.calendar.events' }}</span>
</div>
</div>

View File

@ -18,7 +18,7 @@
<span translate>{{ 'app.admin.calendar.machine' }}</span>
</label>
</div>
<div class="radio" ng-show="modules.spaces">
<div class="radio" ng-show="$root.modules.spaces">
<label>
<input type="radio" id="space" name="available_type" value="space" ng-model="availability.available_type" ng-disabled="spaces.length === 0">
<span translate>{{ 'app.admin.calendar.space' }}</span>

View File

@ -79,6 +79,8 @@
</select>
<span class="help-block error" ng-show="couponForm['coupon[validity_per_user]'].$dirty && couponForm['coupon[validity_per_user]'].$error.required" translate>{{ 'app.shared.coupon.validity_per_user_is_required' }}</span>
</div>
<p class="alert alert-warning" ng-show="coupon.validity_per_user == 'once'" translate>{{ 'app.shared.coupon.warn_validity_once' }}</p>
<p class="alert alert-warning" ng-show="coupon.validity_per_user == 'forever'" translate>{{ 'app.shared.coupon.warn_validity_forever' }}</p>
<div class="form-group" ng-class="{'has-error': errors['valid_until']}">
<label for="coupon[valid_until]" translate>{{ 'app.shared.coupon.valid_until' }}</label>

View File

@ -30,10 +30,14 @@
<div class="row">
<div class="col-md-12" ng-if="isAuthorized('admin')">
<uib-tabset justified="true" active="tabs.active">
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" ng-show="modules.invoicing" index="0">
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" ng-show="$root.modules.invoicing" index="0">
<ng-include src="'/admin/invoices/list.html'"></ng-include>
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" ng-show="$root.modules.invoicing" index="4" class="payment-schedules-list">
<payment-schedules-list current-user="currentUser" />
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.invoicing_settings' | translate }}" index="1" class="invoices-settings">
<ng-include src="'/admin/invoices/settings.html'"></ng-include>
</uib-tab>
@ -49,7 +53,15 @@
</div>
<div class="col-md-12" ng-if="isAuthorized('manager')">
<ng-include src="'/admin/invoices/list.html'"></ng-include>
<uib-tabset justified="true" active="tabs.active">
<uib-tab heading="{{ 'app.admin.invoices.invoices_list' | translate }}" index="0">
<ng-include src="'/admin/invoices/list.html'"></ng-include>
</uib-tab>
<uib-tab heading="{{ 'app.admin.invoices.payment_schedules_list' | translate }}" index="4" class="payment-schedules-list">
<payment-schedules-list current-user="currentUser" />
</uib-tab>
</uib-tabset>
</div>
</div>
</section>

View File

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

View File

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

View File

@ -60,7 +60,7 @@
</form>
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.subscription' | translate }}" ng-if="modules.plans">
<uib-tab heading="{{ 'app.admin.members_edit.subscription' | translate }}" ng-if="$root.modules.plans">
<section class="panel panel-default bg-light m-lg">
@ -192,7 +192,7 @@
</div>
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.invoices' | translate }}" ng-show="modules.invoicing">
<uib-tab heading="{{ 'app.admin.members_edit.invoices' | translate }}" ng-show="$root.modules.invoicing">
<div class="col-md-12 m m-t-lg">
@ -229,7 +229,7 @@
</div>
</uib-tab>
<uib-tab heading="{{ 'app.admin.members_edit.wallet' | translate }}" ng-show="modules.wallet">
<uib-tab heading="{{ 'app.admin.members_edit.wallet' | translate }}" ng-show="$root.modules.wallet">
<div class="col-md-12 m m-t-lg">
<ng-include src="'/wallet/show.html'"></ng-include>

View File

@ -25,7 +25,7 @@
<a class="btn btn-default" ng-href="api/members/export_members.xlsx" target="export-frame" ng-click="alertExport('members')">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.members' | translate }}
</a>
<a class="btn btn-default" ng-href="api/members/export_subscriptions.xlsx" target="export-frame" ng-if="modules.plans" ng-click="alertExport('subscriptions')">
<a class="btn btn-default" ng-href="api/members/export_subscriptions.xlsx" target="export-frame" ng-if="$root.modules.plans" ng-click="alertExport('subscriptions')">
<i class="fa fa-file-excel-o"></i> {{ 'app.admin.members.subscriptions' | translate }}
</a>
<a class="btn btn-default" ng-href="api/members/export_reservations.xlsx" target="export-frame" ng-click="alertExport('reservations')">

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