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

Merge branch 'payzen' into dev

This commit is contained in:
Sylvain 2021-06-04 18:30:59 +02:00
commit 2c37f67137
302 changed files with 11195 additions and 5479 deletions

View File

@ -1,5 +1,35 @@
# Changelog Fab-manager
## Next release
- Ability to use PayZen a the payment gateway
- For payment schedules, ability to update the related payment card before the deadline
- Refactored data architecture to a more generic shopping cart model
- Redesigned the data structure to allow buying multiple and various objects
- Updated React and its dependencies to 17.0.3 and matching
- Updated the dependencies of: webpack, lodash, eslint, webpack-dev-server, react2angular, auto-ngtemplate-loader, angular-bootstrap-switch, react-refresh-webpack-plugin and eslint-plugin-react
- Improved error handling in upgrade script
- Improved the development and production documentations
- Improved the style of the titles of the subscription page
- Check the status of the assets' compilation during the upgrade
- Footprints are now generated in a more reproductible way
- Task to reset the stripe payment methods in test mode
- Validate on server side the reservation of slots restricted to subscribers
Unified and documented upgrade exit codes
- Fix a bug: build status badge is not working
- Fix a bug: unable to set date formats during installation
- Fix a bug: unable to cancel the upgrade before it begins
- Fix a bug: in the admin calendar, the trainings' info panel shows "duration: null minutes"
- Fix a bug: on the subscriptions page, not logged-in users do not see the action button
- Fix a bug: unable to map a new setup to the db network
- `SUPERADMIN_EMAIL` renamed to `ADMINSYS_EMAIL`
- `scripts/run-tests.sh` renamed to `scripts/tests.sh`
- [BREAKING CHANGE] GET `open_api/v1/invoices` won't return `stp_invoice_id` OR `stp_payment_intent_id` anymore. The new field `payment_gateway_object` will contain some similar data if the invoice was paid online by card.
- [BREAKING CHANGE] GET `open_api/v1/invoices` won't return `invoiced_id`, `invoiced_type` OR `invoiced.created_at` anymore. The new field `main_object` will contain the equivalent data.
- [TODO DEPLOY] before running the database migration (db:migrate), run: `rails fablab:chain:all`
- [TODO DEPLOY] `rails fablab:stripe:set_gateway`
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/rename-adminsys.sh | bash`
## v4.7.11 2021 May 26
- Updated ffi to 1.15.1

View File

@ -1,24 +1,20 @@
# Fab-manager
Fab-manager is the Fab Lab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks and your marker's projects.
Fab-manager is the Fab Lab management solution. It provides a comprehensive, web-based, open-source tool to simplify your administrative tasks, and document your marker's projects.
[![Coverage Status](https://coveralls.io/repos/github/sleede/fab-manager/badge.svg)](https://coveralls.io/github/sleede/fab-manager)
[![Docker pulls](https://img.shields.io/docker/pulls/sleede/fab-manager.svg)](https://hub.docker.com/r/sleede/fab-manager/)
[![Docker Build Status](https://img.shields.io/docker/build/sleede/fab-manager.svg)](https://hub.docker.com/r/sleede/fab-manager/builds)
[![Docker Build Status](https://img.shields.io/docker/cloud/build/sleede/fab-manager.svg)](https://hub.docker.com/r/sleede/fab-manager/builds)
[![Crowdin](https://badges.crowdin.net/fab-manager/localized.svg)](https://crowdin.com/project/fab-manager)
##### Table of Contents
1. [Software stack](#software-stack)
2. [Contributing](#contributing)
3. [Setup a production environment](#setup-a-production-environment)
4. [Setup a development environment](#setup-a-development-environment)
5. [Internationalization (i18n)](#i18n)
6. [Open Projects](#open-projects)
7. [Plugins](#plugins)
8. [Single Sign-On](#sso)
9. [Known issues](#known-issues)
10. [Related Documentation](#related-documentation)
3. [Documentation](#documentation)
4. [Open Projects](#open-projects)
5. [Plugins](#plugins)
6. [Single Sign-On](#sso)
7. [Related Documentation](#related-documentation)
<a name="software-stack"></a>
@ -38,26 +34,10 @@ Fab-manager is a Ruby on Rails / AngularJS web application that runs on the foll
Contributions are welcome. Please read [the contribution guidelines](CONTRIBUTING.md) for more information about the contribution process.
<a name="setup-a-production-environment"></a>
## Setup a production environment
<a name="documentation"></a>
## Documentation
To run Fab-manager as a production application, you must follow the procedure described in the [production readme](doc/production_readme.md).
This procedure is using [Docker-compose](https://docs.docker.com/compose/overview/) to manage the application and its dependencies.
<a name="setup-a-development-environment"></a>
## Setup a development environment
In you intend to run Fab-manager on your local machine to contribute to the project development, you can set it up by following the [development readme](doc/development_readme.md).
This procedure relies on [Docker-compose](https://docs.docker.com/compose/overview/) to set-up the dependencies.
Optionally, you can use a virtual development environment that relies on Vagrant and Virtual Box by following the [virtual machine instructions](doc/virtual-machine.md).
<a name="i18n"></a>
## Internationalization (i18n)
The Fab-manager application can only run in a single language but this language can easily be changed.
Please refer to the [translation readme](doc/translation_readme.md) for instructions about configuring the language or to contribute to the translation.
The full documentation is available at [doc.fab.mn](http://doc.fab.mn).
<a name="open-projects"></a>
## Open Projects
@ -102,11 +82,6 @@ Currently, OAuth 2 is the only supported protocol for SSO authentication.
For an example of how to use configure an SSO in Fab-manager, please read [sso_with_github.md](doc/sso_with_github.md).
<a name="known-issues"></a>
## Known issues
Before reporting an issue, please check if your issue is not listed in the [know issues](doc/known-issues.md) with its solution.
<a name="related-documentation"></a>
## Related Documentation

View File

@ -8,7 +8,7 @@ class API::InvoicesController < API::ApiController
def index
authorize Invoice
@invoices = Invoice.includes(
:avoir, :invoiced, :invoicing_profile, invoice_items: %i[subscription invoice_item]
:avoir, :invoicing_profile, invoice_items: %i[subscription invoice_item]
).all.order('reference DESC')
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
# API Controller for handling local payments (at the reception) or when the amount = 0
class API::LocalPaymentController < API::PaymentsController
def confirm_payment
cart = shopping_cart
price = debit_amount(cart)
authorize LocalPaymentContext.new(cart, price[:amount])
render on_payment_success(nil, nil, cart)
end
protected
def shopping_cart
cs = CartService.new(current_user)
cs.from_hash(params)
end
end

View File

@ -37,7 +37,7 @@ class API::PaymentSchedulesController < API::ApiController
def cash_check
authorize @payment_schedule_item.payment_schedule
PaymentScheduleService.new.generate_invoice(@payment_schedule_item)
PaymentScheduleService.new.generate_invoice(@payment_schedule_item, payment_method: 'check')
attrs = { state: 'paid', payment_method: 'check' }
@payment_schedule_item.update_attributes(attrs)
@ -54,17 +54,12 @@ class API::PaymentSchedulesController < API::ApiController
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
res = PaymentGatewayService.new.pay_payment_schedule_item(@payment_schedule_item)
if res.error
render json: res, status: :unprocessable_entity
else
render json: res, status: :ok
end
end
def cancel

View File

@ -1,232 +1,26 @@
# frozen_string_literal: true
# API Controller for handling payments process in the front-end
# Abstract API Controller to be extended by each payment gateway/mean, for handling the payments processes in the front-end
class API::PaymentsController < API::ApiController
before_action :authenticate_user!
##
# Client requests to confirm a card payment will ask this endpoint.
# It will check for the need of a strong customer authentication (SCA) to confirm the payment or confirm that the payment
# was successfully made. After the payment was made, the reservation/subscription will be created
##
# This method must be overridden by the the gateways controllers that inherits API::PaymentsControllers
def confirm_payment
render(json: { error: 'Online payment is disabled' }, status: :unauthorized) and return unless Setting.get('online_payment_module')
amount = nil # will contains the amount and the details of each invoice lines
intent = nil # stripe's payment intent
res = nil # json of the API answer
begin
amount = card_amount
if params[:payment_method_id].present?
check_coupon
check_plan
# Create the PaymentIntent
intent = Stripe::PaymentIntent.create(
{
payment_method: params[:payment_method_id],
amount: StripeService.stripe_amount(amount[:amount]),
currency: Setting.get('stripe_currency'),
confirmation_method: 'manual',
confirm: true,
customer: current_user.stp_customer_id
}, { api_key: Setting.get('stripe_secret_key') }
)
elsif params[:payment_intent_id].present?
intent = Stripe::PaymentIntent.confirm(params[:payment_intent_id], api_key: Setting.get('stripe_secret_key'))
end
rescue Stripe::CardError => e
# Display error on client
res = { status: 200, json: { error: e.message } }
rescue InvalidCouponError
res = { json: { coupon_code: 'wrong coupon code or expired' }, status: :unprocessable_entity }
rescue InvalidGroupError
res = { json: { plan_id: 'this plan is not compatible with your current group' }, status: :unprocessable_entity }
end
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)
raise NoMethodError
end
def online_payment_status
authorize :payment
protected
key = Setting.get('stripe_secret_key')
render json: { status: false } and return unless key&.present?
charges = Stripe::Charge.list({ limit: 1 }, { api_key: key })
render json: { status: charges.data.length.positive? }
rescue Stripe::AuthenticationError
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)
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
{ template: 'api/reservations/show', status: :created, location: @reservation }
else
{ json: @reservation.errors, status: :unprocessable_entity }
end
end
def on_subscription_success(intent, details)
@subscription = Subscription.new(subscription_params)
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 }
else
{ json: @subscription.errors, status: :unprocessable_entity }
end
end
def generate_payment_response(intent, res = nil)
return res unless res.nil?
if intent.status == 'requires_action' && intent.next_action.type == 'use_stripe_sdk'
# Tell the client to handle the action
{
status: 200,
json: {
requires_action: true,
payment_intent_client_secret: intent.client_secret
}
}
elsif intent.status == 'succeeded'
# The payment didn't need any additional actions and is completed!
# Handle post-payment fulfillment
{ status: 200, json: { success: true } }
else
# Invalid status
{ status: 500, json: { error: 'Invalid PaymentIntent status' } }
end
end
def post_save(_gateway_item_id, _gateway_item_type, _payment_document); end
def get_wallet_debit(user, total_amount)
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total_amount ? total_amount : wallet_amount
end
def card_amount
if params[:cart_items][:reservation]
reservable = cart_items_params[:reservable_type].constantize.find(cart_items_params[:reservable_id])
plan_id = cart_items_params[:plan_id]
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]
reservable = nil
plan_id = subscription_params[:plan_id]
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,
User.find(user_id),
reservable,
slots,
plan_id: plan_id,
nb_places: nb_places,
tickets: tickets,
coupon_code: coupon_params[:coupon_code])
def debit_amount(cart)
price_details = cart.total
# Subtract wallet amount from total
total = price_details[:total]
@ -234,43 +28,18 @@ class API::PaymentsController < API::ApiController
{ amount: total - wallet_debit, details: price_details }
end
def check_coupon
return if coupon_params[:coupon_code].nil?
coupon = Coupon.find_by(code: coupon_params[:coupon_code])
raise InvalidCouponError if coupon.nil? || coupon.status(current_user.id) != 'active'
def shopping_cart
cs = CartService.new(current_user)
cs.from_hash(params[:cart_items])
end
def check_plan
plan_id = if params[:cart_items][:subscription]
subscription_params[:plan_id]
elsif params[:cart_items][:reservation]
reservation_params[:plan_id]
end
return unless plan_id
plan = Plan.find(plan_id)
raise InvalidGroupError if plan.group_id != current_user.group_id
end
def reservation_params
params[:cart_items].require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :nb_reserve_places,
tickets_attributes: %i[event_price_category_id booked],
slots_attributes: %i[id start_at end_at availability_id offered])
end
def subscription_params
params[:cart_items].require(:subscription).permit(:plan_id)
end
def cart_items_params
params[:cart_items].require(:reservation).permit(:reservable_id, :reservable_type, :plan_id, :user_id, :nb_reserve_places,
tickets_attributes: %i[event_price_category_id booked],
slots_attributes: %i[id start_at end_at availability_id offered])
end
def coupon_params
params.require(:cart_items).permit(:coupon_code)
def on_payment_success(gateway_item_id, gateway_item_type, cart)
res = cart.build_and_save(gateway_item_id, gateway_item_type)
if res[:success]
post_save(gateway_item_id, gateway_item_type, res[:payment])
res[:payment].render_resource.merge(status: :created)
else
{ json: res[:errors], status: :unprocessable_entity }
end
end
end

View File

@ -0,0 +1,98 @@
# frozen_string_literal: true
# API Controller for accessing PayZen API endpoints through the front-end app
class API::PayzenController < API::PaymentsController
require 'pay_zen/charge'
require 'pay_zen/order'
require 'pay_zen/token'
require 'pay_zen/transaction'
require 'pay_zen/helper'
def sdk_test
str = 'fab-manager'
client = PayZen::Charge.new(base_url: params[:base_url], username: params[:username], password: params[:password])
res = client.sdk_test(str)
@status = (res['answer']['value'] == str)
rescue SocketError
@status = false
end
def create_payment
cart = shopping_cart
amount = debit_amount(cart)
@id = PayZen::Helper.generate_ref(params[:cart_items], params[:customer_id])
client = PayZen::Charge.new
@result = client.create_payment(amount: amount[:amount],
order_id: @id,
customer: PayZen::Helper.generate_customer(params[:customer_id], current_user.id, params[:cart_items]))
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def create_token
@id = PayZen::Helper.generate_ref(params[:cart_items], params[:customer_id])
client = PayZen::Charge.new
@result = client.create_token(order_id: @id,
customer: PayZen::Helper.generate_customer(params[:customer_id], current_user.id, params[:cart_items]))
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def update_token
schedule = PaymentSchedule.find(params[:payment_schedule_id])
token = schedule.gateway_payment_mean
@id = schedule.gateway_order.id
@result = PayZen::Token.new.update(token.id,
PayZen::Helper.generate_customer(schedule.user.id, current_user.id, schedule.to_cart),
order_id: @id)
rescue PayzenError => e
render json: e, status: :unprocessable_entity
end
def check_hash
@result = PayZen::Helper.check_hash(params[:algorithm], params[:hash_key], params[:hash], params[:data])
end
def confirm_payment
render(json: { error: 'Bad gateway or online payment is disabled' }, status: :bad_gateway) and return unless PayZen::Helper.enabled?
client = PayZen::Order.new
order = client.get(params[:order_id], operation_type: 'DEBIT')
cart = shopping_cart
if order['answer']['transactions'].first['status'] == 'PAID'
render on_payment_success(params[:order_id], cart)
else
render json: order['answer'], status: :unprocessable_entity
end
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
def confirm_payment_schedule
render(json: { error: 'Bad gateway or online payment is disabled' }, status: :bad_gateway) and return unless PayZen::Helper.enabled?
client = PayZen::Transaction.new
transaction = client.get(params[:transaction_uuid])
cart = shopping_cart
if transaction['answer']['status'] == 'PAID'
render on_payment_success(params[:order_id], cart)
else
render json: transaction['answer'], status: :unprocessable_entity
end
rescue StandardError => e
render json: e, status: :unprocessable_entity
end
private
def on_payment_success(order_id, cart)
super(order_id, 'PayZen::Order', cart)
end
end

View File

@ -27,8 +27,8 @@ class API::PlansController < API::ApiController
partner = params[:plan][:partner_id].empty? ? nil : User.find(params[:plan][:partner_id])
res = PlansService.create(type, partner, plan_params)
if res[:errors]
render json: res[:errors], status: :unprocessable_entity
if res.errors
render json: res.errors, status: :unprocessable_entity
else
render json: res, status: :created
end

View File

@ -37,39 +37,9 @@ class API::PricesController < API::ApiController
end
def compute
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]) && ['', nil].include?(price_parameters[:plan_id])
@amount = { elements: nil, total: 0, before_coupon: 0 }
else
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] || [],
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
if @amount.nil?
render status: :unprocessable_entity
else
render status: :ok
end
cs = CartService.new(current_user)
cart = cs.from_hash(params)
@amount = cart.total
end
private
@ -77,18 +47,4 @@ class API::PricesController < API::ApiController
def price_params
params.require(:price).permit(:amount)
end
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
end

View File

@ -24,33 +24,6 @@ class API::ReservationsController < API::ApiController
def show; end
# Admins can create any reservations. Members can directly create reservations if total = 0,
# otherwise, they must use payments_controller#confirm_payment.
# Managers can create reservations for other users
def create
user_id = current_user.admin? || current_user.manager? ? params[:reservation][:user_id] : current_user.id
price = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), user_id)
authorize ReservationContext.new(Reservation, price[:amount], user_id)
@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],
schedule: params[:reservation][:payment_schedule],
payment_method: params[:reservation][:payment_method])
if is_reserve
SubscriptionExtensionAfterReservation.new(@reservation).extend_subscription_if_eligible
render :show, status: :created, location: @reservation
else
render json: @reservation.errors, status: :unprocessable_entity
end
rescue InvalidCouponError
render json: { coupon_code: 'wrong coupon code or expired' }, status: :unprocessable_entity
end
def update
authorize @reservation
if @reservation.update(reservation_params)
@ -62,40 +35,13 @@ class API::ReservationsController < API::ApiController
private
def transaction_amount(is_admin, user_id)
user = User.find(user_id)
price_details = Price.compute(is_admin,
user,
reservation_params[:reservable_type].constantize.find(reservation_params[:reservable_id]),
reservation_params[:slots_attributes] || [],
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]
wallet_debit = get_wallet_debit(user, total)
{ price_details: price_details, amount: (total - wallet_debit) }
end
def get_wallet_debit(user, total_amount)
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total_amount ? total_amount : wallet_amount
end
def set_reservation
@reservation = Reservation.find(params[:id])
end
def reservation_params
params.require(:reservation).permit(:message, :reservable_id, :reservable_type, :plan_id, :nb_reserve_places,
params.require(:reservation).permit(:message, :reservable_id, :reservable_type, :nb_reserve_places,
tickets_attributes: %i[event_price_category_id booked],
slots_attributes: %i[id start_at end_at availability_id offered])
end
def coupon_params
params.permit(:coupon_code)
end
end

View File

@ -32,8 +32,11 @@ class API::SettingsController < API::ApiController
db_setting = Setting.find_or_initialize_by(name: setting[:name])
next unless SettingService.before_update(db_setting)
db_setting.save && db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(db_setting)
if db_setting.save
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
SettingService.after_update(db_setting)
end
@settings.push db_setting
end
end

View File

@ -0,0 +1,143 @@
# frozen_string_literal: true
# API Controller for handling the payments process in the front-end, using the Stripe gateway
class API::StripeController < API::PaymentsController
require 'stripe/helper'
require 'stripe/service'
before_action :check_keys
##
# Client requests to confirm a card payment will ask this endpoint.
# It will check for the need of a strong customer authentication (SCA) to confirm the payment or confirm that the payment
# was successfully made. After the payment was made, the reservation/subscription will be created
##
def confirm_payment
render(json: { error: 'Bad gateway or online payment is disabled' }, status: :bad_gateway) and return unless Stripe::Helper.enabled?
intent = nil # stripe's payment intent
res = nil # json of the API answer
cart = shopping_cart
begin
amount = debit_amount(cart) # will contains the amount and the details of each invoice lines
if params[:payment_method_id].present?
# Create the PaymentIntent
intent = Stripe::PaymentIntent.create(
{
payment_method: params[:payment_method_id],
amount: Stripe::Service.new.stripe_amount(amount[:amount]),
currency: Setting.get('stripe_currency'),
confirmation_method: 'manual',
confirm: true,
customer: current_user.payment_gateway_object.gateway_object_id
}, { api_key: Setting.get('stripe_secret_key') }
)
elsif params[:payment_intent_id].present?
intent = Stripe::PaymentIntent.confirm(params[:payment_intent_id], api_key: Setting.get('stripe_secret_key'))
end
rescue Stripe::CardError => e
# Display error on client
res = { status: 200, json: { error: e.message } }
rescue InvalidCouponError
res = { json: { coupon_code: 'wrong coupon code or expired' }, status: :unprocessable_entity }
rescue InvalidGroupError
res = { json: { plan_id: 'this plan is not compatible with your current group' }, status: :unprocessable_entity }
end
res = on_payment_success(intent, cart) if intent&.status == 'succeeded'
render generate_payment_response(intent, res)
end
def online_payment_status
authorize :payment
key = Setting.get('stripe_secret_key')
render json: { status: false } and return unless key&.present?
charges = Stripe::Charge.list({ limit: 1 }, { api_key: key })
render json: { status: charges.data.length.positive? }
rescue Stripe::AuthenticationError
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.payment_gateway_object.gateway_object_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)
cart = shopping_cart
if intent&.status == 'succeeded'
res = on_payment_success(intent, cart)
render generate_payment_response(intent, res)
end
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.payment_gateway_object.gateway_object_id,
{ invoice_settings: { default_payment_method: params[:payment_method_id] } },
{ api_key: key })
if params[:payment_schedule_id]
schedule = PaymentSchedule.find(params[:payment_schedule_id])
subscription = schedule.gateway_subscription.retrieve
Stripe::Subscription.update(subscription.id, { default_payment_method: params[:payment_method_id] }, { api_key: key })
end
render json: { updated: true }, status: :ok
rescue Stripe::StripeError => e
render json: { updated: false, error: e }, status: :unprocessable_entity
end
private
def post_save(intent_id, intent_type, payment_document)
return unless intent_type == 'Stripe::PaymentIntent'
Stripe::PaymentIntent.update(
intent_id,
{ description: "#{payment_document.class.name} reference: #{payment_document.reference}" },
{ api_key: Setting.get('stripe_secret_key') }
)
end
def on_payment_success(intent, cart)
super(intent.id, intent.class.name, cart)
end
def generate_payment_response(intent, res = nil)
return res unless res.nil?
if intent.status == 'requires_action' && intent.next_action.type == 'use_stripe_sdk'
# Tell the client to handle the action
{
status: 200,
json: {
requires_action: true,
payment_intent_client_secret: intent.client_secret
}
}
elsif intent.status == 'succeeded'
# The payment didn't need any additional actions and is completed!
# Handle post-payment fulfillment
{ status: 200, json: { success: true } }
else
# Invalid status
{ status: 500, json: { error: 'Invalid PaymentIntent status' } }
end
end
def check_keys
key = Setting.get('stripe_secret_key')
raise Stripe::StripeError, 'Using live keys in development mode' if key&.match(/^sk_live_/) && Rails.env.development?
end
end

View File

@ -9,28 +9,6 @@ class API::SubscriptionsController < API::ApiController
authorize @subscription
end
# Admins can create any subscriptions. Members can directly create subscriptions if total = 0,
# otherwise, they must use payments_controller#confirm_payment.
# Managers can create subscriptions for other users
def create
user_id = current_user.admin? || current_user.manager? ? params[:subscription][:user_id] : current_user.id
transaction = transaction_amount(current_user.admin? || (current_user.manager? && current_user.id != user_id), 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, payment_details: transaction[:details],
schedule: params[:subscription][:payment_schedule],
payment_method: params[:subscription][:payment_method])
if is_subscribe
render :show, status: :created, location: @subscription
else
render json: @subscription.errors, status: :unprocessable_entity
end
end
def update
authorize @subscription
@ -50,42 +28,11 @@ class API::SubscriptionsController < API::ApiController
private
def transaction_amount(is_admin, user_id)
user = User.find(user_id)
price_details = Price.compute(is_admin,
user,
nil,
[],
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)
{ amount: total - wallet_debit, details: price_details }
end
def get_wallet_debit(user, total_amount)
wallet_amount = (user.wallet.amount * 100).to_i
wallet_amount >= total_amount ? total_amount : wallet_amount
end
# Use callbacks to share common setup or constraints between actions.
def set_subscription
@subscription = Subscription.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def subscription_params
params.require(:subscription).permit(:plan_id)
end
def coupon_params
params.permit(:coupon_code)
end
def subscription_update_params
params.require(:subscription).permit(:expired_at)
end

View File

@ -22,49 +22,55 @@ class OpenAPI::V1::InvoicesDoc < OpenAPI::V1::BaseDoc
"invoices": [
{
"id": 2809,
"invoiced_id": 3257,
"user_id": 211,
"invoiced_type": "Reservation",
"stp_invoice_id": "in_187DLE4zBvgjueAZ6L7SyQlU",
"stp_payment_intent_id": "pi_1Dat4P2eZvKYlo2C3MxszwQp",
"payment_gateway_object": {
id: "in_187DLE4zBvgjueAZ6L7SyQlU",
type: "Stripe::Invoice"
},
"reference": "1605017/VL",
"total": 1000,
"type": null,
"description": null,
"invoice_url": "/open_api/v1/invoices/2809/download",
"invoiced": {
"main_object": {
"type": "Reservation",
"id": 3257,
"created_at": "2016-05-04T01:54:16.686+02:00"
}
},
{
"id": 2783,
"invoiced_id": 3229,
"user_id": 211,
"invoiced_type": "Reservation",
"stp_invoice_id": "in_185Hmt4zBvgjueAZl5lio1pK",
"stp_payment_intent_id": "pi_2Dat4P2eYbKYlo2C3MxszwQp",
"payment_gateway_object": {
id: "pi_2Dat4P2eYbKYlo2C3MxszwQp",
type: "Stripe::PaymentIntent"
},
"reference": "1604176/VL",
"total": 2000,
"type": null,
"description": null,
"invoice_url": "/open_api/v1/invoices/2783/download",
"invoiced": {
"main_object": {
"type": "Reservation",
"id": 3229,
"created_at": "2016-04-28T18:14:52.524+02:00"
}
},
{
"id": 2773,
"invoiced_id": 3218,
"user_id": 211,
"invoiced_type": "Reservation",
"stp_invoice_id": "in_184oNK4zBvgjueAZJdOxHJjT",
"stp_payment_intent_id": "pi_1Pub4P2eZvKYlo2C3MxszwQm",
"payment_gateway_object": {
id: "ba15dc9d8f3e0fa17bf527466",
type: "PayZen::Order"
},
"reference": "1604166/VL",
"total": 2000,
"type": null,
"description": null,
"invoice_url": "/open_api/v1/invoices/2773/download",
"invoiced": {
"main_object": {
"type": "Reservation",
"id": 3218,
"created_at": "2016-04-27T10:50:30.806+02:00"
}
}

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Raised when an invalid invoice is encountered in database
class InvalidInvoiceError < StandardError
def initialize(msg = nil)
super(msg || 'Please run rails `fablab:fix_invoices`')
end
end

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
# Raised when an an error occurred with any payment gateway
class PaymentGatewayError < StandardError
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Raised when an an error occurred with the PayZen payment gateway
class PayzenError < PaymentGatewayError
end

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -13,6 +13,9 @@ 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) {
// 304 Not Modified should be considered as a success
if (error.response?.status === 304) { return Promise.resolve(error.response); }
// 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));

View File

@ -0,0 +1,15 @@
import axios, { AxiosInstance } from 'axios'
function client(key: string): AxiosInstance {
return axios.create({
baseURL: 'https://api.stripe.com/v1/',
headers: {
common: {
Authorization: `Bearer ${key}`
}
}
});
}
export default client;

View File

@ -1,4 +1,4 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { CustomAsset, CustomAssetName } from '../models/custom-asset';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';

View File

@ -1,4 +1,4 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { EventTheme } from '../models/event-theme';
@ -8,4 +8,3 @@ export default class EventThemeAPI {
return res?.data;
}
}

View File

@ -0,0 +1,29 @@
import stripeClient from '../clients/stripe-client';
import { AxiosResponse } from 'axios';
export default class StripeAPI {
/**
* @see https://stripe.com/docs/api/tokens/create_pii
*/
static async createPIIToken(key: string, piiId: string): Promise<any> {
const params = new URLSearchParams();
params.append('pii[id_number]', piiId);
const config = {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
const res: AxiosResponse = await stripeClient(key).post('tokens', params, config);
return res?.data;
}
/**
* @see https://stripe.com/docs/api/charges/list
*/
static async listAllCharges(key: string): Promise<any> {
const res: AxiosResponse = await stripeClient(key).get('charges');
return res?.data;
}
}

View File

@ -1,4 +1,4 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import {
CancelScheduleResponse,
@ -6,7 +6,6 @@ import {
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>> {

View File

@ -1,36 +0,0 @@
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,50 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart, UpdateCardResponse } from '../models/payment';
import { User } from '../models/user';
import {
CheckHashResponse,
CreatePaymentResponse,
CreateTokenResponse,
SdkTestResponse
} from '../models/payzen';
import { Invoice } from '../models/invoice';
import { PaymentSchedule } from '../models/payment-schedule';
export default class PayzenAPI {
static async chargeSDKTest(baseURL: string, username: string, password: string): Promise<SdkTestResponse> {
const res: AxiosResponse<SdkTestResponse> = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password });
return res?.data;
}
static async chargeCreatePayment(cart: ShoppingCart, customer: User): Promise<CreatePaymentResponse> {
const res: AxiosResponse<CreatePaymentResponse> = await apiClient.post('/api/payzen/create_payment', { cart_items: cart, customer_id: customer.id });
return res?.data;
}
static async chargeCreateToken(cart: ShoppingCart, customer: User): Promise<CreateTokenResponse> {
const res: AxiosResponse = await apiClient.post('/api/payzen/create_token', { cart_items: cart, customer_id: customer.id });
return res?.data;
}
static async checkHash(algorithm: string, hashKey: string, hash: string, data: string): Promise<CheckHashResponse> {
const res: AxiosResponse<CheckHashResponse> = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data });
return res?.data;
}
static async confirm(orderId: string, cart: ShoppingCart): Promise<Invoice> {
const res: AxiosResponse<Invoice> = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cart, order_id: orderId });
return res?.data;
}
static async confirmPaymentSchedule(orderId: string, transactionUuid: string, cart: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post('/api/payzen/confirm_payment_schedule', { cart_items: cart, order_id: orderId, transaction_uuid: transactionUuid });
return res?.data;
}
static async updateToken(payment_schedule_id: number): Promise<CreateTokenResponse> {
const res: AxiosResponse<CreateTokenResponse> = await apiClient.post(`/api/payzen/update_token`, { payment_schedule_id });
return res?.data;
}
}

View File

@ -1,11 +1,11 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { CartItems } from '../models/payment';
import { ShoppingCart } 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);
static async compute (cart: ShoppingCart): Promise<ComputePriceResult> {
const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart);
return res?.data;
}
}

View File

@ -1,17 +1,36 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Setting, SettingName } from '../models/setting';
import { Setting, SettingBulkResult, SettingError, 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}`);
const res: AxiosResponse<{setting: Setting}> = 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;
const params = new URLSearchParams();
params.append('names', `['${names.join("','")}']`);
const res: AxiosResponse = await apiClient.get(`/api/settings?${params.toString()}`);
return SettingAPI.toSettingsMap(names, res?.data);
}
async update (name: SettingName, value: any): Promise<Setting> {
const res: AxiosResponse = await apiClient.patch(`/api/settings/${name}`, { setting: { value } });
if (res.status === 304) { return { name, value }; }
return res?.data?.setting;
}
async bulkUpdate (settings: Map<SettingName, any>): Promise<Map<SettingName, SettingBulkResult>> {
const res: AxiosResponse = await apiClient.patch('/api/settings/bulk_update', { settings: SettingAPI.toObjectArray(settings) });
return SettingAPI.toBulkMap(res?.data?.settings);
}
async isPresent (name: SettingName): Promise<boolean> {
const res: AxiosResponse = await apiClient.get(`/api/settings/is_present/${name}`);
return res?.data?.isPresent;
}
static get (name: SettingName): IWrapPromise<Setting> {
@ -19,9 +38,50 @@ export default class SettingAPI {
return wrapPromise(api.get(name));
}
static query(names: Array<SettingName>): IWrapPromise<Map<SettingName, any>> {
static query (names: Array<SettingName>): IWrapPromise<Map<SettingName, any>> {
const api = new SettingAPI();
return wrapPromise(api.query(names));
}
static isPresent (name: SettingName): IWrapPromise<boolean> {
const api = new SettingAPI();
return wrapPromise(api.isPresent(name));
}
private static toSettingsMap(names: Array<SettingName>, data: Object): Map<SettingName, any> {
const map = new Map();
names.forEach(name => {
map.set(name, data[name] || '');
});
return map;
}
private static toBulkMap(data: Array<Setting|SettingError>): Map<SettingName, SettingBulkResult> {
const map = new Map();
data.forEach(item => {
const itemData: SettingBulkResult = { status: true };
if ('error' in item) {
itemData.error = item.error;
itemData.status = false;
}
if ('value' in item) {
itemData.value = item.value;
}
map.set(item.name as SettingName, itemData)
});
return map;
}
private static toObjectArray(data: Map<SettingName, any>): Array<Object> {
const array = [];
data.forEach((value, key) => {
array.push({
name: key,
value
})
});
return array;
}
}

View File

@ -0,0 +1,38 @@
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { ShoppingCart, IntentConfirmation, PaymentConfirmation, UpdateCardResponse } from '../models/payment';
import { PaymentSchedule } from '../models/payment-schedule';
import { Invoice } from '../models/invoice';
export default class StripeAPI {
static async confirm (stp_payment_method_id: string, cart_items: ShoppingCart): Promise<PaymentConfirmation|Invoice> {
const res: AxiosResponse<PaymentConfirmation|Invoice> = await apiClient.post(`/api/stripe/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<IntentConfirmation> = await apiClient.get(`/api/stripe/setup_intent/${user_id}`);
return res?.data;
}
static async confirmPaymentSchedule (setup_intent_id: string, cart_items: ShoppingCart): Promise<PaymentSchedule> {
const res: AxiosResponse<PaymentSchedule> = await apiClient.post(`/api/stripe/confirm_payment_schedule`, {
setup_intent_id,
cart_items
});
return res?.data;
}
static async updateCard (user_id: number, stp_payment_method_id: string, payment_schedule_id?: number): Promise<UpdateCardResponse> {
const res: AxiosResponse<UpdateCardResponse> = await apiClient.post(`/api/stripe/update_card`, {
user_id,
payment_method_id: stp_payment_method_id,
payment_schedule_id
});
return res?.data;
}
}

View File

@ -1,4 +1,4 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import { Theme } from '../models/theme';

View File

@ -1,4 +1,4 @@
import apiClient from './api-client';
import apiClient from './clients/api-client';
import { AxiosResponse } from 'axios';
import wrapPromise, { IWrapPromise } from '../lib/wrap-promise';
import { Wallet } from '../models/wallet';

View File

@ -1,11 +1,7 @@
/**
* This component is a template for a clickable button that wraps the application style
*/
import React, { ReactNode, SyntheticEvent } from 'react';
import React, { ReactNode, BaseSyntheticEvent } from 'react';
interface FabButtonProps {
onClick?: (event: SyntheticEvent) => void,
onClick?: (event: BaseSyntheticEvent) => void,
icon?: ReactNode,
className?: string,
disabled?: boolean,
@ -13,7 +9,9 @@ interface FabButtonProps {
form?: string,
}
/**
* This component is a template for a clickable button that wraps the application style
*/
export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className, disabled, type, form, children }) => {
/**
* Check if the current component was provided an icon to display
@ -25,7 +23,7 @@ export const FabButton: React.FC<FabButtonProps> = ({ onClick, icon, className,
/**
* Handle the action of the button
*/
const handleClick = (e: SyntheticEvent): void => {
const handleClick = (e: BaseSyntheticEvent): void => {
if (typeof onClick === 'function') {
onClick(e);
}

View File

@ -0,0 +1,105 @@
import React, { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import { debounce as _debounce } from 'lodash';
interface FabInputProps {
id: string,
onChange?: (value: any, validity?: ValidityState) => void,
defaultValue: any,
icon?: ReactNode,
addOn?: ReactNode,
addOnClassName?: string,
className?: string,
disabled?: boolean,
required?: boolean,
debounce?: number,
readOnly?: boolean,
maxLength?: number,
pattern?: string,
placeholder?: string,
error?: string,
type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week',
}
/**
* This component is a template for an input component that wraps the application style
*/
export const FabInput: React.FC<FabInputProps> = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error }) => {
const [inputValue, setInputValue] = useState<any>(defaultValue);
/**
* When the component is mounted, initialize the default value for the input.
* If the default value changes, update the value of the input until there's no content in it.
*/
useEffect(() => {
if (!inputValue) {
setInputValue(defaultValue);
if (typeof onChange === 'function') {
onChange(defaultValue);
}
}
}, [defaultValue]);
/**
* Check if the current component was provided an icon to display
*/
const hasIcon = (): boolean => {
return !!icon;
}
/**
* Check if the current component was provided an add-on element to display, at the end of the input
*/
const hasAddOn = (): boolean => {
return !!addOn;
}
/**
* Check if the current component was provided an error string to display, on the input
*/
const hasError = (): boolean => {
return !!error;
}
/**
* Debounced (ie. temporised) version of the 'on change' callback.
*/
const debouncedOnChange = debounce ? useCallback(_debounce(onChange, debounce), [onChange, debounce]) : null;
/**
* Handle the change of content in the input field, and trigger the parent callback, if any
*/
const handleChange = (e: BaseSyntheticEvent): void => {
const { value, validity } = e.target;
setInputValue(value);
if (typeof onChange === 'function') {
if (debounce) {
debouncedOnChange(value, validity);
} else {
onChange(value, validity);
}
}
}
return (
<div className={`fab-input ${className ? className : ''}`}>
<div className={`input-wrapper ${hasError() ? 'input-error' : ''}`}>
{hasIcon() && <span className="fab-input--icon">{icon}</span>}
<input id={id}
type={type}
className="fab-input--input"
value={inputValue}
onChange={handleChange}
disabled={disabled}
required={required}
readOnly={readOnly}
maxLength={maxLength}
pattern={pattern}
placeholder={placeholder} />
{hasAddOn() && <span className={`fab-input--addon ${addOnClassName ? addOnClassName : ''}`}>{addOn}</span>}
</div>
{hasError() && <span className="fab-input--error">{error}</span> }
</div>
);
}
FabInput.defaultProps = { type: 'text', debounce: 0 };

View File

@ -1,13 +1,9 @@
/**
* This component is a template for a modal dialog that wraps the application style
*/
import React, { ReactNode, SyntheticEvent } from 'react';
import React, { ReactNode, BaseSyntheticEvent } 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 CustomAssetAPI from '../../api/custom-asset';
import { CustomAssetName } from '../../models/custom-asset';
import { FabButton } from './fab-button';
Modal.setAppElement('body');
@ -27,14 +23,20 @@ interface FabModalProps {
className?: string,
width?: ModalSize,
customFooter?: ReactNode,
onConfirm?: (event: SyntheticEvent) => void,
onConfirm?: (event: BaseSyntheticEvent) => void,
preventConfirm?: boolean
}
// initial request to the API
const blackLogoFile = CustomAssetAPI.get(CustomAssetName.LogoBlackFile);
/**
* This component is a template for a modal dialog that wraps the application style
*/
export const FabModal: React.FC<FabModalProps> = ({ title, isOpen, toggleModal, children, confirmButton, className, width = 'sm', closeButton, customFooter, onConfirm, preventConfirm }) => {
const { t } = useTranslation('shared');
// the theme's logo, for back backgrounds
const blackLogo = blackLogoFile.read();
/**

View File

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

View File

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

View File

@ -1,10 +1,8 @@
import React, { Suspense } from 'react';
/**
* 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">

View File

@ -1,34 +1,48 @@
/**
* 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 { LabelledInput } from './base/labelled-input';
import { useTranslation } from 'react-i18next';
interface DocumentFiltersProps {
onFilterChange: (value: { reference: string, customer: string, date: Date }) => void
}
/**
* This component shows 3 input fields for filtering invoices/payment-schedules by reference, customer name and date
*/
export const DocumentFilters: React.FC<DocumentFiltersProps> = ({ onFilterChange }) => {
const { t } = useTranslation('admin');
// stores the value of reference input
const [referenceFilter, setReferenceFilter] = useState('');
// stores the value of the customer input
const [customerFilter, setCustomerFilter] = useState('');
// stores the value of the date input
const [dateFilter, setDateFilter] = useState(null);
/**
* When any filter changes, trigger the callback with the current value of all filters
*/
useEffect(() => {
onFilterChange({ reference: referenceFilter, customer: customerFilter, date: dateFilter });
}, [referenceFilter, customerFilter, dateFilter])
/**
* Callback triggered when the input 'reference' is updated.
*/
const handleReferenceUpdate = (e) => {
setReferenceFilter(e.target.value);
}
/**
* Callback triggered when the input 'customer' is updated.
*/
const handleCustomerUpdate = (e) => {
setCustomerFilter(e.target.value);
}
/**
* Callback triggered when the input 'date' is updated.
*/
const handleDateUpdate = (e) => {
let date = e.target.value;
if (e.target.value === '') date = null;

View File

@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Select from 'react-select';
import { react2angular } from 'react2angular';
import { Loader } from './loader';
import { Loader } from './base/loader';
import { Event } from '../models/event';
import { EventTheme } from '../models/event-theme';
import { IApplication } from '../models/application';

View File

@ -1,17 +1,13 @@
/**
* 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';
import '../../lib/i18n';
import { Loader } from '../base/loader';
import { FabModal } from '../base/fab-modal';
import { IFablab } from '../../models/fablab';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
declare var Application: IApplication;
declare var Fablab: IFablab;
@ -20,8 +16,13 @@ interface PaymentScheduleSummaryProps {
schedule: PaymentSchedule
}
/**
* This component displays a summary of the monthly payment schedule for the current cart, with a subscription
*/
const PaymentScheduleSummary: React.FC<PaymentScheduleSummaryProps> = ({ schedule }) => {
const { t } = useTranslation('shared');
// is open, the modal dialog showing the full details of the payment schedule?
const [modal, setModal] = useState(false);
/**

View File

@ -1,33 +1,40 @@
/**
* 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';
import { FabButton } from '../base/fab-button';
import { Loader } from '../base/loader';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
import PaymentScheduleAPI from '../../api/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesDashboardProps {
currentUser: User
currentUser: User,
onError: (message: string) => void,
onCardUpdateSuccess: (message: string) => void,
}
// how many payment schedules should we display for each page?
const PAGE_SIZE = 20;
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
* for the currentUser
*/
const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('logged');
// list of displayed payment schedules
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
// current page
const [pageNumber, setPageNumber] = useState<number>(1);
/**
* When the component is loaded first, refresh the list of schedules to fill the first page.
*/
useEffect(() => {
handleRefreshList();
}, []);
@ -42,21 +49,28 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
api.index({ query: { page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}).catch((error) => onError(error.message));
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const handleRefreshList = (): 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); }
onError(err.message);
});
}
/**
* after a successful card update, provide a success message to the end-user
*/
const handleCardUpdateSuccess = (): void => {
onCardUpdateSuccess(t('app.logged.dashboard.payment_schedules.card_updated_success'));
}
/**
* Check if the current collection of payment schedules is empty or not.
*/
@ -75,7 +89,12 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
<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} />
<PaymentSchedulesTable paymentSchedules={paymentSchedules}
showCustomer={false}
refreshList={handleRefreshList}
operator={currentUser}
onError={onError}
onCardUpdateSuccess={handleCardUpdateSuccess} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.logged.dashboard.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
@ -83,12 +102,12 @@ const PaymentSchedulesDashboard: React.FC<PaymentSchedulesDashboardProps> = ({ c
}
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser }) => {
const PaymentSchedulesDashboardWrapper: React.FC<PaymentSchedulesDashboardProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesDashboard currentUser={currentUser} />
<PaymentSchedulesDashboard currentUser={currentUser} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser']));
Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess']));

View File

@ -1,36 +1,46 @@
/**
* 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 { 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';
import { FabButton } from '../base/fab-button';
import { Loader } from '../base/loader';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { IApplication } from '../../models/application';
import PaymentScheduleAPI from '../../api/payment-schedule';
declare var Application: IApplication;
interface PaymentSchedulesListProps {
currentUser: User
currentUser: User,
onError: (message: string) => void,
onCardUpdateSuccess: (message: string) => void,
}
// how many payment schedules should we display for each page?
const PAGE_SIZE = 20;
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('admin');
// list of displayed payment schedules
const [paymentSchedules, setPaymentSchedules] = useState<Array<PaymentSchedule>>([]);
// current page
const [pageNumber, setPageNumber] = useState<number>(1);
// current filter, by reference, for the schedules
const [referenceFilter, setReferenceFilter] = useState<string>(null);
// current filter, by customer's name, for the schedules
const [customerFilter, setCustomerFilter] = useState<string>(null);
// current filter, by date, for the schedules and the deadlines
const [dateFilter, setDateFilter] = useState<Date>(null);
/**
* When the component is loaded first, refresh the list of schedules to fill the first page.
*/
useEffect(() => {
handleRefreshList();
}, []);
@ -46,7 +56,7 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
const api = new PaymentScheduleAPI();
api.list({ query: { reference, customer, date, page: 1, size: PAGE_SIZE }}).then((res) => {
setPaymentSchedules(res);
});
}).catch((error) => onError(error.message));
};
/**
@ -59,18 +69,18 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
api.list({ query: { reference: referenceFilter, customer: customerFilter, date: dateFilter, page: pageNumber + 1, size: PAGE_SIZE }}).then((res) => {
const list = paymentSchedules.concat(res);
setPaymentSchedules(list);
});
}).catch((error) => onError(error.message));
}
/**
* Reload from te API all the currently displayed payment schedules
*/
const handleRefreshList = (onError?: (msg: any) => void): void => {
const handleRefreshList = (): 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); }
onError(err.message);
});
}
@ -88,6 +98,13 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
return hasSchedules() && paymentSchedules.length < paymentSchedules[0].max_length;
}
/**
* after a successful card update, provide a success message to the operator
*/
const handleCardUpdateSuccess = (): void => {
onCardUpdateSuccess(t('app.admin.invoices.payment_schedules.card_updated_success'));
}
return (
<div className="payment-schedules-list">
<h3>
@ -99,7 +116,12 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
</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} />
<PaymentSchedulesTable paymentSchedules={paymentSchedules}
showCustomer={true}
refreshList={handleRefreshList}
operator={currentUser}
onError={onError}
onCardUpdateSuccess={handleCardUpdateSuccess} />
{hasMoreSchedules() && <FabButton className="load-more" onClick={handleLoadMore}>{t('app.admin.invoices.payment_schedules.load_more')}</FabButton>}
</div>}
</div>
@ -107,12 +129,12 @@ const PaymentSchedulesList: React.FC<PaymentSchedulesListProps> = ({ currentUser
}
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser }) => {
const PaymentSchedulesListWrapper: React.FC<PaymentSchedulesListProps> = ({ currentUser, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesList currentUser={currentUser} />
<PaymentSchedulesList currentUser={currentUser} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser']));
Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess']));

View File

@ -1,48 +1,57 @@
/**
* 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 { Loader } from '../base/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';
import { FabButton } from '../base/fab-button';
import { FabModal } from '../base/fab-modal';
import { UpdateCardModal } from '../payment/update-card-modal';
import { StripeElements } from '../payment/stripe/stripe-elements';
import { StripeConfirm } from '../payment/stripe/stripe-confirm';
import { User, UserRole } from '../../models/user';
import { IFablab } from '../../models/fablab';
import { PaymentSchedule, PaymentScheduleItem, PaymentScheduleItemState } from '../../models/payment-schedule';
import PaymentScheduleAPI from '../../api/payment-schedule';
import { useImmer } from 'use-immer';
import { SettingName } from '../../models/setting';
declare var Fablab: IFablab;
interface PaymentSchedulesTableProps {
paymentSchedules: Array<PaymentSchedule>,
showCustomer?: boolean,
refreshList: (onError: (msg: any) => void) => void,
refreshList: () => void,
operator: User,
onError: (message: string) => void,
onCardUpdateSuccess: () => void
}
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
/**
* This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices
*/
const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator, onError, onCardUpdateSuccess }) => {
const { t } = useTranslation('shared');
// for each payment schedule: are the details (all deadlines) shown or hidden?
const [showExpanded, setShowExpanded] = useState<Map<number, boolean>>(new Map());
// is open, the modal dialog to confirm the cashing of a check?
const [showConfirmCashing, setShowConfirmCashing] = useState<boolean>(false);
// is open, the modal dialog the resolve a pending card payment?
const [showResolveAction, setShowResolveAction] = useState<boolean>(false);
// the user cannot confirm the action modal (3D secure), unless he has resolved the pending action
const [isConfirmActionDisabled, setConfirmActionDisabled] = useState<boolean>(true);
// is open, the modal dialog to update the card details
const [showUpdateCard, setShowUpdateCard] = useState<boolean>(false);
// when an action is triggered on a deadline, the deadline is saved here until the action is done or cancelled.
const [tempDeadline, setTempDeadline] = useState<PaymentScheduleItem>(null);
// when an action is triggered on a deadline, the parent schedule is saved here until the action is done or cancelled.
const [tempSchedule, setTempSchedule] = useState<PaymentSchedule>(null);
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
const [errors, setErrors] = useState<string>(null);
// is open, the modal dialog to cancel the associated subscription?
const [showCancelSubscription, setShowCancelSubscription] = useState<boolean>(false);
// we want to display the card update button, only once. This is an association table keeping when we already shown one
const cardUpdateButton = new Map<number, boolean>();
/**
* Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them
*/
@ -165,12 +174,14 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
);
case PaymentScheduleItemState.RequirePaymentMethod:
return (
<FabButton onClick={handleUpdateCard(item, schedule)}
<FabButton onClick={handleUpdateCard(schedule, item)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
);
case PaymentScheduleItemState.Error:
// if the payment is in error, the schedule is over, and we can't update the card
cardUpdateButton.set(schedule.id, true);
if (isPrivileged()) {
return (
<FabButton onClick={handleCancelSubscription(schedule)}
@ -181,6 +192,17 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
} else {
return <span>{t('app.shared.schedules_table.please_ask_reception')}</span>
}
case PaymentScheduleItemState.New:
if (!cardUpdateButton.get(schedule.id)) {
cardUpdateButton.set(schedule.id, true);
return (
<FabButton onClick={handleUpdateCard(schedule)}
icon={<i className="fas fa-credit-card" />}>
{t('app.shared.schedules_table.update_card')}
</FabButton>
)
}
return <span />
default:
return <span />
}
@ -213,7 +235,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
* Refresh all payment schedules in the table
*/
const refreshSchedulesTable = (): void => {
refreshList(setErrors);
refreshList();
}
/**
@ -262,7 +284,7 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
/**
* Callback triggered when the user's clicks on the "update card" button: show a modal to input a new card
*/
const handleUpdateCard = (item: PaymentScheduleItem, paymentSchedule: PaymentSchedule): ReactEventHandler => {
const handleUpdateCard = (paymentSchedule: PaymentSchedule, item?: PaymentScheduleItem): ReactEventHandler => {
return (): void => {
setTempDeadline(item);
setTempSchedule(paymentSchedule);
@ -277,46 +299,31 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
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();
if (tempDeadline) {
const api = new PaymentScheduleAPI();
api.payItem(tempDeadline.id).then(() => {
refreshSchedulesTable();
onCardUpdateSuccess();
toggleUpdateCardModal();
}).catch((err) => {
handleCardUpdateError(err);
});
} else {
// if no tempDeadline (i.e. PaymentScheduleItem), then the user is updating his card number in a pro-active way, we don't need to trigger the payment
onCardUpdateSuccess();
toggleUpdateCardModal();
}).catch((err) => {
handleCardUpdateError(err);
});
}
}
/**
* When the card was not updated, show the error
* When the card was not updated, raise the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
onError(error);
}
/**
@ -435,31 +442,13 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
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>
{tempSchedule && <UpdateCardModal isOpen={showUpdateCard}
toggleModal={toggleUpdateCardModal}
operator={operator}
afterSuccess={handleCardUpdateSuccess}
onError={handleCardUpdateError}
schedule={tempSchedule}>
</UpdateCardModal>}
</StripeElements>
</div>
</div>
@ -468,10 +457,10 @@ const PaymentSchedulesTableComponent: React.FC<PaymentSchedulesTableProps> = ({
PaymentSchedulesTableComponent.defaultProps = { showCustomer: false };
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator }) => {
export const PaymentSchedulesTable: React.FC<PaymentSchedulesTableProps> = ({ paymentSchedules, showCustomer, refreshList, operator, onError, onCardUpdateSuccess }) => {
return (
<Loader>
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} />
<PaymentSchedulesTableComponent paymentSchedules={paymentSchedules} showCustomer={showCustomer} refreshList={refreshList} operator={operator} onError={onError} onCardUpdateSuccess={onCardUpdateSuccess} />
</Loader>
);
}

View File

@ -1,15 +1,10 @@
/**
* 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';
import '../../lib/i18n';
import { Loader } from '../base/loader';
import { IApplication } from '../../models/application';
declare var Application: IApplication;
@ -20,6 +15,10 @@ interface SelectScheduleProps {
className: string,
}
/**
* This component is a switch enabling the users to choose if they want to pay by monthly schedule
* or with a one time payment
*/
const SelectSchedule: React.FC<SelectScheduleProps> = ({ show, selected, onChange, className }) => {
const { t } = useTranslation('shared');

View File

@ -0,0 +1,230 @@
import React, { FunctionComponent, ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import WalletLib from '../../lib/wallet';
import { WalletInfo } from '../wallet-info';
import { FabModal, ModalSize } from '../base/fab-modal';
import { HtmlTranslate } from '../base/html-translate';
import { CustomAssetName } from '../../models/custom-asset';
import { IFablab } from '../../models/fablab';
import { ShoppingCart } from '../../models/payment';
import { PaymentSchedule } from '../../models/payment-schedule';
import { User } from '../../models/user';
import CustomAssetAPI from '../../api/custom-asset';
import PriceAPI from '../../api/price';
import WalletAPI from '../../api/wallet';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { SettingName } from '../../models/setting';
import { ComputePriceResult } from '../../models/price';
import { Wallet } from '../../models/wallet';
declare var Fablab: IFablab;
export interface GatewayFormProps {
onSubmit: () => void,
onSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: boolean,
cart?: ShoppingCart,
formId: string,
}
interface AbstractPaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
customer: User,
logoFooter: ReactNode,
GatewayForm: FunctionComponent<GatewayFormProps>,
formId: string,
className?: string,
formClassName?: string,
}
// initial request to the API
const cgvFile = CustomAssetAPI.get(CustomAssetName.CgvFile);
/**
* This component is an abstract modal that must be extended by each payment gateway to include its payment form.
*
* This component must not be called directly but must be extended for each implemented payment gateway
* @see https://reactjs.org/docs/composition-vs-inheritance.html
*/
export const AbstractPaymentModal: React.FC<AbstractPaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => {
// customer's wallet
const [wallet, setWallet] = useState<Wallet>(null);
// server-computed price with all details
const [price, setPrice] = useState<ComputePriceResult>(null);
// remaining price = total price - wallet amount
const [remainingPrice, setRemainingPrice] = useState<number>(0);
// is the component ready to display?
const [ready, setReady] = useState<boolean>(false);
// errors to display in the UI (gateway errors mainly)
const [errors, setErrors] = useState<string>(null);
// are we currently processing the payment (ie. the form was submit, but the process is still running)?
const [submitState, setSubmitState] = useState<boolean>(false);
// did the user accepts the terms of services (CGV)?
const [tos, setTos] = useState<boolean>(false);
// currently active payment gateway
const [gateway, setGateway] = useState<string>(null);
const { t } = useTranslation('shared');
const cgv = cgvFile.read();
/**
* When the component is loaded first, get the name of the currently active payment modal
*/
useEffect(() => {
const api = new SettingAPI();
api.get(SettingName.PaymentGateway).then((setting) => {
// we capitalize the first letter of the name
setGateway(setting.value.replace(/^\w/, (c) => c.toUpperCase()));
})
}, []);
/**
* On each display:
* - Refresh the wallet
* - Refresh the price
* - Refresh the remaining price
*/
useEffect(() => {
if (!cart) return;
WalletAPI.getByUser(cart.customer_id).then((wallet) => {
setWallet(wallet);
PriceAPI.compute(cart).then((res) => {
setPrice(res);
const wLib = new WalletLib(wallet);
setRemainingPrice(wLib.computeRemainingPrice(res.price));
setReady(true);
})
})
}, [cart]);
/**
* 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);
}
/**
* 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: Invoice|PaymentSchedule): Promise<void> => {
setSubmitState(false);
afterSuccess(result);
}
/**
* When the payment form raises an error, it is handled by this callback which display it in the modal.
*/
const handleFormError = (message: string): void => {
setSubmitState(false);
setErrors(message);
}
/**
* Check the form can be submitted.
* => We're not currently already submitting the form, and, if the terms of service are enabled, the user must agree with them.
*/
const canSubmit = (): boolean => {
let terms = true;
if (hasCgv()) { terms = tos; }
return !submitState && terms;
}
return (
<FabModal title={t('app.shared.payment.online_payment') }
isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.medium}
closeButton={false}
customFooter={logoFooter}
className={`payment-modal ${className ? className : ''}`}>
{ready && <div>
<WalletInfo cart={cart} currentUser={currentUser} wallet={wallet} price={price?.price} />
<GatewayForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess}
onError={handleFormError}
operator={currentUser}
className={`gateway-form ${formClassName ? formClassName : ''}`}
formId={formId}
cart={cart}
customer={customer}
paymentSchedule={isPaymentSchedule()}>
{hasErrors() && <div className="payment-errors">
{errors}
</div>}
{isPaymentSchedule() && <div className="payment-schedule-info">
<HtmlTranslate trKey="app.shared.payment.payment_schedule_html" options={{ DEADLINES: schedule.items.length, GATEWAY: gateway }} />
</div>}
{hasCgv() && <div className="terms-of-sales">
<input type="checkbox" id="acceptToS" name="acceptCondition" checked={tos} onChange={toggleTos} required />
<label htmlFor="acceptToS">{ t('app.shared.payment.i_have_read_and_accept_') }
<a href={cgv.custom_asset_file_attributes.attachment_url} target="_blank">
{ t('app.shared.payment._the_general_terms_and_conditions') }
</a>
</label>
</div>}
</GatewayForm>
{!submitState && <button type="submit"
disabled={!canSubmit()}
form={formId}
className="validate-btn">
{t('app.shared.payment.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>}
</div>}
</FabModal>
);
}

View File

@ -0,0 +1,93 @@
import React, { ReactElement } from 'react';
import { react2angular } from 'react2angular';
import { Loader } from '../base/loader';
import { StripeModal } from './stripe/stripe-modal';
import { PayZenModal } from './payzen/payzen-modal';
import { IApplication } from '../../models/application';
import { ShoppingCart } from '../../models/payment';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { SettingName } from '../../models/setting';
import { Invoice } from '../../models/invoice';
import SettingAPI from '../../api/setting';
import { useTranslation } from 'react-i18next';
declare var Application: IApplication;
interface PaymentModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
onError: (message: string) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
customer: User
}
// initial request to the API
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
/**
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const PaymentModal: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
const { t } = useTranslation('shared');
const gateway = paymentGateway.read();
/**
* Render the Stripe payment modal
*/
const renderStripeModal = (): ReactElement => {
return <StripeModal isOpen={isOpen}
toggleModal={toggleModal}
afterSuccess={afterSuccess}
cart={cart}
currentUser={currentUser}
schedule={schedule}
customer={customer} />
}
/**
* Render the PayZen payment modal
*/
const renderPayZenModal = (): ReactElement => {
return <PayZenModal isOpen={isOpen}
toggleModal={toggleModal}
afterSuccess={afterSuccess}
cart={cart}
currentUser={currentUser}
schedule={schedule}
customer={customer} />
}
/**
* Determine which gateway is enabled and return the appropriate payment modal
*/
switch (gateway.value) {
case 'stripe':
return renderStripeModal();
case 'payzen':
return renderPayZenModal();
case null:
case undefined:
onError(t('app.shared.payment_modal.online_payment_disabled'));
return <div />;
default:
onError(t('app.shared.payment_modal.unexpected_error'));
console.error(`[PaymentModal] Unimplemented gateway: ${gateway.value}`);
return <div />;
}
}
const PaymentModalWrapper: React.FC<PaymentModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => {
return (
<Loader>
<PaymentModal isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} currentUser={currentUser} schedule={schedule} cart={cart} customer={customer} />
</Loader>
);
}
Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer']));

View File

@ -0,0 +1,90 @@
import React, { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../../base/fab-modal';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import payzenLogo from '../../../../../images/payzen-secure.png';
import { PayzenForm } from './payzen-form';
interface PayzenCardUpdateModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: () => void,
schedule: PaymentSchedule,
operator: User
}
export const PayzenCardUpdateModal: React.FC<PayzenCardUpdateModalProps> = ({ isOpen, toggleModal, onSuccess, schedule, operator }) => {
const { t } = useTranslation('shared');
// prevent submitting the form to update the card details, until the user has filled correctly all required fields
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
// we save errors here, if any, for display purposes.
const [errors, setErrors] = useState<string>(null);
// the unique identifier of the html form
const formId = "payzen-card";
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="payzen-modal-icons">
<img src={payzenLogo} alt="powered by PayZen" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* When the user clicks the submit button, we disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was not updated, show the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
}
return (
<FabModal title={t('app.shared.payzen_card_update_modal.update_card')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
customFooter={logoFooter()}
className="payzen-update-card-modal">
{schedule && <PayzenForm onSubmit={handleCardUpdateSubmit}
onSuccess={onSuccess}
onError={handleCardUpdateError}
className="card-form"
paymentSchedule={true}
operator={operator}
customer={schedule.user as User}
updateCard={true}
paymentScheduleId={schedule.id}
formId={formId} >
{errors && <div className="payzen-errors">
{errors}
</div>}
</PayzenForm>}
<div className="submit-card">
{canSubmitUpdateCard && <button type="submit" disabled={!canSubmitUpdateCard} form={formId} className="submit-card-btn">{t('app.shared.payzen_card_update_modal.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
);
}

View File

@ -0,0 +1,164 @@
import React, { FormEvent, FunctionComponent, useEffect, useRef, useState } from 'react';
import KRGlue from "@lyracom/embedded-form-glue";
import { GatewayFormProps } from '../abstract-payment-modal';
import SettingAPI from '../../../api/setting';
import { SettingName } from '../../../models/setting';
import PayzenAPI from '../../../api/payzen';
import { Loader } from '../../base/loader';
import {
CreateTokenResponse,
KryptonClient,
KryptonError, PaymentTransaction,
ProcessPaymentAnswer
} from '../../../models/payzen';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { Invoice } from '../../../models/invoice';
// we use these two additional parameters to update the card, if provided
interface PayzenFormProps extends GatewayFormProps {
updateCard?: boolean,
paymentScheduleId: number,
}
/**
* 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={formId}.
*/
export const PayzenForm: React.FC<PayzenFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, updateCard = false, cart, customer, formId, paymentScheduleId }) => {
const PayZenKR = useRef<KryptonClient>(null);
const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader');
useEffect(() => {
const api = new SettingAPI();
api.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => {
createToken().then(formToken => {
// Load the remote library
KRGlue.loadLibrary(settings.get(SettingName.PayZenEndpoint), settings.get(SettingName.PayZenPublicKey))
.then(({ KR }) =>
KR.setFormConfig({
formToken: formToken.formToken,
})
)
.then(({ KR }) => KR.addForm("#payzenPaymentForm"))
.then(({ KR, result }) => KR.showForm(result.formId))
.then(({ KR }) => KR.onFormReady(handleFormReady))
.then(({ KR }) => KR.onFormCreated(handleFormCreated))
.then(({ KR }) => PayZenKR.current = KR);
}).catch(error => onError(error));
});
}, [cart, paymentSchedule, customer]);
/**
* Ask the API to create the form token.
* Depending on the current transaction (schedule or not), a PayZen Token or Payment may be created.
*/
const createToken = async (): Promise<CreateTokenResponse> => {
if (updateCard) {
return await PayzenAPI.updateToken(paymentScheduleId);
} else if (paymentSchedule) {
return await PayzenAPI.chargeCreateToken(cart, customer);
} else {
return await PayzenAPI.chargeCreatePayment(cart, customer);
}
}
/**
* Callback triggered on PayZen successful payments
* @see https://docs.lyra.com/fr/rest/V4.0/javascript/features/reference.html#kronsubmit
*/
const onPaid = (event: ProcessPaymentAnswer): boolean => {
PayzenAPI.checkHash(event.hashAlgorithm, event.hashKey, event.hash, event.rawClientAnswer).then(async (hash) => {
if (hash.validity) {
if (updateCard) return onSuccess(null);
const transaction = event.clientAnswer.transactions[0];
if (event.clientAnswer.orderStatus === 'PAID') {
confirmPayment(event, transaction).then((confirmation) => {
PayZenKR.current.removeForms().then(() => {
onSuccess(confirmation);
});
}).catch(e => onError(e))
} else {
const error = `${transaction?.errorMessage}. ${transaction?.detailedErrorMessage || ''}`;
onError(error || event.clientAnswer.orderStatus);
}
}
});
return true;
};
/**
* Confirm the payment, depending on the current type of payment (single shot or recurring)
*/
const confirmPayment = async (event: ProcessPaymentAnswer, transaction: PaymentTransaction): Promise<Invoice|PaymentSchedule> => {
if (paymentSchedule) {
return await PayzenAPI.confirmPaymentSchedule(event.clientAnswer.orderDetails.orderId, transaction.uuid, cart);
} else {
return await PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cart);
}
}
/**
* Callback triggered when the PayZen form was entirely loaded and displayed
* @see https://docs.lyra.com/fr/rest/V4.0/javascript/features/reference.html#%C3%89v%C3%A9nements
*/
const handleFormReady = () => {
setLoadingClass('hidden');
};
/**
* Callback triggered when the PayZen form has started to show up but is not entirely loaded
* @see https://docs.lyra.com/fr/rest/V4.0/javascript/features/reference.html#%C3%89v%C3%A9nements
*/
const handleFormCreated = () => {
setLoadingClass('loader-overlay');
}
/**
* Callback triggered when the PayZen payment was refused
* @see https://docs.lyra.com/fr/rest/V4.0/javascript/features/reference.html#kronerror
*/
const handleError = (answer: KryptonError) => {
const message = `${answer.errorMessage}. ${answer.detailedErrorMessage ? answer.detailedErrorMessage : ''}`;
onError(message);
}
/**
* Handle the submission of the form.
*/
const handleSubmit = async (event: FormEvent): Promise<void> => {
event.preventDefault();
onSubmit();
try {
const { result } = await PayZenKR.current.validateForm();
if (result === null) {
await PayZenKR.current.onSubmit(onPaid);
await PayZenKR.current.onError(handleError);
await PayZenKR.current.submit();
}
} catch (err) {
// catch api errors
onError(err);
}
}
const Loader: FunctionComponent = () => {
return (
<div className={`fa-3x ${loadingClass}`}>
<i className="fas fa-circle-notch fa-spin" />
</div>
);
};
return (
<form onSubmit={handleSubmit} id={formId} className={className ? className : ''}>
<Loader />
<div className="container">
<div id="payzenPaymentForm" />
</div>
{children}
</form>
);
}

View File

@ -0,0 +1,215 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { enableMapSet } from 'immer';
import { useImmer } from 'use-immer';
import { HtmlTranslate } from '../../base/html-translate';
import { FabInput } from '../../base/fab-input';
import { Loader } from '../../base/loader';
import { SettingName } from '../../../models/setting';
import SettingAPI from '../../../api/setting';
import PayzenAPI from '../../../api/payzen';
enableMapSet();
interface PayZenKeysFormProps {
onValidKeys: (payZenSettings: Map<SettingName, string>) => void,
onInvalidKeys: () => void,
}
// all settings related to PayZen that are requested by this form
const payZenSettings: Array<SettingName> = [SettingName.PayZenUsername, SettingName.PayZenPassword, SettingName.PayZenEndpoint, SettingName.PayZenHmacKey, SettingName.PayZenPublicKey];
// settings related the to PayZen REST API (server side)
const restApiSettings: Array<SettingName> = [SettingName.PayZenUsername, SettingName.PayZenPassword, SettingName.PayZenEndpoint, SettingName.PayZenHmacKey];
// Prevent multiples call to the payzen keys validation endpoint.
// this cannot be handled by a React state because of their asynchronous nature
let pendingKeysValidation = false;
/**
* Form to set the PayZen's username, password and public key
*/
const PayZenKeysFormComponent: React.FC<PayZenKeysFormProps> = ({ onValidKeys, onInvalidKeys }) => {
const { t } = useTranslation('admin');
// values of the PayZen settings
const [settings, updateSettings] = useImmer<Map<SettingName, string>>(new Map(payZenSettings.map(name => [name, ''])));
// Icon of the fieldset for the PayZen's keys concerning the REST API. Used to display if the key is valid.
const [restApiAddOn, setRestApiAddOn] = useState<ReactNode>(null);
// Style class for the add-on icon, for the REST API
const [restApiAddOnClassName, setRestApiAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>('');
// Icon of the input field for the PayZen's public key. Used to display if the key is valid.
const [publicKeyAddOn, setPublicKeyAddOn] = useState<ReactNode>(null);
// Style class for the add-on icon, for the public key
const [publicKeyAddOnClassName, setPublicKeyAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>('');
/**
* When the component loads for the first time, initialize the keys with the values fetched from the API (if any)
*/
useEffect(() => {
const api = new SettingAPI();
api.query(payZenSettings).then(payZenKeys => {
updateSettings(new Map(payZenKeys));
}).catch(error => console.error(error));
}, []);
/**
* When the style class for the public key, and the REST API are updated, check if they indicate valid keys.
* If both are valid, run the 'onValidKeys' callback, else run 'onInvalidKeys'
*/
useEffect(() => {
const validClassName = 'key-valid';
if (publicKeyAddOnClassName === validClassName && restApiAddOnClassName === validClassName) {
onValidKeys(settings);
} else {
onInvalidKeys();
}
}, [publicKeyAddOnClassName, restApiAddOnClassName, settings]);
useEffect(() => {
testRestApi();
}, [settings])
/**
* Assign the inputted key to the settings and check if it is valid.
* Depending on the test result, assign an add-on icon plus a style to notify the user.
*/
const testPublicKey = (key: string) => {
if (!key || !key.match(/^[0-9]+:/)) {
setPublicKeyAddOn(<i className="fa fa-times" />);
setPublicKeyAddOnClassName('key-invalid');
return;
}
updateSettings(draft => draft.set(SettingName.PayZenPublicKey, key));
setPublicKeyAddOn(<i className="fa fa-check" />);
setPublicKeyAddOnClassName('key-valid');
}
/**
* Send a test call to the payZen REST API to check if the inputted settings key are valid.
* Depending on the test result, assign an add-on icon and a style to notify the user.
*/
const testRestApi = () => {
let valid: boolean = restApiSettings.map(s => !!settings.get(s))
.reduce((acc, val) => acc && val, true);
if (valid && !pendingKeysValidation) {
pendingKeysValidation = true;
PayzenAPI.chargeSDKTest(
settings.get(SettingName.PayZenEndpoint),
settings.get(SettingName.PayZenUsername),
settings.get(SettingName.PayZenPassword)
).then(result => {
pendingKeysValidation = false;
if (result.success) {
setRestApiAddOn(<i className="fa fa-check" />);
setRestApiAddOnClassName('key-valid');
} else {
setRestApiAddOn(<i className="fa fa-times" />);
setRestApiAddOnClassName('key-invalid');
}
}, () => {
pendingKeysValidation = false;
setRestApiAddOn(<i className="fa fa-times" />);
setRestApiAddOnClassName('key-invalid');
});
}
if (!valid) {
setRestApiAddOn(<i className="fa fa-times" />);
setRestApiAddOnClassName('key-invalid');
}
}
/**
* Assign the inputted key to the given settings
*/
const setApiKey = (setting: SettingName.PayZenUsername | SettingName.PayZenPassword | SettingName.PayZenEndpoint | SettingName.PayZenHmacKey) => {
return (key: string) => {
updateSettings(draft => draft.set(setting, key));
}
}
/**
* Check if an add-on icon must be shown for the API settings
*/
const hasApiAddOn = () => {
return restApiAddOn !== null;
}
return (
<div className="payzen-keys-form">
<div className="payzen-keys-info">
<HtmlTranslate trKey="app.admin.invoices.payment.payzen_keys_info_html" />
</div>
<form name="payzenKeysForm">
<fieldset>
<legend>{t('app.admin.invoices.payment.client_keys')}</legend>
<div className="payzen-public-input">
<label htmlFor="payzen_public_key">{ t('app.admin.invoices.payment.payzen.payzen_public_key') } *</label>
<FabInput id="payzen_public_key"
icon={<i className="fas fa-info" />}
defaultValue={settings.get(SettingName.PayZenPublicKey)}
onChange={testPublicKey}
addOn={publicKeyAddOn}
addOnClassName={publicKeyAddOnClassName}
debounce={200}
required />
</div>
</fieldset>
<fieldset>
<legend className={hasApiAddOn() ? 'with-addon' : ''}>
<span>{t('app.admin.invoices.payment.api_keys')}</span>
{hasApiAddOn() && <span className={`fieldset-legend--addon ${restApiAddOnClassName ? restApiAddOnClassName : ''}`}>{restApiAddOn}</span>}
</legend>
<div className="payzen-api-user-input">
<label htmlFor="payzen_username">{ t('app.admin.invoices.payment.payzen.payzen_username') } *</label>
<FabInput id="payzen_username"
type="number"
icon={<i className="fas fa-user-alt" />}
defaultValue={settings.get(SettingName.PayZenUsername)}
onChange={setApiKey(SettingName.PayZenUsername)}
debounce={200}
required />
</div>
<div className="payzen-api-password-input">
<label htmlFor="payzen_password">{ t('app.admin.invoices.payment.payzen.payzen_password') } *</label>
<FabInput id="payzen_password"
icon={<i className="fas fa-key" />}
defaultValue={settings.get(SettingName.PayZenPassword)}
onChange={setApiKey(SettingName.PayZenPassword)}
debounce={200}
required />
</div>
<div className="payzen-api-endpoint-input">
<label htmlFor="payzen_endpoint">{ t('app.admin.invoices.payment.payzen.payzen_endpoint') } *</label>
<FabInput id="payzen_endpoint"
type="url"
icon={<i className="fas fa-link" />}
defaultValue={settings.get(SettingName.PayZenEndpoint)}
onChange={setApiKey(SettingName.PayZenEndpoint)}
debounce={200}
required />
</div>
<div className="payzen-api-hmac-input">
<label htmlFor="payzen_hmac">{ t('app.admin.invoices.payment.payzen.payzen_hmac') } *</label>
<FabInput id="payzen_hmac"
icon={<i className="fas fa-subscript" />}
defaultValue={settings.get(SettingName.PayZenHmacKey)}
onChange={setApiKey(SettingName.PayZenHmacKey)}
debounce={200}
required />
</div>
</fieldset>
</form>
</div>
);
}
export const PayZenKeysForm: React.FC<PayZenKeysFormProps> = ({ onValidKeys, onInvalidKeys }) => {
return (
<Loader>
<PayZenKeysFormComponent onValidKeys={onValidKeys} onInvalidKeys={onInvalidKeys} />
</Loader>
);
}

View File

@ -0,0 +1,78 @@
import React, { FunctionComponent, ReactNode } from 'react';
import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal';
import { ShoppingCart } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import { Invoice } from '../../../models/invoice';
import payzenLogo from '../../../../../images/payzen-secure.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { PayzenForm } from './payzen-form';
interface PayZenModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
customer: User
}
/**
* This component enables the user to input his card data or process payments, using the PayZen gateway.
* Supports Strong-Customer Authentication (SCA).
*
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const PayZenModal: React.FC<PayZenModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
/**
* Return the logos, shown in the modal footer.
*/
const logoFooter = (): ReactNode => {
return (
<div className="payzen-modal-icons">
<img src={payzenLogo} alt="powered by PayZen" />
<img src={mastercardLogo} alt="mastercard" />
<img src={visaLogo} alt="visa" />
</div>
);
}
/**
* Integrates the PayzenForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
return (
<PayzenForm onSubmit={onSubmit}
onSuccess={onSuccess}
onError={onError}
customer={customer}
operator={operator}
formId={formId}
cart={cart}
className={className}
paymentSchedule={paymentSchedule}>
{children}
</PayzenForm>
);
}
return (
<AbstractPaymentModal isOpen={isOpen}
toggleModal={toggleModal}
logoFooter={logoFooter()}
formId="payzen-form"
formClassName="payzen-form"
className="payzen-modal"
currentUser={currentUser}
cart={cart}
customer={customer}
afterSuccess={afterSuccess}
schedule={schedule}
GatewayForm={renderForm} />
);
}

View File

@ -0,0 +1,160 @@
import React, { useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { useTranslation } from 'react-i18next';
import { useImmer } from 'use-immer';
import { FabInput } from '../../base/fab-input';
import { FabButton } from '../../base/fab-button';
import { Loader } from '../../base/loader';
import { HtmlTranslate } from '../../base/html-translate';
import { SettingName } from '../../../models/setting';
import { IApplication } from '../../../models/application';
import SettingAPI from '../../../api/setting';
declare var Application: IApplication;
interface PayzenSettingsProps {
onEditKeys: (onlinePaymentModule: { value: boolean }) => void,
onCurrencyUpdateSuccess: (currency: string) => void
}
// placeholder value for the hidden settings
const PAYZEN_HIDDEN = 'HiDdEnHIddEnHIdDEnHiDdEnHIddEnHIdDEn';
// settings related to PayZen that can be shown publicly
const payZenPublicSettings: Array<SettingName> = [SettingName.PayZenPublicKey, SettingName.PayZenEndpoint, SettingName.PayZenUsername];
// settings related to PayZen that must be kept on server-side
const payZenPrivateSettings: Array<SettingName> = [SettingName.PayZenPassword, SettingName.PayZenHmacKey];
// other settings related to PayZen
const payZenOtherSettings: Array<SettingName> = [SettingName.PayZenCurrency];
// all PayZen settings
const payZenSettings: Array<SettingName> = payZenPublicSettings.concat(payZenPrivateSettings).concat(payZenOtherSettings);
// icons for the inputs of each setting
const icons:Map<SettingName, string> = new Map([
[SettingName.PayZenHmacKey, 'subscript'],
[SettingName.PayZenPassword, 'key'],
[SettingName.PayZenUsername, 'user'],
[SettingName.PayZenEndpoint, 'link'],
[SettingName.PayZenPublicKey, 'info']
])
/**
* This component displays a summary of the PayZen account keys, with a button triggering the modal to edit them
*/
export const PayzenSettings: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCurrencyUpdateSuccess }) => {
const { t } = useTranslation('admin');
// all the values of the settings related to PayZen
const [settings, updateSettings] = useImmer<Map<SettingName, string>>(new Map(payZenSettings.map(name => [name, ''])));
// store a possible error state for currency
const [error, setError] = useState<string>('');
/**
* When the component is mounted, we initialize the values of the settings with those fetched from the API.
* For the private settings, we initialize them with the placeholder value, if the setting is set.
*/
useEffect(() => {
const api = new SettingAPI();
api.query(payZenPublicSettings.concat(payZenOtherSettings)).then(payZenKeys => {
api.isPresent(SettingName.PayZenPassword).then(pzPassword => {
api.isPresent(SettingName.PayZenHmacKey).then(pzHmac => {
const map = new Map(payZenKeys);
map.set(SettingName.PayZenPassword, pzPassword ? PAYZEN_HIDDEN : '');
map.set(SettingName.PayZenHmacKey, pzHmac ? PAYZEN_HIDDEN : '');
updateSettings(map);
}).catch(error => { console.error(error); })
}).catch(error => { console.error(error); });
}).catch(error => { console.error(error); });
}, []);
/**
* Callback triggered when the user clicks on the "update keys" button.
* This will open the modal dialog allowing to change the keys
*/
const handleKeysUpdate = (): void => {
onEditKeys({ value: true });
}
/**
* Callback triggered when the user changes the content of the currency input field.
*/
const handleCurrencyUpdate = (value: string, validity?: ValidityState): void => {
if (!validity || validity.valid) {
setError('');
updateSettings(draft => draft.set(SettingName.PayZenCurrency, value));
} else {
setError(t('app.admin.invoices.payment.payzen.currency_error'));
}
}
/**
* Callback triggered when the user clicks on the "save currency" button.
* This will update the setting on the server.
*/
const saveCurrency = (): void => {
const api = new SettingAPI();
api.update(SettingName.PayZenCurrency, settings.get(SettingName.PayZenCurrency)).then(result => {
setError('');
updateSettings(draft => draft.set(SettingName.PayZenCurrency, result.value));
onCurrencyUpdateSuccess(result.value);
}, reason => {
setError(t('app.admin.invoices.payment.payzen.error_while_saving')+reason);
})
}
return (
<div className="payzen-settings">
<h3 className="title">{t('app.admin.invoices.payment.payzen.payzen_keys')}</h3>
<div className="payzen-keys">
{payZenPublicSettings.concat(payZenPrivateSettings).map(setting => {
return (
<div className="key-wrapper" key={setting}>
<label htmlFor={setting}>{t(`app.admin.invoices.payment.payzen.${setting}`)}</label>
<FabInput defaultValue={settings.get(setting)}
id={setting}
type={payZenPrivateSettings.indexOf(setting) > -1 ? 'password' : 'text'}
icon={<i className={`fas fa-${icons.get(setting)}`} />}
readOnly
disabled />
</div>
);
})}
<div className="edit-keys">
<FabButton className="edit-keys-btn" onClick={handleKeysUpdate}>{t('app.admin.invoices.payment.edit_keys')}</FabButton>
</div>
</div>
<div className="payzen-currency">
<h3 className="title">{t('app.admin.invoices.payment.payzen.currency')}</h3>
<p className="currency-info">
<HtmlTranslate trKey="app.admin.invoices.payment.payzen.currency_info_html" />
</p>
<div className="payzen-currency-form">
<div className="currency-wrapper">
<label htmlFor="payzen_currency">{t('app.admin.invoices.payment.payzen.payzen_currency')}</label>
<FabInput defaultValue={settings.get(SettingName.PayZenCurrency)}
id="payzen_currency"
icon={<i className="fas fa-money-bill" />}
onChange={handleCurrencyUpdate}
maxLength={3}
pattern="[A-Z]{3}"
error={error} />
</div>
<FabButton className="save-currency" onClick={saveCurrency}>{t('app.admin.invoices.payment.payzen.save')}</FabButton>
</div>
</div>
</div>
);
}
const PayzenSettingsWrapper: React.FC<PayzenSettingsProps> = ({ onEditKeys, onCurrencyUpdateSuccess }) => {
return (
<Loader>
<PayzenSettings onEditKeys={onEditKeys} onCurrencyUpdateSuccess={onCurrencyUpdateSuccess} />
</Loader>
);
}
Application.Components.component('payzenSettings', react2angular(PayzenSettingsWrapper, ['onEditKeys', 'onCurrencyUpdateSuccess']));

View File

@ -0,0 +1,84 @@
import React, { ReactNode, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FabModal } from '../../base/fab-modal';
import { StripeCardUpdate } from './stripe-card-update';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import stripeLogo from '../../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
interface StripeCardUpdateModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: () => void,
schedule: PaymentSchedule,
operator: User
}
export const StripeCardUpdateModal: React.FC<StripeCardUpdateModalProps> = ({ isOpen, toggleModal, onSuccess, schedule, operator }) => {
const { t } = useTranslation('shared');
// prevent submitting the form to update the card details, until the user has filled correctly all required fields
const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState<boolean>(true);
// we save errors here, if any, for display purposes.
const [errors, setErrors] = useState<string>(null);
/**
* 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 user clicks the submit button, we disable it to prevent double form submission
*/
const handleCardUpdateSubmit = (): void => {
setCanSubmitUpdateCard(false);
}
/**
* When the card was not updated, show the error
*/
const handleCardUpdateError = (error): void => {
setErrors(error);
setCanSubmitUpdateCard(true);
}
return (
<FabModal title={t('app.shared.stripe_card_update_modal.update_card')}
isOpen={isOpen}
toggleModal={toggleModal}
closeButton={false}
customFooter={logoFooter()}
className="stripe-update-card-modal">
{schedule && <StripeCardUpdate onSubmit={handleCardUpdateSubmit}
onSuccess={onSuccess}
onError={handleCardUpdateError}
schedule={schedule}
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.stripe_card_update_modal.validate_button')}</button>}
{!canSubmitUpdateCard && <div className="payment-pending">
<div className="fa-2x">
<i className="fas fa-circle-notch fa-spin" />
</div>
</div>}
</div>
</FabModal>
);
}

View File

@ -1,15 +1,14 @@
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';
import { User } from '../../../models/user';
import StripeAPI from '../../../api/stripe';
import { PaymentSchedule } from '../../../models/payment-schedule';
interface StripeCardUpdateProps {
onSubmit: () => void,
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onSuccess: () => void,
onError: (message: string) => void,
customerId: number,
schedule: PaymentSchedule
operator: User,
className?: string,
}
@ -19,7 +18,7 @@ interface StripeCardUpdateProps {
*
* 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 }) => {
export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, onSuccess, onError, className, schedule, operator, children }) => {
const stripe = useStripe();
const elements = useElements();
@ -47,7 +46,7 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
} else {
try {
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customerId);
const { client_secret } = await StripeAPI.setupIntent(schedule.user.id);
const { error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id,
mandate_data: {
@ -64,8 +63,12 @@ export const StripeCardUpdate: React.FC<StripeCardUpdateProps> = ({ onSubmit, on
onError(error.message);
} else {
// then we update the default payment method
const res = await PaymentAPI.updateCard(customerId, paymentMethod.id);
onSuccess(res);
const res = await StripeAPI.updateCard(schedule.user.id, paymentMethod.id, schedule.id);
if (res.updated) {
onSuccess();
} else {
onError(res.error);
}
}
} catch (err) {
// catch api errors

View File

@ -7,13 +7,23 @@ interface StripeConfirmProps {
onResponse: () => void,
}
/**
* This component runs a 3D secure confirmation for the given Stripe payment (identified by clientSecret).
* A message is shown, depending on the result of the confirmation.
* In case of success, a callback "onResponse" is also run.
*/
export const StripeConfirm: React.FC<StripeConfirmProps> = ({ clientSecret, onResponse }) => {
const stripe = useStripe();
const { t } = useTranslation('shared');
// the message displayed to the user
const [message, setMessage] = useState<string>(t('app.shared.stripe_confirm.pending'));
// the style class of the message
const [type, setType] = useState<string>('info');
/**
* When the component is mounted, run the 3DS confirmation.
*/
useEffect(() => {
stripe.confirmCardPayment(clientSecret).then(function(result) {
onResponse();
@ -27,7 +37,8 @@ export const StripeConfirm: React.FC<StripeConfirmProps> = ({ clientSecret, onRe
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

@ -1,18 +1,21 @@
/**
* 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';
import { SettingName } from '../../../models/setting';
import SettingAPI from '../../../api/setting';
// initial request to the API
const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey);
/**
* This component initializes the stripe's Elements tag with the API key
*/
export const StripeElements: React.FC = memo(({ children }) => {
const [stripe, setStripe] = useState(undefined);
/**
* When this component is mounted, we initialize the <Elements> tag with the Stripe's public key
*/
useEffect(() => {
const key = stripePublicKey.read();
const promise = loadStripe(key.value);

View File

@ -1,27 +1,16 @@
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
}
import { GatewayFormProps } from '../abstract-payment-modal';
import { PaymentConfirmation } from '../../../models/payment';
import StripeAPI from '../../../api/stripe';
import { Invoice } from '../../../models/invoice';
/**
* 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".
* The form validation button must be created elsewhere, using the attribute form={formId}.
*/
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => {
export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => {
const { t } = useTranslation('shared');
@ -52,11 +41,11 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
try {
if (!paymentSchedule) {
// process the normal payment pipeline, including SCA validation
const res = await PaymentAPI.confirm(paymentMethod.id, cartItems);
const res = await StripeAPI.confirm(paymentMethod.id, cart);
await handleServerConfirmation(res);
} else {
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customer.id);
const { client_secret } = await StripeAPI.setupIntent(customer.id);
const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id,
mandate_data: {
@ -73,7 +62,7 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
onError(error.message);
} else {
// then we confirm the payment schedule
const res = await PaymentAPI.confirmPaymentSchedule(setupIntent.id, cartItems);
const res = await StripeAPI.confirmPaymentSchedule(setupIntent.id, cart);
onSuccess(res);
}
}
@ -86,19 +75,17 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
/**
* 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
* @param response can be a PaymentConfirmation, or an Invoice (if the payment succeeded)
* @see app/controllers/api/stripe_controller.rb#confirm_payment
*/
const handleServerConfirmation = async (response: PaymentConfirmation|any) => {
if (response.error) {
const handleServerConfirmation = async (response: PaymentConfirmation|Invoice) => {
if ('error' in response) {
if (response.error.statusText) {
onError(response.error.statusText);
} else {
onError(`${t('app.shared.messages.payment_card_error')} ${response.error}`);
}
} else if (response.requires_action) {
} else if ('requires_action' in response) {
// Use Stripe.js to handle required card action
const result = await stripe.handleCardAction(response.payment_intent_client_secret);
if (result.error) {
@ -107,14 +94,16 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
// 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);
const confirmation = await StripeAPI.confirm(result.paymentIntent.id, cart);
await handleServerConfirmation(confirmation);
} catch (e) {
onError(e);
}
}
} else {
} else if ('id' in response) {
onSuccess(response);
} else {
console.error(`[StripeForm] unknown response received: ${response}`);
}
}
@ -138,7 +127,7 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
};
return (
<form onSubmit={handleSubmit} id="stripe-form" className={className}>
<form onSubmit={handleSubmit} id={formId} className={className ? className : ''}>
<CardElement options={cardOptions} />
{children}
</form>

View File

@ -0,0 +1,161 @@
import React, { ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HtmlTranslate } from '../../base/html-translate';
import { FabInput } from '../../base/fab-input';
import { Loader } from '../../base/loader';
import { SettingName } from '../../../models/setting';
import StripeAPI from '../../../api/external/stripe';
import SettingAPI from '../../../api/setting';
interface StripeKeysFormProps {
onValidKeys: (stripePublic: string, stripeSecret:string) => void,
onInvalidKeys: () => void,
}
/**
* Form to set the stripe's public and private keys
*/
const StripeKeysFormComponent: React.FC<StripeKeysFormProps> = ({ onValidKeys, onInvalidKeys }) => {
const { t } = useTranslation('admin');
// used to prevent promises from resolving if the component was unmounted
const mounted = useRef(false);
// Stripe's public key
const [publicKey, setPublicKey] = useState<string>('');
// Icon of the input field for the Stripe's public key. Used to display if the key is valid.
const [publicKeyAddOn, setPublicKeyAddOn] = useState<ReactNode>(null);
// Style class for the add-on icon, for the public key
const [publicKeyAddOnClassName, setPublicKeyAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>('');
// Stripe's secret key
const [secretKey, setSecretKey] = useState<string>('');
// Icon of the input field for the Stripe's secret key. Used to display if the key is valid.
const [secretKeyAddOn, setSecretKeyAddOn] = useState<ReactNode>(null);
// Style class for the add-on icon, for the public key
const [secretKeyAddOnClassName, setSecretKeyAddOnClassName] = useState<'key-invalid' | 'key-valid' | ''>('');
/**
* When the component loads for the first time:
* - mark it as mounted
* - initialize the keys with the values fetched from the API (if any)
*/
useEffect(() => {
mounted.current = true;
const api = new SettingAPI();
api.query([SettingName.StripePublicKey, SettingName.StripeSecretKey]).then(stripeKeys => {
setPublicKey(stripeKeys.get(SettingName.StripePublicKey));
setSecretKey(stripeKeys.get(SettingName.StripeSecretKey));
}).catch(error => console.error(error));
// when the component unmounts, mark it as unmounted
return () => {
mounted.current = false;
};
}, []);
/**
* When the style class for the public and private key are updated, check if they indicate valid keys.
* If both are valid, run the 'onValidKeys' callback
*/
useEffect(() => {
const validClassName = 'key-valid';
if (publicKeyAddOnClassName === validClassName && secretKeyAddOnClassName === validClassName) {
onValidKeys(publicKey, secretKey);
} else {
onInvalidKeys();
}
}, [publicKeyAddOnClassName, secretKeyAddOnClassName]);
/**
* Send a test call to the Stripe API to check if the inputted public key is valid
*/
const testPublicKey = (key: string) => {
if (!key.match(/^pk_/)) {
setPublicKeyAddOn(<i className="fa fa-times" />);
setPublicKeyAddOnClassName('key-invalid');
return;
}
StripeAPI.createPIIToken(key, 'test').then(() => {
if (!mounted.current) return;
setPublicKey(key);
setPublicKeyAddOn(<i className="fa fa-check" />);
setPublicKeyAddOnClassName('key-valid');
}, reason => {
if (!mounted.current) return;
if (reason.response.status === 401) {
setPublicKeyAddOn(<i className="fa fa-times" />);
setPublicKeyAddOnClassName('key-invalid');
}
});
}
/**
* Send a test call to the Stripe API to check if the inputted secret key is valid
*/
const testSecretKey = (key: string) => {
if (!key.match(/^sk_/)) {
setSecretKeyAddOn(<i className="fa fa-times" />);
setSecretKeyAddOnClassName('key-invalid');
return;
}
StripeAPI.listAllCharges(key).then(() => {
if (!mounted.current) return;
setSecretKey(key);
setSecretKeyAddOn(<i className="fa fa-check" />);
setSecretKeyAddOnClassName('key-valid');
}, reason => {
if (!mounted.current) return;
if (reason.response.status === 401) {
setSecretKeyAddOn(<i className="fa fa-times" />);
setSecretKeyAddOnClassName('key-invalid');
}
});
}
return (
<div className="stripe-keys-form">
<div className="stripe-keys-info">
<HtmlTranslate trKey="app.admin.invoices.payment.stripe_keys_info_html" />
</div>
<form name="stripeKeysForm">
<div className="stripe-public-input">
<label htmlFor="stripe_public_key">{ t('app.admin.invoices.payment.public_key') } *</label>
<FabInput id="stripe_public_key"
icon={<i className="fa fa-info" />}
defaultValue={publicKey}
onChange={testPublicKey}
addOn={publicKeyAddOn}
addOnClassName={publicKeyAddOnClassName}
debounce={200}
required />
</div>
<div className="stripe-secret-input">
<label htmlFor="stripe_secret_key">{ t('app.admin.invoices.payment.secret_key') } *</label>
<FabInput id="stripe_secret_key"
icon={<i className="fa fa-key" />}
defaultValue={secretKey}
onChange={testSecretKey}
addOn={secretKeyAddOn}
addOnClassName={secretKeyAddOnClassName}
debounce={200}
required/>
</div>
</form>
</div>
);
}
export const StripeKeysForm: React.FC<StripeKeysFormProps> = ({ onValidKeys, onInvalidKeys }) => {
return (
<Loader>
<StripeKeysFormComponent onValidKeys={onValidKeys} onInvalidKeys={onInvalidKeys} />
</Loader>
);
}

View File

@ -0,0 +1,82 @@
import React, { FunctionComponent, ReactNode } from 'react';
import { StripeElements } from './stripe-elements';
import { StripeForm } from './stripe-form';
import { GatewayFormProps, AbstractPaymentModal } from '../abstract-payment-modal';
import { ShoppingCart } from '../../../models/payment';
import { PaymentSchedule } from '../../../models/payment-schedule';
import { User } from '../../../models/user';
import stripeLogo from '../../../../../images/powered_by_stripe.png';
import mastercardLogo from '../../../../../images/mastercard.png';
import visaLogo from '../../../../../images/visa.png';
import { Invoice } from '../../../models/invoice';
interface StripeModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: (result: Invoice|PaymentSchedule) => void,
cart: ShoppingCart,
currentUser: User,
schedule: PaymentSchedule,
customer: User
}
/**
* This component enables the user to input his card data or process payments, using the Stripe gateway.
* Supports Strong-Customer Authentication (SCA).
*
* This component should not be called directly. Prefer using <PaymentModal> which can handle the configuration
* of a different payment gateway.
*/
export const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => {
/**
* 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>
);
}
/**
* Integrates the StripeForm into the parent PaymentModal
*/
const renderForm: FunctionComponent<GatewayFormProps> = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => {
return (
<StripeElements>
<StripeForm onSubmit={onSubmit}
onSuccess={onSuccess}
onError={onError}
operator={operator}
className={className}
formId={formId}
cart={cart}
customer={customer}
paymentSchedule={paymentSchedule}>
{children}
</StripeForm>
</StripeElements>
);
}
return (
<AbstractPaymentModal className="stripe-modal"
isOpen={isOpen}
toggleModal={toggleModal}
logoFooter={logoFooter()}
formId="stripe-form"
formClassName="stripe-form"
currentUser={currentUser}
cart={cart}
customer={customer}
afterSuccess={afterSuccess}
schedule={schedule}
GatewayForm={renderForm} />
);
}

View File

@ -0,0 +1,83 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { Loader } from '../base/loader';
import { StripeCardUpdateModal } from './stripe/stripe-card-update-modal';
import { PayzenCardUpdateModal } from './payzen/payzen-card-update-modal';
import { User } from '../../models/user';
import { PaymentSchedule } from '../../models/payment-schedule';
import { useTranslation } from 'react-i18next';
interface UpdateCardModalProps {
isOpen: boolean,
toggleModal: () => void,
afterSuccess: () => void,
onError: (message: string) => void,
schedule: PaymentSchedule,
operator: User
}
/**
* This component open a modal dialog for the configured payment gateway, allowing the user to input his card data
* to process an online payment.
*/
const UpdateCardModalComponent: React.FC<UpdateCardModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => {
const { t } = useTranslation('shared');
const [gateway, setGateway] = useState<string>('');
useEffect(() => {
if (schedule.gateway_subscription.classname.match(/^PayZen::/)) {
setGateway('payzen');
} else if (schedule.gateway_subscription.classname.match(/^Stripe::/)) {
setGateway('stripe');
}
}, [schedule]);
/**
* Render the Stripe update-card modal
*/
const renderStripeModal = (): ReactElement => {
return <StripeCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
onSuccess={afterSuccess}
operator={operator}
schedule={schedule} />
}
/**
* Render the PayZen update-card modal
*/ // 1
const renderPayZenModal = (): ReactElement => {
return <PayzenCardUpdateModal isOpen={isOpen}
toggleModal={toggleModal}
onSuccess={afterSuccess}
operator={operator}
schedule={schedule} />
}
/**
* Determine which gateway is in use with the current schedule and return the appropriate modal
*/
switch (gateway) {
case 'stripe':
return renderStripeModal();
case 'payzen':
return renderPayZenModal();
case '':
return <div/>;
default:
onError(t('app.shared.update_card_modal.unexpected_error'));
console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway_subscription?.classname}`);
return <div />;
}
}
export const UpdateCardModal: React.FC<UpdateCardModalProps> = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => {
return (
<Loader>
<UpdateCardModalComponent isOpen={isOpen} toggleModal={toggleModal} afterSuccess={afterSuccess} onError={onError} operator={operator} schedule={schedule} />
</Loader>
);
}

View File

@ -1,7 +1,3 @@
/**
* 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';
@ -10,7 +6,7 @@ import _ from 'lodash'
import { IApplication } from '../models/application';
import { Plan } from '../models/plan';
import { User, UserRole } from '../models/user';
import { Loader } from './loader';
import { Loader } from './base/loader';
import '../lib/i18n';
import { IFablab } from '../models/fablab';
@ -24,9 +20,13 @@ interface PlanCardProps {
operator: User,
isSelected: boolean,
onSelectPlan: (plan: Plan) => void,
onLoginRequested: () => void,
}
const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
/**
* This component is a "card" (visually), publicly presenting the details of a plan and allowing a user to subscribe.
*/
const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
const { t } = useTranslation('public');
/**
* Return the formatted localized amount of the given plan (eg. 20.5 => "20,50 €")
@ -47,6 +47,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, ope
const duration = (): string => {
return moment.duration(plan.interval_count, plan.interval).humanize();
}
/**
* Check if no users are currently logged-in
*/
const mustLogin = (): boolean => {
return _.isNil(operator);
}
/**
* Check if the user can subscribe to the current plan, for himself
*/
@ -89,6 +95,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, ope
const handleSelectPlan = (): void => {
onSelectPlan(plan);
}
/**
* Callback triggered when a visitor (not logged-in user) select a plan
*/
const handleLoginRequest = (): void => {
onLoginRequested();
}
return (
<div className="plan-card">
<h3 className="title">{plan.base_name}</h3>
@ -109,12 +121,14 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, ope
<div className="card-footer">
{hasDescription() && <div className="plan-description" dangerouslySetInnerHTML={{__html: plan.description}}/>}
{hasAttachment() && <a className="info-link" href={ plan.plan_file_url } target="_blank">{ t('app.public.plans.more_information') }</a>}
{mustLogin() && <div className="cta-button">
<button className="subscribe-button" onClick={handleLoginRequest}>{t('app.public.plans.i_subscribe_online')}</button>
</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>}
{t('app.public.plans.i_choose_that_plan')}
</button>}
{hasSubscribedToThisPlan() && <button className="subscribe-button selected-card" disabled>
{ t('app.public.plans.i_already_subscribed') }
@ -132,12 +146,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, ope
);
}
const PlanCardWrapper: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected }) => {
const PlanCardWrapper: React.FC<PlanCardProps> = ({ plan, userId, subscribedPlanId, operator, onSelectPlan, isSelected, onLoginRequested }) => {
return (
<Loader>
<PlanCard plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan}/>
<PlanCard plan={plan} userId={userId} subscribedPlanId={subscribedPlanId} operator={operator} isSelected={isSelected} onSelectPlan={onSelectPlan} onLoginRequested={onLoginRequested}/>
</Loader>
);
}
Application.Components.component('planCard', react2angular(PlanCardWrapper, ['plan', 'userId', 'subscribedPlanId', 'operator', 'onSelectPlan', 'isSelected']));
Application.Components.component('planCard', react2angular(PlanCardWrapper, ['plan', 'userId', 'subscribedPlanId', 'operator', 'onSelectPlan', 'isSelected', 'onLoginRequested']));

