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:
commit
2c37f67137
30
CHANGELOG.md
30
CHANGELOG.md
@ -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
|
||||
|
45
README.md
45
README.md
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
20
app/controllers/api/local_payment_controller.rb
Normal file
20
app/controllers/api/local_payment_controller.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
98
app/controllers/api/payzen_controller.rb
Normal file
98
app/controllers/api/payzen_controller.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
143
app/controllers/api/stripe_controller.rb
Normal file
143
app/controllers/api/stripe_controller.rb
Normal 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
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
8
app/exceptions/invalid_invoice_error.rb
Normal file
8
app/exceptions/invalid_invoice_error.rb
Normal 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
|
5
app/exceptions/payment_gateway_error.rb
Normal file
5
app/exceptions/payment_gateway_error.rb
Normal file
@ -0,0 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when an an error occurred with any payment gateway
|
||||
class PaymentGatewayError < StandardError
|
||||
end
|
6
app/exceptions/payzen_error.rb
Normal file
6
app/exceptions/payzen_error.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when an an error occurred with the PayZen payment gateway
|
||||
class PayzenError < PaymentGatewayError
|
||||
end
|
||||
|
BIN
app/frontend/images/payzen-secure.png
Normal file
BIN
app/frontend/images/payzen-secure.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
@ -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));
|
15
app/frontend/src/javascript/api/clients/stripe-client.ts
Normal file
15
app/frontend/src/javascript/api/clients/stripe-client.ts
Normal 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;
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
29
app/frontend/src/javascript/api/external/stripe.ts
vendored
Normal file
29
app/frontend/src/javascript/api/external/stripe.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
@ -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>> {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
50
app/frontend/src/javascript/api/payzen.ts
Normal file
50
app/frontend/src/javascript/api/payzen.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
38
app/frontend/src/javascript/api/stripe.ts
Normal file
38
app/frontend/src/javascript/api/stripe.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import apiClient from './api-client';
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Theme } from '../models/theme';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
}
|
105
app/frontend/src/javascript/components/base/fab-input.tsx
Normal file
105
app/frontend/src/javascript/components/base/fab-input.tsx
Normal 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 };
|
@ -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();
|
||||
|
||||
/**
|
@ -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]);
|
||||
|
@ -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">
|
@ -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">
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
|
||||
/**
|
@ -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']));
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
}
|
@ -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');
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
);
|
||||
}
|
@ -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']));
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
@ -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>;
|
@ -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);
|
@ -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>
|
@ -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>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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']));
|
||||
|
148
app/frontend/src/javascript/components/select-gateway-modal.tsx
Normal file
148
app/frontend/src/javascript/components/select-gateway-modal.tsx
Normal 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']));
|
@ -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']));
|
@ -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']));
|
||||
|
@ -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);
|
||||
};
|
||||
}]);
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
]);
|
||||
|
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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();
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}]);
|
@ -5,7 +5,7 @@ export interface IFablab {
|
||||
statisticsModule: boolean,
|
||||
defaultHost: string,
|
||||
trackingId: string,
|
||||
superadminId: number,
|
||||
adminSysId: number,
|
||||
baseHostUrl: string,
|
||||
locale: string,
|
||||
moment_locale: string,
|
||||
|
5
app/frontend/src/javascript/models/gateway.ts
Normal file
5
app/frontend/src/javascript/models/gateway.ts
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
export enum Gateway {
|
||||
Stripe = 'stripe',
|
||||
PayZen = 'payzen',
|
||||
}
|
26
app/frontend/src/javascript/models/invoice.ts
Normal file
26
app/frontend/src/javascript/models/invoice.ts
Normal 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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
215
app/frontend/src/javascript/models/payzen.ts
Normal file
215
app/frontend/src/javascript/models/payzen.ts
Normal 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}>,
|
||||
}
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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;
|
||||
}],
|
||||
|
13
app/frontend/src/javascript/services/local_payment.js
Normal file
13
app/frontend/src/javascript/services/local_payment.js
Normal 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
|
||||
}
|
||||
}
|
||||
);
|
||||
}]);
|
@ -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'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
@ -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 {
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -17,6 +17,7 @@
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
text-decoration: none;
|
||||
height: 38px;
|
||||
|
||||
&:hover {
|
||||
background-color: #f2f2f2;
|
||||
|
88
app/frontend/src/stylesheets/modules/fab-input.scss
Normal file
88
app/frontend/src/stylesheets/modules/fab-input.scss
Normal 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;
|
||||
}
|
||||
}
|
79
app/frontend/src/stylesheets/modules/payment-modal.scss
Normal file
79
app/frontend/src/stylesheets/modules/payment-modal.scss
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
61
app/frontend/src/stylesheets/modules/payzen-keys-form.scss
Normal file
61
app/frontend/src/stylesheets/modules/payzen-keys-form.scss
Normal 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;
|
||||
}
|
||||
}
|
36
app/frontend/src/stylesheets/modules/payzen-modal.scss
Normal file
36
app/frontend/src/stylesheets/modules/payzen-modal.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
41
app/frontend/src/stylesheets/modules/payzen-settings.scss
Normal file
41
app/frontend/src/stylesheets/modules/payzen-settings.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
27
app/frontend/src/stylesheets/modules/stripe-keys-form.scss
Normal file
27
app/frontend/src/stylesheets/modules/stripe-keys-form.scss
Normal 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
Loading…
Reference in New Issue
Block a user