diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d522fb75..5df6bb294 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 0c478bfb6..43f9144c9 100644 --- a/README.md +++ b/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) @@ -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. - -## Setup a production environment + +## 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. - - -## 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). - - -## 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). ## 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). - -## 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. - ## Related Documentation diff --git a/app/controllers/api/invoices_controller.rb b/app/controllers/api/invoices_controller.rb index 7819dada4..8a45329fa 100644 --- a/app/controllers/api/invoices_controller.rb +++ b/app/controllers/api/invoices_controller.rb @@ -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 diff --git a/app/controllers/api/local_payment_controller.rb b/app/controllers/api/local_payment_controller.rb new file mode 100644 index 000000000..126510dd4 --- /dev/null +++ b/app/controllers/api/local_payment_controller.rb @@ -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 diff --git a/app/controllers/api/payment_schedules_controller.rb b/app/controllers/api/payment_schedules_controller.rb index 901bb73b2..f195ed22b 100644 --- a/app/controllers/api/payment_schedules_controller.rb +++ b/app/controllers/api/payment_schedules_controller.rb @@ -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 diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 2d5df145e..06e49ed47 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -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 diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb new file mode 100644 index 000000000..07653f854 --- /dev/null +++ b/app/controllers/api/payzen_controller.rb @@ -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 diff --git a/app/controllers/api/plans_controller.rb b/app/controllers/api/plans_controller.rb index 7e15e5015..704064021 100644 --- a/app/controllers/api/plans_controller.rb +++ b/app/controllers/api/plans_controller.rb @@ -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 diff --git a/app/controllers/api/prices_controller.rb b/app/controllers/api/prices_controller.rb index 9cc7db30c..eb0ace25d 100644 --- a/app/controllers/api/prices_controller.rb +++ b/app/controllers/api/prices_controller.rb @@ -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 diff --git a/app/controllers/api/reservations_controller.rb b/app/controllers/api/reservations_controller.rb index fd52e4d9e..92c159b8b 100644 --- a/app/controllers/api/reservations_controller.rb +++ b/app/controllers/api/reservations_controller.rb @@ -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 diff --git a/app/controllers/api/settings_controller.rb b/app/controllers/api/settings_controller.rb index 0ce7598f1..25724bdcd 100644 --- a/app/controllers/api/settings_controller.rb +++ b/app/controllers/api/settings_controller.rb @@ -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 diff --git a/app/controllers/api/stripe_controller.rb b/app/controllers/api/stripe_controller.rb new file mode 100644 index 000000000..cdc37559b --- /dev/null +++ b/app/controllers/api/stripe_controller.rb @@ -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 diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index db8c1ce12..bca08d5e5 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -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 diff --git a/app/doc/open_api/v1/invoices_doc.rb b/app/doc/open_api/v1/invoices_doc.rb index d9c966cfc..a2a880d73 100644 --- a/app/doc/open_api/v1/invoices_doc.rb +++ b/app/doc/open_api/v1/invoices_doc.rb @@ -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" } } diff --git a/app/exceptions/invalid_invoice_error.rb b/app/exceptions/invalid_invoice_error.rb new file mode 100644 index 000000000..3d5842a43 --- /dev/null +++ b/app/exceptions/invalid_invoice_error.rb @@ -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 diff --git a/app/exceptions/payment_gateway_error.rb b/app/exceptions/payment_gateway_error.rb new file mode 100644 index 000000000..5ee6aa66e --- /dev/null +++ b/app/exceptions/payment_gateway_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# Raised when an an error occurred with any payment gateway +class PaymentGatewayError < StandardError +end diff --git a/app/exceptions/payzen_error.rb b/app/exceptions/payzen_error.rb new file mode 100644 index 000000000..e449f6965 --- /dev/null +++ b/app/exceptions/payzen_error.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Raised when an an error occurred with the PayZen payment gateway +class PayzenError < PaymentGatewayError +end + diff --git a/app/frontend/images/payzen-secure.png b/app/frontend/images/payzen-secure.png new file mode 100644 index 000000000..aefcb7f3f Binary files /dev/null and b/app/frontend/images/payzen-secure.png differ diff --git a/app/frontend/src/javascript/api/api-client.ts b/app/frontend/src/javascript/api/clients/api-client.ts similarity index 93% rename from app/frontend/src/javascript/api/api-client.ts rename to app/frontend/src/javascript/api/clients/api-client.ts index a1f9c1fe4..ab6ef7833 100644 --- a/app/frontend/src/javascript/api/api-client.ts +++ b/app/frontend/src/javascript/api/clients/api-client.ts @@ -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)); diff --git a/app/frontend/src/javascript/api/clients/stripe-client.ts b/app/frontend/src/javascript/api/clients/stripe-client.ts new file mode 100644 index 000000000..5fb10ec72 --- /dev/null +++ b/app/frontend/src/javascript/api/clients/stripe-client.ts @@ -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; + diff --git a/app/frontend/src/javascript/api/custom-asset.ts b/app/frontend/src/javascript/api/custom-asset.ts index ecde60ab4..254241090 100644 --- a/app/frontend/src/javascript/api/custom-asset.ts +++ b/app/frontend/src/javascript/api/custom-asset.ts @@ -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'; diff --git a/app/frontend/src/javascript/api/event-theme.ts b/app/frontend/src/javascript/api/event-theme.ts index 56c741757..507bf1920 100644 --- a/app/frontend/src/javascript/api/event-theme.ts +++ b/app/frontend/src/javascript/api/event-theme.ts @@ -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; } } - diff --git a/app/frontend/src/javascript/api/external/stripe.ts b/app/frontend/src/javascript/api/external/stripe.ts new file mode 100644 index 000000000..48a58c14d --- /dev/null +++ b/app/frontend/src/javascript/api/external/stripe.ts @@ -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 { + 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 { + const res: AxiosResponse = await stripeClient(key).get('charges'); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index 21d56f359..882d9548a 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -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> { diff --git a/app/frontend/src/javascript/api/payment.ts b/app/frontend/src/javascript/api/payment.ts deleted file mode 100644 index eff57cfd7..000000000 --- a/app/frontend/src/javascript/api/payment.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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 { - const res: AxiosResponse = await apiClient.post(`/api/payments/update_card`, { - user_id, - payment_method_id: stp_payment_method_id, - }); - return res?.data; - } -} - diff --git a/app/frontend/src/javascript/api/payzen.ts b/app/frontend/src/javascript/api/payzen.ts new file mode 100644 index 000000000..fff1940b3 --- /dev/null +++ b/app/frontend/src/javascript/api/payzen.ts @@ -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 { + const res: AxiosResponse = await apiClient.post('/api/payzen/sdk_test', { base_url: baseURL, username, password }); + return res?.data; + } + + static async chargeCreatePayment(cart: ShoppingCart, customer: User): Promise { + const res: AxiosResponse = 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 { + 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 { + const res: AxiosResponse = await apiClient.post('/api/payzen/check_hash', { algorithm, hash_key: hashKey, hash, data }); + return res?.data; + } + + static async confirm(orderId: string, cart: ShoppingCart): Promise { + const res: AxiosResponse = 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 { + const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.post(`/api/payzen/update_token`, { payment_schedule_id }); + return res?.data; + } +} diff --git a/app/frontend/src/javascript/api/price.ts b/app/frontend/src/javascript/api/price.ts index c0e1b0f91..dfd9ea901 100644 --- a/app/frontend/src/javascript/api/price.ts +++ b/app/frontend/src/javascript/api/price.ts @@ -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 { - const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cartItems); + static async compute (cart: ShoppingCart): Promise { + const res: AxiosResponse = await apiClient.post(`/api/prices/compute`, cart); return res?.data; } } diff --git a/app/frontend/src/javascript/api/setting.ts b/app/frontend/src/javascript/api/setting.ts index 2bea6bf1d..421d2f2d7 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -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 { - 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): Promise> { - 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 { + 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): Promise> { + 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 { + const res: AxiosResponse = await apiClient.get(`/api/settings/is_present/${name}`); + return res?.data?.isPresent; } static get (name: SettingName): IWrapPromise { @@ -19,9 +38,50 @@ export default class SettingAPI { return wrapPromise(api.get(name)); } - static query(names: Array): IWrapPromise> { + static query (names: Array): IWrapPromise> { const api = new SettingAPI(); return wrapPromise(api.query(names)); } + + static isPresent (name: SettingName): IWrapPromise { + const api = new SettingAPI(); + return wrapPromise(api.isPresent(name)); + } + + private static toSettingsMap(names: Array, data: Object): Map { + const map = new Map(); + names.forEach(name => { + map.set(name, data[name] || ''); + }); + return map; + } + + private static toBulkMap(data: Array): Map { + 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): Array { + const array = []; + data.forEach((value, key) => { + array.push({ + name: key, + value + }) + }); + return array; + } } diff --git a/app/frontend/src/javascript/api/stripe.ts b/app/frontend/src/javascript/api/stripe.ts new file mode 100644 index 000000000..e739f1d76 --- /dev/null +++ b/app/frontend/src/javascript/api/stripe.ts @@ -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 { + const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.get(`/api/stripe/setup_intent/${user_id}`); + return res?.data; + } + + static async confirmPaymentSchedule (setup_intent_id: string, cart_items: ShoppingCart): Promise { + const res: AxiosResponse = 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 { + const res: AxiosResponse = await apiClient.post(`/api/stripe/update_card`, { + user_id, + payment_method_id: stp_payment_method_id, + payment_schedule_id + }); + return res?.data; + } +} + diff --git a/app/frontend/src/javascript/api/theme.ts b/app/frontend/src/javascript/api/theme.ts index f781584bd..596225278 100644 --- a/app/frontend/src/javascript/api/theme.ts +++ b/app/frontend/src/javascript/api/theme.ts @@ -1,4 +1,4 @@ -import apiClient from './api-client'; +import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; import { Theme } from '../models/theme'; diff --git a/app/frontend/src/javascript/api/wallet.ts b/app/frontend/src/javascript/api/wallet.ts index 590085611..8b818a42d 100644 --- a/app/frontend/src/javascript/api/wallet.ts +++ b/app/frontend/src/javascript/api/wallet.ts @@ -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'; diff --git a/app/frontend/src/javascript/components/fab-button.tsx b/app/frontend/src/javascript/components/base/fab-button.tsx similarity index 84% rename from app/frontend/src/javascript/components/fab-button.tsx rename to app/frontend/src/javascript/components/base/fab-button.tsx index e08689fa9..61d355deb 100644 --- a/app/frontend/src/javascript/components/fab-button.tsx +++ b/app/frontend/src/javascript/components/base/fab-button.tsx @@ -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 = ({ 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 = ({ 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); } diff --git a/app/frontend/src/javascript/components/base/fab-input.tsx b/app/frontend/src/javascript/components/base/fab-input.tsx new file mode 100644 index 000000000..214921256 --- /dev/null +++ b/app/frontend/src/javascript/components/base/fab-input.tsx @@ -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 = ({ id, onChange, defaultValue, icon, className, disabled, type, required, debounce, addOn, addOnClassName, readOnly, maxLength, pattern, placeholder, error }) => { + const [inputValue, setInputValue] = useState(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 ( +
+
+ {hasIcon() && {icon}} + + {hasAddOn() && {addOn}} +
+ {hasError() && {error} } +
+ ); +} + +FabInput.defaultProps = { type: 'text', debounce: 0 }; diff --git a/app/frontend/src/javascript/components/fab-modal.tsx b/app/frontend/src/javascript/components/base/fab-modal.tsx similarity index 88% rename from app/frontend/src/javascript/components/fab-modal.tsx rename to app/frontend/src/javascript/components/base/fab-modal.tsx index 0f1d18a41..9474f1952 100644 --- a/app/frontend/src/javascript/components/fab-modal.tsx +++ b/app/frontend/src/javascript/components/base/fab-modal.tsx @@ -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 = ({ 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(); /** diff --git a/app/frontend/src/javascript/components/html-translate.tsx b/app/frontend/src/javascript/components/base/html-translate.tsx similarity index 100% rename from app/frontend/src/javascript/components/html-translate.tsx rename to app/frontend/src/javascript/components/base/html-translate.tsx index 42f4cfeca..08cfa6988 100644 --- a/app/frontend/src/javascript/components/html-translate.tsx +++ b/app/frontend/src/javascript/components/base/html-translate.tsx @@ -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 = ({ trKey, options }) => { const { t } = useTranslation(trKey?.split('.')[1]); diff --git a/app/frontend/src/javascript/components/labelled-input.tsx b/app/frontend/src/javascript/components/base/labelled-input.tsx similarity index 99% rename from app/frontend/src/javascript/components/labelled-input.tsx rename to app/frontend/src/javascript/components/base/labelled-input.tsx index 6714c436a..7161bf814 100644 --- a/app/frontend/src/javascript/components/labelled-input.tsx +++ b/app/frontend/src/javascript/components/base/labelled-input.tsx @@ -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 = ({ id, type, label, value, onChange }) => { return (
diff --git a/app/frontend/src/javascript/components/loader.tsx b/app/frontend/src/javascript/components/base/loader.tsx similarity index 99% rename from app/frontend/src/javascript/components/loader.tsx rename to app/frontend/src/javascript/components/base/loader.tsx index ff933f86d..6da35bf5a 100644 --- a/app/frontend/src/javascript/components/loader.tsx +++ b/app/frontend/src/javascript/components/base/loader.tsx @@ -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 = (
diff --git a/app/frontend/src/javascript/components/document-filters.tsx b/app/frontend/src/javascript/components/document-filters.tsx index 6a465fffa..87f53d44a 100644 --- a/app/frontend/src/javascript/components/document-filters.tsx +++ b/app/frontend/src/javascript/components/document-filters.tsx @@ -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 = ({ 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; diff --git a/app/frontend/src/javascript/components/event-themes.tsx b/app/frontend/src/javascript/components/event-themes.tsx index c5056f229..341141d60 100644 --- a/app/frontend/src/javascript/components/event-themes.tsx +++ b/app/frontend/src/javascript/components/event-themes.tsx @@ -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'; diff --git a/app/frontend/src/javascript/components/payment-schedule-summary.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-summary.tsx similarity index 90% rename from app/frontend/src/javascript/components/payment-schedule-summary.tsx rename to app/frontend/src/javascript/components/payment-schedule/payment-schedule-summary.tsx index 5a0caf7c3..b6bde67a4 100644 --- a/app/frontend/src/javascript/components/payment-schedule-summary.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedule-summary.tsx @@ -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 = ({ schedule }) => { const { t } = useTranslation('shared'); + + // is open, the modal dialog showing the full details of the payment schedule? const [modal, setModal] = useState(false); /** diff --git a/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-dashboard.tsx similarity index 60% rename from app/frontend/src/javascript/components/payment-schedules-dashboard.tsx rename to app/frontend/src/javascript/components/payment-schedule/payment-schedules-dashboard.tsx index cfe580ef4..2809c2431 100644 --- a/app/frontend/src/javascript/components/payment-schedules-dashboard.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-dashboard.tsx @@ -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 = ({ 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 = ({ currentUser, onError, onCardUpdateSuccess }) => { const { t } = useTranslation('logged'); + // list of displayed payment schedules const [paymentSchedules, setPaymentSchedules] = useState>([]); + // current page const [pageNumber, setPageNumber] = useState(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 = ({ 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 = ({ c
{!hasSchedules() &&
{t('app.logged.dashboard.payment_schedules.no_payment_schedules')}
} {hasSchedules() &&
- + {hasMoreSchedules() && {t('app.logged.dashboard.payment_schedules.load_more')}}
}
@@ -83,12 +102,12 @@ const PaymentSchedulesDashboard: React.FC = ({ c } -const PaymentSchedulesDashboardWrapper: React.FC = ({ currentUser }) => { +const PaymentSchedulesDashboardWrapper: React.FC = ({ currentUser, onError, onCardUpdateSuccess }) => { return ( - + ); } -Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser'])); +Application.Components.component('paymentSchedulesDashboard', react2angular(PaymentSchedulesDashboardWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess'])); diff --git a/app/frontend/src/javascript/components/payment-schedules-list.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-list.tsx similarity index 65% rename from app/frontend/src/javascript/components/payment-schedules-list.tsx rename to app/frontend/src/javascript/components/payment-schedule/payment-schedules-list.tsx index 01c381e28..ac812175a 100644 --- a/app/frontend/src/javascript/components/payment-schedules-list.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-list.tsx @@ -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 = ({ currentUser }) => { +/** + * This component shows a list of all payment schedules with their associated deadlines (aka. PaymentScheduleItem) and invoices + */ +const PaymentSchedulesList: React.FC = ({ currentUser, onError, onCardUpdateSuccess }) => { const { t } = useTranslation('admin'); + // list of displayed payment schedules const [paymentSchedules, setPaymentSchedules] = useState>([]); + // current page const [pageNumber, setPageNumber] = useState(1); + // current filter, by reference, for the schedules const [referenceFilter, setReferenceFilter] = useState(null); + // current filter, by customer's name, for the schedules const [customerFilter, setCustomerFilter] = useState(null); + // current filter, by date, for the schedules and the deadlines const [dateFilter, setDateFilter] = useState(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 = ({ 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 = ({ 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 = ({ 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 (

@@ -99,7 +116,12 @@ const PaymentSchedulesList: React.FC = ({ currentUser

{!hasSchedules() &&
{t('app.admin.invoices.payment_schedules.no_payment_schedules')}
} {hasSchedules() &&
- + {hasMoreSchedules() && {t('app.admin.invoices.payment_schedules.load_more')}}
}
@@ -107,12 +129,12 @@ const PaymentSchedulesList: React.FC = ({ currentUser } -const PaymentSchedulesListWrapper: React.FC = ({ currentUser }) => { +const PaymentSchedulesListWrapper: React.FC = ({ currentUser, onError, onCardUpdateSuccess }) => { return ( - + ); } -Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser'])); +Application.Components.component('paymentSchedulesList', react2angular(PaymentSchedulesListWrapper, ['currentUser', 'onError', 'onCardUpdateSuccess'])); diff --git a/app/frontend/src/javascript/components/payment-schedules-table.tsx b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx similarity index 80% rename from app/frontend/src/javascript/components/payment-schedules-table.tsx rename to app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx index e40109cf9..1c4f78dab 100644 --- a/app/frontend/src/javascript/components/payment-schedules-table.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/payment-schedules-table.tsx @@ -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, showCustomer?: boolean, - refreshList: (onError: (msg: any) => void) => void, + refreshList: () => void, operator: User, + onError: (message: string) => void, + onCardUpdateSuccess: () => void } -const PaymentSchedulesTableComponent: React.FC = ({ 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 = ({ 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>(new Map()); + // is open, the modal dialog to confirm the cashing of a check? const [showConfirmCashing, setShowConfirmCashing] = useState(false); + // is open, the modal dialog the resolve a pending card payment? const [showResolveAction, setShowResolveAction] = useState(false); + // the user cannot confirm the action modal (3D secure), unless he has resolved the pending action const [isConfirmActionDisabled, setConfirmActionDisabled] = useState(true); + // is open, the modal dialog to update the card details const [showUpdateCard, setShowUpdateCard] = useState(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(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(null); - const [canSubmitUpdateCard, setCanSubmitUpdateCard] = useState(true); - const [errors, setErrors] = useState(null); + // is open, the modal dialog to cancel the associated subscription? const [showCancelSubscription, setShowCancelSubscription] = useState(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(); + /** * Check if the requested payment schedule is displayed with its deadlines (PaymentScheduleItem) or without them */ @@ -165,12 +174,14 @@ const PaymentSchedulesTableComponent: React.FC = ({ ); case PaymentScheduleItemState.RequirePaymentMethod: return ( - }> {t('app.shared.schedules_table.update_card')} ); 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 ( = ({ } else { return {t('app.shared.schedules_table.please_ask_reception')} } + case PaymentScheduleItemState.New: + if (!cardUpdateButton.get(schedule.id)) { + cardUpdateButton.set(schedule.id, true); + return ( + }> + {t('app.shared.schedules_table.update_card')} + + ) + } + return default: return } @@ -213,7 +235,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ * Refresh all payment schedules in the table */ const refreshSchedulesTable = (): void => { - refreshList(setErrors); + refreshList(); } /** @@ -262,7 +284,7 @@ const PaymentSchedulesTableComponent: React.FC = ({ /** * 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 = ({ setShowUpdateCard(!showUpdateCard); } - /** - * Return the logos, shown in the modal footer. - */ - const logoFooter = (): ReactNode => { - return ( -
- - powered by stripe - mastercard - visa -
- ); - } - - /** - * 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 = ({ preventConfirm={isConfirmActionDisabled}> {tempDeadline && } - - {tempDeadline && tempSchedule && - {errors &&
- {errors} -
} -
} -
- {canSubmitUpdateCard && } - {!canSubmitUpdateCard &&
-
- -
-
} -
-
+ {tempSchedule && + }
@@ -468,10 +457,10 @@ const PaymentSchedulesTableComponent: React.FC = ({ PaymentSchedulesTableComponent.defaultProps = { showCustomer: false }; -export const PaymentSchedulesTable: React.FC = ({ paymentSchedules, showCustomer, refreshList, operator }) => { +export const PaymentSchedulesTable: React.FC = ({ paymentSchedules, showCustomer, refreshList, operator, onError, onCardUpdateSuccess }) => { return ( - + ); } diff --git a/app/frontend/src/javascript/components/select-schedule.tsx b/app/frontend/src/javascript/components/payment-schedule/select-schedule.tsx similarity index 91% rename from app/frontend/src/javascript/components/select-schedule.tsx rename to app/frontend/src/javascript/components/payment-schedule/select-schedule.tsx index 982fc05e4..6be8451aa 100644 --- a/app/frontend/src/javascript/components/select-schedule.tsx +++ b/app/frontend/src/javascript/components/payment-schedule/select-schedule.tsx @@ -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 = ({ show, selected, onChange, className }) => { const { t } = useTranslation('shared'); diff --git a/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx new file mode 100644 index 000000000..7fd8247e8 --- /dev/null +++ b/app/frontend/src/javascript/components/payment/abstract-payment-modal.tsx @@ -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, + 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 = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer, logoFooter, GatewayForm, formId, className, formClassName }) => { + // 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 (gateway 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); + // currently active payment gateway + const [gateway, setGateway] = useState(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 => { + 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 ( + + {ready &&
+ + + {hasErrors() &&
+ {errors} +
} + {isPaymentSchedule() &&
+ +
} + {hasCgv() &&
+ + +
} +
+ {!submitState && } + {submitState &&
+
+ +
+
} +
} +
+ ); +} diff --git a/app/frontend/src/javascript/components/payment/payment-modal.tsx b/app/frontend/src/javascript/components/payment/payment-modal.tsx new file mode 100644 index 000000000..9aa27aa3a --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payment-modal.tsx @@ -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 = ({ 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 + } + + /** + * Render the PayZen payment modal + */ + const renderPayZenModal = (): ReactElement => { + return + } + + /** + * 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
; + default: + onError(t('app.shared.payment_modal.unexpected_error')); + console.error(`[PaymentModal] Unimplemented gateway: ${gateway.value}`); + return
; + } +} + + +const PaymentModalWrapper: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, currentUser, schedule , cart, customer }) => { + return ( + + + + ); +} + +Application.Components.component('paymentModal', react2angular(PaymentModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess', 'onError', 'currentUser', 'schedule', 'cart', 'customer'])); diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-card-update-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-card-update-modal.tsx new file mode 100644 index 000000000..b8eb500cb --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-card-update-modal.tsx @@ -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 = ({ 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(true); + // we save errors here, if any, for display purposes. + const [errors, setErrors] = useState(null); + + // the unique identifier of the html form + const formId = "payzen-card"; + + /** + * Return the logos, shown in the modal footer. + */ + const logoFooter = (): ReactNode => { + return ( +
+ powered by PayZen + mastercard + visa +
+ ); + } + + + /** + * 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 ( + + {schedule && + {errors &&
+ {errors} +
} +
} +
+ {canSubmitUpdateCard && } + {!canSubmitUpdateCard &&
+
+ +
+
} +
+
+ ); +} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx new file mode 100644 index 000000000..c8b85e765 --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -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 = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, updateCard = false, cart, customer, formId, paymentScheduleId }) => { + + const PayZenKR = useRef(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 => { + 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 => { + 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 => { + 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 ( +
+ +
+ ); + }; + + return ( +
+ +
+
+
+ {children} + + ); +} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-keys-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-keys-form.tsx new file mode 100644 index 000000000..947e9e0ed --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-keys-form.tsx @@ -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) => void, + onInvalidKeys: () => void, +} + +// all settings related to PayZen that are requested by this form +const payZenSettings: Array = [SettingName.PayZenUsername, SettingName.PayZenPassword, SettingName.PayZenEndpoint, SettingName.PayZenHmacKey, SettingName.PayZenPublicKey]; +// settings related the to PayZen REST API (server side) +const restApiSettings: Array = [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 = ({ onValidKeys, onInvalidKeys }) => { + const { t } = useTranslation('admin'); + + // values of the PayZen settings + const [settings, updateSettings] = useImmer>(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(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(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(); + setPublicKeyAddOnClassName('key-invalid'); + return; + } + updateSettings(draft => draft.set(SettingName.PayZenPublicKey, key)); + setPublicKeyAddOn(); + 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(); + setRestApiAddOnClassName('key-valid'); + } else { + setRestApiAddOn(); + setRestApiAddOnClassName('key-invalid'); + } + }, () => { + pendingKeysValidation = false; + + setRestApiAddOn(); + setRestApiAddOnClassName('key-invalid'); + }); + } + if (!valid) { + setRestApiAddOn(); + 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 ( +
+
+ +
+
+
+ {t('app.admin.invoices.payment.client_keys')} +
+ + } + defaultValue={settings.get(SettingName.PayZenPublicKey)} + onChange={testPublicKey} + addOn={publicKeyAddOn} + addOnClassName={publicKeyAddOnClassName} + debounce={200} + required /> +
+
+
+ + {t('app.admin.invoices.payment.api_keys')} + {hasApiAddOn() && {restApiAddOn}} + +
+ + } + defaultValue={settings.get(SettingName.PayZenUsername)} + onChange={setApiKey(SettingName.PayZenUsername)} + debounce={200} + required /> +
+
+ + } + defaultValue={settings.get(SettingName.PayZenPassword)} + onChange={setApiKey(SettingName.PayZenPassword)} + debounce={200} + required /> +
+
+ + } + defaultValue={settings.get(SettingName.PayZenEndpoint)} + onChange={setApiKey(SettingName.PayZenEndpoint)} + debounce={200} + required /> +
+
+ + } + defaultValue={settings.get(SettingName.PayZenHmacKey)} + onChange={setApiKey(SettingName.PayZenHmacKey)} + debounce={200} + required /> +
+
+
+
+ ); +} + +export const PayZenKeysForm: React.FC = ({ onValidKeys, onInvalidKeys }) => { + return ( + + + + ); +} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx new file mode 100644 index 000000000..c7f4f5873 --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-modal.tsx @@ -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 which can handle the configuration + * of a different payment gateway. + */ +export const PayZenModal: React.FC = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => { + /** + * Return the logos, shown in the modal footer. + */ + const logoFooter = (): ReactNode => { + return ( +
+ powered by PayZen + mastercard + visa +
+ ); + } + + /** + * Integrates the PayzenForm into the parent PaymentModal + */ + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => { + return ( + + {children} + + ); + } + + return ( + + ); +} diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-settings.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-settings.tsx new file mode 100644 index 000000000..1e65b0f9b --- /dev/null +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-settings.tsx @@ -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.PayZenPublicKey, SettingName.PayZenEndpoint, SettingName.PayZenUsername]; +// settings related to PayZen that must be kept on server-side +const payZenPrivateSettings: Array = [SettingName.PayZenPassword, SettingName.PayZenHmacKey]; +// other settings related to PayZen +const payZenOtherSettings: Array = [SettingName.PayZenCurrency]; +// all PayZen settings +const payZenSettings: Array = payZenPublicSettings.concat(payZenPrivateSettings).concat(payZenOtherSettings); + +// icons for the inputs of each setting +const icons:Map = 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 = ({ onEditKeys, onCurrencyUpdateSuccess }) => { + const { t } = useTranslation('admin'); + + // all the values of the settings related to PayZen + const [settings, updateSettings] = useImmer>(new Map(payZenSettings.map(name => [name, '']))); + // store a possible error state for currency + const [error, setError] = useState(''); + + /** + * 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 ( +
+

{t('app.admin.invoices.payment.payzen.payzen_keys')}

+
+ {payZenPublicSettings.concat(payZenPrivateSettings).map(setting => { + return ( +
+ + -1 ? 'password' : 'text'} + icon={} + readOnly + disabled /> +
+ ); + })} +
+ {t('app.admin.invoices.payment.edit_keys')} +
+
+
+

{t('app.admin.invoices.payment.payzen.currency')}

+

+ +

+
+
+ + } + onChange={handleCurrencyUpdate} + maxLength={3} + pattern="[A-Z]{3}" + error={error} /> +
+ {t('app.admin.invoices.payment.payzen.save')} +
+
+
+ ); +} + + +const PayzenSettingsWrapper: React.FC = ({ onEditKeys, onCurrencyUpdateSuccess }) => { + return ( + + + + ); +} + +Application.Components.component('payzenSettings', react2angular(PayzenSettingsWrapper, ['onEditKeys', 'onCurrencyUpdateSuccess'])); diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-card-update-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update-modal.tsx new file mode 100644 index 000000000..60781c1ea --- /dev/null +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update-modal.tsx @@ -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 = ({ 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(true); + // we save errors here, if any, for display purposes. + const [errors, setErrors] = useState(null); + + /** + * Return the logos, shown in the modal footer. + */ + const logoFooter = (): ReactNode => { + return ( +
+ + powered by stripe + mastercard + visa +
+ ); + } + + + /** + * 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 ( + + {schedule && + {errors &&
+ {errors} +
} +
} +
+ {canSubmitUpdateCard && } + {!canSubmitUpdateCard &&
+
+ +
+
} +
+
+ ); +} diff --git a/app/frontend/src/javascript/components/stripe-card-update.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx similarity index 80% rename from app/frontend/src/javascript/components/stripe-card-update.tsx rename to app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx index 3bbf618f4..b26aae357 100644 --- a/app/frontend/src/javascript/components/stripe-card-update.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-card-update.tsx @@ -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 = ({ onSubmit, onSuccess, onError, className, customerId, operator, children }) => { +export const StripeCardUpdate: React.FC = ({ onSubmit, onSuccess, onError, className, schedule, operator, children }) => { const stripe = useStripe(); const elements = useElements(); @@ -47,7 +46,7 @@ export const StripeCardUpdate: React.FC = ({ 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 = ({ 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 diff --git a/app/frontend/src/javascript/components/stripe-confirm.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-confirm.tsx similarity index 73% rename from app/frontend/src/javascript/components/stripe-confirm.tsx rename to app/frontend/src/javascript/components/payment/stripe/stripe-confirm.tsx index 4064aa3b4..296c94c30 100644 --- a/app/frontend/src/javascript/components/stripe-confirm.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-confirm.tsx @@ -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 = ({ clientSecret, onResponse }) => { const stripe = useStripe(); const { t } = useTranslation('shared'); + // the message displayed to the user const [message, setMessage] = useState(t('app.shared.stripe_confirm.pending')); + // the style class of the message const [type, setType] = useState('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 = ({ clientSecret, onRe setMessage(t('app.shared.stripe_confirm.success')); } }); - }, []) + }, []); + return
{message}
; diff --git a/app/frontend/src/javascript/components/stripe-elements.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx similarity index 73% rename from app/frontend/src/javascript/components/stripe-elements.tsx rename to app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx index a3864495b..ed14d3b27 100644 --- a/app/frontend/src/javascript/components/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-elements.tsx @@ -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 tag with the Stripe's public key + */ useEffect(() => { const key = stripePublicKey.read(); const promise = loadStripe(key.value); diff --git a/app/frontend/src/javascript/components/stripe-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx similarity index 68% rename from app/frontend/src/javascript/components/stripe-form.tsx rename to app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx index ba0cdcfba..52b71f462 100644 --- a/app/frontend/src/javascript/components/stripe-form.tsx +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-form.tsx @@ -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 = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => { +export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cart, customer, operator, formId }) => { const { t } = useTranslation('shared'); @@ -52,11 +41,11 @@ export const StripeForm: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onSubmit, onSuccess, onE }; return ( -
+ {children} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx new file mode 100644 index 000000000..efa56180d --- /dev/null +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-keys-form.tsx @@ -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 = ({ 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(''); + // Icon of the input field for the Stripe's public key. Used to display if the key is valid. + const [publicKeyAddOn, setPublicKeyAddOn] = useState(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(''); + // Icon of the input field for the Stripe's secret key. Used to display if the key is valid. + const [secretKeyAddOn, setSecretKeyAddOn] = useState(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(); + setPublicKeyAddOnClassName('key-invalid'); + return; + } + StripeAPI.createPIIToken(key, 'test').then(() => { + if (!mounted.current) return; + + setPublicKey(key); + setPublicKeyAddOn(); + setPublicKeyAddOnClassName('key-valid'); + }, reason => { + if (!mounted.current) return; + + if (reason.response.status === 401) { + setPublicKeyAddOn(); + 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(); + setSecretKeyAddOnClassName('key-invalid'); + return; + } + StripeAPI.listAllCharges(key).then(() => { + if (!mounted.current) return; + + setSecretKey(key); + setSecretKeyAddOn(); + setSecretKeyAddOnClassName('key-valid'); + }, reason => { + if (!mounted.current) return; + + if (reason.response.status === 401) { + setSecretKeyAddOn(); + setSecretKeyAddOnClassName('key-invalid'); + } + }); + } + + return ( +
+
+ +
+
+
+ + } + defaultValue={publicKey} + onChange={testPublicKey} + addOn={publicKeyAddOn} + addOnClassName={publicKeyAddOnClassName} + debounce={200} + required /> +
+
+ + } + defaultValue={secretKey} + onChange={testSecretKey} + addOn={secretKeyAddOn} + addOnClassName={secretKeyAddOnClassName} + debounce={200} + required/> +
+
+
+ ); +} + +export const StripeKeysForm: React.FC = ({ onValidKeys, onInvalidKeys }) => { + return ( + + + + ); +} diff --git a/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx new file mode 100644 index 000000000..899d56868 --- /dev/null +++ b/app/frontend/src/javascript/components/payment/stripe/stripe-modal.tsx @@ -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 which can handle the configuration + * of a different payment gateway. + */ +export const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuccess, cart, currentUser, schedule, customer }) => { + /** + * Return the logos, shown in the modal footer. + */ + const logoFooter = (): ReactNode => { + return ( +
+ + powered by stripe + mastercard + visa +
+ ); + } + + /** + * Integrates the StripeForm into the parent PaymentModal + */ + const renderForm: FunctionComponent = ({ onSubmit, onSuccess, onError, operator, className, formId, cart, customer, paymentSchedule, children}) => { + return ( + + + {children} + + + ); + } + + return ( + + ); +} diff --git a/app/frontend/src/javascript/components/payment/update-card-modal.tsx b/app/frontend/src/javascript/components/payment/update-card-modal.tsx new file mode 100644 index 000000000..1dc16b2e2 --- /dev/null +++ b/app/frontend/src/javascript/components/payment/update-card-modal.tsx @@ -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 = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => { + const { t } = useTranslation('shared'); + const [gateway, setGateway] = useState(''); + + 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 + } + + /** + * Render the PayZen update-card modal + */ // 1 + const renderPayZenModal = (): ReactElement => { + return + } + + /** + * 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
; + default: + onError(t('app.shared.update_card_modal.unexpected_error')); + console.error(`[UpdateCardModal] unexpected gateway: ${schedule.gateway_subscription?.classname}`); + return
; + } +} + + +export const UpdateCardModal: React.FC = ({ isOpen, toggleModal, afterSuccess, onError, operator, schedule }) => { + return ( + + + + ); +} diff --git a/app/frontend/src/javascript/components/plan-card.tsx b/app/frontend/src/javascript/components/plan-card.tsx index 2911e8e12..92de9615c 100644 --- a/app/frontend/src/javascript/components/plan-card.tsx +++ b/app/frontend/src/javascript/components/plan-card.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 (

{plan.base_name}

@@ -109,12 +121,14 @@ const PlanCard: React.FC = ({ plan, userId, subscribedPlanId, ope
{hasDescription() &&
} {hasAttachment() && { t('app.public.plans.more_information') }} + {mustLogin() &&
+ +
} {canSubscribeForMe() &&
{!hasSubscribedToThisPlan() && } {hasSubscribedToThisPlan() && } - {submitState &&
-
- -
-
} - } - - ); -} - -const StripeModalWrapper: React.FC = ({ isOpen, toggleModal, afterSuccess, currentUser, schedule , cartItems, customer }) => { - return ( - - - - ); -} - -Application.Components.component('stripeModal', react2angular(StripeModalWrapper, ['isOpen', 'toggleModal', 'afterSuccess','currentUser', 'schedule', 'cartItems', 'customer'])); diff --git a/app/frontend/src/javascript/components/wallet-info.tsx b/app/frontend/src/javascript/components/wallet-info.tsx index c2a332eb9..4b345e81e 100644 --- a/app/frontend/src/javascript/components/wallet-info.tsx +++ b/app/frontend/src/javascript/components/wallet-info.tsx @@ -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 = ({ 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 = ({ cart, currentUser, wallet, price }) => { const { t } = useTranslation('shared'); const [remainingPrice, setRemainingPrice] = useState(0); @@ -49,13 +46,7 @@ export const WalletInfo: React.FC = ({ 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 = ({ 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 = ({ cartItems, currentUser, ); } -const WalletInfoWrapper: React.FC = ({ currentUser, cartItems, price, wallet }) => { +const WalletInfoWrapper: React.FC = ({ currentUser, cart, price, wallet }) => { return ( - + ); } -Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cartItems', 'wallet'])); +Application.Components.component('walletInfo', react2angular(WalletInfoWrapper, ['currentUser', 'price', 'cart', 'wallet'])); diff --git a/app/frontend/src/javascript/controllers/admin/events.js b/app/frontend/src/javascript/controllers/admin/events.js index b2036f7a4..ecd75667d 100644 --- a/app/frontend/src/javascript/controllers/admin/events.js +++ b/app/frontend/src/javascript/controllers/admin/events.js @@ -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); }; }]); diff --git a/app/frontend/src/javascript/controllers/admin/invoices.js b/app/frontend/src/javascript/controllers/admin/invoices.js index 0d57b9a16..f2c099a4d 100644 --- a/app/frontend/src/javascript/controllers/admin/invoices.js +++ b/app/frontend/src/javascript/controllers/admin/invoices.js @@ -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

, , ...). @@ -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(); - } -]); diff --git a/app/frontend/src/javascript/controllers/admin/members.js b/app/frontend/src/javascript/controllers/admin/members.js index ad5e718db..1ad5f24b4 100644 --- a/app/frontend/src/javascript/controllers/admin/members.js +++ b/app/frontend/src/javascript/controllers/admin/members.js @@ -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; diff --git a/app/frontend/src/javascript/controllers/dashboard.js b/app/frontend/src/javascript/controllers/dashboard.js index aa908849c..f589b4cf8 100644 --- a/app/frontend/src/javascript/controllers/dashboard.js +++ b/app/frontend/src/javascript/controllers/dashboard.js @@ -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(); } diff --git a/app/frontend/src/javascript/controllers/events.js.erb b/app/frontend/src/javascript/controllers/events.js.erb index 562bd4163..3f9d0a54d 100644 --- a/app/frontend/src/javascript/controllers/events.js.erb +++ b/app/frontend/src/javascript/controllers/events.js.erb @@ -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, nb_reserve_places:number}} + * @return {{reservable_id:number, reservable_type:string, slots_attributes:Array, 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; } diff --git a/app/frontend/src/javascript/controllers/machines.js.erb b/app/frontend/src/javascript/controllers/machines.js.erb index 1b9ec88f0..2815e92b6 100644 --- a/app/frontend/src/javascript/controllers/machines.js.erb +++ b/app/frontend/src/javascript/controllers/machines.js.erb @@ -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(); }; /** diff --git a/app/frontend/src/javascript/controllers/members.js b/app/frontend/src/javascript/controllers/members.js index 05c233fe0..388bffbff 100644 --- a/app/frontend/src/javascript/controllers/members.js +++ b/app/frontend/src/javascript/controllers/members.js @@ -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 diff --git a/app/frontend/src/javascript/controllers/plans.js b/app/frontend/src/javascript/controllers/plans.js index db6c0e1ac..abfae2f31 100644 --- a/app/frontend/src/javascript/controllers/plans.js +++ b/app/frontend/src/javascript/controllers/plans.js @@ -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 diff --git a/app/frontend/src/javascript/controllers/spaces.js.erb b/app/frontend/src/javascript/controllers/spaces.js.erb index 36b126532..2f57f5d46 100644 --- a/app/frontend/src/javascript/controllers/spaces.js.erb +++ b/app/frontend/src/javascript/controllers/spaces.js.erb @@ -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(); + }); }; /** diff --git a/app/frontend/src/javascript/controllers/trainings.js.erb b/app/frontend/src/javascript/controllers/trainings.js.erb index ff07aa00e..a4e2740b6 100644 --- a/app/frontend/src/javascript/controllers/trainings.js.erb +++ b/app/frontend/src/javascript/controllers/trainings.js.erb @@ -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(); + }); }; /** diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index 6510b643e..fbbb180ff 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -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} 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); } } }); diff --git a/app/frontend/src/javascript/directives/stripe-form.js b/app/frontend/src/javascript/directives/stripe-form.js deleted file mode 100644 index cd0929cff..000000000 --- a/app/frontend/src/javascript/directives/stripe-form.js +++ /dev/null @@ -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`
. - 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); - } - } - } - }); - }]); diff --git a/app/frontend/src/javascript/models/fablab.ts b/app/frontend/src/javascript/models/fablab.ts index 83a915b2a..5767a52ce 100644 --- a/app/frontend/src/javascript/models/fablab.ts +++ b/app/frontend/src/javascript/models/fablab.ts @@ -5,7 +5,7 @@ export interface IFablab { statisticsModule: boolean, defaultHost: string, trackingId: string, - superadminId: number, + adminSysId: number, baseHostUrl: string, locale: string, moment_locale: string, diff --git a/app/frontend/src/javascript/models/gateway.ts b/app/frontend/src/javascript/models/gateway.ts new file mode 100644 index 000000000..1e9164dbf --- /dev/null +++ b/app/frontend/src/javascript/models/gateway.ts @@ -0,0 +1,5 @@ + +export enum Gateway { + Stripe = 'stripe', + PayZen = 'payzen', +} diff --git a/app/frontend/src/javascript/models/invoice.ts b/app/frontend/src/javascript/models/invoice.ts new file mode 100644 index 000000000..285180756 --- /dev/null +++ b/app/frontend/src/javascript/models/invoice.ts @@ -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 + } +} diff --git a/app/frontend/src/javascript/models/payment-schedule.ts b/app/frontend/src/javascript/models/payment-schedule.ts index 77be171e1..85b523e90 100644 --- a/app/frontend/src/javascript/models/payment-schedule.ts +++ b/app/frontend/src/javascript/models/payment-schedule.ts @@ -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, 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 } } diff --git a/app/frontend/src/javascript/models/payment.ts b/app/frontend/src/javascript/models/payment.ts index 32b641ccd..9106eb8a8 100644 --- a/app/frontend/src/javascript/models/payment.ts +++ b/app/frontend/src/javascript/models/payment.ts @@ -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, + coupon_code?: string, + payment_schedule?: boolean, + payment_method: PaymentMethod } export interface UpdateCardResponse { diff --git a/app/frontend/src/javascript/models/payzen.ts b/app/frontend/src/javascript/models/payzen.ts new file mode 100644 index 000000000..7addfeffb --- /dev/null +++ b/app/frontend/src/javascript/models/payzen.ts @@ -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, + _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, + 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) => 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}>, +} diff --git a/app/frontend/src/javascript/models/reservation.ts b/app/frontend/src/javascript/models/reservation.ts index fd5f5fb28..49dc54115 100644 --- a/app/frontend/src/javascript/models/reservation.ts +++ b/app/frontend/src/javascript/models/reservation.ts @@ -7,13 +7,10 @@ export interface ReservationSlot { } export interface Reservation { - user_id: number, reservable_id: number, reservable_type: string, slots_attributes: Array, - plan_id?: number, nb_reserve_places?: number, - payment_schedule?: boolean, tickets_attributes?: { event_price_category_id: number, booked: boolean, diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index ccde19dc3..bbc5e0fae 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -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 + last_update?: Date, + history?: Array +} + +export interface SettingError { + error: string, + id: number, + name: string +} + +export interface SettingBulkResult { + status: boolean, + value?: any, + error?: string } diff --git a/app/frontend/src/javascript/models/subscription.ts b/app/frontend/src/javascript/models/subscription.ts index d7b1191b2..6ac23d88e 100644 --- a/app/frontend/src/javascript/models/subscription.ts +++ b/app/frontend/src/javascript/models/subscription.ts @@ -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 } diff --git a/app/frontend/src/javascript/router.js b/app/frontend/src/javascript/router.js index 688443b1f..7094c817f 100644 --- a/app/frontend/src/javascript/router.js +++ b/app/frontend/src/javascript/router.js @@ -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; }], diff --git a/app/frontend/src/javascript/services/local_payment.js b/app/frontend/src/javascript/services/local_payment.js new file mode 100644 index 000000000..1c85e689c --- /dev/null +++ b/app/frontend/src/javascript/services/local_payment.js @@ -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 + } + } + ); +}]); diff --git a/app/frontend/src/javascript/services/payment.js b/app/frontend/src/javascript/services/payment.js index 69c80d865..70ba1f51b 100644 --- a/app/frontend/src/javascript/services/payment.js +++ b/app/frontend/src/javascript/services/payment.js @@ -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' } } ); diff --git a/app/frontend/src/stylesheets/app.components.scss b/app/frontend/src/stylesheets/app.components.scss index aa82c2ed4..44b235726 100644 --- a/app/frontend/src/stylesheets/app.components.scss +++ b/app/frontend/src/stylesheets/app.components.scss @@ -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 { diff --git a/app/frontend/src/stylesheets/application.scss b/app/frontend/src/stylesheets/application.scss index 1dd1401b8..4e6090bab 100644 --- a/app/frontend/src/stylesheets/application.scss +++ b/app/frontend/src/stylesheets/application.scss @@ -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"; diff --git a/app/frontend/src/stylesheets/bootstrap_and_overrides.scss b/app/frontend/src/stylesheets/bootstrap_and_overrides.scss index 5b5ea3b87..6784a7b1a 100644 --- a/app/frontend/src/stylesheets/bootstrap_and_overrides.scss +++ b/app/frontend/src/stylesheets/bootstrap_and_overrides.scss @@ -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; diff --git a/app/frontend/src/stylesheets/modules/fab-button.scss b/app/frontend/src/stylesheets/modules/fab-button.scss index 68cd97f54..5f70a2d3a 100644 --- a/app/frontend/src/stylesheets/modules/fab-button.scss +++ b/app/frontend/src/stylesheets/modules/fab-button.scss @@ -17,6 +17,7 @@ border-radius: 4px; user-select: none; text-decoration: none; + height: 38px; &:hover { background-color: #f2f2f2; diff --git a/app/frontend/src/stylesheets/modules/fab-input.scss b/app/frontend/src/stylesheets/modules/fab-input.scss new file mode 100644 index 000000000..8ef691ee1 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/fab-input.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/payment-modal.scss b/app/frontend/src/stylesheets/modules/payment-modal.scss new file mode 100644 index 000000000..ad905afb0 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payment-modal.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/payment-schedules-table.scss b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss index a6be6075a..86121e97a 100644 --- a/app/frontend/src/stylesheets/modules/payment-schedules-table.scss +++ b/app/frontend/src/stylesheets/modules/payment-schedules-table.scss @@ -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; - } - } - } -} diff --git a/app/frontend/src/stylesheets/modules/payzen-keys-form.scss b/app/frontend/src/stylesheets/modules/payzen-keys-form.scss new file mode 100644 index 000000000..c73cc6796 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payzen-keys-form.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/payzen-modal.scss b/app/frontend/src/stylesheets/modules/payzen-modal.scss new file mode 100644 index 000000000..ef6a8b63f --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payzen-modal.scss @@ -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; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/payzen-settings.scss b/app/frontend/src/stylesheets/modules/payzen-settings.scss new file mode 100644 index 000000000..d460f9249 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payzen-settings.scss @@ -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; + } + } + } +} diff --git a/app/frontend/src/stylesheets/modules/payzen-update-card-modal.scss b/app/frontend/src/stylesheets/modules/payzen-update-card-modal.scss new file mode 100644 index 000000000..413e5e43a --- /dev/null +++ b/app/frontend/src/stylesheets/modules/payzen-update-card-modal.scss @@ -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; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/select-gateway-modal.scss b/app/frontend/src/stylesheets/modules/select-gateway-modal.scss new file mode 100644 index 000000000..971a07989 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/select-gateway-modal.scss @@ -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; + } +} diff --git a/app/frontend/src/stylesheets/modules/stripe-keys-form.scss b/app/frontend/src/stylesheets/modules/stripe-keys-form.scss new file mode 100644 index 000000000..3c851fca8 --- /dev/null +++ b/app/frontend/src/stylesheets/modules/stripe-keys-form.scss @@ -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; + } + } +} diff --git a/app/frontend/src/stylesheets/modules/stripe-modal.scss b/app/frontend/src/stylesheets/modules/stripe-modal.scss index 095fc04d6..1340cf746 100644 --- a/app/frontend/src/stylesheets/modules/stripe-modal.scss +++ b/app/frontend/src/stylesheets/modules/stripe-modal.scss @@ -1,34 +1,4 @@ .stripe-modal { - .fab-modal-content { - padding-bottom: 0; - } - .stripe-form { - background-color: #f4f3f3; - border: 1px solid #ddd; - border-radius: 6px 6px 0 0; - box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05); - padding: 15px; - - .stripe-errors { - padding: 4px 0; - color: #9e2146; - overflow: auto; - margin-bottom: 1.2em; - } - } - .terms-of-sales { - margin-top: 1em; - margin-bottom: 1em; - font-size: 1.4rem; - font-weight: 600; - input { - display: inline; - margin-right: 0.5em; - } - label { - display: inline; - } - } .stripe-modal-icons { text-align: center; @@ -41,39 +11,4 @@ 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; - } } diff --git a/app/frontend/src/stylesheets/modules/stripe-update-card-modal.scss b/app/frontend/src/stylesheets/modules/stripe-update-card-modal.scss new file mode 100644 index 000000000..ba5bbf02b --- /dev/null +++ b/app/frontend/src/stylesheets/modules/stripe-update-card-modal.scss @@ -0,0 +1,57 @@ +.stripe-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; + } + } + } +} diff --git a/app/frontend/templates/admin/calendar/calendar.html b/app/frontend/templates/admin/calendar/calendar.html index fee9dc7bf..b15596f49 100644 --- a/app/frontend/templates/admin/calendar/calendar.html +++ b/app/frontend/templates/admin/calendar/calendar.html @@ -58,12 +58,12 @@
-
+

{{ 'app.admin.calendar.info' }}

-
{{ 'app.admin.calendar.slot_duration' }}
+
{{ 'app.admin.calendar.slot_duration' }}
{{ 'app.admin.calendar.tags' }}
    diff --git a/app/frontend/templates/admin/invoices/index.html b/app/frontend/templates/admin/invoices/index.html index e857d49a3..0bbd4b08a 100644 --- a/app/frontend/templates/admin/invoices/index.html +++ b/app/frontend/templates/admin/invoices/index.html @@ -35,7 +35,7 @@ - + @@ -59,7 +59,7 @@ - +
diff --git a/app/frontend/templates/admin/invoices/payment.html b/app/frontend/templates/admin/invoices/payment.html index 037b2a310..6c8b4aa5c 100644 --- a/app/frontend/templates/admin/invoices/payment.html +++ b/app/frontend/templates/admin/invoices/payment.html @@ -10,11 +10,16 @@ settings="allSettings" label="app.admin.invoices.payment.enable_online_payment" classes="m-l" - on-before-save="requireStripeKeys" + on-before-save="selectPaymentGateway" fa-icon="fa-font"> +
-
+

{{ 'app.admin.invoices.payment.stripe_keys' }}

@@ -39,10 +44,10 @@
- +
-
+

{{ 'app.admin.invoices.payment.currency' }}

@@ -59,5 +64,8 @@
+
+ +
diff --git a/app/frontend/templates/admin/invoices/settings.html b/app/frontend/templates/admin/invoices/settings.html index a862f4b63..53c8b5d47 100644 --- a/app/frontend/templates/admin/invoices/settings.html +++ b/app/frontend/templates/admin/invoices/settings.html @@ -137,7 +137,7 @@ diff --git a/app/frontend/templates/dashboard/payment_schedules.html b/app/frontend/templates/dashboard/payment_schedules.html index bea0c0280..aea1a8b12 100644 --- a/app/frontend/templates/dashboard/payment_schedules.html +++ b/app/frontend/templates/dashboard/payment_schedules.html @@ -7,5 +7,5 @@ - + diff --git a/app/frontend/templates/dashboard/settings.html b/app/frontend/templates/dashboard/settings.html index 669504e5c..4890f310f 100644 --- a/app/frontend/templates/dashboard/settings.html +++ b/app/frontend/templates/dashboard/settings.html @@ -84,7 +84,7 @@
{{ 'app.logged.dashboard.settings.cookies_unset' }}
-
+
diff --git a/app/frontend/templates/events/show.html b/app/frontend/templates/events/show.html index 785e5335a..694301aba 100644 --- a/app/frontend/templates/events/show.html +++ b/app/frontend/templates/events/show.html @@ -205,5 +205,14 @@ +
+ +
diff --git a/app/frontend/templates/plans/index.html b/app/frontend/templates/plans/index.html index bf24792d2..5810d57d0 100644 --- a/app/frontend/templates/plans/index.html +++ b/app/frontend/templates/plans/index.html @@ -17,12 +17,9 @@
-
- +
-
-

{{plansGroup.name}}

-
+

{{plansGroup.name}}

@@ -32,6 +29,7 @@ subscribed-plan-id="ctrl.member.subscribed_plan.id" operator="currentUser" on-select-plan="selectPlan" + on-login-requested="userLogin" is-selected="isSelected(plan)">
diff --git a/app/frontend/templates/shared/_cart.html b/app/frontend/templates/shared/_cart.html index 99e3f5def..35dc247f0 100644 --- a/app/frontend/templates/shared/_cart.html +++ b/app/frontend/templates/shared/_cart.html @@ -199,12 +199,13 @@
-
- +
+
diff --git a/app/frontend/templates/shared/valid_reservation_modal.html b/app/frontend/templates/shared/valid_reservation_modal.html index 6c6aea905..1558aa6fe 100644 --- a/app/frontend/templates/shared/valid_reservation_modal.html +++ b/app/frontend/templates/shared/valid_reservation_modal.html @@ -1,19 +1,19 @@