From 8e8fc9b68289a6f92d00909f5fdfe77bb65b8920 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 8 Dec 2020 17:30:33 +0100 Subject: [PATCH] validate reservation/subscription after payment schedule creation --- app/controllers/api/payments_controller.rb | 43 ++++++++++---- app/frontend/src/javascript/api/payment.ts | 8 +++ .../javascript/components/stripe-elements.tsx | 20 +++++-- .../src/javascript/components/stripe-form.tsx | 20 +++---- .../javascript/components/stripe-modal.tsx | 9 ++- .../src/javascript/directives/cart.js | 58 +++++++++---------- .../shared/valid_reservation_modal.html | 2 +- config/routes.rb | 1 + 8 files changed, 99 insertions(+), 62 deletions(-) diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index 9c7f8f595..2bfe19502 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -75,17 +75,37 @@ class API::PaymentsController < API::ApiController render json: { 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) + end + end + + render generate_payment_response(intent, res) + rescue Stripe::InvalidRequestError + render json: { error: 'no such setup intent' }, status: :unprocessable_entity + end + private def on_reservation_success(intent, details) @reservation = Reservation.new(reservation_params) is_reserve = Reservations::Reserve.new(current_user.id, current_user.invoicing_profile.id) .pay_and_save(@reservation, payment_details: details, payment_intent_id: intent.id) - Stripe::PaymentIntent.update( - intent.id, - { description: "Invoice reference: #{@reservation.invoice.reference}" }, - { api_key: Setting.get('stripe_secret_key') } - ) + 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 @@ -104,12 +124,13 @@ class API::PaymentsController < API::ApiController invoice: true, payment_intent_id: intent.id, payment_method: 'stripe') - - Stripe::PaymentIntent.update( - intent.id, - { description: "Invoice reference: #{@subscription.invoices.first.reference}" }, - { api_key: Setting.get('stripe_secret_key') } - ) + 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 } diff --git a/app/frontend/src/javascript/api/payment.ts b/app/frontend/src/javascript/api/payment.ts index e19a65b5e..b82472960 100644 --- a/app/frontend/src/javascript/api/payment.ts +++ b/app/frontend/src/javascript/api/payment.ts @@ -15,5 +15,13 @@ export default class PaymentAPI { const res: AxiosResponse = await apiClient.get(`/api/payments/setup_intent/${user_id}`); return res?.data; } + + 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; + } } diff --git a/app/frontend/src/javascript/components/stripe-elements.tsx b/app/frontend/src/javascript/components/stripe-elements.tsx index 67a8bed3b..513df741b 100644 --- a/app/frontend/src/javascript/components/stripe-elements.tsx +++ b/app/frontend/src/javascript/components/stripe-elements.tsx @@ -2,21 +2,29 @@ * This component initializes the stripe's Elements tag with the API key */ -import React, { memo } from 'react'; +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 { Loader } from './loader'; const stripePublicKey = SettingAPI.get(SettingName.StripePublicKey); export const StripeElements: React.FC = memo(({ children }) => { - const publicKey = stripePublicKey.read(); - const stripePromise = loadStripe(publicKey.value); + const [stripe, setStripe] = useState(undefined); + + useEffect(() => { + const key = stripePublicKey.read(); + const promise = loadStripe(key.value); + setStripe(promise); + }, []) return ( - - {children} - +
+ {stripe && + {children} + } +
); }) diff --git a/app/frontend/src/javascript/components/stripe-form.tsx b/app/frontend/src/javascript/components/stripe-form.tsx index 939e30f9e..be6325aa2 100644 --- a/app/frontend/src/javascript/components/stripe-form.tsx +++ b/app/frontend/src/javascript/components/stripe-form.tsx @@ -1,6 +1,6 @@ import React, { FormEvent } from 'react'; import { CardElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import { PaymentIntent } from "@stripe/stripe-js"; +import { SetupIntent } from "@stripe/stripe-js"; import PaymentAPI from '../api/payment'; import { CartItems, PaymentConfirmation } from '../models/payment'; import { useTranslation } from 'react-i18next'; @@ -8,11 +8,11 @@ import { User } from '../models/user'; interface StripeFormProps { onSubmit: () => void, - onSuccess: (result: PaymentIntent|PaymentConfirmation|any) => void, + onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void, onError: (message: string) => void, customer: User, className?: string, - processPayment?: boolean, + paymentSchedule?: boolean, cartItems?: CartItems } @@ -20,7 +20,7 @@ interface StripeFormProps { * A form component to collect the credit card details and to create the payment method on Stripe. * The form validation button must be created elsewhere, using the attribute form="stripe-form". */ -export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, processPayment = true, cartItems, customer }) => { +export const StripeForm: React.FC = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer }) => { const { t } = useTranslation('shared'); @@ -47,12 +47,12 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, onE if (error) { onError(error.message); } else { - if (processPayment) { - // process the full payment pipeline, including SCA validation + if (!paymentSchedule) { + // process the normal payment pipeline, including SCA validation const res = await PaymentAPI.confirm(paymentMethod.id, cartItems); await handleServerConfirmation(res); } else { - // we don't want to process the payment, only associate the payment method with the user + // we start by associating the payment method with the user const { client_secret } = await PaymentAPI.setupIntent(customer.id); const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, { payment_method: paymentMethod.id @@ -60,9 +60,9 @@ export const StripeForm: React.FC = ({ onSubmit, onSuccess, onE if (error) { onError(error.message); } else { - if (setupIntent.status === 'succeeded') { - onSuccess(setupIntent); - } + // then we confirm the payment schedule + const res = await PaymentAPI.confirmPaymentSchedule(setupIntent.id, cartItems); + onSuccess(res); } } } diff --git a/app/frontend/src/javascript/components/stripe-modal.tsx b/app/frontend/src/javascript/components/stripe-modal.tsx index e7267edf4..6fd52f012 100644 --- a/app/frontend/src/javascript/components/stripe-modal.tsx +++ b/app/frontend/src/javascript/components/stripe-modal.tsx @@ -10,7 +10,7 @@ import { IApplication } from '../models/application'; import { StripeElements } from './stripe-elements'; import { useTranslation } from 'react-i18next'; import { FabModal, ModalSize } from './fab-modal'; -import { PaymentIntent } from '@stripe/stripe-js'; +import { SetupIntent } from '@stripe/stripe-js'; import { WalletInfo } from './wallet-info'; import { User } from '../models/user'; import CustomAssetAPI from '../api/custom-asset'; @@ -33,7 +33,7 @@ declare var Fablab: IFablab; interface StripeModalProps { isOpen: boolean, toggleModal: () => void, - afterSuccess: (result: PaymentIntent|PaymentConfirmation) => void, + afterSuccess: (result: SetupIntent|PaymentConfirmation) => void, cartItems: CartItems, currentUser: User, schedule: PaymentSchedule, @@ -126,7 +126,6 @@ const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuc powered by stripe mastercard visa - {/* compile */} ); } @@ -141,7 +140,7 @@ const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuc /** * After sending the form with success, process the resulting payment method */ - const handleFormSuccess = async (result: PaymentIntent|PaymentConfirmation|any): Promise => { + const handleFormSuccess = async (result: SetupIntent|PaymentConfirmation|any): Promise => { setSubmitState(false); afterSuccess(result); } @@ -181,7 +180,7 @@ const StripeModal: React.FC = ({ isOpen, toggleModal, afterSuc className="stripe-form" cartItems={cartItems} customer={customer} - processPayment={!isPaymentSchedule()}> + paymentSchedule={isPaymentSchedule()}> {hasErrors() &&
{errors}
} diff --git a/app/frontend/src/javascript/directives/cart.js b/app/frontend/src/javascript/directives/cart.js index eb1612c84..4d70a16f7 100644 --- a/app/frontend/src/javascript/directives/cart.js +++ b/app/frontend/src/javascript/directives/cart.js @@ -10,8 +10,8 @@ * DS102: Remove unnecessary code created because of implicit returns * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ -Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'helpers', '_t', - function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, helpers, _t) { +Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', 'growl', 'Auth', 'Price', 'Wallet', 'CustomAsset', 'Slot', 'AuthService', 'Payment', 'helpers', '_t', + function ($rootScope, $uibModal, dialogs, growl, Auth, Price, Wallet, CustomAsset, Slot, AuthService, Payment, helpers, _t) { return ({ restrict: 'E', scope: { @@ -331,11 +331,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', */ $scope.afterStripeSuccess = (result) => { $scope.toggleStripeModal(); - if ($scope.schedule.requested_schedule) { - afterPaymentIntentCreation(result); - } else { - afterPayment(result); - } + afterPayment(result); }; /* PRIVATE SCOPE */ @@ -700,18 +696,28 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', }; }; + /** + * Build the CartItems object, from the current reservation + * @param reservation {*} + * @param paymentMethod {string} + * @return {CartItems} + */ + const mkCartItems = function (reservation, paymentMethod) { + let request = { reservation }; + if (reservation.slots_attributes.length === 0 && reservation.plan_id) { + request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule, paymentMethod); + } else { + request.reservation.payment_method = paymentMethod; + } + return mkRequestParams(request, $scope.coupon.applied); + }; + /** * Open a modal window that allows the user to process a credit card payment for his current shopping cart. */ const payByStripe = function (reservation) { $scope.toggleStripeModal(() => { - let request = { reservation }; - if (reservation.slots_attributes.length === 0 && reservation.plan_id) { - request = mkSubscription($scope.selectedPlan.id, reservation.user_id, $scope.schedule.requested_schedule, 'stripe'); - } else { - request.reservation.payment_method = 'stripe'; - } - $scope.stripe.cartItems = mkRequestParams(request, $scope.coupon.applied); + $scope.stripe.cartItems = mkCartItems(reservation, 'stripe'); }); }; /** @@ -729,7 +735,7 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', return Price.compute(mkRequestParams({ reservation }, $scope.coupon.applied)).$promise; }, cartItems () { - return mkRequestParams({ reservation }, $scope.coupon.applied); + return mkCartItems(reservation, 'stripe'); }, wallet () { return Wallet.getWalletByUser({ user_id: reservation.user_id }).$promise; @@ -834,8 +840,12 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', }, 50); }; - $scope.afterCreatePaymentMethod = function (a) { - console.log('TODO', a); + /** + * After creating a payment schedule by card, from an administrator. + * @param result {*} Reservation or Subscription + */ + $scope.afterCreatePaymentSchedule = function (result) { + console.log('TODO', result); }; /* PRIVATE SCOPE */ @@ -844,8 +854,9 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', * Kind of constructor: these actions will be realized first when the directive is loaded */ const initialize = function () { - $scope.$watch('method.payment_method', function () { + $scope.$watch('method.payment_method', function (newValue) { $scope.validButtonName = computeValidButtonName(); + $scope.cartItems = mkCartItems($scope.reservation, newValue); }); }; @@ -905,17 +916,6 @@ Application.Directives.directive('cart', ['$rootScope', '$uibModal', 'dialogs', $scope.schedule.payment_schedule = undefined; }; - /** - * Actions to run after the payment intent was created on Stripe. - * A payment intent associates a payment method with a stripe customer. - * This is used for payment schedules. - * @param intent {PaymentIntent} - */ - const afterPaymentIntentCreation = function (intent) { - // TODO, create an API endpoint for payment_schedule validation - // or: POST reservation || POST subscription (if admin/manager) - }; - /** * Actions to pay slots */ diff --git a/app/frontend/templates/shared/valid_reservation_modal.html b/app/frontend/templates/shared/valid_reservation_modal.html index 2ab579b0c..6f52252e3 100644 --- a/app/frontend/templates/shared/valid_reservation_modal.html +++ b/app/frontend/templates/shared/valid_reservation_modal.html @@ -51,7 +51,7 @@ 'payments/confirm_payment' get 'payments/online_payment_status' => 'payments/online_payment_status' get 'payments/setup_intent/:user_id' => 'payments#setup_intent' + post 'payments/confirm_payment_schedule' => 'payments#confirm_payment_schedule' # FabAnalytics get 'analytics/data' => 'analytics#data'