From 4e512dda451ee5a912e7541385cc1f19087573d5 Mon Sep 17 00:00:00 2001 From: Sylvain Date: Tue, 13 Apr 2021 17:16:05 +0200 Subject: [PATCH] validate the payment server side --- app/controllers/api/payments_controller.rb | 6 ++ app/controllers/api/payzen_controller.rb | 85 ++++++++++++++++++- app/exceptions/payzen_error.rb | 6 ++ app/frontend/src/javascript/api/payzen.ts | 11 ++- .../components/payment/payzen/payzen-form.tsx | 50 ++++++++++- app/frontend/src/javascript/models/payzen.ts | 4 + app/models/user.rb | 4 + config/routes.rb | 1 + lib/pay_zen/helper.rb | 29 ++++++- lib/pay_zen/order.rb | 19 +++++ 10 files changed, 206 insertions(+), 9 deletions(-) create mode 100644 app/exceptions/payzen_error.rb create mode 100644 lib/pay_zen/order.rb diff --git a/app/controllers/api/payments_controller.rb b/app/controllers/api/payments_controller.rb index e7ed4a40e..795d57007 100644 --- a/app/controllers/api/payments_controller.rb +++ b/app/controllers/api/payments_controller.rb @@ -4,6 +4,12 @@ class API::PaymentsController < API::ApiController before_action :authenticate_user! + + # This method must be overridden by the the gateways controllers that inherits API::PaymentsControllers + def confirm_payment + raise NoMethodError + end + protected def get_wallet_debit(user, total_amount) diff --git a/app/controllers/api/payzen_controller.rb b/app/controllers/api/payzen_controller.rb index 1f5fe9141..4216e6f88 100644 --- a/app/controllers/api/payzen_controller.rb +++ b/app/controllers/api/payzen_controller.rb @@ -3,6 +3,7 @@ # 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/helper' def sdk_test @@ -23,7 +24,89 @@ class API::PayzenController < API::PaymentsController client = PayZen::Charge.new @result = client.create_payment(amount: amount[:amount], order_id: @id, - customer: { reference: params[:customer][:id], email: params[:customer][:email] }) + customer: PayZen::Helper.generate_customer(params[:customer_id])) @result 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]) + + amount = card_amount + + if order[:transactions].first.status == 'PAID' + 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 StandardError => e + render json: 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 end diff --git a/app/exceptions/payzen_error.rb b/app/exceptions/payzen_error.rb new file mode 100644 index 000000000..e0b325111 --- /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 < StandardError +end + diff --git a/app/frontend/src/javascript/api/payzen.ts b/app/frontend/src/javascript/api/payzen.ts index 4ff7009de..379394aa4 100644 --- a/app/frontend/src/javascript/api/payzen.ts +++ b/app/frontend/src/javascript/api/payzen.ts @@ -2,7 +2,7 @@ import apiClient from './clients/api-client'; import { AxiosResponse } from 'axios'; import { CartItems } from '../models/payment'; import { User } from '../models/user'; -import { CreatePaymentResponse, SdkTestResponse } from '../models/payzen'; +import { ConfirmPaymentResponse, CreatePaymentResponse, SdkTestResponse } from '../models/payzen'; export default class PayzenAPI { @@ -11,8 +11,13 @@ export default class PayzenAPI { return res?.data; } - static async chargeCreatePayment(cart_items: CartItems, customer: User): Promise { - const res: AxiosResponse = await apiClient.post('/api/payzen/create_payment', { cart_items, customer: { id: customer.id, email: customer.email } }); + static async chargeCreatePayment(cartItems: CartItems, customer: User): Promise { + const res: AxiosResponse = await apiClient.post('/api/payzen/create_payment', { cart_items: cartItems, customer_id: customer.id }); + return res?.data; + } + + static async confirm(orderId: string, cartItems: CartItems): Promise { + const res: AxiosResponse = await apiClient.post('/api/payzen/confirm_payment', { cart_items: cartItems, order_id: orderId }); return res?.data; } } diff --git a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx index d9af43cbc..544cb99c0 100644 --- a/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx +++ b/app/frontend/src/javascript/components/payment/payzen/payzen-form.tsx @@ -30,10 +30,12 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE const { t } = useTranslation('shared'); const PayZenKR = useRef(null); const [loadingClass, setLoadingClass] = useState<'hidden' | 'loader' | 'loader-overlay'>('loader'); + const [hmacKey, setHmacKey] = useState(null); useEffect(() => { const api = new SettingAPI(); - api.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey]).then(settings => { + api.query([SettingName.PayZenEndpoint, SettingName.PayZenPublicKey, SettingName.PayZenHmacKey]).then(settings => { + setHmacKey(settings.get(SettingName.PayZenHmacKey)); PayzenAPI.chargeCreatePayment(cartItems, customer).then(formToken => { KRGlue.loadLibrary(settings.get(SettingName.PayZenEndpoint), settings.get(SettingName.PayZenPublicKey)) /* Load the remote library */ .then(({ KR }) => @@ -54,11 +56,17 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE * Callback triggered on PayZen successful payments */ const onPaid = (event: ProcessPaymentAnswer): boolean => { + // TODO check hash + + const transaction = event.clientAnswer.transactions[0]; + if (event.clientAnswer.orderStatus === 'PAID') { - PayZenKR.current.removeForms(); - onSuccess(event.clientAnswer); + PayzenAPI.confirm(event.clientAnswer.orderDetails.orderId, cartItems).then(() => { + PayZenKR.current.removeForms().then(() => { + onSuccess(event.clientAnswer); + }); + }) } else { - const transaction = event.clientAnswer.transactions[0]; const error = `${transaction?.errorMessage}. ${transaction?.detailedErrorMessage || ''}`; onError(error || event.clientAnswer.orderStatus); } @@ -107,6 +115,40 @@ export const PayzenForm: React.FC = ({ onSubmit, onSuccess, onE } } + + const checkHash = (answer: ProcessPaymentAnswer, key: string = hmacKey): boolean => { + /* + TODO: convert the following to JS + + ## Check Kr-Answser object signature + def check_hash(answer, key = nil) + supported_hash_algorithm = ['sha256_hmac'] + + # check if the hash algorithm is supported + unless supported_hash_algorithm.include? answer[:hashAlgorithm] + raise PayzenError("hash algorithm not supported: #{answer[:hashAlgorithm]}. Update your SDK") + end + + # if key is not defined, we use kr-hash-key parameter to choose it + if key.nil? + if answer[:hashKey] == 'sha256_hmac' + key = Setting.get('payzen_hmac') + elsif answer[:hashKey] == 'password' + key = Setting.get('payzen_password') + else + raise PayzenError('invalid hash-key parameter') + end + end + + hash = OpenSSL::HMAC.hexdigest('SHA256', key, answer[:rawClientAnswer]) + + # return true if calculated hash and sent hash are the same + hash == answer[:hash] + end + */ + return true; + } + const Loader: FunctionComponent = () => { return (
diff --git a/app/frontend/src/javascript/models/payzen.ts b/app/frontend/src/javascript/models/payzen.ts index de8dc999f..c853e2573 100644 --- a/app/frontend/src/javascript/models/payzen.ts +++ b/app/frontend/src/javascript/models/payzen.ts @@ -7,6 +7,10 @@ export interface CreatePaymentResponse { orderId: string } +export interface ConfirmPaymentResponse { + todo?: any +} + export interface OrderDetails { mode?: 'TEST' | 'PRODUCTION', orderCurrency?: string, diff --git a/app/models/user.rb b/app/models/user.rb index 01307ab03..971563326 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -330,6 +330,10 @@ class User < ApplicationRecord ) end + def organization? + !invoicing_profile.organization.nil? + end + protected # remove projects drafts that are not linked to another user diff --git a/config/routes.rb b/config/routes.rb index 7b0f3c907..a31dde1c9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -182,6 +182,7 @@ Rails.application.routes.draw do ## PayZen gateway post 'payzen/sdk_test' => 'payzen#sdk_test' post 'payzen/create_payment' => 'payzen#create_payment' + post 'payzen/confirm_payment' => 'payzen#confirm_payment' # FabAnalytics get 'analytics/data' => 'analytics#data' diff --git a/lib/pay_zen/helper.rb b/lib/pay_zen/helper.rb index deb6f93ff..65c1e6509 100644 --- a/lib/pay_zen/helper.rb +++ b/lib/pay_zen/helper.rb @@ -6,6 +6,7 @@ module PayZen; end ## Provides various methods around the PayZen payment gateway class PayZen::Helper class << self + ## Is the PayZen gateway enabled? def enabled? return false unless Setting.get('online_payment_module') return false unless Setting.get('payment_gateway') == 'payzen' @@ -17,11 +18,37 @@ class PayZen::Helper res end + ## generate an unique string reference for the content of a cart def generate_ref(cart_items, customer) require 'sha3' content = { cart_items: cart_items, customer: customer }.to_json + DateTime.current.to_s - SHA3::Digest.hexdigest(:sha256, content)[0...9] + SHA3::Digest.hexdigest(:sha256, content)[0...12] + end + + ## Generate a hash map compatible with PayZen 'V4/Customer/Customer' + def generate_customer(customer_id) + customer = User.find(customer_id) + address = if customer.organization? + customer.invoicing_profile.organization.address&.address + else + customer.invoicing_profile.address&.address + end + + { + reference: customer.id, + email: customer.invoicing_profile.email, + billingDetails: { + firstName: customer.invoicing_profile.first_name, + lastName: customer.invoicing_profile.last_name, + legalName: customer.organization? ? customer.invoicing_profile.organization.name : nil, + address: address + }, + shippingDetails: { + category: customer.organization? ? 'COMPANY' : 'PRIVATE', + shippingMethod: 'ETICKET' + } + } end end end diff --git a/lib/pay_zen/order.rb b/lib/pay_zen/order.rb new file mode 100644 index 000000000..80db36f7e --- /dev/null +++ b/lib/pay_zen/order.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'pay_zen/client' + +# Order/* endpoints of the PayZen REST API +class PayZen::Order < PayZen::Client + def initialize(base_url: nil, username: nil, password: nil) + super(base_url: base_url, username: username, password: password) + end + + ## + # @see https://payzen.io/en-EN/rest/V4.0/api/playground/Order/Get/ + ## + def get(order_id, operation_type: nil) + post('/Transaction/Get/', orderId: order_id, operationType: operation_type) + end + +end +