1
0
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:
Sylvain 2021-06-10 15:04:41 +02:00 committed by GitHub
commit 272dd74be6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 729 additions and 59 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View 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;
}
}

View File

@ -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)
});

View File

@ -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;

View File

@ -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}

View File

@ -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);

View File

@ -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>
)
}

View File

@ -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">

View File

@ -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 => {

View File

@ -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);
};
/**

View File

@ -41,3 +41,8 @@ export interface Plan {
plan_file_url: string,
partners: Array<Partner>
}
export interface PlansDuration {
name: string,
plans_ids: Array<number>
}

View File

@ -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,
}

View File

@ -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;
}],

View File

@ -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";

View File

@ -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;

View 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%;
}
}
}

View File

@ -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">

View File

@ -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>

View File

@ -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 }}"

View File

@ -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|

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
json.array!(@durations) do |duration|
json.name duration[:name]
json.plans_ids duration[:plans]
end

View File

@ -1 +1,4 @@
# frozen_string_literal: true
json.extract! setting, :name, :value, :last_update
json.localized I18n.t("settings.#{setting[:name]}")

View File

@ -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)

View File

@ -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,

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -525,3 +525,4 @@ fr:
payzen_public_key: "Clé publique du client PayZen"
payzen_hmac: "Clef HMAC-SHA-256 PayZen"
payzen_currency: "Devise PayZen"

View File

@ -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
View 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

View File

@ -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'

View File

@ -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:

View 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