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/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/frontend/src/javascript/api/payment-schedule.ts b/app/frontend/src/javascript/api/payment-schedule.ts index 21d56f359..97b3990ed 100644 --- a/app/frontend/src/javascript/api/payment-schedule.ts +++ b/app/frontend/src/javascript/api/payment-schedule.ts @@ -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/setting.ts b/app/frontend/src/javascript/api/setting.ts index cbc396bfd..6e6ce3128 100644 --- a/app/frontend/src/javascript/api/setting.ts +++ b/app/frontend/src/javascript/api/setting.ts @@ -1,6 +1,6 @@ import apiClient from './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 { @@ -10,10 +10,18 @@ export default class SettingAPI { } async query (names: Array): Promise> { - const res: AxiosResponse = await apiClient.get(`/api/settings/?names=[${names.join(',')}]`); + const params = new URLSearchParams(); + params.append('names', `['${names.join("','")}']`); + + const res: AxiosResponse = await apiClient.get(`/api/settings?${params.toString()}`); return SettingAPI.toSettingsMap(res?.data); } + 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); + } + static get (name: SettingName): IWrapPromise { const api = new SettingAPI(); return wrapPromise(api.get(name)); @@ -24,15 +32,41 @@ export default class SettingAPI { return wrapPromise(api.query(names)); } - private - - static toSettingsMap(data: Object): Map { + private static toSettingsMap(data: Object): Map { const dataArray: Array> = Object.entries(data); const map = new Map(); dataArray.forEach(item => { - map.set(SettingName[item[0]], item[1]); + map.set(item[0] as SettingName, item[1]); }); 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/components/fab-input.tsx b/app/frontend/src/javascript/components/fab-input.tsx index 34be6b247..5448ef8ec 100644 --- a/app/frontend/src/javascript/components/fab-input.tsx +++ b/app/frontend/src/javascript/components/fab-input.tsx @@ -2,12 +2,15 @@ * This component is a template for an input component that wraps the application style */ -import React, { BaseSyntheticEvent, ReactNode, useCallback, useState } from 'react'; +import React, { BaseSyntheticEvent, ReactNode, useCallback, useEffect, useState } from 'react'; import { debounce as _debounce } from 'lodash'; +import SettingAPI from '../api/setting'; +import { SettingName } from '../models/setting'; +import { loadStripe } from '@stripe/stripe-js'; interface FabInputProps { id: string, - onChange?: (event: BaseSyntheticEvent) => void, + onChange?: (value: any) => void, value: any, icon?: ReactNode, addOn?: ReactNode, @@ -19,10 +22,14 @@ interface FabInputProps { type?: 'text' | 'date' | 'password' | 'url' | 'time' | 'tel' | 'search' | 'number' | 'month' | 'email' | 'datetime-local' | 'week', } - export const FabInput: React.FC = ({ id, onChange, value, icon, className, disabled, type, required, debounce, addOn, addOnClassName }) => { const [inputValue, setInputValue] = useState(value); + useEffect(() => { + setInputValue(value); + onChange(value); + }, [value]); + /** * Check if the current component was provided an icon to display */ @@ -46,12 +53,13 @@ export const FabInput: React.FC = ({ id, onChange, value, icon, c * Handle the action of the button */ const handleChange = (e: BaseSyntheticEvent): void => { - setInputValue(e.target.value); + const newValue = e.target.value; + setInputValue(newValue); if (typeof onChange === 'function') { if (debounce) { - handler(e); + handler(newValue); } else { - onChange(e); + onChange(newValue); } } } diff --git a/app/frontend/src/javascript/components/select-gateway-modal.tsx b/app/frontend/src/javascript/components/select-gateway-modal.tsx index 3ea25d8a4..7a3abb843 100644 --- a/app/frontend/src/javascript/components/select-gateway-modal.tsx +++ b/app/frontend/src/javascript/components/select-gateway-modal.tsx @@ -3,7 +3,7 @@ * The configuration of a payment gateway is required to enable the online payments. */ -import React, { BaseSyntheticEvent, useState } from 'react'; +import React, { BaseSyntheticEvent, useEffect, useState } from 'react'; import { react2angular } from 'react2angular'; import { Loader } from './loader'; import { IApplication } from '../models/application'; @@ -12,7 +12,8 @@ import { FabModal, ModalSize } from './fab-modal'; import { User } from '../models/user'; import { Gateway } from '../models/gateway'; import { StripeKeysForm } from './stripe-keys-form'; -import { SettingName } from '../models/setting'; +import { SettingBulkResult, SettingName } from '../models/setting'; +import SettingAPI from '../api/setting'; declare var Application: IApplication; @@ -21,22 +22,31 @@ interface SelectGatewayModalModalProps { isOpen: boolean, toggleModal: () => void, currentUser: User, + onError: (errors: Map|any) => void, + onSuccess: (results: Map) => void, } -const SelectGatewayModal: React.FC = ({ isOpen, toggleModal }) => { +const paymentGateway = SettingAPI.get(SettingName.PaymentGateway); + +const SelectGatewayModal: React.FC = ({ isOpen, toggleModal, onError, onSuccess }) => { const { t } = useTranslation('admin'); const [preventConfirmGateway, setPreventConfirmGateway] = useState(true); const [selectedGateway, setSelectedGateway] = useState(''); const [gatewayConfig, setGatewayConfig] = useState>(new Map()); + useEffect(() => { + const gateway = paymentGateway.read(); + setSelectedGateway(gateway.value); + }, []); /** * Callback triggered when the user has filled and confirmed the settings of his gateway */ const onGatewayConfirmed = () => { setPreventConfirmGateway(true); - toggleModal(); + updateSettings(); + setPreventConfirmGateway(false); } /** @@ -67,6 +77,25 @@ const SelectGatewayModal: React.FC = ({ isOpen, to setPreventConfirmGateway(false); } + /** + * Send the new gateway settings to the API to save them + */ + const updateSettings = (): void => { + const settings = new Map(gatewayConfig); + settings.set(SettingName.PaymentGateway, selectedGateway); + + const api = new SettingAPI(); + api.bulkUpdate(settings).then(result => { + if (Array.from(result.values()).filter(item => !item.status).length > 0) { + onError(result); + } else { + onSuccess(result); + } + }, reason => { + onError(reason); + }); + } + return ( = ({ isOpen, to ); }; -const SelectGatewayModalWrapper: React.FC = ({ isOpen, toggleModal, currentUser }) => { +const SelectGatewayModalWrapper: React.FC = ({ isOpen, toggleModal, currentUser, onSuccess, onError }) => { return ( - + ); } -Application.Components.component('selectGatewayModal', react2angular(SelectGatewayModalWrapper, ['isOpen', 'toggleModal', 'currentUser'])); +Application.Components.component('selectGatewayModal', react2angular(SelectGatewayModalWrapper, ['isOpen', 'toggleModal', 'currentUser', 'onSuccess', 'onError'])); diff --git a/app/frontend/src/javascript/components/stripe-keys-form.tsx b/app/frontend/src/javascript/components/stripe-keys-form.tsx index cc66a727b..e4f323c43 100644 --- a/app/frontend/src/javascript/components/stripe-keys-form.tsx +++ b/app/frontend/src/javascript/components/stripe-keys-form.tsx @@ -44,8 +44,7 @@ const StripeKeysFormComponent: React.FC = ({ onValidKeys }) /** * Send a test call to the Stripe API to check if the inputted public key is valid */ - const testPublicKey = (e: BaseSyntheticEvent) => { - const key = e.target.value; + const testPublicKey = (key: string) => { if (!key.match(/^pk_/)) { setPublicKeyAddOn(); setPublicKeyAddOnClassName('key-invalid'); @@ -66,8 +65,7 @@ const StripeKeysFormComponent: React.FC = ({ onValidKeys }) /** * Send a test call to the Stripe API to check if the inputted secret key is valid */ - const testSecretKey = (e: BaseSyntheticEvent) => { - const key = e.target.value; + const testSecretKey = (key: string) => { if (!key.match(/^sk_/)) { setSecretKeyAddOn(); setSecretKeyAddOnClassName('key-invalid'); diff --git a/app/frontend/src/javascript/controllers/admin/invoices.js b/app/frontend/src/javascript/controllers/admin/invoices.js index 16d6b79b5..821ce446c 100644 --- a/app/frontend/src/javascript/controllers/admin/invoices.js +++ b/app/frontend/src/javascript/controllers/admin/invoices.js @@ -698,6 +698,21 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I }, 50); }; + /** + * Callback triggered after the gateway was successfully configured in the dedicated modal + */ + $scope.onGatewayModalSuccess = function (settings) { + $scope.toggleSelectGatewayModal(); + }; + + /** + * 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); + }; + /** * Setup the feature-tour for the admin/invoices page. * This is intended as a contextual help (when pressing F1) diff --git a/app/frontend/src/javascript/models/setting.ts b/app/frontend/src/javascript/models/setting.ts index ccde19dc3..d587a5642 100644 --- a/app/frontend/src/javascript/models/setting.ts +++ b/app/frontend/src/javascript/models/setting.ts @@ -101,7 +101,8 @@ export enum SettingName { UpcomingEventsShown = 'upcoming_events_shown', PaymentSchedulePrefix = 'payment_schedule_prefix', TrainingsModule = 'trainings_module', - AddressRequired = 'address_required' + AddressRequired = 'address_required', + PaymentGateway = 'payment_gateway' } export interface Setting { @@ -110,3 +111,15 @@ export interface Setting { 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/templates/admin/invoices/payment.html b/app/frontend/templates/admin/invoices/payment.html index 07509f76b..248c3cccc 100644 --- a/app/frontend/templates/admin/invoices/payment.html +++ b/app/frontend/templates/admin/invoices/payment.html @@ -13,7 +13,11 @@ on-before-save="selectPaymentGateway" fa-icon="fa-font"> - +

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