View File

@ -0,0 +1,148 @@
/**
* This component allows an administrator to select and configure a payment gateway.
* The configuration of a payment gateway is required to enable the online payments.
*/
import React, { BaseSyntheticEvent, useEffect, useState } from 'react';
import { react2angular } from 'react2angular';
import { useTranslation } from 'react-i18next';
import { StripeKeysForm } from './payment/stripe/stripe-keys-form';
import { PayZenKeysForm } from './payment/payzen/payzen-keys-form';
import { FabModal, ModalSize } from './base/fab-modal';
import { Loader } from './base/loader';
import { User } from '../models/user';
import { Gateway } from '../models/gateway';
import { SettingBulkResult, SettingName } from '../models/setting';
import { IApplication } from '../models/application';
import SettingAPI from '../api/setting';
declare var Application: IApplication;
interface SelectGatewayModalModalProps {
isOpen: boolean,
toggleModal: () => void,
currentUser: User,
onError: (errors: Map<SettingName, SettingBulkResult>|any) => void,
onSuccess: (results: Map<SettingName, SettingBulkResult>) => void,
}
// initial request to the API
const paymentGateway = SettingAPI.get(SettingName.PaymentGateway);
const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, toggleModal, onError, onSuccess }) => {
const { t } = useTranslation('admin');
const [preventConfirmGateway, setPreventConfirmGateway] = useState<boolean>(true);
const [selectedGateway, setSelectedGateway] = useState<string>('');
const [gatewayConfig, setGatewayConfig] = useState<Map<SettingName, string>>(new Map());
useEffect(() => {
const gateway = paymentGateway.read();
setSelectedGateway(gateway.value ? gateway.value : '');
}, []);
/**
* Callback triggered when the user has filled and confirmed the settings of his gateway
*/
const onGatewayConfirmed = () => {
setPreventConfirmGateway(true);
updateSettings();
setPreventConfirmGateway(false);
}
/**
* Save the gateway provided by the target input into the component state
*/
const setGateway = (event: BaseSyntheticEvent) => {
const gateway = event.target.value;
setSelectedGateway(gateway);
}
/**
* Check if any payment gateway was selected
*/
const hasSelectedGateway = (): boolean => {
return selectedGateway !== '';
}
/**
* Callback triggered when the embedded form has validated all the stripe keys
*/
const handleValidStripeKeys = (publicKey: string, secretKey: string): void => {
setGatewayConfig((prev) => {
const newMap = new Map(prev);
newMap.set(SettingName.StripeSecretKey, secretKey);
newMap.set(SettingName.StripePublicKey, publicKey);
return newMap;
});
setPreventConfirmGateway(false);
}
/**
* Callback triggered when the embedded form has validated all the PayZen keys
*/
const handleValidPayZenKeys = (payZenKeys: Map<SettingName, string>): void => {
setGatewayConfig(payZenKeys);
setPreventConfirmGateway(false);
}
/**
* Callback triggered when the embedded form has not validated all keys
*/
const handleInvalidKeys = (): void => {
setPreventConfirmGateway(true);
}
/**
* Send the new gateway settings to the API to save them
*/
const updateSettings = (): void => {
const settings = new Map<SettingName, string>(gatewayConfig);
settings.set(SettingName.PaymentGateway, selectedGateway);
const api = new SettingAPI();
api.bulkUpdate(settings).then(result => {
if (Array.from(result.values()).filter(item => !item.status).length > 0) {
onError(result);
} else {
onSuccess(result);
}
}, reason => {
onError(reason);
});
}
return (
<FabModal title={t('app.admin.invoices.payment.gateway_modal.select_gateway_title')}
isOpen={isOpen}
toggleModal={toggleModal}
width={ModalSize.medium}
className="gateway-modal"
confirmButton={t('app.admin.invoices.payment.gateway_modal.confirm_button')}
onConfirm={onGatewayConfirmed}
preventConfirm={preventConfirmGateway}>
{!hasSelectedGateway() && <p className="info-gateway">
{t('app.admin.invoices.payment.gateway_modal.gateway_info')}
</p>}
<label htmlFor="gateway">{t('app.admin.invoices.payment.gateway_modal.select_gateway')}</label>
<select id="gateway" className="select-gateway" onChange={setGateway} value={selectedGateway}>
<option />
<option value={Gateway.Stripe}>{t('app.admin.invoices.payment.gateway_modal.stripe')}</option>
<option value={Gateway.PayZen}>{t('app.admin.invoices.payment.gateway_modal.payzen')}</option>
</select>
{selectedGateway === Gateway.Stripe && <StripeKeysForm onValidKeys={handleValidStripeKeys} onInvalidKeys={handleInvalidKeys} />}
{selectedGateway === Gateway.PayZen && <PayZenKeysForm onValidKeys={handleValidPayZenKeys} onInvalidKeys={handleInvalidKeys} />}
</FabModal>
);
};
const SelectGatewayModalWrapper: React.FC<SelectGatewayModalModalProps> = ({ isOpen, toggleModal, currentUser, onSuccess, onError }) => {
return (
<Loader>
<SelectGatewayModal isOpen={isOpen} toggleModal={toggleModal} currentUser={currentUser} onSuccess={onSuccess} onError={onError} />
</Loader>
);
}
Application.Components.component('selectGatewayModal', react2angular(SelectGatewayModalWrapper, ['isOpen', 'toggleModal', 'currentUser', 'onSuccess', 'onError']));

