mirror of
https://github.com/LaCasemate/fab-manager.git
synced 2024-11-29 10:24:20 +01:00
Merge branch 'dev' into l10n_dev
This commit is contained in:
commit
272dd74be6
@ -11,11 +11,13 @@
|
||||
- Improved 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
|
||||
- Generate footprints 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
|
||||
- During setup, ask for the name of the external network and create it, if it does not already exists
|
||||
- Ability to configure the prefix of the payment-schedules' files
|
||||
- Filter plans by group and by duration
|
||||
- Fix a bug: cannot select the recurrence end date on Safari or Internet Explorer
|
||||
- Fix a bug: build status badge is not working
|
||||
- Fix a bug: unable to set date formats during installation
|
||||
@ -23,6 +25,7 @@
|
||||
- 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
|
||||
- Fix a bug: do not allow users to register with an invalid email address
|
||||
- Fix a security issue: updated dns-packet to 1.3.4 to fix [CVE-2021-23386](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-23386)
|
||||
- `SUPERADMIN_EMAIL` renamed to `ADMINSYS_EMAIL`
|
||||
- `scripts/run-tests.sh` renamed to `scripts/tests.sh`
|
||||
@ -33,6 +36,10 @@
|
||||
- [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.12 2021 June 09
|
||||
|
||||
- Fix a bug: unable to process stripe payments
|
||||
|
||||
## v4.7.11 2021 May 26
|
||||
|
||||
- Updated ffi to 1.15.1
|
||||
|
@ -4,7 +4,7 @@
|
||||
# Plan are used to define subscription's characteristics.
|
||||
# PartnerPlan is a special kind of plan which send notifications to an external user
|
||||
class API::PlansController < API::ApiController
|
||||
before_action :authenticate_user!, except: [:index]
|
||||
before_action :authenticate_user!, except: [:index, :durations]
|
||||
|
||||
def index
|
||||
@plans = Plan.includes(:plan_file)
|
||||
@ -51,6 +51,17 @@ class API::PlansController < API::ApiController
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def durations
|
||||
grouped = Plan.all.map { |p| [p.human_readable_duration, p.id] }.group_by { |i| i[0] }
|
||||
@durations = []
|
||||
grouped.each_pair do |duration, plans|
|
||||
@durations.push(
|
||||
name: duration,
|
||||
plans: plans.map { |p| p[1] }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def plan_params
|
||||
|
@ -12,7 +12,7 @@ class API::SettingsController < API::ApiController
|
||||
authorize Setting
|
||||
@setting = Setting.find_or_initialize_by(name: params[:name])
|
||||
render status: :not_modified and return if setting_params[:value] == @setting.value
|
||||
render status: :locked, json: { error: 'locked setting' } and return unless SettingService.before_update(@setting)
|
||||
render status: :locked, json: { error: I18n.t('settings.locked_setting') } and return unless SettingService.before_update(@setting)
|
||||
|
||||
if @setting.save && @setting.history_values.create(value: setting_params[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
SettingService.after_update(@setting)
|
||||
@ -26,18 +26,21 @@ class API::SettingsController < API::ApiController
|
||||
authorize Setting
|
||||
|
||||
@settings = []
|
||||
params[:settings].each do |setting|
|
||||
next if !setting[:name] || !setting[:value]
|
||||
may_transaction(params[:transactional]) do
|
||||
params[:settings].each do |setting|
|
||||
next if !setting[:name] || !setting[:value]
|
||||
|
||||
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
||||
next unless SettingService.before_update(db_setting)
|
||||
db_setting = Setting.find_or_initialize_by(name: setting[:name])
|
||||
if !SettingService.before_update(db_setting)
|
||||
db_setting.errors[:-] << I18n.t("settings.#{setting[:name]}") + ': ' + I18n.t('settings.locked_setting')
|
||||
elsif db_setting.save
|
||||
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
SettingService.after_update(db_setting)
|
||||
end
|
||||
|
||||
if db_setting.save
|
||||
db_setting.history_values.create(value: setting[:value], invoicing_profile: current_user.invoicing_profile)
|
||||
SettingService.after_update(db_setting)
|
||||
@settings.push db_setting
|
||||
may_rollback(params[:transactional]) if db_setting.errors.keys.count.positive?
|
||||
end
|
||||
|
||||
@settings.push db_setting
|
||||
end
|
||||
end
|
||||
|
||||
@ -79,4 +82,20 @@ class API::SettingsController < API::ApiController
|
||||
def names_as_string_to_array
|
||||
params[:names][1..-2].split(',').map(&:strip).map { |param| param[1..-2] }.map(&:strip)
|
||||
end
|
||||
|
||||
# run the given block in a transaction if `should` is true. Just run it normally otherwise
|
||||
def may_transaction(should)
|
||||
if should == 'true'
|
||||
ActiveRecord::Base.transaction do
|
||||
yield
|
||||
end
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
# rollback the current DB transaction if `should` is true
|
||||
def may_rollback(should)
|
||||
raise ActiveRecord::Rollback if should == 'true'
|
||||
end
|
||||
end
|
||||
|
0
app/frontend/src/javascript/api/payment.ts
Normal file
0
app/frontend/src/javascript/api/payment.ts
Normal file
@ -1,11 +1,16 @@
|
||||
import apiClient from './clients/api-client';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Plan } from '../models/plan';
|
||||
import { Plan, PlansDuration } from '../models/plan';
|
||||
|
||||
export default class PlanAPI {
|
||||
static async index (): Promise<Array<Plan>> {
|
||||
const res: AxiosResponse<Array<Plan>> = await apiClient.get('/api/plans');
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async durations (): Promise<Array<PlansDuration>> {
|
||||
const res: AxiosResponse<Array<PlansDuration>> = await apiClient.get('/api/plans/durations');
|
||||
return res?.data;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,8 +23,8 @@ export default class SettingAPI {
|
||||
return res?.data?.setting;
|
||||
}
|
||||
|
||||
async bulkUpdate (settings: Map<SettingName, any>): Promise<Map<SettingName, SettingBulkResult>> {
|
||||
const res: AxiosResponse = await apiClient.patch('/api/settings/bulk_update', { settings: SettingAPI.toObjectArray(settings) });
|
||||
async bulkUpdate (settings: Map<SettingName, any>, transactional: boolean = false): Promise<Map<SettingName, SettingBulkResult>> {
|
||||
const res: AxiosResponse = await apiClient.patch(`/api/settings/bulk_update?transactional=${transactional}`, { settings: SettingAPI.toObjectArray(settings) });
|
||||
return SettingAPI.toBulkMap(res?.data?.settings);
|
||||
}
|
||||
|
||||
@ -67,6 +67,9 @@ export default class SettingAPI {
|
||||
if ('value' in item) {
|
||||
itemData.value = item.value;
|
||||
}
|
||||
if ('localized' in item) {
|
||||
itemData.localized = item.localized;
|
||||
}
|
||||
|
||||
map.set(item.name as SettingName, itemData)
|
||||
});
|
||||
|
@ -5,7 +5,7 @@ import { PaymentSchedule } from '../models/payment-schedule';
|
||||
import { Invoice } from '../models/invoice';
|
||||
|
||||
export default class StripeAPI {
|
||||
static async confirm (stp_payment_method_id: string, cart_items: ShoppingCart): Promise<PaymentConfirmation|Invoice> {
|
||||
static async confirmMethod (stp_payment_method_id: string, cart_items: ShoppingCart): Promise<PaymentConfirmation|Invoice> {
|
||||
const res: AxiosResponse<PaymentConfirmation|Invoice> = await apiClient.post(`/api/stripe/confirm_payment`, {
|
||||
payment_method_id: stp_payment_method_id,
|
||||
cart_items
|
||||
@ -13,6 +13,14 @@ export default class StripeAPI {
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async confirmIntent (stp_payment_intent_id: string, cart_items: ShoppingCart): Promise<PaymentConfirmation|Invoice> {
|
||||
const res: AxiosResponse = await apiClient.post(`/api/payments/confirm_payment`, {
|
||||
payment_intent_id: stp_payment_intent_id,
|
||||
cart_items
|
||||
});
|
||||
return res?.data;
|
||||
}
|
||||
|
||||
static async setupIntent (user_id: number): Promise<IntentConfirmation> {
|
||||
const res: AxiosResponse<IntentConfirmation> = await apiClient.get(`/api/stripe/setup_intent/${user_id}`);
|
||||
return res?.data;
|
||||
|
@ -8,7 +8,7 @@ export const Loader: React.FC = ({children }) => {
|
||||
<div className="fa-3x">
|
||||
<i className="fas fa-circle-notch fa-spin" />
|
||||
</div>
|
||||
);// compile
|
||||
);
|
||||
return (
|
||||
<Suspense fallback={loading}>
|
||||
{children}
|
||||
|
@ -41,7 +41,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
try {
|
||||
if (!paymentSchedule) {
|
||||
// process the normal payment pipeline, including SCA validation
|
||||
const res = await StripeAPI.confirm(paymentMethod.id, cart);
|
||||
const res = await StripeAPI.confirmMethod(paymentMethod.id, cart);
|
||||
await handleServerConfirmation(res);
|
||||
} else {
|
||||
// we start by associating the payment method with the user
|
||||
@ -94,7 +94,7 @@ export const StripeForm: React.FC<GatewayFormProps> = ({ onSubmit, onSuccess, on
|
||||
// The card action has been handled
|
||||
// The PaymentIntent can be confirmed again on the server
|
||||
try {
|
||||
const confirmation = await StripeAPI.confirm(result.paymentIntent.id, cart);
|
||||
const confirmation = await StripeAPI.confirmIntent(result.paymentIntent.id, cart);
|
||||
await handleServerConfirmation(confirmation);
|
||||
} catch (e) {
|
||||
onError(e);
|
||||
|
@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Select from 'react-select';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Group } from '../../models/group';
|
||||
import { User } from '../../models/user';
|
||||
import PlanAPI from '../../api/plan';
|
||||
import { PlansDuration } from '../../models/plan';
|
||||
|
||||
interface PlansFilterProps {
|
||||
user?: User,
|
||||
groups: Array<Group>,
|
||||
onGroupSelected: (groupId: number) => void,
|
||||
onError: (message: string) => void,
|
||||
onDurationSelected: (plansIds: Array<number>) => void,
|
||||
}
|
||||
|
||||
/**
|
||||
* Option format, expected by react-select
|
||||
* @see https://github.com/JedWatson/react-select
|
||||
*/
|
||||
type selectOption = { value: number, label: string };
|
||||
|
||||
export const PlansFilter: React.FC<PlansFilterProps> = ({ user, groups, onGroupSelected, onError, onDurationSelected }) => {
|
||||
const { t } = useTranslation('public');
|
||||
|
||||
const [durations, setDurations] = useState<Array<PlansDuration>>(null);
|
||||
|
||||
// get the plans durations on component load
|
||||
useEffect(() => {
|
||||
PlanAPI.durations().then(data => {
|
||||
setDurations(data);
|
||||
}).catch(error => onError(error));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Convert all groups to the react-select format
|
||||
*/
|
||||
const buildGroupOptions = (): Array<selectOption> => {
|
||||
return groups.filter(g => !g.disabled && g.slug !== 'admins').map(g => {
|
||||
return { value: g.id, label: g.name }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all durations to the react-select format
|
||||
*/
|
||||
const buildDurationOptions = (): Array<selectOption> => {
|
||||
const options = durations.map((d, index) => {
|
||||
return { value: index, label: d.name };
|
||||
});
|
||||
options.unshift({ value: null, label: t('app.public.plans_filter.all_durations') });
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a group in the dropdown list
|
||||
*/
|
||||
const handleGroupSelected = (option: selectOption): void => {
|
||||
onGroupSelected(option.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a duration in the dropdown list
|
||||
*/
|
||||
const handleDurationSelected = (option: selectOption): void => {
|
||||
onDurationSelected(durations[option.value]?.plans_ids);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="plans-filter">
|
||||
{!user && <div className="group-filter">
|
||||
<label htmlFor="group">{t('app.public.plans_filter.i_am')}</label>
|
||||
<Select placeholder={t('app.public.plans_filter.select_group')}
|
||||
id="group"
|
||||
className="group-select"
|
||||
onChange={handleGroupSelected}
|
||||
options={buildGroupOptions()}/>
|
||||
</div>}
|
||||
{durations && <div className="duration-filter">
|
||||
<label htmlFor="duration">{t('app.public.plans_filter.i_want_duration')}</label>
|
||||
<Select placeholder={t('app.public.plans_filter.select_duration')}
|
||||
id="duration"
|
||||
className="duration-select"
|
||||
onChange={handleDurationSelected}
|
||||
options={buildDurationOptions()}/>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
@ -12,6 +12,7 @@ import { Loader } from '../base/loader';
|
||||
import { react2angular } from 'react2angular';
|
||||
import { IApplication } from '../../models/application';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlansFilter } from './plans-filter';
|
||||
|
||||
declare var Application: IApplication;
|
||||
|
||||
@ -41,6 +42,10 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
const [groups, setGroups] = useState<Array<Group>>(null);
|
||||
// currently selected plan
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>(null);
|
||||
// filtering shown plans by only one group
|
||||
const [groupFilter, setGroupFilter] = useState<number>(null);
|
||||
// filtering shown plans by ids
|
||||
const [plansFilter, setPlansFilter] = useState<Array<number>>(null);
|
||||
|
||||
// fetch data on component mounted
|
||||
useEffect(() => {
|
||||
@ -60,6 +65,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
// reset the selected plan when the user changes
|
||||
useEffect(() => {
|
||||
setSelectedPlan(null);
|
||||
setGroupFilter(null);
|
||||
}, [customer, operator]);
|
||||
|
||||
/**
|
||||
@ -96,10 +102,11 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the plans to display, depending on the connected/selected user
|
||||
* Filter the plans to display, depending on the connected/selected user and on the selected group filter (if any)
|
||||
*/
|
||||
const filteredPlans = (): PlansTree => {
|
||||
if (_.isEmpty(customer)) return plans;
|
||||
if (_.isEmpty(customer) && !groupFilter) return plans;
|
||||
if (groupFilter) return new Map([[groupFilter, plans.get(groupFilter)]]);
|
||||
|
||||
return new Map([[customer.group_id, plans.get(customer.group_id)]]);
|
||||
}
|
||||
@ -154,6 +161,30 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
onPlanSelection(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a group to filter the current list
|
||||
*/
|
||||
const handleFilterByGroup = (groupId: number): void => {
|
||||
setGroupFilter(groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback triggered when the user selects a duration to filter the current list
|
||||
*/
|
||||
const handleFilterByDuration = (plansIds: Array<number>): void => {
|
||||
setPlansFilter(plansIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for filtering plans to display, depending on the filter-by-plans-ids selection
|
||||
* @see https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Global_Objects/Array/filter
|
||||
*/
|
||||
const filterPlan = (plan: Plan): boolean => {
|
||||
if (!plansFilter) return true;
|
||||
|
||||
return plansFilter.includes(plan.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the provided list of categories, with each associated plans
|
||||
*/
|
||||
@ -181,7 +212,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
{categoryPlans.length === 0 && <span className="no-plans">
|
||||
{t('app.public.plans.no_plans')}
|
||||
</span>}
|
||||
{categoryPlans.sort(comparePlans).map(plan => (
|
||||
{categoryPlans.filter(filterPlan).sort(comparePlans).map(plan => (
|
||||
<PlanCard key={plan.id}
|
||||
userId={customer?.id}
|
||||
subscribedPlanId={subscribedPlanId}
|
||||
@ -197,6 +228,7 @@ const PlansList: React.FC<PlansListProps> = ({ onError, onPlanSelection, onLogin
|
||||
|
||||
return (
|
||||
<div className="plans-list">
|
||||
{groups && <PlansFilter user={customer} groups={groups} onGroupSelected={handleFilterByGroup} onError={onError} onDurationSelected={handleFilterByDuration} />}
|
||||
{plans && Array.from(filteredPlans()).map(([groupId, plansByGroup]) => {
|
||||
return (
|
||||
<div key={groupId} className="plans-per-group">
|
||||
|
@ -23,7 +23,7 @@ interface SelectGatewayModalModalProps {
|
||||
isOpen: boolean,
|
||||
toggleModal: () => void,
|
||||
currentUser: User,
|
||||
onError: (errors: Map<SettingName, SettingBulkResult>|any) => void,
|
||||
onError: (errors: string) => void,
|
||||
onSuccess: (results: Map<SettingName, SettingBulkResult>) => void,
|
||||
}
|
||||
|
||||
@ -102,10 +102,12 @@ const SelectGatewayModal: React.FC<SelectGatewayModalModalProps> = ({ isOpen, to
|
||||
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);
|
||||
api.bulkUpdate(settings, true).then(result => {
|
||||
const errorResults = Array.from(result.values()).filter(item => !item.status);
|
||||
if (errorResults.length > 0) {
|
||||
onError(errorResults.map(item => item.error[0]).join(' '));
|
||||
} else {
|
||||
// we call the success callback only in case of full success (transactional bulk update)
|
||||
onSuccess(result);
|
||||
}
|
||||
}, reason => {
|
||||
|
@ -61,6 +61,14 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
templateUrl: '/admin/invoices/settings/editPrefix.html'
|
||||
};
|
||||
|
||||
// Payment Schedule PDF filename settings (and example)
|
||||
$scope.scheduleFile = {
|
||||
prefix: settings.payment_schedule_prefix,
|
||||
nextId: 11,
|
||||
date: moment().format('DDMMYYYY'),
|
||||
templateUrl: '/admin/invoices/settings/editSchedulePrefix.html'
|
||||
};
|
||||
|
||||
// Invoices parameters
|
||||
$scope.invoice = {
|
||||
logo: null,
|
||||
@ -529,6 +537,38 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Open a modal dialog allowing the user to edit the prefix of the payment schedules file names
|
||||
*/
|
||||
$scope.openEditSchedulePrefix = function () {
|
||||
const modalInstance = $uibModal.open({
|
||||
animation: true,
|
||||
templateUrl: $scope.scheduleFile.templateUrl,
|
||||
size: 'lg',
|
||||
resolve: {
|
||||
model () { return $scope.scheduleFile.prefix; }
|
||||
},
|
||||
controller: ['$scope', '$uibModalInstance', 'model', function ($scope, $uibModalInstance, model) {
|
||||
$scope.model = model;
|
||||
$scope.ok = function () { $uibModalInstance.close($scope.model); };
|
||||
$scope.cancel = function () { $uibModalInstance.dismiss('cancel'); };
|
||||
}]
|
||||
});
|
||||
|
||||
modalInstance.result.then(function (model) {
|
||||
Setting.update({ name: 'payment_schedule_prefix' }, { value: model }, function (data) {
|
||||
$scope.scheduleFile.prefix = model;
|
||||
return growl.success(_t('app.admin.invoices.prefix_successfully_saved'));
|
||||
}
|
||||
, function (error) {
|
||||
if (error.status === 304) return;
|
||||
|
||||
growl.error(_t('app.admin.invoices.an_error_occurred_while_saving_the_prefix'));
|
||||
console.error(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback to save the value of the text zone when editing is done
|
||||
*/
|
||||
@ -720,9 +760,8 @@ Application.Controllers.controller('InvoicesController', ['$scope', '$state', 'I
|
||||
/**
|
||||
* 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);
|
||||
$scope.onGatewayModalError = function (message) {
|
||||
growl.error(_t('app.admin.invoices.payment.gateway_configuration_error') + message);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -41,3 +41,8 @@ export interface Plan {
|
||||
plan_file_url: string,
|
||||
partners: Array<Partner>
|
||||
}
|
||||
|
||||
export interface PlansDuration {
|
||||
name: string,
|
||||
plans_ids: Array<number>
|
||||
}
|
||||
|
@ -24,7 +24,6 @@ export enum SettingName {
|
||||
InvoiceLegals = 'invoice_legals',
|
||||
BookingWindowStart = 'booking_window_start',
|
||||
BookingWindowEnd = 'booking_window_end',
|
||||
BookingSlotDuration = 'booking_slot_duration',
|
||||
BookingMoveEnable = 'booking_move_enable',
|
||||
BookingMoveDelay = 'booking_move_delay',
|
||||
BookingCancelEnable = 'booking_cancel_enable',
|
||||
@ -113,6 +112,7 @@ export enum SettingName {
|
||||
|
||||
export interface Setting {
|
||||
name: SettingName,
|
||||
localized?: string,
|
||||
value: string,
|
||||
last_update?: Date,
|
||||
history?: Array<HistoryValue>
|
||||
@ -127,5 +127,6 @@ export interface SettingError {
|
||||
export interface SettingBulkResult {
|
||||
status: boolean,
|
||||
value?: any,
|
||||
error?: string
|
||||
error?: string,
|
||||
localized?: string,
|
||||
}
|
||||
|
@ -874,7 +874,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', " +
|
||||
"'payment_gateway', 'accounting_Error_code', 'accounting_Error_label', " +
|
||||
"'payment_gateway', 'accounting_Error_code', 'accounting_Error_label', 'payment_schedule_prefix', " +
|
||||
"'feature_tour_display', 'online_payment_module', 'stripe_public_key', 'stripe_currency', 'invoice_prefix']"
|
||||
}).$promise;
|
||||
}],
|
||||
|
@ -49,5 +49,6 @@
|
||||
@import "modules/create-plan-category";
|
||||
@import "modules/edit-plan-category";
|
||||
@import "modules/delete-plan-category";
|
||||
@import "modules/plans-filter";
|
||||
|
||||
@import "app.responsive";
|
||||
|
@ -30,6 +30,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-file {
|
||||
text-align: center;
|
||||
line-height: 4em;
|
||||
margin-top: 2em;
|
||||
|
||||
.fa-stack {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 75px;
|
||||
|
||||
.fa-file-pdf {
|
||||
font-size: 4em;
|
||||
vertical-align: middle;
|
||||
position: absolute;
|
||||
background-color: white;
|
||||
text-shadow: 0 0 2px white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.filename {
|
||||
font-size: 1.1em;
|
||||
vertical-align: middle;
|
||||
margin-left: 1em;
|
||||
|
||||
.prefix:hover {
|
||||
background-color: $yellow;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.invoice-placeholder {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
|
39
app/frontend/src/stylesheets/modules/plans-filter.scss
Normal file
39
app/frontend/src/stylesheets/modules/plans-filter.scss
Normal file
@ -0,0 +1,39 @@
|
||||
.plans-filter {
|
||||
margin: 1.5em;
|
||||
|
||||
.group-filter {
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
|
||||
.duration-filter,
|
||||
.group-filter {
|
||||
& {
|
||||
display: inline-flex;
|
||||
width: 50%;
|
||||
}
|
||||
& > label {
|
||||
white-space: nowrap;
|
||||
line-height: 2em;
|
||||
}
|
||||
& > * {
|
||||
display: inline-block;
|
||||
}
|
||||
.duration-select,
|
||||
.group-select {
|
||||
width: 100%;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px){
|
||||
.plans-filter {
|
||||
.group-filter {
|
||||
padding-right: 0;
|
||||
}
|
||||
.group-filter, .duration-filter {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -92,6 +92,14 @@
|
||||
<i class="fa fa-file-pdf-o" aria-hidden="true"></i>
|
||||
<span class="filename"><span class="prefix" ng-click="openEditPrefix()">{{file.prefix}}</span>-{{file.nextId}}_{{file.date}}.pdf</span>
|
||||
</div>
|
||||
<div class="schedule-file">
|
||||
<h3 class="m-l" translate>{{ 'app.admin.invoices.schedule_filename' }}</h3>
|
||||
<span class="fa-stack">
|
||||
<i class="fa fa-file-pdf" style="top: 0; left: 0" aria-hidden="true"></i>
|
||||
<i class="fa fa-file-pdf" style="top: 10px; left: 10px" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="filename"><span class="prefix" ng-click="openEditSchedulePrefix()">{{scheduleFile.prefix}}</span>-{{scheduleFile.nextId}}_{{scheduleFile.date}}.pdf</span>
|
||||
</div>
|
||||
|
||||
|
||||
<script type="text/ng-template" id="addYear.html">
|
||||
|
@ -0,0 +1,20 @@
|
||||
<div class="custom-invoice">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" translate>{{ 'app.admin.invoices.filename' }}</h3>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="alert alert-info m-h-md" translate>
|
||||
{{ 'app.admin.invoices.schedule_prefix_info' }}
|
||||
</p>
|
||||
<div>
|
||||
<div class="model">
|
||||
<label for="prefix" translate>{{ 'app.admin.invoices.prefix' }}</label>
|
||||
<input type="text" id="prefix" class="form-control" ng-model="model">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-warning" ng-click="ok()" translate>{{ 'app.shared.buttons.confirm' }}</button>
|
||||
<button class="btn btn-default" ng-click="cancel()" translate>{{ 'app.shared.buttons.cancel' }}</button>
|
||||
</div>
|
||||
</div>
|
@ -74,6 +74,7 @@
|
||||
<span class="input-group-addon"><i class="fa fa-envelope"></i></span>
|
||||
<input type="email"
|
||||
name="email"
|
||||
ng-pattern="/^[^@]+@[^\.]+\..{2,}$/"
|
||||
ng-model="user.email"
|
||||
class="form-control"
|
||||
placeholder="{{ 'app.public.common.your_email_address' | translate }}"
|
||||
|
@ -27,6 +27,10 @@ class AccountingPeriod < ApplicationRecord
|
||||
Invoice.where('created_at >= :start_date AND CAST(created_at AS DATE) <= :end_date', start_date: start_at, end_date: end_at)
|
||||
end
|
||||
|
||||
def payment_schedules
|
||||
PaymentSchedule.where('created_at >= :start_date AND CAST(created_at AS DATE) <= :end_date', start_date: start_at, end_date: end_at)
|
||||
end
|
||||
|
||||
def invoices_with_vat(invoices)
|
||||
vat_service = VatHistoryService.new
|
||||
invoices.map do |i|
|
||||
|
@ -34,7 +34,7 @@ class Footprintable < ApplicationRecord
|
||||
FootprintService.debug_footprint(self.class, self)
|
||||
end
|
||||
|
||||
protected
|
||||
#protected
|
||||
|
||||
def compute_footprint
|
||||
FootprintService.compute_footprint(self.class, self)
|
||||
|
@ -7,7 +7,7 @@
|
||||
class Setting < ApplicationRecord
|
||||
has_many :history_values
|
||||
# The following list contains all the settings that can be customized from the Fab-manager's UI.
|
||||
# A few of them that are system settings, that should not be updated manually (uuid, origin).
|
||||
# A few of them that are system settings, that should not be updated manually (uuid, origin...).
|
||||
validates :name, inclusion:
|
||||
{ in: %w[about_title
|
||||
about_body
|
||||
@ -32,7 +32,6 @@ class Setting < ApplicationRecord
|
||||
invoice_legals
|
||||
booking_window_start
|
||||
booking_window_end
|
||||
booking_slot_duration
|
||||
booking_move_enable
|
||||
booking_move_delay
|
||||
booking_cancel_enable
|
||||
|
@ -34,7 +34,7 @@ class SettingPolicy < ApplicationPolicy
|
||||
def self.public_whitelist
|
||||
%w[about_title about_body about_contacts privacy_body privacy_dpo twitter_name home_blogpost machine_explications_alert
|
||||
training_explications_alert training_information_message subscription_explications_alert booking_window_start
|
||||
booking_window_end booking_slot_duration booking_move_enable booking_move_delay booking_cancel_enable booking_cancel_delay
|
||||
booking_window_end booking_move_enable booking_move_delay booking_cancel_enable booking_cancel_delay
|
||||
fablab_name name_genre event_explications_alert space_explications_alert link_name home_content phone_required
|
||||
tracking_id book_overlapping_slots slot_duration events_in_calendar spaces_module plans_module invoicing_module
|
||||
recaptcha_site_key feature_tour_display disqus_shortname allowed_cad_extensions openlab_app_id openlab_default
|
||||
|
@ -16,9 +16,9 @@ class CartService
|
||||
|
||||
items = []
|
||||
cart_items[:items].each do |item|
|
||||
if item.keys.first == 'subscription'
|
||||
if ['subscription', :subscription].include?(item.keys.first)
|
||||
items.push(CartItem::Subscription.new(plan_info[:plan], @customer)) if plan_info[:new_subscription]
|
||||
elsif item.keys.first == 'reservation'
|
||||
elsif ['reservation', :reservation].include?(item.keys.first)
|
||||
items.push(reservable_from_hash(item[:reservation], plan_info))
|
||||
end
|
||||
end
|
||||
@ -66,8 +66,8 @@ class CartService
|
||||
|
||||
def plan(cart_items)
|
||||
new_plan_being_bought = false
|
||||
plan = if cart_items[:items].any? { |item| item.keys.first == 'subscription' }
|
||||
index = cart_items[:items].index { |item| item.keys.first == 'subscription' }
|
||||
plan = if cart_items[:items].any? { |item| ['subscription', :subscription].include?(item.keys.first) }
|
||||
index = cart_items[:items].index { |item| ['subscription', :subscription].include?(item.keys.first) }
|
||||
if cart_items[:items][index][:subscription][:plan_id]
|
||||
new_plan_being_bought = true
|
||||
Plan.find(cart_items[:items][index][:subscription][:plan_id])
|
||||
|
6
app/views/api/plans/durations.json.jbuilder
Normal file
6
app/views/api/plans/durations.json.jbuilder
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.array!(@durations) do |duration|
|
||||
json.name duration[:name]
|
||||
json.plans_ids duration[:plans]
|
||||
end
|
@ -1 +1,4 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
json.extract! setting, :name, :value, :last_update
|
||||
json.localized I18n.t("settings.#{setting[:name]}")
|
||||
|
@ -32,12 +32,44 @@ json.invoices do
|
||||
json.object do
|
||||
json.type item.object_type
|
||||
json.id item.object_id
|
||||
json.main item.main
|
||||
end
|
||||
json.partial! 'archive/vat', price: item.amount, vat_rate: invoice[:vat_rate]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json.payment_schedules do
|
||||
json.array!(schedules) do |schedule|
|
||||
json.extract! schedule, :id, :payment_method, :created_at, :reference, :footprint
|
||||
json.payment_gateway_objects schedule.payment_gateway_objects do |object|
|
||||
json.id object.gateway_object_id
|
||||
json.type object.gateway_object_type
|
||||
end
|
||||
json.total number_to_currency(schedule.total / 100.0)
|
||||
json.user do
|
||||
json.extract! schedule.invoicing_profile, :user_id, :email, :first_name, :last_name
|
||||
json.address schedule.invoicing_profile&.address&.address
|
||||
json.invoicing_profile_id schedule.invoicing_profile.id
|
||||
if schedule.invoicing_profile.organization
|
||||
json.organization do
|
||||
json.extract! schedule.invoicing_profile.organization, :name, :id
|
||||
json.address schedule.invoicing_profile.organization&.address&.address
|
||||
end
|
||||
end
|
||||
end
|
||||
json.deadlines schedule.payment_schedule_items do |item|
|
||||
json.extract! item, :id, :due_date, :state, :details, :invoice_id, :footprint, :created_at
|
||||
json.amount number_to_currency(item.amount / 100.0)
|
||||
end
|
||||
json.objects schedule.payment_schedule_objects do |object|
|
||||
json.type object.object_type
|
||||
json.id object.object_id
|
||||
json.main object.main
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
json.totals do
|
||||
json.period_total number_to_currency(period_total / 100.0)
|
||||
json.perpetual_total number_to_currency(perpetual_total / 100.0)
|
||||
|
@ -9,11 +9,11 @@ class ArchiveWorker
|
||||
|
||||
def perform(accounting_period_id)
|
||||
period = AccountingPeriod.find(accounting_period_id)
|
||||
|
||||
data = period.invoices.includes(:invoice_items).order(id: :asc)
|
||||
invoices = period.invoices.includes(:invoice_items).order(created_at: :asc)
|
||||
schedules = period.payment_schedules.includes(:payment_schedule_items, :payment_schedule_objects).order(created_at: :asc)
|
||||
previous_file = period.previous_period&.archive_file
|
||||
last_archive_checksum = previous_file ? Integrity::Checksum.file(previous_file) : nil
|
||||
json_data = to_json_archive(period, data, previous_file, last_archive_checksum)
|
||||
json_data = to_json_archive(period, invoices, schedules, previous_file, last_archive_checksum)
|
||||
current_archive_checksum = Integrity::Checksum.text(json_data)
|
||||
date = DateTime.iso8601
|
||||
chained = Integrity::Checksum.text("#{current_archive_checksum}#{last_archive_checksum}#{date}")
|
||||
@ -34,12 +34,13 @@ class ArchiveWorker
|
||||
|
||||
private
|
||||
|
||||
def to_json_archive(period, invoices, previous_file, last_checksum)
|
||||
def to_json_archive(period, invoices, schedules, previous_file, last_checksum)
|
||||
code_checksum = Integrity::Checksum.code
|
||||
ApplicationController.new.view_context.render(
|
||||
partial: 'archive/accounting',
|
||||
locals: {
|
||||
invoices: period.invoices_with_vat(invoices),
|
||||
schedules: schedules,
|
||||
period_total: period.period_total,
|
||||
perpetual_total: period.perpetual_total,
|
||||
period_footprint: period.footprint,
|
||||
|
@ -530,7 +530,9 @@ en:
|
||||
logo_successfully_saved: "Logo successfully saved."
|
||||
an_error_occurred_while_saving_the_logo: "An error occurred while saving the logo."
|
||||
filename: "File name"
|
||||
schedule_filename: "Schedule file name"
|
||||
prefix_info: "The invoices will be generated as PDF files, named with the following prefix."
|
||||
schedule_prefix_info: "The payment schedules will be generated as PDF files, named with the following prefix."
|
||||
prefix: "Prefix"
|
||||
prefix_successfully_saved: "File prefix successfully saved"
|
||||
an_error_occurred_while_saving_the_prefix: "An error occurred while saving the file prefix"
|
||||
@ -650,7 +652,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 <a href='https://stripe.com/docs/currencies' target='_blank'>Stripe supported currencies</a>."
|
||||
currency_alert_html: "<strong>Warning</strong>: 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."
|
||||
gateway_configuration_error: "An error occurred while configuring the payment gateway: "
|
||||
payzen:
|
||||
payzen_keys: "PayZen keys"
|
||||
payzen_username: "Username"
|
||||
|
@ -259,6 +259,12 @@ en:
|
||||
the_user_s_group_was_successfully_changed: "The user's group was successfully changed."
|
||||
an_error_prevented_your_group_from_being_changed: "An error prevented your group from being changed."
|
||||
an_error_prevented_to_change_the_user_s_group: "An error prevented to change the user's group."
|
||||
plans_filter:
|
||||
i_am: "I am"
|
||||
select_group: "select a group"
|
||||
i_want_duration: "I want to subscribe for"
|
||||
all_durations: "All durations"
|
||||
select_duration: "select a duration"
|
||||
#Fablab's events list
|
||||
events_list:
|
||||
the_fablab_s_events: "The Fablab's events"
|
||||
|
@ -414,3 +414,114 @@ en:
|
||||
group:
|
||||
#name of the user's group for administrators
|
||||
admins: 'Administrators'
|
||||
settings:
|
||||
locked_setting: "the setting is locked."
|
||||
about_title: "\"About\" page title"
|
||||
about_body: "\"About\" page content"
|
||||
about_contacts: "\"About\" page contacts"
|
||||
privacy_draft: "Privacy policy draft"
|
||||
privacy_body: "Privacy policy"
|
||||
privacy_dpo: "Data protection officer address"
|
||||
twitter_name: "Twitter feed name"
|
||||
home_blogpost: "Homepage's brief"
|
||||
machine_explications_alert: "Explanation message on the machine reservation page"
|
||||
training_explications_alert: "Explanation message on the training reservation page"
|
||||
training_information_message: "Information message on the machine reservation page"
|
||||
subscription_explications_alert: "Explanation message on the subscription page"
|
||||
invoice_logo: "Invoices' logo"
|
||||
invoice_reference: "Invoice's reference"
|
||||
invoice_code-active: "Activation of the invoices' code"
|
||||
invoice_code-value: "Invoices' code"
|
||||
invoice_order-nb: "Invoice's order number"
|
||||
invoice_VAT-active: "Activation of the VAT"
|
||||
invoice_VAT-rate: "VAT rate"
|
||||
invoice_text: "Invoices' text"
|
||||
invoice_legals: "Invoices' legal information"
|
||||
booking_window_start: "Opening time"
|
||||
booking_window_end: "Closing time"
|
||||
booking_move_enable: "Activation of reservations moving"
|
||||
booking_move_delay: "Preventive delay before any reservation move"
|
||||
booking_cancel_enable: "Activation of reservations cancelling"
|
||||
booking_cancel_delay: "Preventive delay before any reservation cancellation"
|
||||
main_color: "Main colour"
|
||||
secondary_color: "Secondary colour"
|
||||
fablab_name: "Fablab's name"
|
||||
name_genre: "Title concordance"
|
||||
reminder_enable: "Activation of reservations reminding"
|
||||
reminder_delay: "Delay before sending the reminder"
|
||||
event_explications_alert: "Explanation message on the event reservation page"
|
||||
space_explications_alert: "Explanation message on the space reservation page"
|
||||
visibility_yearly: "Maximum visibility for annual subscribers"
|
||||
visibility_others: "Maximum visibility for other members"
|
||||
display_name_enable: "Display names in the calendar"
|
||||
machines_sort_by: "Machines display order"
|
||||
accounting_journal_code: "Journal code"
|
||||
accounting_card_client_code: "Card clients code"
|
||||
accounting_card_client_label: "Card clients label"
|
||||
accounting_wallet_client_code: "Wallet clients code"
|
||||
accounting_wallet_client_label: "Wallet clients label"
|
||||
accounting_other_client_code: "Other means client code"
|
||||
accounting_other_client_label: "Other means client label"
|
||||
accounting_wallet_code: "Wallet code"
|
||||
accounting_wallet_label: "Wallet label"
|
||||
accounting_VAT_code: "VAT code"
|
||||
accounting_VAT_label: "VAT label"
|
||||
accounting_subscription_code: "Subscriptions code"
|
||||
accounting_subscription_label: "Subscriptions label"
|
||||
accounting_Machine_code: "Machines code"
|
||||
accounting_Machine_label: "Machines label"
|
||||
accounting_Training_code: "Trainings code"
|
||||
accounting_Training_label: "Trainings label"
|
||||
accounting_Event_code: "Events code"
|
||||
accounting_Event_label: "Events label"
|
||||
accounting_Space_code: "Spaces code"
|
||||
accounting_Space_label: "Spaces label"
|
||||
hub_last_version: "Last Fab-manager's version"
|
||||
hub_public_key: "Instance public key"
|
||||
fab_analytics: "Fab Analytics"
|
||||
link_name: "Link title to the \"About\" page"
|
||||
home_content: "The home page"
|
||||
home_css: "Stylesheet of the home page"
|
||||
origin: "Instance URL"
|
||||
uuid: "Instance ID"
|
||||
phone_required: "Phone required?"
|
||||
tracking_id: "Tracking ID"
|
||||
book_overlapping_slots: "Book overlapping slots"
|
||||
slot_duration: "Default duration of booking slots"
|
||||
events_in_calendar: "Display events in the calendar"
|
||||
spaces_module: "Spaces module"
|
||||
plans_module: "Plans modules"
|
||||
invoicing_module: "Invoicing module"
|
||||
facebook_app_id: "Facebook App ID"
|
||||
twitter_analytics: "Twitter analytics account"
|
||||
recaptcha_site_key: "reCAPTCHA Site Key"
|
||||
recaptcha_secret_key: "reCAPTCHA Secret Key"
|
||||
feature_tour_display: "Feature tour display mode"
|
||||
email_from: "Expeditor's address"
|
||||
disqus_shortname: "Disqus shortname"
|
||||
allowed_cad_extensions: "Allowed CAD files extensions"
|
||||
allowed_cad_mime_types: "Allowed CAD files MIME types"
|
||||
openlab_app_id: "OpenLab ID"
|
||||
openlab_app_secret: "OpenLab secret"
|
||||
openlab_default: "Default projects gallery view"
|
||||
online_payment_module: "Online payments module"
|
||||
stripe_public_key: "Stripe public key"
|
||||
stripe_secret_key: "Stripe secret key"
|
||||
stripe_currency: "Stripe currency"
|
||||
invoice_prefix: "Invoices' files prefix"
|
||||
confirmation_required: "Confirmation required"
|
||||
wallet_module: "Wallet module"
|
||||
statistics_module: "Statistics module"
|
||||
upcoming_events_shown: "Display limit for upcoming events"
|
||||
payment_schedule_prefix: "Payment schedule's files prefix"
|
||||
trainings_module: "Trainings module"
|
||||
address_required: "Address required"
|
||||
accounting_Error_code: "Errors code"
|
||||
accounting_Error_label: "Errors label"
|
||||
payment_gateway: "Payment gateway"
|
||||
payzen_username: "PayZen username"
|
||||
payzen_password: "PayZen password"
|
||||
payzen_endpoint: "PayZen API endpoint"
|
||||
payzen_public_key: "PayZen client public key"
|
||||
payzen_hmac: "PayZen HMAC-SHA-256 key"
|
||||
payzen_currency: "PayZen currency"
|
||||
|
@ -525,3 +525,4 @@ fr:
|
||||
payzen_public_key: "Clé publique du client PayZen"
|
||||
payzen_hmac: "Clef HMAC-SHA-256 PayZen"
|
||||
payzen_currency: "Devise PayZen"
|
||||
|
||||
|
@ -97,7 +97,9 @@ Rails.application.routes.draw do
|
||||
resources :groups, only: %i[index create update destroy]
|
||||
resources :subscriptions, only: %i[show update]
|
||||
resources :plan_categories
|
||||
resources :plans
|
||||
resources :plans do
|
||||
get 'durations', on: :collection
|
||||
end
|
||||
resources :slots, only: [:update] do
|
||||
put 'cancel', on: :member
|
||||
end
|
||||
|
37
lib/pay_zen/pci/charge.rb
Normal file
37
lib/pay_zen/pci/charge.rb
Normal file
@ -0,0 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'pay_zen/client'
|
||||
|
||||
# PayZen PCI endpoints
|
||||
module PayZen::PCI; end
|
||||
|
||||
# PCI/Charge/* endpoints of the PayZen REST API
|
||||
class PayZen::PCI::Charge < 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/PCI/Charge/CreatePayment/
|
||||
##
|
||||
def create_payment(amount: 0,
|
||||
currency: Setting.get('payzen_currency'),
|
||||
order_id: nil,
|
||||
form_action: 'PAYMENT',
|
||||
contrib: "fab-manager #{Version.current}",
|
||||
customer: nil,
|
||||
device: nil,
|
||||
payment_forms: nil)
|
||||
post('/PCI/Charge/CreatePayment',
|
||||
amount: amount,
|
||||
currency: currency,
|
||||
orderId: order_id,
|
||||
formAction: form_action,
|
||||
contrib: contrib,
|
||||
customer: customer,
|
||||
device: device,
|
||||
paymentForms: payment_forms)
|
||||
end
|
||||
|
||||
end
|
||||
|
@ -71,7 +71,7 @@ namespace :fablab do
|
||||
end
|
||||
|
||||
def chain_history_values
|
||||
HistoryValue.order(:id).all.each(&:chain_record)
|
||||
HistoryValue.order(:created_at).all.each(&:chain_record)
|
||||
end
|
||||
|
||||
desc 'assign all footprints to existing PaymentSchedule records'
|
||||
|
24
test/fixtures/history_values.yml
vendored
24
test/fixtures/history_values.yml
vendored
@ -756,55 +756,55 @@ history_value_78:
|
||||
history_value_79:
|
||||
id: 79
|
||||
setting_id: 79
|
||||
value: ''
|
||||
value: 69876357
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.503801'
|
||||
footprint: 6bce8d94ae14b39a89f220d8c73f4daf44a795888ca011bf1a11da3a35eaf171
|
||||
footprint: 55d2a2354a3a12ed7bba0a3854f9f4559aed546c81040fbe35cd94d80b8bb811
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_80:
|
||||
id: 80
|
||||
setting_id: 80
|
||||
value: ''
|
||||
value: testpassword_DEMOPRIVATEKEY23G4475zXZQ2UA5x7M
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.518625'
|
||||
footprint: b1b314da439b81e95281909992e79f4d118e08633607010f777d6ef40ad28d8e
|
||||
footprint: a0f076a64824b3473c60dacd18892da21d14830dedd7bb959c442faa59d1313d
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_81:
|
||||
id: 81
|
||||
setting_id: 81
|
||||
value: ''
|
||||
value: https://api.payzen.eu
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.535175'
|
||||
footprint: 57dac418c605a111ab2a9c253704f9d9b182dfbb74068fc6596f3d20c611fb42
|
||||
footprint: 2d0d3090a7976a5b41fb4ccaa1e87e8ee61116813691280f3a2f19c574f0bea5
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_82:
|
||||
id: 82
|
||||
setting_id: 82
|
||||
value: ''
|
||||
value: 69876357:testpublickey_DEMOPUBLICKEY95me92597fd28tGD4r5
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.551956'
|
||||
footprint: 68a9d660f03f0e1408ee406ccf7df781147f3a7a2e5ac158a8d3ee4aaaca655a
|
||||
footprint: 9fbe6418520003f7dc9e37c5a9a3256adc4963d7a5d2402ded929d4154d17a81
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_83:
|
||||
id: 83
|
||||
setting_id: 83
|
||||
value: ''
|
||||
value: 83daf5e7b80d990f037407bab78dff9904aaf3c19
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.568426'
|
||||
footprint: 5a984dc2686c93d0380e04c017ce8ec05d18cb3c4c0fec8d4154605986078903
|
||||
footprint: b0d7f03eff084c39cb87dbf01e3d05f217203d776b0e01ce002a55b426abfe23
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_84:
|
||||
id: 84
|
||||
setting_id: 84
|
||||
value: ''
|
||||
value: EUR
|
||||
created_at: '2020-04-15 14:38:40.000421'
|
||||
updated_at: '2021-05-31 15:00:37.593645'
|
||||
footprint: bdf31d00b03e511c0a2a1bf18f628462655f32834b6ee6822d1a883055a191f4
|
||||
footprint: 3545edef1b4367afd0d36742e30e35832e56c32882f3876dba76fc69211950db
|
||||
invoicing_profile_id: 1
|
||||
|
||||
history_value_85:
|
||||
|
144
test/integration/payzen_test.rb
Normal file
144
test/integration/payzen_test.rb
Normal file
@ -0,0 +1,144 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'test_helper'
|
||||
|
||||
class PayzenTest < ActionDispatch::IntegrationTest
|
||||
def setup
|
||||
@user = User.members.first
|
||||
login_as(@user, scope: :user)
|
||||
|
||||
Setting.set('payment_gateway', 'payzen')
|
||||
end
|
||||
|
||||
test 'create payment with payzen' do
|
||||
training = Training.first
|
||||
availability = training.availabilities.first
|
||||
plan = Plan.find_by(group_id: @user.group.id, type: 'Plan')
|
||||
|
||||
VCR.use_cassette('create_payzen_payment_token_success') do
|
||||
post '/api/payzen/create_payment',
|
||||
params: {
|
||||
customer_id: @user.id,
|
||||
cart_items: {
|
||||
items: [
|
||||
{
|
||||
reservation: {
|
||||
reservable_id: training.id,
|
||||
reservable_type: training.class.name,
|
||||
slots_attributes: [
|
||||
{
|
||||
start_at: availability.start_at.to_s(:iso8601),
|
||||
end_at: availability.end_at.to_s(:iso8601),
|
||||
availability_id: availability.id
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
subscription: {
|
||||
plan_id: plan.id
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}.to_json, headers: default_headers
|
||||
end
|
||||
|
||||
# Check the response
|
||||
assert_equal 200, response.status
|
||||
payment = json_response(response.body)
|
||||
assert_not_nil payment[:formToken]
|
||||
assert_not_nil payment[:orderId]
|
||||
end
|
||||
|
||||
|
||||
test 'confirm payment with payzen' do
|
||||
require 'pay_zen/helper'
|
||||
require 'pay_zen/pci/charge'
|
||||
|
||||
training = Training.first
|
||||
availability = training.availabilities.first
|
||||
plan = Plan.find_by(group_id: @user.group.id, type: 'Plan')
|
||||
|
||||
reservations_count = Reservation.count
|
||||
availabilities_count = Availability.count
|
||||
invoices_count = Invoice.count
|
||||
slots_count = Slot.count
|
||||
|
||||
|
||||
cart_items = {
|
||||
items: [
|
||||
{
|
||||
reservation: {
|
||||
reservable_id: training.id,
|
||||
reservable_type: training.class.name,
|
||||
slots_attributes: [
|
||||
{
|
||||
start_at: availability.start_at.to_s(:iso8601),
|
||||
end_at: availability.end_at.to_s(:iso8601),
|
||||
availability_id: availability.id
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
subscription: {
|
||||
plan_id: plan.id
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
cs = CartService.new(@user)
|
||||
cart = cs.from_hash(cart_items)
|
||||
amount = cart.total[:total]
|
||||
id = PayZen::Helper.generate_ref(cart_items, @user.id)
|
||||
|
||||
VCR.use_cassette('confirm_payzen_payment_success') do
|
||||
client = PayZen::PCI::Charge.new
|
||||
result = client.create_payment(amount: amount,
|
||||
order_id: id,
|
||||
customer: PayZen::Helper.generate_customer(@user.id, @user.id, cart_items),
|
||||
device: {
|
||||
deviceType: 'BROWSER',
|
||||
acceptHeader: 'text/html',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101',
|
||||
ip: '69.89.31.226',
|
||||
javaEnabled: true,
|
||||
language: 'fr-FR',
|
||||
colorDepth: '32',
|
||||
screenHeight: 768,
|
||||
screenWidth: 1258,
|
||||
timeZoneOffset: -120
|
||||
},
|
||||
payment_forms: [{
|
||||
paymentMethodType: 'CARD',
|
||||
pan: '4970100000000055',
|
||||
expiryMonth: 12,
|
||||
expiryYear: DateTime.current.strftime('%y'),
|
||||
securityCode: 123
|
||||
}])
|
||||
|
||||
assert_equal 'PAID', result['answer']['orderStatus'], 'Order is not PAID, something went wrong with PayZen'
|
||||
assert_equal id, result['answer']['orderDetails']['orderId'], 'Order ID does not match, something went wrong with PayZen'
|
||||
|
||||
post '/api/payzen/confirm_payment',
|
||||
params: {
|
||||
cart_items: cart_items,
|
||||
order_id: result['answer']['orderDetails']['orderId']
|
||||
}.to_json, headers: default_headers
|
||||
end
|
||||
|
||||
# Check the response
|
||||
assert_equal 201, response.status
|
||||
invoice = json_response(response.body)
|
||||
assert_equal Invoice.last.id, invoice[:id]
|
||||
assert_equal amount / 100.0, invoice[:total]
|
||||
|
||||
assert_equal reservations_count + 1, Reservation.count
|
||||
assert_equal invoices_count + 1, Invoice.count
|
||||
assert_equal slots_count + 1, Slot.count
|
||||
assert_equal availabilities_count, Availability.count
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user