diff --git a/app/views/api/settings/bulk_update.json.jbuilder b/app/views/api/settings/bulk_update.json.jbuilder index db2ba1972..2f5a9744b 100644 --- a/app/views/api/settings/bulk_update.json.jbuilder +++ b/app/views/api/settings/bulk_update.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.settings @settings.each do |setting| - if setting[:errors] + if setting.errors.keys.count.positive? json.error setting.errors.full_messages json.id setting.id json.name setting.name diff --git a/config/locales/app.admin.en.yml b/config/locales/app.admin.en.yml index 06e5d128b..d5e5767ea 100644 --- a/config/locales/app.admin.en.yml +++ b/config/locales/app.admin.en.yml @@ -643,6 +643,7 @@ en: currency_info_html: "Please specify below the currency used for online payment. You should provide a three-letter ISO code, from the list of Stripe supported currencies." currency_alert_html: "Warning: the currency cannot be changed after the first online payment was made. Please define this setting carefully before opening Fab-manager to your members." stripe_currency: "Stripe currency" + gateway_configuration_error: "An error occurred while configuring the payment gateway." # select a payment gateway gateway_modal: select_gateway_title: "Select a payment gateway" diff --git a/config/locales/app.admin.fr.yml b/config/locales/app.admin.fr.yml index a12e67556..fcdb2e22d 100644 --- a/config/locales/app.admin.fr.yml +++ b/config/locales/app.admin.fr.yml @@ -643,6 +643,7 @@ fr: currency_info_html: "Veuillez indiquer la devise à utiliser lors des paiements en ligne. Vous devez fournir un code ISO à trois lettres, issu de la liste des devises supportées par Stripe." currency_alert_html: "Attention : la devise ne peut pas être modifiée après que le premier paiement en ligne ait été effectué. Veuillez définir attentivement ce paramètre avant d'ouvrir Fab-manager à vos membres." stripe_currency: "Devise Stripe" + gateway_configuration_error: "Une erreur est survenue lors de la configuration de la passerelle de paiement." # select a payment gateway gateway_modal: select_gateway_title: "Sélectionnez une passerelle de paiement"