View File

@ -1,225 +0,0 @@
/**
* 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,32 +1,29 @@
/**
* 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 { Loader } from './base/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';
import { ShoppingCart } from '../models/payment';
declare var Application: IApplication;
declare var Fablab: IFablab;
interface WalletInfoProps {
cartItems: CartItems,
cart: ShoppingCart,
currentUser: User,
wallet: Wallet,
price: number,
}
export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser, wallet, price }) => {
/**
* This component displays a summary of the amount paid with the virtual wallet, for the current transaction
*/
export const WalletInfo: React.FC<WalletInfoProps> = ({ cart, currentUser, wallet, price }) => {
const { t } = useTranslation('shared');
const [remainingPrice, setRemainingPrice] = useState(0);
@ -49,13 +46,7 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
* 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;
return currentUser.id == cart.customer_id;
}
/**
* If the client has some money in his wallet & the price is not zero, then we should display this component.
@ -74,17 +65,17 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
* Does the current cart contains a payment schedule?
*/
const isPaymentSchedule = (): boolean => {
return buyingItem().plan_id && buyingItem().payment_schedule;
return cart.items.find(i => 'subscription' in i) && cart.payment_schedule;
}
/**
* Return the human-readable name of the item currently bought with the wallet
*/
const getPriceItem = (): string => {
let item = 'other';
if (cartItems.reservation) {
if (cart.items.find(i => 'reservation' in i)) {
item = 'reservation';
} else if (cartItems.subscription) {
if (cartItems.subscription.payment_schedule) {
} else if (cart.items.find(i => 'subscription' in i)) {
if (cart.payment_schedule) {
item = 'first_deadline';
} else item = 'subscription';
}
@ -128,12 +119,12 @@ export const WalletInfo: React.FC<WalletInfoProps> = ({ cartItems, currentUser,
);
}
const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, cartItems, price, wallet }) => {
const WalletInfoWrapper: React.FC<WalletInfoProps> = ({ currentUser, cart, price, wallet }) => {
return (
<Loader>
<WalletInfo currentUser={currentUser} cartItems={cartItems} price={price} wallet={wallet}/>
<WalletInfo currentUser={currentUser} cart={cart} price={price} wallet={wallet}/>
</Loader>
);
}
Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cartItems', 'wallet']));
Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cart', 'wallet']));

View File

@ -548,7 +548,7 @@ Application.Controllers.controller('ShowEventReservationsController', ['$scope',
* @returns {boolean}
*/
$scope.isCancelled = function (reservation) {
return !!(reservation.slots[0].canceled_at);
return !!(reservation.slots_attributes[0].canceled_at);
};
}]);

View File

@ -206,6 +206,9 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
// Placeholding date for the reservation end
$scope.inOneWeekAndOneHour = moment().add(1, 'week').add(1, 'hour').startOf('hour');
// Is shown the modal dialog to select a payment gateway
$scope.openSelectGatewayModal = false;
/**
* Change the invoices ordering criterion to the one provided
* @param orderBy {string} ordering criterion
@ -646,39 +649,87 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
};
/**
* Open a modal dialog which ask for the stripe keys
* Open a modal dialog which ask the user to select the payment gateway to use
* @param onlinePaymentModule {{name: String, value: String}} setting that defines the next status of the online payment module
* @return {boolean} false if the keys were not provided
*/
$scope.requireStripeKeys = function (onlinePaymentModule) {
$scope.selectPaymentGateway = function (onlinePaymentModule) {
// if the online payment is about to be disabled, accept the change without any further question
if (onlinePaymentModule.value === false) return true;
// otherwise, open a modal to ask for the stripe keys
const modalInstance = $uibModal.open({
templateUrl: '/admin/invoices/settings/stripeKeys.html',
controller: 'StripeKeysModalController',
resolve: {
stripeKeys: ['Setting', function (Setting) { return Setting.query({ names: "['stripe_public_key', 'stripe_secret_key']" }).$promise; }]
}
});
// otherwise, open a modal to ask for the selection of a payment gateway
setTimeout(() => {
$scope.openSelectGatewayModal = true;
$scope.$apply();
}, 50);
return new Promise(function (resolve, reject) {
gatewayHandlers.resolve = resolve;
gatewayHandlers.reject = reject;
}).catch(() => { /* WORKAROUND: it seems we can't catch the rejection from the boolean-setting directive */ });
};
modalInstance.result.then(function (success) {
if (success) {
Setting.get({ name: 'stripe_public_key' }, function (res) {
$scope.allSettings.stripe_public_key = res.setting.value;
});
Setting.isPresent({ name: 'stripe_secret_key' }, function (res) {
$scope.stripeSecretKey = (res.isPresent ? STRIPE_SK_HIDDEN : '');
});
Payment.onlinePaymentStatus(function (res) {
$scope.onlinePaymentStatus = res.status;
});
/**
* This will open/close the gateway selection modal
*/
$scope.toggleSelectGatewayModal = function () {
setTimeout(() => {
$scope.openSelectGatewayModal = !$scope.openSelectGatewayModal;
$scope.$apply();
if (!$scope.openSelectGatewayModal && gatewayHandlers.reject) {
gatewayHandlers.reject();
resetPromiseHandlers();
}
});
}, 50);
};
// return the promise
return modalInstance.result;
/**
* Callback triggered after the gateway was successfully configured in the dedicated modal
*/
$scope.onGatewayModalSuccess = function (updatedSettings) {
if (gatewayHandlers.resolve) {
gatewayHandlers.resolve(true);
resetPromiseHandlers();
}
$scope.toggleSelectGatewayModal();
$scope.allSettings.payment_gateway = updatedSettings.get('payment_gateway').value;
if ($scope.allSettings.payment_gateway === 'stripe') {
$scope.allSettings.stripe_public_key = updatedSettings.get('stripe_public_key').value;
Setting.isPresent({ name: 'stripe_secret_key' }, function (res) {
$scope.stripeSecretKey = (res.isPresent ? STRIPE_SK_HIDDEN : '');
});
Payment.onlinePaymentStatus(function (res) {
$scope.onlinePaymentStatus = res.status;
});
}
};
/**
* Callback used in PaymentScheduleList, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
/**
* Callback triggered after the gateway failed to be configured
*/
$scope.onGatewayModalError = function (errors) {
growl.error(_t('app.admin.invoices.payment.gateway_configuration_error'));
console.error(errors);
};
/**
* Callback triggered when the PayZen currency was successfully updated
*/
$scope.alertPayZenCurrencyUpdated = function (currency) {
growl.success(_t('app.admin.invoices.payment.payzen.currency_updated', { CURRENCY: currency }));
};
/**
@ -879,12 +930,30 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
);
}
});
// Clean before the controller is destroyed
$scope.$on('$destroy', function () {
if (gatewayHandlers.reject) {
gatewayHandlers.reject();
resetPromiseHandlers();
}
});
};
/**
* Will temporize the search query to prevent overloading the API
*/
var searchTimeout = null;
let searchTimeout = null;
/**
* We must delay the save of the 'payment gateway' parameter, until the gateway is configured.
* To do so, we use a promise, with the resolve/reject callback stored here
* @see https://stackoverflow.com/q/26150232
*/
const gatewayHandlers = {
resolve: null,
reject: null
};
/**
* Output the given integer with leading zeros. If the given value is longer than the given
@ -892,7 +961,16 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
* @param value {number} the integer to pad
* @param length {number} the length of the resulting string.
*/
var padWithZeros = function (value, length) { return (1e15 + value + '').slice(-length); };
const padWithZeros = function (value, length) { return (1e15 + value + '').slice(-length); };
/**
* Reset the promise handlers (reject/resolve) to their initial value.
* This will prevent an already resolved promise to be triggered again.
*/
const resetPromiseHandlers = function () {
gatewayHandlers.resolve = null;
gatewayHandlers.reject = null;
};
/**
* Remove every unsupported html tag from the given html text (like <p>, <span>, ...).
@ -900,7 +978,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
* @param html {string} single line html text
* @return {string} multi line simplified html text
*/
var parseHtml = function (html) {
const parseHtml = function (html) {
return html.replace(/<\/?(\w+)((\s+\w+(\s*=\s*(?:".*?"|'.*?'|[^'">\s]+))?)+\s*|\s*)\/?>/g, function (match, p1, offset, string) {
if (['b', 'u', 'i', 'br'].includes(p1)) {
return match;
@ -913,7 +991,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
/**
* Reinitialize the context of invoices' search to display new results set
*/
var resetSearchInvoice = function () {
const resetSearchInvoice = function () {
$scope.page = 1;
return $scope.noMoreResults = false;
};
@ -923,7 +1001,7 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
* to $scope.invoices
* @param [concat] {boolean} if true, the result will be append to $scope.invoices instead of being affected
*/
var invoiceSearch = function (concat) {
const invoiceSearch = function (concat) {
Invoice.list({
query: {
number: $scope.searchInvoice.reference,
@ -1061,7 +1139,7 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// if the invoice was payed with stripe, allow to refund through stripe
// if the invoice was paid with stripe, allow refunding through stripe
Invoice.get({ id: invoice.id }, function (data) {
$scope.invoice = data;
// default : all elements of the invoice are refund
@ -1070,8 +1148,8 @@ Application.Controllers.controller('AvoirModalController', ['$scope', '$uibModal
});
});
if (invoice.stripe) {
return $scope.avoirModes.push({ name: _t('app.admin.invoices.online_payment'), value: 'stripe' });
if (invoice.online_payment) {
return $scope.avoirModes.push({ name: _t('app.admin.invoices.online_payment'), value: 'card' });
}
};
@ -1295,7 +1373,7 @@ Application.Controllers.controller('AccountingExportModalController', ['$scope',
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
// if the invoice was payed with stripe, allow to refund through stripe
// Get info about the very first invoice on the system
Invoice.first(function (data) {
$scope.firstInvoice = data.date;
$scope.exportTarget.startDate = data.date;
@ -1329,126 +1407,3 @@ Application.Controllers.controller('AccountingExportModalController', ['$scope',
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}]);
/**
* Controller used in the modal window allowing an admin to close an accounting period
*/
Application.Controllers.controller('StripeKeysModalController', ['$scope', '$uibModalInstance', '$http', '$httpParamSerializerJQLike', 'stripeKeys', 'Setting', 'growl', '_t',
function ($scope, $uibModalInstance, $http, $httpParamSerializerJQLike, stripeKeys, Setting, growl, _t) {
/* PUBLIC SCOPE */
// public key
$scope.publicKey = stripeKeys.stripe_public_key || '';
// test status of the public key
$scope.publicKeyStatus = undefined;
// secret key
$scope.secretKey = stripeKeys.stripe_secret_key || '';
// test status of the secret key
$scope.secretKeyStatus = undefined;
/**
* Trigger the test of the secret key and set the result in $scope.secretKeyStatus
*/
$scope.testSecretKey = function () {
if (!$scope.secretKey.match(/^sk_/)) {
$scope.secretKeyStatus = false;
return;
}
$http({
method: 'GET',
url: 'https://api.stripe.com/v1/charges',
headers: {
Authorization: `Bearer ${$scope.secretKey}`
}
}).then(function () {
$scope.secretKeyStatus = true;
}, function (err) {
if (err.status === 401) $scope.secretKeyStatus = false;
});
};
/**
* Trigger the test of the secret key and set the result in $scope.secretKeyStatus
*/
$scope.testPublicKey = function () {
if (!$scope.publicKey.match(/^pk_/)) {
$scope.publicKeyStatus = false;
return;
}
$http({
method: 'POST',
url: 'https://api.stripe.com/v1/tokens',
headers: {
Authorization: `Bearer ${$scope.publicKey}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
data: $httpParamSerializerJQLike({
'pii[id_number]': 'test'
})
}).then(function () {
$scope.publicKeyStatus = true;
}, function (err) {
if (err.status === 401) $scope.publicKeyStatus = false;
});
};
/**
* Validate the keys
*/
$scope.ok = function () {
if ($scope.secretKeyStatus && $scope.publicKeyStatus) {
Setting.bulkUpdate(
{
settings: [
{
name: 'stripe_public_key',
value: $scope.publicKey
},
{
name: 'stripe_secret_key',
value: $scope.secretKey
}
]
},
function () {
growl.success(_t('app.admin.invoices.payment.stripe_keys_saved'));
$uibModalInstance.close(true);
},
function (error) {
growl.error('app.admin.invoices.payment.error_saving_stripe_keys');
console.error(error);
}
);
} else {
growl.error(_t('app.admin.invoices.payment.error_check_keys'));
}
};
/**
* Just dismiss the modal window
*/
$scope.cancel = function () {
$uibModalInstance.dismiss('cancel');
};
/* PRIVATE SCOPE */
/**
* Kind of constructor: these actions will be realized first when the controller is loaded
*/
const initialize = function () {
if (stripeKeys.stripe_public_key) {
$scope.testPublicKey();
}
if (stripeKeys.stripe_secret_key) {
$scope.testSecretKey();
}
};
// !!! MUST BE CALLED AT THE END of the controller!
return initialize();
}
]);

View File

@ -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.adminSysId; });
// Admins ordering/sorting. Default: not sorted
$scope.orderAdmin = null;

View File

@ -62,6 +62,20 @@ Application.Controllers.controller('DashboardController', ['$scope', 'memberProm
return networks;
};
/**
* Callback used in PaymentScheduleDashboard, in case of error
*/
$scope.onError = function (message) {
growl.error(message);
};
/**
* Callback triggered when the user has successfully updated his card
*/
$scope.onCardUpdateSuccess = function (message) {
growl.success(message);
};
// !!! MUST BE CALLED AT THE END of the controller
return initialize();
}

View File

@ -13,8 +13,8 @@
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
Application.Controllers.controller('EventsController', ['$scope', '$state', 'Event', 'categoriesPromise', 'themesPromise', 'ageRangesPromise', 'settingsPromise',
function ($scope, $state, Event, categoriesPromise, themesPromise, ageRangesPromise, settingsPromise) {
Application.Controllers.controller('EventsController', ['$scope', '$state', 'Event', 'categoriesPromise', 'themesPromise', 'ageRangesPromise',
function ($scope, $state, Event, categoriesPromise, themesPromise, ageRangesPromise) {
/* PUBLIC SCOPE */
// The events displayed on the page
@ -178,11 +178,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// Message displayed to the end user about rules that applies to events reservations
$scope.eventExplicationsAlert = settingsPromise.event_explications_alert;
// online payments (by card)
$scope.onlinePayment = {
showModal: false,
cartItems: undefined
};
/**
* Callback to delete the provided event (admins only)
* @param event {$resource} angular's Event $resource
*/
$scope.deleteEvent = function (event) {
$scope.deleteEvent = function () {
// open a confirmation dialog
const modalInstance = $uibModal.open({
animation: true,
@ -195,7 +200,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
});
// once the dialog was closed, do things depending on the result
modalInstance.result.then(function (res) {
if (res.status == 'success') {
if (res.status === 'success') {
$state.go('app.public.events_list');
}
});
@ -205,7 +210,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* Callback to call when the number of tickets to book changes in the current booking
*/
$scope.changeNbPlaces = function () {
// compute the total remaning places
// compute the total remaining places
let remain = $scope.event.nb_free_places - $scope.reserve.nbReservePlaces;
for (let ticket in $scope.reserve.tickets) {
remain -= $scope.reserve.tickets[ticket];
@ -299,7 +304,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.payEvent = function () {
// first, we check that a user was selected
if (Object.keys($scope.ctrl.member).length > 0) {
const reservation = mkReservation($scope.ctrl.member, $scope.reserve, $scope.event);
const reservation = mkReservation($scope.reserve, $scope.event);
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }, function (wallet) {
const amountToPay = helpers.getAmountToPay($scope.reserve.amountTotal, wallet.amount);
@ -308,7 +313,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
if (settingsPromise.online_payment_module !== 'true') {
growl.error(_t('app.public.events_show.online_payment_disabled'));
} else {
return payByStripe(reservation);
return payOnline(reservation);
}
} else {
if (AuthService.isAuthorized('admin')
@ -328,33 +333,41 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* Callback to validate the booking of a free event
*/
$scope.validReserveEvent = function () {
const reservation = {
user_id: $scope.ctrl.member.id,
reservable_id: $scope.event.id,
reservable_type: 'Event',
slots_attributes: [],
nb_reserve_places: $scope.reserve.nbReservePlaces,
tickets_attributes: []
};
const cartItems = {
customer_id: $scope.ctrl.member.id,
items: [
{
reservation: {
reservable_id: $scope.event.id,
reservable_type: 'Event',
slots_attributes: [],
nb_reserve_places: $scope.reserve.nbReservePlaces,
tickets_attributes: []
}
}
]
}
// a single slot is used for events
reservation.slots_attributes.push({
cartItems.items[0].reservation.slots_attributes.push({
start_at: $scope.event.start_date,
end_at: $scope.event.end_date,
availability_id: $scope.event.availability.id
});
// iterate over reservations per prices
for (let price_id in $scope.reserve.tickets) {
const seats = $scope.reserve.tickets[price_id];
reservation.tickets_attributes.push({
event_price_category_id: price_id,
booked: seats
});
if (Object.prototype.hasOwnProperty.call($scope.reserve.tickets, price_id)) {
const seats = $scope.reserve.tickets[price_id];
cartItems.items[0].reservation.tickets_attributes.push({
event_price_category_id: price_id,
booked: seats
});
}
}
// set the attempting marker
$scope.attempting = true;
// save the reservation to the API
return Reservation.save({ reservation }, function (reservation) {
// reservation successfull
return Reservation.save(cartItems, function (reservation) {
// reservation successful
afterPayment(reservation);
return $scope.attempting = false;
}
@ -372,7 +385,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Callback to cancel a reservation
* @param reservation {{id:number, reservable_id:number, nb_reserve_places:number}}
* @param reservation {{id:number, reservable_id:number, nb_reserve_places:number, slots_attributes:[{id: number, canceled_at: string}], total_booked_seats: number}}
*/
$scope.cancelReservation = function(reservation) {
dialogs.confirm({
@ -386,13 +399,13 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
}, function() { // cancel confirmed
Slot.cancel({
id: reservation.slots[0].id
id: reservation.slots_attributes[0].id
}, function() { // successfully canceled
let index;
growl.success(_t('app.public.events_show.reservation_was_successfully_cancelled'));
index = $scope.reservations.indexOf(reservation);
$scope.event.nb_free_places = $scope.event.nb_free_places + reservation.total_booked_seats;
$scope.reservations[index].slots[0].canceled_at = new Date();
$scope.reservations[index].slots_attributes[0].canceled_at = new Date();
}, function(error) {
growl.warning(_t('app.public.events_show.cancellation_failed'));
});
@ -401,11 +414,11 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Test if the provided reservation has been cancelled
* @param reservation {Reservation}
* @param reservation {{slots_attributes: [{canceled_at: string}]}}
* @returns {boolean}
*/
$scope.isCancelled = function(reservation) {
return !!(reservation.slots[0].canceled_at);
return !!(reservation.slots_attributes[0].canceled_at);
}
/**
@ -442,10 +455,9 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
return eventToPlace = e;
}
});
$scope.reservation.slots[0].start_at = eventToPlace.start_date;
$scope.reservation.slots[0].end_at = eventToPlace.end_date;
$scope.reservation.slots[0].availability_id = eventToPlace.availability_id;
$scope.reservation.slots_attributes = $scope.reservation.slots;
$scope.reservation.slots_attributes[0].start_at = eventToPlace.start_date;
$scope.reservation.slots_attributes[0].end_at = eventToPlace.end_date;
$scope.reservation.slots_attributes[0].availability_id = eventToPlace.availability_id;
$scope.attempting = true;
Reservation.update({ id: reservation.id }, { reservation: $scope.reservation }, function (reservation) {
$uibModalInstance.close(reservation);
@ -482,10 +494,10 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Checks if the provided reservation is able to be moved (date change)
* @param reservation {{slots:[], total_booked_seats:number}}
* @param reservation {{slots_attributes:[], total_booked_seats:number}}
*/
$scope.reservationCanModify = function (reservation) {
const slotStart = moment(reservation.slots[0].start_at);
const slotStart = moment(reservation.slots_attributes[0].start_at);
const now = moment();
let isAble = false;
@ -497,12 +509,11 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* Checks if the provided reservation is able to be cancelled
* @param reservation {{slots:[]}}
* @param reservation {{slots_attributes:[]}}
*/
$scope.reservationCanCancel = function(reservation) {
var now, slotStart;
slotStart = moment(reservation.slots[0].start_at);
now = moment();
const slotStart = moment(reservation.slots_attributes[0].start_at);
const now = moment();
return $scope.enableBookingCancel && slotStart.diff(now, "hours") >= $scope.cancelBookingDelay;
};
@ -513,8 +524,8 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
$scope.computeEventAmount = function () {
// first we check that a user was selected
if (Object.keys($scope.ctrl.member).length > 0) {
const r = mkReservation($scope.ctrl.member, $scope.reserve, $scope.event);
return Price.compute(mkRequestParams(r, $scope.coupon.applied), function (res) {
const r = mkReservation($scope.reserve, $scope.event);
return Price.compute(mkCartItems(r, $scope.coupon.applied), function (res) {
$scope.reserve.amountTotal = res.price;
return $scope.reserve.totalNoCoupon = res.price_without_coupon;
});
@ -545,6 +556,36 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
}
};
/**
* This will open/close the online payment modal
*/
$scope.toggleOnlinePaymentModal = (beforeApply) => {
setTimeout(() => {
$scope.onlinePayment.showModal = !$scope.onlinePayment.showModal;
if (typeof beforeApply === 'function') {
beforeApply();
}
$scope.$apply();
}, 50);
};
/**
* Invoked atfer a successful card payment
* @param invoice {*} the invoice
*/
$scope.afterOnlinePaymentSuccess = (invoice) => {
$scope.toggleOnlinePaymentModal();
afterPayment(invoice);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
*/
$scope.onOnlinePaymentError = (message) => {
growl.error(message);
};
/* PRIVATE SCOPE */
/**
@ -561,7 +602,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// initialize the "reserve" object with the event's data
resetEventReserve();
// if non-admin, get the current user's reservations into $scope.reservations
// get the current user's reservations into $scope.reservations
if ($scope.currentUser) {
getReservations($scope.event.id, 'Event', $scope.currentUser.id);
}
@ -589,15 +630,13 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
};
/**
* Create an hash map implementing the Reservation specs
* @param member {Object} User as retreived from the API: current user / selected user if current is admin
* Create a hash map implementing the Reservation specs
* @param reserve {Object} Reservation parameters (places...)
* @param event {Object} Current event
* @return {{user_id:number, reservable_id:number, reservable_type:string, slots_attributes:Array<Object>, nb_reserve_places:number}}
* @return {{reservable_id:number, reservable_type:string, slots_attributes:Array<Object>, nb_reserve_places:number}}
*/
const mkReservation = function (member, reserve, event) {
const mkReservation = function (reserve, event) {
const reservation = {
user_id: member.id,
reservable_id: event.id,
reservable_type: 'Event',
slots_attributes: [],
@ -629,15 +668,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* 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 paymentMethod {string} 'card' | ''
* @return {ShoppingCart}
*/
const mkRequestParams = function (reservation, coupon) {
const params = {
reservation,
coupon_code: ((coupon ? coupon.code : undefined))
const mkCartItems = function (reservation, coupon, paymentMethod = '') {
return {
customer_id: $scope.ctrl.member.id,
items: [reservation],
coupon_code: ((coupon ? coupon.code : undefined)),
payment_method: paymentMethod,
};
return params;
};
/**
@ -669,71 +709,15 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
* Open a modal window which trigger the stripe payment process
* @param reservation {Object} to book
*/
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;
},
objectToPay () {
return {
eventToReserve: $scope.event,
reserve: $scope.reserve,
member: $scope.ctrl.member
};
},
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', '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.wallet = wallet;
// Price
$scope.price = price.price;
// Amount to pay
$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 (reservation) {
$uibModalInstance.close(reservation);
};
}
]
}).result['finally'](null).then(function (reservation) { afterPayment(reservation); });
const payOnline = function (reservation) {
// check that the online payment is enabled
if (settingsPromise.online_payment_module !== 'true') {
growl.error(_t('app.shared.cart.online_payment_disabled'));
} else {
$scope.toggleOnlinePaymentModal(() => {
$scope.onlinePayment.cartItems = mkCartItems(reservation, 'card');
});
}
};
/**
@ -749,20 +733,20 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
return reservation;
},
price () {
return Price.compute(mkRequestParams(reservation, $scope.coupon.applied)).$promise;
return Price.compute(mkCartItems(reservation, $scope.coupon.applied)).$promise;
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
return Wallet.getWalletByUser({ user_id: $scope.ctrl.member.id }).$promise;
},
coupon () {
return $scope.coupon.applied;
},
cartItems () {
return mkRequestParams(reservation, $scope.coupon.applied);
return mkCartItems(reservation, $scope.coupon.applied);
},
},
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) {
controller: ['$scope', '$uibModalInstance', '$state', 'reservation', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'cartItems',
function ($scope, $uibModalInstance, $state, reservation, price, Auth, LocalPayment, wallet, helpers, $filter, coupon, cartItems) {
// User's wallet amount
$scope.wallet = wallet;
@ -795,7 +779,7 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
// Callback to validate the payment
$scope.ok = function () {
$scope.attempting = true;
return Reservation.save(mkRequestParams($scope.reservation, coupon), function (reservation) {
return LocalPayment.confirm(cartItems, function (reservation) {
$uibModalInstance.close(reservation);
return $scope.attempting = true;
}
@ -822,14 +806,16 @@ Application.Controllers.controller('ShowEventController', ['$scope', '$state', '
/**
* What to do after the payment was successful
* @param reservation {Object} booked reservation
* @param invoice {Object} the invoice for the booked reservation
*/
const afterPayment = function (reservation) {
$scope.event.nb_free_places = $scope.event.nb_free_places - reservation.total_booked_seats;
const afterPayment = function (invoice) {
Reservation.get({ id: invoice.main_object.id }, function (reservation) {
$scope.event.nb_free_places = $scope.event.nb_free_places - reservation.total_booked_seats;
$scope.reservations.push(reservation);
});
resetEventReserve();
$scope.reserveSuccess = true;
$scope.coupon.applied = null;
$scope.reservations.push(reservation);
if ($scope.currentUser.role === 'admin') {
return $scope.ctrl.member = null;
}

View File

@ -407,8 +407,8 @@ Application.Controllers.controller('ShowMachineController', ['$scope', '$state',
* This controller workflow is pretty similar to the trainings reservation controller.
*/
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig',
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig) {
Application.Controllers.controller('ReserveMachineController', ['$scope', '$stateParams', '_t', 'moment', 'Auth', '$timeout', 'Member', 'Availability', 'plansPromise', 'groupsPromise', 'machinePromise', 'settingsPromise', 'uiCalendarConfig', 'CalendarConfig', 'Reservation',
function ($scope, $stateParams, _t, moment, Auth, $timeout, Member, Availability, plansPromise, groupsPromise, machinePromise, settingsPromise, uiCalendarConfig, CalendarConfig, Reservation) {
/* PRIVATE STATIC CONSTANTS */
// Slot free to be booked
@ -643,36 +643,38 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
/**
* Once the reservation is booked (payment process successfully completed), change the event style
* in fullCalendar, update the user's subscription and free-credits if needed
* @param reservation {Object}
* @param paymentDocument {Invoice|PaymentSchedule}
*/
$scope.afterPayment = function (reservation) {
angular.forEach($scope.events.reserved, function (machineSlot, key) {
machineSlot.is_reserved = true;
machineSlot.can_modify = true;
if ($scope.currentUser.role === 'admin' || ($scope.currentUser.role === 'manager' && reservation.user_id !== $scope.currentUser.id)) {
// an admin or a manager booked for someone else
machineSlot.title = _t('app.logged.machines_reserve.not_available');
machineSlot.borderColor = UNAVAILABLE_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.ctrl.member);
} else {
// booked for "myself"
machineSlot.title = _t('app.logged.machines_reserve.i_ve_reserved');
machineSlot.borderColor = BOOKED_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.currentUser);
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.reserved, function (machineSlot, key) {
machineSlot.is_reserved = true;
machineSlot.can_modify = true;
if ($scope.currentUser.role === 'admin' || ($scope.currentUser.role === 'manager' && reservation.user_id !== $scope.currentUser.id)) {
// an admin or a manager booked for someone else
machineSlot.title = _t('app.logged.machines_reserve.not_available');
machineSlot.borderColor = UNAVAILABLE_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.ctrl.member);
} else {
// booked for "myself"
machineSlot.title = _t('app.logged.machines_reserve.i_ve_reserved');
machineSlot.borderColor = BOOKED_SLOT_BORDER_COLOR;
updateMachineSlot(machineSlot, reservation, $scope.currentUser);
}
machineSlot.backgroundColor = 'white';
});
if ($scope.selectedPlan) {
$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.plansAreShown = false;
$scope.selectedPlan = null;
}
machineSlot.backgroundColor = 'white';
refetchCalendar();
});
if ($scope.selectedPlan) {
$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.plansAreShown = false;
$scope.selectedPlan = null;
}
refetchCalendar();
};
/**

View File

@ -125,8 +125,8 @@ Application.Controllers.controller('EditProfileController', ['$scope', '$rootSco
}
};
// This boolean value will tell if the current user is the super-admin
$scope.isSuperAdmin = memberPromise.id === Fablab.superadminId;
// This boolean value will tell if the current user is the system admin
$scope.isAdminSys = memberPromise.id === Fablab.adminSysId;
/**
* Return the group object, identified by the ID set in $scope.userGroup

View File

@ -89,6 +89,20 @@ Application.Controllers.controller('PlansIndexController', ['$scope', '$rootScop
}, 50);
};
/**
* Open the modal dialog allowing the user to log into the system
*/
$scope.userLogin = function () {
console.log('userLogin');
setTimeout(() => {
console.log('going throught timeout');
if (!$scope.isAuthenticated()) {
console.log('! authenticated');
$scope.login();
}
}, 50);
};
/**
* Check if the provided plan is currently selected
* @param plan {Object} Resource plan

View File

@ -307,8 +307,8 @@ Application.Controllers.controller('ShowSpaceController', ['$scope', '$state', '
* per slots.
*/
Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateParams', 'Auth', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'spacePromise', '_t', 'uiCalendarConfig', 'CalendarConfig',
function ($scope, $stateParams, Auth, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, spacePromise, _t, uiCalendarConfig, CalendarConfig) {
Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateParams', 'Auth', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'spacePromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation',
function ($scope, $stateParams, Auth, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, spacePromise, _t, uiCalendarConfig, CalendarConfig, Reservation) {
/* PRIVATE STATIC CONSTANTS */
// Color of the selected event backgound
@ -556,35 +556,37 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
/**
* Once the reservation is booked (payment process successfully completed), change the event style
* in fullCalendar, update the user's subscription and free-credits if needed
* @param reservation {Object}
* @param paymentDocument {Invoice|PaymentSchedule}
*/
$scope.afterPayment = function (reservation) {
angular.forEach($scope.events.paid, function (spaceSlot, key) {
spaceSlot.is_reserved = true;
spaceSlot.can_modify = true;
spaceSlot.title = _t('app.logged.space_reserve.i_ve_reserved');
spaceSlot.backgroundColor = 'white';
spaceSlot.borderColor = RESERVED_SLOT_BORDER_COLOR;
updateSpaceSlotId(spaceSlot, reservation);
updateEvents(spaceSlot);
});
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.paid, function (spaceSlot, key) {
spaceSlot.is_reserved = true;
spaceSlot.can_modify = true;
spaceSlot.title = _t('app.logged.space_reserve.i_ve_reserved');
spaceSlot.backgroundColor = 'white';
spaceSlot.borderColor = RESERVED_SLOT_BORDER_COLOR;
updateSpaceSlotId(spaceSlot, reservation);
updateEvents(spaceSlot);
});
if ($scope.selectedPlan) {
$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);
if ($scope.selectedPlan) {
$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.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);
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);
}
$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);
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();
refetchCalendar();
});
};
/**

View File

@ -91,8 +91,8 @@ Application.Controllers.controller('ShowTrainingController', ['$scope', '$state'
* training can be reserved during the reservation process (the shopping cart may contains only one training and a subscription).
*/
Application.Controllers.controller('ReserveTrainingController', ['$scope', '$stateParams', 'Auth', 'AuthService', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'trainingPromise', '_t', 'uiCalendarConfig', 'CalendarConfig',
function ($scope, $stateParams, Auth, AuthService, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, trainingPromise, _t, uiCalendarConfig, CalendarConfig) {
Application.Controllers.controller('ReserveTrainingController', ['$scope', '$stateParams', 'Auth', 'AuthService', '$timeout', 'Availability', 'Member', 'plansPromise', 'groupsPromise', 'settingsPromise', 'trainingPromise', '_t', 'uiCalendarConfig', 'CalendarConfig', 'Reservation',
function ($scope, $stateParams, Auth, AuthService, $timeout, Availability, Member, plansPromise, groupsPromise, settingsPromise, trainingPromise, _t, uiCalendarConfig, CalendarConfig, Reservation) {
/* PRIVATE STATIC CONSTANTS */
// Color of the selected event backgound
@ -346,35 +346,37 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
/**
* Once the reservation is booked (payment process successfully completed), change the event style
* in fullCalendar, update the user's subscription and free-credits if needed
* @param reservation {Object}
* @param paymentDocument {Invoice|PaymentSchedule}
*/
$scope.afterPayment = function (reservation) {
angular.forEach($scope.events.paid, function (trainingSlot, key) {
trainingSlot.backgroundColor = 'white';
trainingSlot.is_reserved = true;
trainingSlot.can_modify = true;
updateTrainingSlotId(trainingSlot, reservation);
trainingSlot.borderColor = '#b2e774';
trainingSlot.title = trainingSlot.training.name + ' - ' + _t('app.logged.trainings_reserve.i_ve_reserved');
updateEvents(trainingSlot);
});
$scope.afterPayment = function (paymentDocument) {
Reservation.get({ id: paymentDocument.main_object.id }, function (reservation) {
angular.forEach($scope.events.paid, function (trainingSlot, key) {
trainingSlot.backgroundColor = 'white';
trainingSlot.is_reserved = true;
trainingSlot.can_modify = true;
updateTrainingSlotId(trainingSlot, reservation);
trainingSlot.borderColor = '#b2e774';
trainingSlot.title = trainingSlot.training.name + ' - ' + _t('app.logged.trainings_reserve.i_ve_reserved');
updateEvents(trainingSlot);
});
if ($scope.selectedPlan) {
$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);
if ($scope.selectedPlan) {
$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.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);
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);
}
$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);
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();
refetchCalendar();
});
};
/**

View File

@ -73,8 +73,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
payment_schedule: undefined // the effective computed payment schedule
};
// online payments (stripe)
$scope.stripe = {
// online payments (by card)
$scope.onlinePayment = {
showModal: false,
cartItems: undefined
};
@ -313,11 +313,11 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
};
/**
* This will open/close the stripe payment modal
* This will open/close the online payment modal
*/
$scope.toggleStripeModal = (beforeApply) => {
$scope.toggleOnlinePaymentModal = (beforeApply) => {
setTimeout(() => {
$scope.stripe.showModal = !$scope.stripe.showModal;
$scope.onlinePayment.showModal = !$scope.onlinePayment.showModal;
if (typeof beforeApply === 'function') {
beforeApply();
}
@ -326,12 +326,20 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
};
/**
* Invoked atfer a successful Stripe payment
* @param result {*} may be a reservation or a subscription
* Invoked atfer a successful card payment
* @param invoice {*} may be an Invoice or a paymentSchedule
*/
$scope.afterStripeSuccess = (result) => {
$scope.toggleStripeModal();
afterPayment(result);
$scope.afterOnlinePaymentSuccess = (invoice) => {
$scope.toggleOnlinePaymentModal();
afterPayment(invoice);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
*/
$scope.onOnlinePaymentError = (message) => {
growl.error(message);
};
/* PRIVATE SCOPE */
@ -612,8 +620,15 @@ 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({ reservation: r }, $scope.coupon.applied), function (res) {
const items = [];
if ($scope.events.reserved && $scope.events.reserved.length > 0) {
items.push(mkReservation($scope.events.reserved));
}
if ($scope.selectedPlan) {
items.push(mkSubscription($scope.selectedPlan.id));
}
return Price.compute(mkCartItems(items), function (res) {
$scope.amountTotal = res.price;
$scope.schedule.payment_schedule = res.schedule;
$scope.totalNoCoupon = res.price_without_coupon;
@ -637,33 +652,16 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
});
};
/**
* Format the parameters expected by /api/prices/compute or /api/reservations and return the resulting object
* @param request {{reservation: *}|{subscription: *}} as returned by mkReservation()
* @param coupon {{code: string}} Coupon as returned from the API
* @return {CartItems}
*/
const mkRequestParams = function (request, coupon) {
return Object.assign({
coupon_code: ((coupon ? coupon.code : undefined))
}, request);
};
/**
* 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 {{reservable_type: string, payment_schedule: boolean, user_id: *, reservable_id: string, slots_attributes: [], plan_id: (*|undefined)}}
* @return {{reservation: {reservable_type: string, reservable_id: string, slots_attributes: []}}}
*/
const mkReservation = function (member, slots, plan) {
const mkReservation = function (slots) {
const reservation = {
user_id: member.id,
reservable_id: $scope.reservableId,
reservable_type: $scope.reservableType,
slots_attributes: [],
plan_id: ((plan ? plan.id : undefined)),
payment_schedule: $scope.schedule.requested_schedule
slots_attributes: []
};
angular.forEach(slots, function (slot) {
reservation.slots_attributes.push({
@ -674,76 +672,67 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
});
});
return reservation;
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}}}
* @return {{subscription: {plan_id: number}}}
*/
const mkSubscription = function (planId, userId, schedule, method) {
const mkSubscription = function (planId) {
return {
subscription: {
plan_id: planId,
user_id: userId,
payment_schedule: schedule,
payment_method: method
plan_id: planId
}
};
};
/**
* Build the CartItems object, from the current reservation
* @param reservation {*}
* Build the ShoppingCart object, from the current reservation
* @param items {Array<{reservation:{reservable_type: string, reservable_id: string, slots_attributes: []}}|{subscription: {plan_id: number}}>}
* @param paymentMethod {string}
* @return {CartItems}
* @return {ShoppingCart}
*/
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);
const mkCartItems = function (items, paymentMethod = '') {
return {
customer_id: $scope.user.id,
items,
payment_schedule: $scope.schedule.requested_schedule,
payment_method: paymentMethod,
coupon_code: (($scope.coupon.applied ? $scope.coupon.applied.code : undefined))
};
};
/**
* Open a modal window that allows the user to process a credit card payment for his current shopping cart.
*/
const payByStripe = function (reservation) {
const payOnline = function (items) {
// 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');
$scope.toggleOnlinePaymentModal(() => {
$scope.onlinePayment.cartItems = mkCartItems(items, 'card');
});
}
};
/**
* Open a modal window that allows the user to process a local payment for his current shopping cart (admin only).
*/
const payOnSite = function (reservation) {
const payOnSite = function (items) {
$uibModal.open({
templateUrl: '/shared/valid_reservation_modal.html',
size: $scope.schedule.payment_schedule ? 'lg' : 'sm',
resolve: {
reservation () {
return reservation;
},
price () {
return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise;
return Price.compute(mkCartItems(items, '')).$promise;
},
cartItems () {
return mkCartItems(reservation, 'stripe');
return mkCartItems(items, '');
},
wallet () {
return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise;
return Wallet.getWalletByUser({ user_id: $scope.user.id }).$promise;
},
coupon () {
return $scope.coupon.applied;
@ -761,8 +750,8 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
return $scope.settings;
}
},
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) {
controller: ['$scope', '$uibModalInstance', '$state', 'price', 'Auth', 'LocalPayment', 'wallet', 'helpers', '$filter', 'coupon', 'selectedPlan', 'schedule', 'cartItems', 'user', 'settings',
function ($scope, $uibModalInstance, $state, price, Auth, LocalPayment, wallet, helpers, $filter, coupon, selectedPlan, schedule, cartItems, user, settings) {
// user wallet amount
$scope.wallet = wallet;
@ -772,8 +761,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// Price to pay (wallet deducted)
$scope.amount = helpers.getAmountToPay(price.price, wallet.amount);
// Reservation (simple & cartItems format)
$scope.reservation = reservation;
// Reservation &| subscription
$scope.cartItems = cartItems;
// Subscription
@ -787,49 +775,57 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
// how should we collect payments for the payment schedule
$scope.method = {
payment_method: 'stripe'
payment_method: 'card'
};
// "valid" Button label
$scope.validButtonName = '';
// stripe modal state
// online payment modal state
// this is used to collect card data when a payment-schedule was selected, and paid with a card
$scope.isOpenStripeModal = false;
$scope.isOpenOnlinePaymentModal = false;
// the customer
$scope.user = user;
/**
* Check if the shopping cart contains a reservation
* @return {Reservation|boolean}
*/
$scope.reservation = (function () {
const item = cartItems.items.find(i => i.reservation);
if (item && item.reservation.slots_attributes.length > 0) {
return item.reservation;
}
return false;
})();
/**
* Check if the shopping cart contains a subscription
* @return {Subscription|boolean}
*/
$scope.subscription = (function () {
const item = cartItems.items.find(i => i.subscription);
if (item && item.subscription.plan_id) {
return item.subscription;
}
return false;
})();
/**
* Callback to process the local payment, triggered on button click
*/
$scope.ok = function () {
if ($scope.schedule && $scope.method.payment_method === 'stripe') {
if ($scope.schedule && $scope.method.payment_method === 'card') {
// 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();
return $scope.toggleOnlinePaymentModal();
}
}
$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) {
LocalPayment.confirm(cartItems, function (reservation) {
$uibModalInstance.close(reservation);
$scope.attempting = true;
}, function (response) {
@ -844,24 +840,32 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
/**
* Asynchronously updates the status of the stripe modal
* Asynchronously updates the status of the online payment modal
*/
$scope.toggleStripeModal = function () {
$scope.toggleOnlinePaymentModal = function () {
setTimeout(() => {
$scope.isOpenStripeModal = !$scope.isOpenStripeModal;
$scope.isOpenOnlinePaymentModal = !$scope.isOpenOnlinePaymentModal;
$scope.$apply();
}, 50);
};
/**
* After creating a payment schedule by card, from an administrator.
* @param result {*} Reservation or Subscription
* @param result {*} PaymentSchedule
*/
$scope.afterCreatePaymentSchedule = function (result) {
$scope.toggleStripeModal();
$scope.toggleOnlinePaymentModal();
$uibModalInstance.close(result);
};
/**
* Invoked when something wrong occurred during the payment dialog initialization
* @param message {string}
*/
$scope.onCreatePaymentScheduleError = (message) => {
growl.error(message);
};
/* PRIVATE SCOPE */
/**
@ -870,7 +874,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const initialize = function () {
$scope.$watch('method.payment_method', function (newValue) {
$scope.validButtonName = computeValidButtonName();
$scope.cartItems = mkCartItems($scope.reservation, newValue);
$scope.cartItems.payment_method = newValue;
});
};
@ -880,10 +884,10 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
const computeValidButtonName = function () {
let method = '';
if ($scope.schedule) {
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== reservation.user_id) {
if (AuthService.isAuthorized(['admin', 'manager']) && $rootScope.currentUser.id !== cartItems.customer_id) {
method = $scope.method.payment_method;
} else {
method = 'stripe';
method = 'card';
}
}
if ($scope.amount > 0) {
@ -901,19 +905,19 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
initialize();
}
]
}).result.finally(null).then(function (reservation) { afterPayment(reservation); });
}).result.finally(null).then(function (paymentSchedule) { afterPayment(paymentSchedule); });
};
/**
* Actions to run after the payment was successful
* @param paymentResult {*} may be a reservation or a subscription
* @param paymentDocument {*} may be an Invoice or a PaymentSchedule
*/
const afterPayment = function (paymentResult) {
const afterPayment = function (paymentDocument) {
// 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(paymentResult); }
if (typeof $scope.afterPayment === 'function') { $scope.afterPayment(paymentDocument); }
// we reset the coupon, and the cart content, and we unselect the slot
$scope.coupon.applied = undefined;
if ($scope.slot) {
@ -931,21 +935,27 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs',
};
/**
* Actions to pay slots
* Actions to pay slots (or subscription)
*/
const paySlots = function () {
const reservation = mkReservation($scope.user, $scope.events.reserved, $scope.selectedPlan);
const items = [];
if ($scope.events.reserved && $scope.events.reserved.length > 0) {
items.push(mkReservation($scope.events.reserved));
}
if ($scope.selectedPlan) {
items.push(mkSubscription($scope.selectedPlan.id));
}
return Wallet.getWalletByUser({ user_id: $scope.user.id }, function (wallet) {
const amountToPay = helpers.getAmountToPay($scope.amountTotal, wallet.amount);
if ((AuthService.isAuthorized(['member']) && (amountToPay > 0 || (amountToPay === 0 && hasOtherDeadlines()))) ||
(AuthService.isAuthorized('manager') && $scope.user.id === $rootScope.currentUser.id && amountToPay > 0)) {
return payByStripe(reservation);
return payOnline(items);
} else {
if (AuthService.isAuthorized(['admin']) ||
(AuthService.isAuthorized('manager') && $scope.user.id !== $rootScope.currentUser.id) ||
(amountToPay === 0 && !hasOtherDeadlines())) {
return payOnSite(reservation);
return payOnSite(items);
}
}
});

View File

@ -1,102 +0,0 @@
/* global Stripe */
/**
* This directive allows to extend a form with the Stripe payment input and error handling area.
* Strong-customer authentication is supported.
* --
* https://stripe.com/docs/payments/payment-intents/web-manual
*/
Application.Directives.directive('stripeForm', ['Payment', 'growl', '_t',
function (Payment, growl, _t) {
return ({
restrict: 'A',
scope: {
cartItems: '=',
onPaymentSuccess: '=',
stripeKey: '@'
},
link: function ($scope, element, attributes) {
const stripe = Stripe($scope.stripeKey);
const elements = stripe.elements();
const style = {
base: {
color: '#32325d',
fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
fontSmoothing: 'antialiased',
fontSize: '16px',
'::placeholder': {
color: '#aab7c4'
}
},
invalid: {
color: '#fa755a',
iconColor: '#fa755a'
}
};
const card = elements.create('card', { style, hidePostalCode: true });
card.addEventListener('change', function ({ error }) {
const displayError = document.getElementById('card-errors');
if (error) {
displayError.textContent = error.message;
} else {
displayError.textContent = '';
}
});
// Add an instance of the card Element into the `card-element` <div>.
const form = angular.element(element);
const cardElement = form.find('#card-element');
card.mount(cardElement[0]);
form.bind('submit', function () {
const button = form.find('button');
button.prop('disabled', true);
stripe.createPaymentMethod('card', card).then(function ({ paymentMethod, error }) {
if (error) {
growl.error(error.message);
button.prop('disabled', false);
} else {
// Send paymentMethod.id to your server (see Step 2)
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 handleServerResponse (response, confirmButton) {
if (response.error) {
if (response.error.statusText) {
growl.error(response.error.statusText);
} else {
growl.error(`${_t('app.shared.messages.payment_card_error')} ${response.error}`);
}
confirmButton.prop('disabled', false);
} else if (response.requires_action) {
// Use Stripe.js to handle required card action
stripe.handleCardAction(
response.payment_intent_client_secret
).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) {
handleServerResponse(confirmResult, confirmButton);
}, function (error) { handleServerResponse({ error }, confirmButton); });
}
});
} else {
$scope.onPaymentSuccess(response);
}
}
}
});
}]);

View File

@ -5,7 +5,7 @@ export interface IFablab {
statisticsModule: boolean,
defaultHost: string,
trackingId: string,
superadminId: number,
adminSysId: number,
baseHostUrl: string,
locale: string,
moment_locale: string,

View File

@ -0,0 +1,5 @@
export enum Gateway {
Stripe = 'stripe',
PayZen = 'payzen',
}

View File

@ -0,0 +1,26 @@
export interface Invoice {
id: number,
created_at: Date,
reference: string,
avoir_date: Date,
description: string
user_id: number,
total: number,
name: string,
has_avoir: boolean,
is_avoir: boolean,
is_subscription_invoice: boolean,
is_online_card: boolean,
date: Date,
chained_footprint: boolean,
main_object: {
type: string,
id: number
},
items: {
id: number,
amount: number,
description: string,
avoir_item_id: number
}
}

View File

@ -20,29 +20,22 @@ export interface PaymentScheduleItem {
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
}
client_secret?: string
}
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,
main_object: {
type: string,
id: number
},
user: {
id: number,
name: string
@ -51,6 +44,9 @@ export interface PaymentSchedule {
id: number,
first_name: string,
last_name: string,
},
gateway_subscription: {
classname: string
}
}

View File

@ -15,14 +15,19 @@ export interface IntentConfirmation {
}
export enum PaymentMethod {
Stripe = 'stripe',
Card = 'card',
Other = ''
}
export interface CartItems {
reservation?: Reservation,
subscription?: SubscriptionRequest,
coupon_code?: string
export type CartItem = { reservation: Reservation }|{ subscription: SubscriptionRequest }|{ card_update: { date: Date } };
export interface ShoppingCart {
customer_id: number,
// WARNING: items ordering matters! The first item in the array will be considered as the main item
items: Array<CartItem>,
coupon_code?: string,
payment_schedule?: boolean,
payment_method: PaymentMethod
}
export interface UpdateCardResponse {

View File

@ -0,0 +1,215 @@
export interface SdkTestResponse {
success: boolean
}
export interface CreateTokenResponse {
formToken: string
orderId: string
}
export interface CreatePaymentResponse extends CreateTokenResponse {}
export interface CheckHashResponse {
validity: boolean
}
export interface OrderDetails {
mode?: 'TEST' | 'PRODUCTION',
orderCurrency?: string,
orderEffectiveAmount?: number,
orderId?: string,
orderTotalAmount?: number,
_type: 'V4/OrderDetails'
}
export interface Customer {
email?: string,
reference?: string,
billingDetails?: {
address?: string,
address2?: string,
category?: 'PRIVATE' | 'COMPANY',
cellPhoneNumber?: string,
city?: string
country?: string,
district?: string,
firstName?: string,
identityCode?: string,
language?: 'DE' | 'EN' | 'ZH' | 'ES' | 'FR' | 'IT' | 'JP' | 'NL' | 'PL' | 'PT' | 'RU',
lastName?: string,
phoneNumber?: string,
state?: string,
streetNumber?: string,
title?: string,
zipCode?: string,
_type: 'V4/Customer/BillingDetails'
},
shippingDetails: {
address?: string,
address2?: string,
category?: 'PRIVATE' | 'COMPANY',
city?: string
country?: string,
deliveryCompanyName?: string,
district?: string,
firstName?: string,
identityCode?: string,
lastName?: string,
legalName?: string,
phoneNumber?: string,
shippingMethod?: 'RECLAIM_IN_SHOP' | 'RELAY_POINT' | 'RECLAIM_IN_STATION' | 'PACKAGE_DELIVERY_COMPANY' | 'ETICKET',
shippingSpeed?: 'STANDARD' | 'EXPRESS' | 'PRIORITY',
state?: string,
streetNumber?: string,
zipCode?: string,
_type: 'V4/Customer/ShippingDetails'
},
shoppingCart: {
insuranceAmount?: number,
shippingAmount?: number,
taxAmount?: number
cartItemInfo: Array<{
productAmount?: string,
productLabel?: string
productQty?: number,
productRef?: string,
productType?: 'FOOD_AND_GROCERY' | 'AUTOMOTIVE' | 'ENTERTAINMENT' | 'HOME_AND_GARDEN' | 'HOME_APPLIANCE' | 'AUCTION_AND_GROUP_BUYING' | 'FLOWERS_AND_GIFTS' | 'COMPUTER_AND_SOFTWARE' | 'HEALTH_AND_BEAUTY' | 'SERVICE_FOR_INDIVIDUAL' | 'SERVICE_FOR_BUSINESS' | 'SPORTS' | 'CLOTHING_AND_ACCESSORIES' | 'TRAVEL' | 'HOME_AUDIO_PHOTO_VIDEO' | 'TELEPHONY',
productVat?: number,
}>,
_type: 'V4/Customer/ShoppingCart'
}
_type: 'V4/Customer/Customer'
}
export interface PaymentTransaction {
amount?: number,
creationDate?: string,
currency?: string,
detailedErrorCode? : string,
detailedErrorMessage?: string,
detailedStatus?: 'ACCEPTED' | 'AUTHORISED' | 'AUTHORISED_TO_VALIDATE' | 'CANCELLED' | 'CAPTURED' | 'EXPIRED' | 'PARTIALLY_AUTHORISED' | 'REFUSED' | 'UNDER_VERIFICATION' | 'WAITING_AUTHORISATION' | 'WAITING_AUTHORISATION_TO_VALIDATE' | 'ERROR',
effectiveStrongAuthentication?: 'ENABLED' | 'DISABLED' ,
errorCode?: string,
errorMessage?: string,
metadata?: any,
operationType?: 'DEBIT' | 'CREDIT' | 'VERIFICATION',
orderDetails?: OrderDetails,
paymentMethodToken?: string,
paymentMethodType?: 'CARD',
shopId?: string,
status?: 'PAID' | 'UNPAID' | 'RUNNING' | 'PARTIALLY_PAID',
transactionDetails?: {
creationContext?: 'CHARGE' | 'REFUND',
effectiveAmount?: number,
effectiveCurrency?: string,
liabilityShift?: 'YES' | 'NO',
mid?: string,
parentTransactionUuid?: string,
sequenceNumber?: string,
cardDetails?: any,
fraudManagement?: any,
taxAmount?: number,
taxRate?: number,
preTaxAmount?: number,
externalTransactionId?: number,
dcc?: any,
nsu?: string,
tid?: string,
acquirerNetwork?: string,
taxRefundAmount?: number,
occurrenceType?: string
},
uuid?: string,
_type: 'V4/PaymentTransaction'
}
export interface Payment {
customer: Customer,
orderCycle: 'OPEN' | 'CLOSED',
orderDetails: OrderDetails,
orderStatus: 'PAID' | 'UNPAID' | 'RUNNING' | 'PARTIALLY_PAID',
serverDate: string,
shopId: string,
transactions: Array<PaymentTransaction>,
_type: 'V4/Payment'
}
export interface ProcessPaymentAnswer {
clientAnswer: Payment,
hash: string,
hashAlgorithm: string,
hashKey: string,
rawClientAnswer: string
_type: 'V4/Charge/ProcessPaymentAnswer'
}
export interface KryptonError {
children: Array<KryptonError>,
detailedErrorCode: string,
detailedErrorMessage: string,
errorCode: string,
errorMessage: string,
field: any,
formId: string,
metadata: {
answer: ProcessPaymentAnswer,
formToken: string
},
_errorKey: string,
_type: 'krypton/error'
}
export interface KryptonFocus {
field: string,
formId: string,
_type: 'krypton/focus'
}
export interface KryptonConfig {
formToken?: string,
'kr-public-key'?: string,
'kr-language'?: string,
'kr-post-url-success'?: string,
'kr-get-url-success'?: string,
'kr-post-url-refused'?: string,
'kr-get-url-refused'?: string,
'kr-clear-on-error'?: boolean,
'kr-hide-debug-toolbar'?: boolean,
'kr-spa-mode'?: boolean
}
type DefaultCallback = () => void
type BrandChangedCallback = (event: {KR: KryptonClient, cardInfo: {brand: string}}) => void
type ErrorCallback = (event: KryptonError) => void
type FocusCallback = (event: KryptonFocus) => void
type InstallmentChangedCallback = (event: {KR: KryptonClient, installmentInfo: {brand: string, hasInterests: boolean, installmentCount: number, totalAmount: number}}) => void
type SubmitCallback = (event: ProcessPaymentAnswer) => boolean
type ClickCallback = (event: any) => boolean
export interface KryptonClient {
addForm: (selector: string) => Promise<{KR: KryptonClient, result: {formId: string}}>,
showForm: (formId: string) => Promise<{KR: KryptonClient}>,
hideForm: (formId: string) => Promise<{KR: KryptonClient}>,
removeForms: () => Promise<{KR: KryptonClient}>,
attachForm: (selector: string) => Promise<{KR: KryptonClient}>,
onBrandChanged: (callback: BrandChangedCallback) => Promise<{KR: KryptonClient}>,
onError: (callback: ErrorCallback) => Promise<{KR: KryptonClient}>,
onFocus: (callback: FocusCallback) => Promise<{KR: KryptonClient}>,
onInstallmentChanged: (callback: InstallmentChangedCallback) => Promise<{KR: KryptonClient}>
onFormReady: (callback: DefaultCallback) => Promise<{KR: KryptonClient}>,
onFormCreated: (callback: DefaultCallback) => Promise<{KR: KryptonClient}>,
onSubmit: (callback: SubmitCallback) => Promise<{KR: KryptonClient}>,
button: {
onClick: (callback: ClickCallback | Promise<boolean>) => Promise<{KR: KryptonClient}>
},
openPopin: () => Promise<{KR: KryptonClient}>,
closePopin: () => Promise<{KR: KryptonClient}>,
fields: {
focus: (selector: string) => Promise<{KR: KryptonClient}>,
},
setFormConfig: (config: KryptonConfig) => Promise<{KR: KryptonClient}>,
setShopName: (name: string) => Promise<{KR: KryptonClient}>,
setFormToken: (formToken: string) => Promise<{KR: KryptonClient}>,
validateForm: () => Promise<{KR: KryptonClient, result?: KryptonError}>,
submit: () => Promise<{KR: KryptonClient}>,
}

View File

@ -7,13 +7,10 @@ export interface ReservationSlot {
}
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

@ -101,12 +101,31 @@ export enum SettingName {
UpcomingEventsShown = 'upcoming_events_shown',
PaymentSchedulePrefix = 'payment_schedule_prefix',
TrainingsModule = 'trainings_module',
AddressRequired = 'address_required'
AddressRequired = 'address_required',
PaymentGateway = 'payment_gateway',
PayZenUsername = 'payzen_username',
PayZenPassword = 'payzen_password',
PayZenEndpoint = 'payzen_endpoint',
PayZenPublicKey = 'payzen_public_key',
PayZenHmacKey = 'payzen_hmac',
PayZenCurrency = 'payzen_currency'
}
export interface Setting {
name: SettingName,
value: string,
last_update: Date,
history: Array<HistoryValue>
last_update?: Date,
history?: Array<HistoryValue>
}
export interface SettingError {
error: string,
id: number,
name: string
}
export interface SettingBulkResult {
status: boolean,
value?: any,
error?: string
}

View File

@ -11,8 +11,5 @@ export interface Subscription {
}
export interface SubscriptionRequest {
plan_id: number,
user_id: number,
payment_schedule: boolean,
payment_method: PaymentMethod
plan_id: number
}

View File

@ -364,7 +364,7 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['machine_explications_alert', 'booking_window_start', 'booking_window_end', 'booking_move_enable', " +
"'booking_move_delay', 'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'online_payment_module']"
"'online_payment_module', 'payment_gateway']"
}).$promise;
}]
}
@ -450,7 +450,7 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'space_explications_alert', 'online_payment_module']"
"'space_explications_alert', 'online_payment_module', 'payment_gateway']"
}).$promise;
}]
}
@ -503,7 +503,8 @@ angular.module('application.router', ['ui.router'])
return Setting.query({
names: "['booking_window_start', 'booking_window_end', 'booking_move_enable', 'booking_move_delay', " +
"'booking_cancel_enable', 'booking_cancel_delay', 'subscription_explications_alert', " +
"'training_explications_alert', 'training_information_message', 'online_payment_module']"
"'training_explications_alert', 'training_information_message', 'online_payment_module', " +
"'payment_gateway']"
}).$promise;
}]
}
@ -533,7 +534,7 @@ angular.module('application.router', ['ui.router'])
subscriptionExplicationsPromise: ['Setting', function (Setting) { return Setting.get({ name: 'subscription_explications_alert' }).$promise; }],
plansPromise: ['Plan', function (Plan) { return Plan.query().$promise; }],
groupsPromise: ['Group', function (Group) { return Group.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module']" }).$promise; }]
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module', 'payment_gateway']" }).$promise; }]
}
})
@ -550,7 +551,6 @@ angular.module('application.router', ['ui.router'])
categoriesPromise: ['Category', function (Category) { return Category.query().$promise; }],
themesPromise: ['EventTheme', function (EventTheme) { return EventTheme.query().$promise; }],
ageRangesPromise: ['AgeRange', function (AgeRange) { return AgeRange.query().$promise; }],
settingsPromise: ['Setting', function (Setting) { return Setting.query({ names: "['online_payment_module']" }).$promise; }]
}
})
.state('app.public.events_show', {
@ -861,7 +861,7 @@ angular.module('application.router', ['ui.router'])
"'accounting_VAT_code', 'accounting_VAT_label', 'accounting_subscription_code', 'accounting_subscription_label', " +
"'accounting_Machine_code', 'accounting_Machine_label', 'accounting_Training_code', 'accounting_Training_label', " +
"'accounting_Event_code', 'accounting_Event_label', 'accounting_Space_code', 'accounting_Space_label', " +
"'accounting_Error_code', 'accounting_Error_label', " +
"'payment_gateway', 'accounting_Error_code', 'accounting_Error_label', " +
"'feature_tour_display', 'online_payment_module', 'stripe_public_key', 'stripe_currency', 'invoice_prefix']"
}).$promise;
}],

View File

@ -0,0 +1,13 @@
'use strict';
Application.Services.factory('LocalPayment', ['$resource', function ($resource) {
return $resource('/api/local_payment',
{}, {
confirm: {
method: 'POST',
url: '/api/local_payment/confirm_payment',
isArray: false
}
}
);
}]);

View File

@ -5,12 +5,12 @@ Application.Services.factory('Payment', ['$resource', function ($resource) {
{}, {
confirm: {
method: 'POST',
url: '/api/payments/confirm_payment',
url: '/api/stripe/confirm_payment',
isArray: false
},
onlinePaymentStatus: {
method: 'GET',
url: '/api/payments/online_payment_status'
url: '/api/stripe/online_payment_status'
}
}
);

View File

@ -268,11 +268,32 @@
}
.list-of-plans {
.active-group ~ .active-group .group-title {
/* select all active groups but the first (the first have no margin at the top) */
margin: 3em auto 1em;
}
.group-title {
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 2em;
width: 83.33%;
border-bottom: 1px solid;
padding-bottom: 2em;
margin: auto auto 1em;
box-sizing: border-box;
margin: 0 auto 1em;
$border: 5px;
background: #FFF;
background-clip: padding-box;
border: solid $border transparent;
&:before {
content: '';
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
z-index: -1;
margin: -$border;
border-radius: inherit;
}
}
.plans-per-group {

View File

@ -22,6 +22,7 @@
@import "modules/stripe";
@import "modules/tour";
@import "modules/fab-modal";
@import "modules/fab-input";
@import "modules/fab-button";
@import "modules/payment-schedule-summary";
@import "modules/wallet-info";
@ -34,5 +35,13 @@
@import "modules/payment-schedule-dashboard";
@import "modules/plan-card";
@import "modules/event-themes";
@import "modules/select-gateway-modal";
@import "modules/stripe-keys-form";
@import "modules/payzen-keys-form";
@import "modules/payzen-settings";
@import "modules/payment-modal";
@import "modules/payzen-modal";
@import "modules/stripe-update-card-modal";
@import "modules/payzen-update-card-modal";
@import "app.responsive";

View File

@ -1,3 +1,5 @@
@use 'sass:math';
// a flag to toggle asset pipeline / compass integration
// defaults to true if twbs-font-path function is present (no function => twbs-font-path('') parsed as string == right side)
// in Sass 3.3 this can be improved with: function-exists(twbs-font-path)
@ -471,8 +473,8 @@ $container-lg: $container-large-desktop !default;
$navbar-height: 50px !default;
$navbar-margin-bottom: $line-height-computed !default;
$navbar-border-radius: $border-radius-base !default;
$navbar-padding-horizontal: floor($grid-gutter-width / 2) !default;
$navbar-padding-vertical: ($navbar-height - $line-height-computed) / 2 !default;
$navbar-padding-horizontal: floor(math.div($grid-gutter-width, 2)) !default;
$navbar-padding-vertical: math.div($navbar-height - $line-height-computed, 2) !default;
$navbar-collapse-max-height: 340px !default;
$navbar-default-color: #777 !default;

View File

@ -17,6 +17,7 @@
border-radius: 4px;
user-select: none;
text-decoration: none;
height: 38px;
&:hover {
background-color: #f2f2f2;

View File

@ -0,0 +1,88 @@
.fab-input {
.input-wrapper {
position: relative;
display: table;
border-collapse: separate;
}
&--icon {
min-width: 40px;
padding: 6px 12px;
font-size: 16px;
font-weight: 400;
line-height: 1;
color: #555;
text-align: center;
background-color: #eee;
border: 1px solid #c4c4c4;
border-radius: 4px 0 0 4px;
width: 1%;
white-space: nowrap;
vertical-align: middle;
display: table-cell;
&:first-child {
border-right: 0;
}
}
&--input {
display: block;
width: 100%;
height: 38px;
padding: 6px 12px;
font-size: 16px;
line-height: 1.5;
color: #555555;
background-color: #fff;
background-image: none;
border: 1px solid #c4c4c4;
border-radius: 0;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .08);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
&[disabled], &[readonly] {
background-color: #eee;
opacity: 1;
}
}
&--addon {
padding: 6px 12px;
font-size: 16px;
font-weight: 400;
line-height: 1;
color: #555;
text-align: center;
background-color: #eee;
border: 1px solid #c4c4c4;
border-radius: 0 4px 4px 0;
width: 1%;
white-space: nowrap;
vertical-align: middle;
display: table-cell;
&:last-child, &:not(:first-child) {
border-left: 0;
}
}
.input-error {
.fab-input--icon {
color: #a94442;
background-color: #f2dede;
border-color: #a94442;
}
.fab-input--input {
border-color: #a94442;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.08);
}
}
&--error {
display: block;
color: #a94442;
margin-top: 5px;
margin-bottom: 10px;
}
}

View File

@ -0,0 +1,79 @@
.payment-modal {
.fab-modal-content {
padding-bottom: 0;
}
.gateway-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;
.payment-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;
background-color: lighten(#1d98ec, 20%);
text-align: center;
padding: 4px;
}
}

View File

@ -121,61 +121,3 @@
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;
background-color: lighten(#1d98ec, 20%);
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,61 @@
.payzen-keys-form {
& {
margin-top: 1em;
}
.payzen-keys-info {
border: 1px solid #bce8f1;
border-radius: 4px;
color: #31708f;
background-color: #d9edf7;
padding: 15px;
}
fieldset {
border: 1px solid #c4c4c4;
border-radius: 4px;
margin-top: 1em;
padding: 7px;
& > legend {
padding: 3px 6px;
width: fit-content;
font-size: 1em;
border-radius: 4px;
margin-left: 1em;
margin-bottom: 0;
position: relative;
&.with-addon {
border-radius: 4px 0 0 4px;;
}
}
.fieldset-legend--addon {
display: block;
position: absolute;
top: 0;
font-size: 1em;
padding: 3px 12px;
font-weight: 400;
text-align: center;
border-radius: 0 4px 4px 0;
vertical-align: middle;
&.key-invalid {
right: -35px;
}
&.key-valid {
right: -40px;
}
}
}
.key-valid {
background-color: #7bca38;
}
.key-invalid {
background-color: #d92227;
color: white;
}
}

View File

@ -0,0 +1,36 @@
.payzen-modal {
.payzen-form {
.hidden {
display: none;
}
.loader {
text-align: center;
}
.loader-overlay {
position: absolute;
top: 65px;
left: 190px;
z-index: 1;
}
.container {
display: flex;
justify-content: center;
width: inherit;
.kr-payment-button {
display: none;
}
.kr-form-error {
display: none;
}
}
}
.payzen-modal-icons {
text-align: center;
img {
margin-right: 10px;
}
}
}

View File

@ -0,0 +1,41 @@
.payzen-settings {
margin: 15px;
.payzen-keys {
display: flex;
flex-direction: row;
justify-content: flex-start;
flex-wrap: wrap;
.key-wrapper {
padding: 5px 15px;
}
}
.edit-keys {
margin: 5px 15px;
padding-top: 28px;
}
.payzen-currency {
.currency-info {
padding: 15px;
margin-bottom: 24px;
border: 1px solid #faebcc;
border-radius: 4px;
color: #8a6d3b;
background-color: #fcf8e3;
}
.payzen-currency-form {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
.currency-wrapper {
padding: 5px 15px;
}
.save-currency {
margin: 34px 15px 5px;
}
}
}
}

View File

@ -0,0 +1,73 @@
.payzen-update-card-modal {
.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;
.payzen-errors {
padding: 4px 0;
color: #9e2146;
overflow: auto;
}
.hidden {
display: none;
}
.loader {
text-align: center;
}
.loader-overlay {
position: absolute;
top: 65px;
left: 190px;
z-index: 1;
}
.container {
display: flex;
justify-content: center;
width: inherit;
.kr-payment-button {
display: none;
}
.kr-form-error {
display: none;
}
}
}
.fab-modal-content {
.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;
background-color: lighten(#1d98ec, 20%);
text-align: center;
padding: 4px;
}
}
}
.payzen-modal-icons {
text-align: center;
img {
margin-right: 10px;
}
}
}

View File

@ -0,0 +1,25 @@
.gateway-modal {
.info-gateway {
border: 1px solid #bce8f1;
border-radius: 4px;
color: #31708f;
background-color: #d9edf7;
padding: 15px;
}
.select-gateway {
display: block;
width: 100%;
height: 38px;
padding: 6px 12px;
font-size: 16px;
line-height: 1.5;
color: #555555;
background-color: #fff;
background-image: none;
border: 1px solid #c4c4c4;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .08);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
}

View File

@ -0,0 +1,27 @@
.stripe-keys-form {
& {
margin-top: 1em;
}
.stripe-keys-info {
border: 1px solid #bce8f1;
border-radius: 4px;
color: #31708f;
background-color: #d9edf7;
padding: 15px;
}
.stripe-public-input, .stripe-secret-input {
display: block;
margin: 7px;
.key-valid {
background-color: #7bca38;
}
.key-invalid {
background-color: #d92227;
color: white;
}
}
}

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