1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-30 19:52:20 +01:00

Merge branch 'dev' into dependabot/npm_and_yarn/webpack-5.76.0

This commit is contained in:
Sylvain 2023-03-15 14:59:15 +01:00 committed by GitHub
commit b426a3988a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
98 changed files with 2481 additions and 998 deletions

View File

@ -1,5 +1,8 @@
# Changelog Fab-manager
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
## v5.8.2 2023 March 13
- Improved upgrade script

View File

@ -8,6 +8,8 @@ class API::LocalPaymentController < API::PaymentsController
authorize LocalPaymentContext.new(cart, price[:amount])
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
render on_payment_success(nil, nil, cart)
end

View File

@ -55,7 +55,7 @@ class API::PayzenController < API::PaymentsController
def check_cart
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
render json: { cart: 'ok' }, status: :ok
end

View File

@ -80,11 +80,13 @@ class API::PlansController < API::ApiController
end
@parameters = @parameters.require(:plan)
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling,
.permit(:base_name, :type, :group_id, :amount, :interval, :interval_count, :is_rolling, :limiting,
:training_credit_nb, :ui_weight, :disabled, :monthly_payment, :description, :plan_category_id,
:machines_visibility,
plan_file_attributes: %i[id attachment _destroy],
prices_attributes: %i[id amount],
advanced_accounting_attributes: %i[code analytical_section])
advanced_accounting_attributes: %i[code analytical_section],
plan_limitations_attributes: %i[id limitable_id limitable_type limit _destroy])
end
end
end

View File

@ -19,7 +19,7 @@ class API::StripeController < API::PaymentsController
res = nil # json of the API answer
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
begin
amount = debit_amount(cart) # will contains the amount and the details of each invoice lines
@ -73,7 +73,7 @@ class API::StripeController < API::PaymentsController
def setup_subscription
cart = shopping_cart
render json: { error: cart.errors }, status: :unprocessable_entity and return unless cart.valid?
render json: cart.errors, status: :unprocessable_entity and return unless cart.valid?
service = Stripe::Service.new
method = service.attach_method_as_default(

View File

@ -5,24 +5,34 @@ import { useTranslation } from 'react-i18next';
import { FabButton } from './fab-button';
import { FabModal } from './fab-modal';
interface EditDestroyButtonsProps {
onDeleteSuccess: (message: string) => void,
type EditDestroyButtonsCommon = {
onError: (message: string) => void,
onEdit: () => void,
itemId: number,
itemType: string,
apiDestroy: (itemId: number) => Promise<void>,
confirmationMessage?: string|ReactNode,
destroy: (itemId: number) => Promise<void>,
className?: string,
iconSize?: number
iconSize?: number,
showEditButton?: boolean,
}
type DeleteSuccess =
{ onDeleteSuccess: (message: string) => void, deleteSuccessMessage: string } |
{ onDeleteSuccess?: never, deleteSuccessMessage?: never }
type DestroyMessages =
({ showDestroyConfirmation?: true } &
({ itemType: string, confirmationTitle?: string, confirmationMessage?: string|ReactNode } |
{ itemType?: never, confirmationTitle: string, confirmationMessage: string|ReactNode })) |
{ showDestroyConfirmation: false, itemType?: never, confirmationTitle?: never, confirmationMessage?: never };
type EditDestroyButtonsProps = EditDestroyButtonsCommon & DeleteSuccess & DestroyMessages;
/**
* This component shows a group of two buttons.
* Destroy : shows a modal dialog to ask the user for confirmation about the deletion of the provided item.
* Edit : triggers the provided function.
*/
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, apiDestroy, confirmationMessage, className, iconSize = 20 }) => {
export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDeleteSuccess, onError, onEdit, itemId, itemType, destroy, confirmationTitle, confirmationMessage, deleteSuccessMessage, className, iconSize = 20, showEditButton = true, showDestroyConfirmation = true }) => {
const { t } = useTranslation('admin');
const [deletionModal, setDeletionModal] = useState<boolean>(false);
@ -34,30 +44,41 @@ export const EditDestroyButtons: React.FC<EditDestroyButtonsProps> = ({ onDelete
setDeletionModal(!deletionModal);
};
/**
* Triggered when the user clicks on the 'destroy' button
*/
const handleDestroyRequest = (): void => {
if (showDestroyConfirmation) {
toggleDeletionModal();
} else {
onDeleteConfirmed();
}
};
/**
* The deletion has been confirmed by the user.
* Call the API to trigger the deletion of the given item
*/
const onDeleteConfirmed = (): void => {
apiDestroy(itemId).then(() => {
onDeleteSuccess(t('app.admin.edit_destroy_buttons.deleted', { TYPE: itemType }));
destroy(itemId).then(() => {
typeof onDeleteSuccess === 'function' && onDeleteSuccess(deleteSuccessMessage || t('app.admin.edit_destroy_buttons.deleted'));
}).catch((error) => {
onError(t('app.admin.edit_destroy_buttons.unable_to_delete', { TYPE: itemType }) + error);
onError(t('app.admin.edit_destroy_buttons.unable_to_delete') + error);
});
toggleDeletionModal();
setDeletionModal(false);
};
return (
<>
<div className={`edit-destroy-buttons ${className || ''}`}>
<FabButton className='edit-btn' onClick={onEdit}>
{showEditButton && <FabButton className='edit-btn' onClick={onEdit}>
<PencilSimple size={iconSize} weight="fill" />
</FabButton>
<FabButton type='button' className='delete-btn' onClick={toggleDeletionModal}>
</FabButton>}
<FabButton type='button' className='delete-btn' onClick={handleDestroyRequest}>
<Trash size={iconSize} weight="fill" />
</FabButton>
</div>
<FabModal title={t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
<FabModal title={confirmationTitle || t('app.admin.edit_destroy_buttons.delete_item', { TYPE: itemType })}
isOpen={deletionModal}
toggleModal={toggleDeletionModal}
closeButton={true}

View File

@ -58,10 +58,10 @@ export const FormSelect = <TFieldValues extends FieldValues, TContext extends ob
<AbstractSelect ref={ref}
classNamePrefix="rs"
className="rs"
value={options.find(c => c.value === value)}
value={value === null ? null : options.find(c => c.value === value)}
onChange={val => {
onChangeCb(val.value);
onChange(val.value);
onChangeCb(val?.value);
onChange(val?.value);
}}
placeholder={placeholder}
isDisabled={isDisabled}

View File

@ -0,0 +1,74 @@
import { FieldArrayWithId } from 'react-hook-form/dist/types/fieldArray';
import { UseFormRegister } from 'react-hook-form';
import { FieldValues } from 'react-hook-form/dist/types/fields';
import { useTranslation } from 'react-i18next';
import React, { ReactNode } from 'react';
import { X } from 'phosphor-react';
import { FormInput } from './form-input';
import { FieldArrayPath } from 'react-hook-form/dist/types/path';
interface FormUnsavedListProps<TFieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues>, TKeyName extends string> {
fields: Array<FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>>,
onRemove: (index: number) => void,
register: UseFormRegister<TFieldValues>,
className?: string,
title: string,
shouldRenderField?: (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>) => boolean,
renderField: (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>) => ReactNode,
formAttributeName: `${string}_attributes`,
formAttributes: Array<keyof FieldArrayWithId<TFieldValues, TFieldArrayName>>,
saveReminderLabel?: string | ReactNode,
cancelLabel?: string | ReactNode
}
/**
* This component render a list of unsaved attributes, created elsewhere than in the form (e.g. in a modal dialog)
* and pending for the form to be saved.
*
* The `renderField` attribute should return a JSX element composed like the following example:
* ```
* <> <!-- empty tag -->
* <div className="group"> <!-- the group class is important -->
* <span>Attribute 1</span> <!-- a span tag for the title -->
* <p>{item.attr1}</p> <!-- a paragraph tag for the value -->
* </div>
* <div className="group">
* ...
* </div>
* </>
* ```
*/
export const FormUnsavedList = <TFieldValues extends FieldValues = FieldValues, TFieldArrayName extends FieldArrayPath<TFieldValues> = FieldArrayPath<TFieldValues>, TKeyName extends string = 'id'>({ fields, onRemove, register, className, title, shouldRenderField = () => true, renderField, formAttributeName, formAttributes, saveReminderLabel, cancelLabel }: FormUnsavedListProps<TFieldValues, TFieldArrayName, TKeyName>) => {
const { t } = useTranslation('shared');
/**
* Render an unsaved field
*/
const renderUnsavedField = (field: FieldArrayWithId<TFieldValues, TFieldArrayName, TKeyName>, index: number): ReactNode => {
return (
<div key={index} className="unsaved-field">
{renderField(field)}
<p className="cancel-action" onClick={() => onRemove(index)}>
{cancelLabel || t('app.shared.form_unsaved_list.cancel')}
<X size={20} />
</p>
{formAttributes.map((attribute, attrIndex) => (
<FormInput key={attrIndex} id={`${formAttributeName}.${index}.${attribute}`} register={register} type="hidden" />
))}
</div>
);
};
if (fields.filter(shouldRenderField).length === 0) return null;
return (
<div className={`form-unsaved-list ${className || ''}`}>
<span className="title">{title}</span>
<span className="save-notice">{saveReminderLabel || t('app.shared.form_unsaved_list.save_reminder')}</span>
{fields.map((field, index) => {
if (!shouldRenderField(field)) return false;
return renderUnsavedField(field, index);
}).filter(Boolean)}
</div>
);
};

View File

@ -124,7 +124,7 @@ export const MachineCategoriesList: React.FC<MachineCategoriesListProps> = ({ on
onEdit={editMachineCategory(category)}
itemId={category.id}
itemType={t('app.admin.machine_categories_list.machine_category')}
apiDestroy={MachineCategoryAPI.destroy} />
destroy={MachineCategoryAPI.destroy} />
</div>
</td>
</tr>

View File

@ -24,6 +24,10 @@ import { UserPlus } from 'phosphor-react';
import { PartnerModal } from './partner-modal';
import { PlanPricingForm } from './plan-pricing-form';
import { AdvancedAccountingForm } from '../accounting/advanced-accounting-form';
import { FabTabs } from '../base/fab-tabs';
import { PlanLimitForm } from './plan-limit-form';
import { UnsavedFormAlert } from '../form/unsaved-form-alert';
import { UIRouter } from '@uirouter/angularjs';
declare const Application: IApplication;
@ -33,13 +37,14 @@ interface PlanFormProps {
onError: (message: string) => void,
onSuccess: (message: string) => void,
beforeSubmit?: (data: Plan) => void,
uiRouter: UIRouter
}
/**
* Form to edit or create subscription plans
*/
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess, beforeSubmit }) => {
const { handleSubmit, register, control, formState, setValue } = useForm<Plan>({ defaultValues: { ...plan } });
export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuccess, beforeSubmit, uiRouter }) => {
const { handleSubmit, register, control, formState, setValue, getValues, resetField } = useForm<Plan>({ defaultValues: { ...plan } });
const output = useWatch<Plan>({ control }); // eslint-disable-line
const { t } = useTranslation('admin');
@ -51,13 +56,19 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
useEffect(() => {
GroupAPI.index({ disabled: false })
.then(res => setGroups(res.map(g => { return { value: g.id, label: g.name }; })))
.then(res => setGroups(res.map(g => {
return { value: g.id, label: g.name };
})))
.catch(onError);
PlanCategoryAPI.index()
.then(res => setCategories(res.map(c => { return { value: c.id, label: c.name }; })))
.then(res => setCategories(res.map(c => {
return { value: c.id, label: c.name };
})))
.catch(onError);
UserAPI.index({ role: 'partner' })
.then(res => setPartners(res.map(p => { return { value: p.id, label: p.name }; })))
.then(res => setPartners(res.map(p => {
return { value: p.id, label: p.name };
})))
.catch(onError);
}, []);
@ -101,7 +112,9 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
* Return the available options for the plan period
*/
const buildPeriodsOptions = (): Array<SelectOption<string>> => {
return ['week', 'month', 'year'].map(d => { return { value: d, label: t(`app.admin.plan_form.${d}`) }; });
return ['week', 'month', 'year'].map(d => {
return { value: d, label: t(`app.admin.plan_form.${d}`) };
});
};
/**
@ -130,140 +143,225 @@ export const PlanForm: React.FC<PlanFormProps> = ({ action, plan, onError, onSuc
setValue('partner_id', user.id);
};
return (
<div className="plan-form">
<form onSubmit={handleSubmit(onSubmit)}>
<h4>{t('app.admin.plan_form.general_information')}</h4>
<FormInput register={register}
id="base_name"
formState={formState}
rules={{
required: true,
maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') }
}}
label={t('app.admin.plan_form.name')} />
{action === 'create' && <FormSwitch control={control}
formState={formState}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />}
{!allGroups && groups && <FormSelect options={groups}
formState={formState}
control={control}
rules={{ required: !allGroups }}
disabled={action === 'update'}
label={t('app.admin.plan_form.group')}
id="group_id" />}
{categories?.length > 0 && <FormSelect options={categories}
formState={formState}
control={control}
id="plan_category_id"
tooltip={t('app.admin.plan_form.category_help')}
label={t('app.admin.plan_form.category')} />}
{action === 'update' && <FabAlert level="warning">
{t('app.admin.plan_form.edit_amount_info')}
</FabAlert>}
<FormInput register={register}
formState={formState}
id="amount"
type="number"
step={0.01}
addOn={FormatLib.currencySymbol()}
rules={{ required: true, min: 0 }}
label={t('app.admin.plan_form.subscription_price')} />
<FormInput register={register}
formState={formState}
id="ui_weight"
type="number"
label={t('app.admin.plan_form.visual_prominence')}
tooltip={t('app.admin.plan_form.visual_prominence_help')} />
<FormSwitch control={control}
formState={formState}
id="is_rolling"
label={t('app.admin.plan_form.rolling_subscription')}
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.rolling_subscription_help')} />
<FormSwitch control={control}
formState={formState}
id="monthly_payment"
label={t('app.admin.plan_form.monthly_payment')}
disabled={action === 'update' || output.interval === 'week'}
tooltip={t('app.admin.plan_form.monthly_payment_help')} />
<FormRichText control={control}
formState={formState}
id="description"
label={t('app.admin.plan_form.description')}
limit={200}
heading link blockquote />
<FormFileUpload setValue={setValue}
register={register}
formState={formState}
defaultFile={output.plan_file_attributes}
id="plan_file_attributes"
className="plan-sheet"
label={t('app.admin.plan_form.information_sheet')} />
<FormSwitch control={control}
formState={formState}
id="disabled"
defaultValue={false}
label={t('app.admin.plan_form.disabled')}
tooltip={t('app.admin.plan_form.disabled_help')} />
<h4>{t('app.admin.plan_form.duration')}</h4>
<div className="duration">
/**
* Render the content of the 'subscriptions settings' tab
*/
const renderSettingsTab = () => (
<div className="plan-form-content">
<section>
<header>
<p className="title">{t('app.admin.plan_form.description')}</p>
</header>
<div className="content">
<FormInput register={register}
rules={{ required: true, min: 1 }}
disabled={action === 'update'}
id="base_name"
formState={formState}
label={t('app.admin.plan_form.number_of_periods')}
type="number"
id="interval_count" />
<FormSelect options={buildPeriodsOptions()}
control={control}
disabled={action === 'update'}
onChange={handlePeriodUpdate}
id="interval"
label={t('app.admin.plan_form.period')}
formState={formState}
rules={{ required: true }} />
rules={{
required: true,
maxLength: { value: 24, message: t('app.admin.plan_form.name_max_length') }
}}
label={t('app.admin.plan_form.name')} />
<FormRichText control={control}
formState={formState}
id="description"
label={t('app.admin.plan_form.description')}
limit={200}
heading link blockquote />
<FormFileUpload setValue={setValue}
register={register}
formState={formState}
defaultFile={output.plan_file_attributes}
id="plan_file_attributes"
className="plan-sheet"
label={t('app.admin.plan_form.information_sheet')} />
</div>
<h4>{t('app.admin.plan_form.partnership')}</h4>
<div className="partnership">
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.general_settings')}</p>
<p className="description">{t('app.admin.plan_form.general_settings_info')}</p>
</header>
<div className="content">
{action === 'create' && <FormSwitch control={control}
formState={formState}
onChange={handleAllGroupsChange}
defaultValue={false}
label={t('app.admin.plan_form.transversal')}
tooltip={t('app.admin.plan_form.transversal_help')}
id="all_groups" />}
{!allGroups && groups && <FormSelect options={groups}
formState={formState}
control={control}
rules={{ required: !allGroups }}
disabled={action === 'update'}
label={t('app.admin.plan_form.group')}
id="group_id" />}
<div className="grp">
<FormInput register={register}
rules={{ required: true, min: 1 }}
disabled={action === 'update'}
formState={formState}
label={t('app.admin.plan_form.number_of_periods')}
type="number"
id="interval_count" />
<FormSelect options={buildPeriodsOptions()}
control={control}
disabled={action === 'update'}
onChange={handlePeriodUpdate}
id="interval"
label={t('app.admin.plan_form.period')}
formState={formState}
rules={{ required: true }} />
</div>
{action === 'update' && <FabAlert level="info">
{t('app.admin.plan_form.edit_amount_info')}
</FabAlert>}
<FormInput register={register}
formState={formState}
id="amount"
type="number"
step={0.01}
addOn={FormatLib.currencySymbol()}
rules={{ required: true, min: 0 }}
label={t('app.admin.plan_form.subscription_price')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.activation_and_payment')}</p>
</header>
<div className="content">
<FormSwitch control={control}
formState={formState}
id="disabled"
defaultValue={false}
label={t('app.admin.plan_form.disabled')}
tooltip={t('app.admin.plan_form.disabled_help')} />
<FormSwitch control={control}
formState={formState}
id="is_rolling"
label={t('app.admin.plan_form.rolling_subscription')}
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.rolling_subscription_help')} />
<FormSwitch control={control}
formState={formState}
id="monthly_payment"
label={t('app.admin.plan_form.monthly_payment')}
disabled={action === 'update' || output.interval === 'week'}
tooltip={t('app.admin.plan_form.monthly_payment_help')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.partnership')}</p>
<p className="description">{t('app.admin.plan_form.partner_plan_help')}</p>
</header>
<div className="content">
<FormSwitch control={control}
id="partnership"
disabled={action === 'update'}
tooltip={t('app.admin.plan_form.partner_plan_help')}
defaultValue={plan?.type === 'PartnerPlan'}
onChange={handlePartnershipChange}
formState={formState}
label={t('app.admin.plan_form.partner_plan')} />
<FormInput register={register} type="hidden" id="type" defaultValue="Plan" />
{output.type === 'PartnerPlan' && <div className="partner">
<FabButton className="add-partner is-info" icon={<UserPlus size={20} />} onClick={tooglePartnerModal}>
{t('app.admin.plan_form.new_user')}
</FabButton>
{partners && <FormSelect id="partner_id"
options={partners}
control={control}
formState={formState}
rules={{ required: output.type === 'PartnerPlan' }}
tooltip={t('app.admin.plan_form.alert_partner_notification')}
label={t('app.admin.plan_form.notified_partner')} />}
{output.partner_id && <FabAlert level="info">
{t('app.admin.plan_form.alert_partner_notification')}
</FabAlert>}
<FabButton className="is-secondary" icon={<UserPlus size={20} />} onClick={tooglePartnerModal}>
{t('app.admin.plan_form.new_user')}
</FabButton>
</div>}
</div>
<AdvancedAccountingForm register={register} onError={onError} />
{action === 'update' && <PlanPricingForm formState={formState}
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.slots_visibility')}</p>
<p className="description">{t('app.admin.plan_form.slots_visibility_help')}</p>
</header>
<div className="content">
<FormInput register={register}
formState={formState}
nullable
id="machines_visibility"
type="number"
label={t('app.admin.plan_form.machines_visibility')} />
</div>
</section>
<section>
<header>
<p className="title">{t('app.admin.plan_form.display')} </p>
</header>
<div className="content">
{categories?.length > 0 && <FormSelect options={categories}
formState={formState}
control={control}
onError={onError}
setValue={setValue}
register={register} />}
<FabButton type="submit" className="is-info submit-btn">
{t('app.admin.plan_form.ACTION_plan', { ACTION: action })}
</FabButton>
id="plan_category_id"
tooltip={t('app.admin.plan_form.category_help')}
label={t('app.admin.plan_form.category')} />}
<FormInput register={register}
formState={formState}
id="ui_weight"
type="number"
label={t('app.admin.plan_form.visual_prominence')}
tooltip={t('app.admin.plan_form.visual_prominence_help')} />
</div>
</section>
<section>
<AdvancedAccountingForm register={register} onError={onError} />
</section>
{action === 'update' && <PlanPricingForm formState={formState}
control={control}
onError={onError}
setValue={setValue}
register={register} />}
</div>
);
return (
<div className="plan-form">
<header>
<h2>{t('app.admin.plan_form.ACTION_title', { ACTION: action })}</h2>
<div className="grpBtn">
<FabButton type="submit" onClick={handleSubmit(onSubmit)} className="fab-button is-main">
{t('app.admin.plan_form.save')}
</FabButton>
</div>
</header>
<form onSubmit={handleSubmit(onSubmit)}>
<UnsavedFormAlert uiRouter={uiRouter} formState={formState} />
<FabTabs tabs={[
{
id: 'settings',
title: t('app.admin.plan_form.tab_settings'),
content: renderSettingsTab()
},
{
id: 'usageLimits',
title: t('app.admin.plan_form.tab_usage_limits'),
content: <PlanLimitForm control={control}
register={register}
formState={formState}
onError={onError}
getValues={getValues}
resetField={resetField} />
}
]} />
</form>
<PartnerModal isOpen={isOpenPartnerModal}
toggleModal={tooglePartnerModal}
onError={onError}
@ -281,4 +379,4 @@ const PlanFormWrapper: React.FC<PlanFormProps> = (props) => {
</Loader>
);
};
Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess']));
Application.Components.component('planForm', react2angular(PlanFormWrapper, ['action', 'plan', 'onError', 'onSuccess', 'uiRouter']));

View File

@ -0,0 +1,261 @@
import React, { ReactNode, useEffect, useState } from 'react';
import { Control, FormState, UseFormGetValues, UseFormResetField } from 'react-hook-form/dist/types/form';
import { FormSwitch } from '../form/form-switch';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { PlanLimitModal } from './plan-limit-modal';
import { Plan, PlanLimitation } from '../../models/plan';
import { useFieldArray, UseFormRegister, useWatch } from 'react-hook-form';
import { Machine } from '../../models/machine';
import { MachineCategory } from '../../models/machine-category';
import MachineAPI from '../../api/machine';
import MachineCategoryAPI from '../../api/machine-category';
import { FormUnsavedList } from '../form/form-unsaved-list';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
import { X } from 'phosphor-react';
interface PlanLimitFormProps<TContext extends object> {
register: UseFormRegister<Plan>,
control: Control<Plan, TContext>,
formState: FormState<Plan>,
onError: (message: string) => void,
getValues: UseFormGetValues<Plan>,
resetField: UseFormResetField<Plan>
}
/**
* Form tab to manage a subscription's usage limit
*/
export const PlanLimitForm = <TContext extends object> ({ register, control, formState, onError, getValues, resetField }: PlanLimitFormProps<TContext>) => {
const { t } = useTranslation('admin');
const { fields, append, remove, update } = useFieldArray<Plan, 'plan_limitations_attributes'>({ control, name: 'plan_limitations_attributes' });
const limiting = useWatch<Plan>({ control, name: 'limiting' });
const [isOpen, setIsOpen] = useState<boolean>(false);
const [machines, setMachines] = useState<Array<Machine>>([]);
const [categories, setCategories] = useState<Array<MachineCategory>>([]);
const [edited, setEdited] = useState<{index: number, limitation: PlanLimitation}>(null);
useEffect(() => {
MachineAPI.index({ disabled: false })
.then(setMachines)
.catch(onError);
MachineCategoryAPI.index()
.then(setCategories)
.catch(onError);
}, []);
/**
* Opens/closes the product stock edition modal
*/
const toggleModal = (): void => {
setIsOpen(!isOpen);
};
/**
* Triggered when the user clicks on 'add a limitation'
*/
const onAddLimitation = (): void => {
setEdited(null);
toggleModal();
};
/**
* Triggered when a new limit was added or an existing limit was modified
*/
const onLimitationSuccess = (limitation: PlanLimitation): void => {
const id = getValues(`plan_limitations_attributes.${edited?.index}.id`);
if (id) {
update(edited.index, { ...limitation, id });
setEdited(null);
} else {
append({ ...limitation, id });
}
};
/**
* Triggered when an unsaved limit was removed from the "pending" list.
*/
const onRemoveUnsaved = (index: number): void => {
const id = getValues(`plan_limitations_attributes.${index}.id`);
if (id) {
// will reset the field to its default values
resetField(`plan_limitations_attributes.${index}`);
// unmount and remount the field
update(index, getValues(`plan_limitations_attributes.${index}`));
} else {
remove(index);
}
};
/**
* Callback triggered when a saved limitation is requested to be deleted
*/
const handleLimitationDelete = (index: number): () => Promise<void> => {
return () => {
return new Promise<void>((resolve) => {
update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: true });
resolve();
});
};
};
/**
* Triggered when the user clicks on "cancel" for a limitated previsouly marked as deleted
*/
const cancelDeletion = (index: number): (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void => {
return (event) => {
event.preventDefault();
update(index, { ...getValues(`plan_limitations_attributes.${index}`), _destroy: false });
};
};
/**
* Callback triggered when the user wants to modify a limitation. Return a callback
*/
const onEditLimitation = (limitation: PlanLimitation, index: number): () => void => {
return () => {
setEdited({ index, limitation });
toggleModal();
};
};
/**
* Render an unsaved limitation of use
*/
const renderOngoingLimit = (limit: PlanLimitation): ReactNode => (
<>
{(limit.limitable_type === 'MachineCategory' && <div className="group">
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories?.find(c => c.id === limit.limitable_id)?.name}</p>
</div>) ||
<div className="group">
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines?.find(m => m.id === limit.limitable_id)?.name}</p>
</div>}
<div className="group">
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limit.limit}</p>
</div>
</>
);
return (
<div className="plan-limit-form">
<section>
<header>
<p className="title">{t('app.admin.plan_limit_form.usage_limitation')}</p>
<p className="description">{t('app.admin.plan_limit_form.usage_limitation_info')}</p>
</header>
<div className="content">
<FormSwitch control={control}
formState={formState}
defaultValue={false}
label={t('app.admin.plan_limit_form.usage_limitation_switch')}
id="limiting" />
</div>
</section>
{limiting && <div className="plan-limit-grp">
<header>
<p>{t('app.admin.plan_limit_form.all_limitations')}</p>
<div className="grpBtn">
<FabButton onClick={onAddLimitation} className="is-main">
{t('app.admin.plan_limit_form.new_usage_limitation')}
</FabButton>
</div>
</header>
<FormUnsavedList fields={fields}
onRemove={onRemoveUnsaved}
register={register}
title={t('app.admin.plan_limit_form.ongoing_limitations')}
shouldRenderField={(limit: PlanLimitation) => limit._modified}
formAttributeName="plan_limitations_attributes"
formAttributes={['id', 'limitable_type', 'limitable_id', 'limit']}
renderField={renderOngoingLimit}
cancelLabel={t('app.admin.plan_limit_form.cancel')} />
{fields.filter(f => f._modified).length > 0 &&
<p className="title">{t('app.admin.plan_limit_form.saved_limitations')}</p>
}
{fields.filter(f => f.limitable_type === 'MachineCategory' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_category')}</p>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'MachineCategory' || limitation._modified) return false;
return (
<div className={`plan-limit-item ${limitation._destroy ? 'is-destroying' : ''}`} key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.category')}</span>
<p>{categories.find(c => c.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
</div>
<div className='actions'>
<EditDestroyButtons onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={getValues(`plan_limitations_attributes.${index}.id`)}
showDestroyConfirmation={false}
destroy={handleLimitationDelete(index)} />
</div>
</div>
);
}).filter(Boolean)}
</div>
}
{fields.filter(f => f.limitable_type === 'Machine' && !f._modified).length > 0 &&
<div className='plan-limit-list'>
<p className="title">{t('app.admin.plan_limit_form.by_machine')}</p>
{fields.map((limitation, index) => {
if (limitation.limitable_type !== 'Machine' || limitation._modified) return false;
return (
<div className={`plan-limit-item ${limitation._destroy ? 'is-destroying' : ''}`} key={limitation.id}>
<div className="grp">
<div>
<span>{t('app.admin.plan_limit_form.machine')}</span>
<p>{machines.find(m => m.id === limitation.limitable_id)?.name}</p>
</div>
<div>
<span>{t('app.admin.plan_limit_form.max_hours_per_day')}</span>
<p>{limitation.limit}</p>
</div>
{limitation._destroy && <div className="marker">{t('app.admin.plan_limit_form.ongoing_deletion')}</div>}
</div>
<div className='actions'>
{(limitation._destroy &&
<p className="cancel-action" onClick={cancelDeletion(index)}>
{t('app.admin.plan_limit_form.cancel_deletion')}
<X size={14} />
</p>) ||
<EditDestroyButtons onError={onError}
onEdit={onEditLimitation(limitation, index)}
itemId={getValues(`plan_limitations_attributes.${index}.id`)}
showDestroyConfirmation={false}
destroy={handleLimitationDelete(index)} />}
</div>
</div>
);
}).filter(Boolean)}
</div>
}
</div>}
<PlanLimitModal isOpen={isOpen}
machines={machines}
categories={categories}
toggleModal={toggleModal}
onSuccess={onLimitationSuccess}
limitation={edited?.limitation}
existingLimitations={fields} />
</div>
);
};

View File

@ -0,0 +1,124 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabAlert } from '../base/fab-alert';
import { FabModal, ModalSize } from '../base/fab-modal';
import { useForm, useWatch } from 'react-hook-form';
import { FormSelect } from '../form/form-select';
import { FormInput } from '../form/form-input';
import { LimitableType, PlanLimitation } from '../../models/plan';
import { Machine } from '../../models/machine';
import { MachineCategory } from '../../models/machine-category';
import { SelectOption } from '../../models/select';
import { FabButton } from '../base/fab-button';
import { useEffect } from 'react';
interface PlanLimitModalProps {
isOpen: boolean,
toggleModal: () => void,
onSuccess: (limit: PlanLimitation) => void,
machines: Array<Machine>
categories: Array<MachineCategory>,
limitation?: PlanLimitation,
existingLimitations: Array<PlanLimitation>;
}
/**
* Form to manage subscriptions limitations of use
*/
export const PlanLimitModal: React.FC<PlanLimitModalProps> = ({ isOpen, toggleModal, machines, categories, onSuccess, limitation, existingLimitations = [] }) => {
const { t } = useTranslation('admin');
const { register, control, formState, setValue, handleSubmit, reset } = useForm<PlanLimitation>({ defaultValues: limitation || { limitable_type: 'MachineCategory' } });
const limitType = useWatch({ control, name: 'limitable_type' });
useEffect(() => {
reset(limitation);
}, [limitation]);
/**
* Toggle the form between 'categories' and 'machine'
*/
const toggleLimitType = (evt: React.MouseEvent<HTMLButtonElement, MouseEvent>, type: LimitableType) => {
evt.preventDefault();
setValue('limitable_type', type);
setValue('limitable_id', null);
};
/**
* Callback triggered when the user validates the new limit.
* We do not use handleSubmit() directly to prevent the propagaion of the "submit" event to the parent form
*/
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
if (event) {
event.stopPropagation();
event.preventDefault();
}
return handleSubmit((data: PlanLimitation) => {
onSuccess({ ...data, _modified: true });
reset({ limitable_type: 'MachineCategory', limitable_id: null, limit: null });
toggleModal();
})(event);
};
/**
* Creates options to the react-select format
*/
const buildOptions = (): Array<SelectOption<number>> => {
if (limitType === 'MachineCategory') {
return categories
.filter(c => limitation || !existingLimitations.filter(l => l.limitable_type === 'MachineCategory').map(l => l.limitable_id).includes(c.id))
.map(cat => {
return { value: cat.id, label: cat.name };
});
} else {
return machines
.filter(m => limitation || !existingLimitations.filter(l => l.limitable_type === 'Machine').map(l => l.limitable_id).includes(m.id))
.map(machine => {
return { value: machine.id, label: machine.name };
});
}
};
return (
<FabModal title={t('app.admin.plan_limit_modal.title')}
width={ModalSize.large}
isOpen={isOpen}
toggleModal={toggleModal}
onClose={() => reset({ limitable_type: 'MachineCategory' })}
closeButton>
<form className='plan-limit-modal' onSubmit={onSubmit}>
<p className='subtitle'>{t('app.admin.plan_limit_modal.limit_reservations')}</p>
<div className="grp">
<button onClick={evt => toggleLimitType(evt, 'MachineCategory')}
className={limitType === 'MachineCategory' ? 'is-active' : ''}
disabled={!!limitation}>
{t('app.admin.plan_limit_modal.by_category')}
</button>
<button onClick={evt => toggleLimitType(evt, 'Machine')}
className={limitType === 'Machine' ? 'is-active' : ''}
disabled={!!limitation}>
{t('app.admin.plan_limit_modal.by_machine')}
</button>
</div>
<FabAlert level='info'>{limitType === 'Machine' ? t('app.admin.plan_limit_modal.machine_info') : t('app.admin.plan_limit_modal.categories_info')}</FabAlert>
<FormInput register={register} id="id" type="hidden" />
<FormInput register={register} id="limitable_type" type="hidden" />
<FormSelect options={buildOptions()}
disabled={!!limitation}
control={control}
id="limitable_id"
rules={{ required: true }}
formState={formState}
label={t('app.admin.plan_limit_modal.machine')} />
<FormInput id="limit"
type="number"
register={register}
rules={{ required: true, min: 1 }}
nullable
step={1}
formState={formState}
label={t('app.admin.plan_limit_modal.max_hours_per_day')} />
<FabButton type="submit">{t('app.admin.plan_limit_modal.confirm')}</FabButton>
</form>
</FabModal>
);
};

View File

@ -92,32 +92,37 @@ export const PlanPricingForm = <TContext extends object>({ register, control, fo
};
return (
<div data-testid="plan-pricing-form">
<h4>{t('app.admin.plan_pricing_form.prices')}</h4>
{plans && <FormSelect options={plans}
label={t('app.admin.plan_pricing_form.copy_prices_from')}
tooltip={t('app.admin.plan_pricing_form.copy_prices_from_help')}
control={control}
onChange={handleCopyPrices}
id="parent_plan_id" />}
{<FabTabs tabs={[
machines && {
id: 'machines',
title: t('app.admin.plan_pricing_form.machines'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Machine') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
},
spaces && {
id: 'spaces',
title: t('app.admin.plan_pricing_form.spaces'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Space') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
}
]} />}
</div>
<section className="plan-pricing-form" data-testid="plan-pricing-form">
<header>
<p className="title">{t('app.admin.plan_pricing_form.prices')}</p>
<p className="description">{t('app.admin.plan_pricing_form.about_prices')}</p>
</header>
<div className="content">
{plans && <FormSelect options={plans}
control={control}
label={t('app.admin.plan_pricing_form.copy_prices_from')}
tooltip={t('app.admin.plan_pricing_form.copy_prices_from_help')}
onChange={handleCopyPrices}
id="parent_plan_id" />}
{<FabTabs tabs={[
machines && {
id: 'machines',
title: t('app.admin.plan_pricing_form.machines'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Machine') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
},
spaces && {
id: 'spaces',
title: t('app.admin.plan_pricing_form.spaces'),
content: fields.map((price, index) => {
if (price.priceable_type !== 'Space') return false;
return renderPriceElement(price, index);
}).filter(Boolean)
}
]} />}
</div>
</section>
);
};

View File

@ -114,7 +114,7 @@ export const ConfigurePacksButton: React.FC<ConfigurePacksButtonProps> = ({ pack
onEdit={() => handleRequestEdit(p)}
itemId={p.id}
itemType={t('app.admin.configure_packs_button.pack')}
apiDestroy={PrepaidPackAPI.destroy}/>
destroy={PrepaidPackAPI.destroy}/>
<FabModal isOpen={isOpen}
toggleModal={toggleModal}
title={t('app.admin.configure_packs_button.edit_pack')}

View File

@ -1,22 +1,23 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { FabButton } from '../base/fab-button';
import { Product } from '../../models/product';
import { PencilSimple, Trash } from 'phosphor-react';
import noImage from '../../../../images/no_image.png';
import { FabStateLabel } from '../base/fab-state-label';
import { ProductPrice } from './product-price';
import { EditDestroyButtons } from '../base/edit-destroy-buttons';
import ProductAPI from '../../api/product';
interface ProductItemProps {
product: Product,
onEdit: (product: Product) => void,
onDelete: (productId: number) => void,
onDelete: (message: string) => void,
onError: (message: string) => void,
}
/**
* This component shows a product item in the admin view
*/
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete }) => {
export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDelete, onError }) => {
const { t } = useTranslation('admin');
/**
@ -34,15 +35,6 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
};
};
/**
* Init the process of delete the given product
*/
const deleteProduct = (productId: number): () => void => {
return (): void => {
onDelete(productId);
};
};
/**
* Returns CSS class from stock status
*/
@ -80,14 +72,13 @@ export const ProductItem: React.FC<ProductItemProps> = ({ product, onEdit, onDel
<ProductPrice product={product} className="price" />
</div>
<div className='actions'>
<div className='manage'>
<FabButton className='edit-btn' onClick={editProduct(product)}>
<PencilSimple size={20} weight="fill" />
</FabButton>
<FabButton className='delete-btn' onClick={deleteProduct(product.id)}>
<Trash size={20} weight="fill" />
</FabButton>
</div>
<EditDestroyButtons onDeleteSuccess={onDelete}
className="manage"
onError={onError}
onEdit={editProduct(product)}
itemId={product.id}
itemType={t('app.admin.store.product_item.product')}
destroy={ProductAPI.destroy} />
</div>
</div>
);

View File

@ -1,11 +1,11 @@
import { useEffect, useState } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import Select from 'react-select';
import { PencilSimple, X } from 'phosphor-react';
import { PencilSimple } from 'phosphor-react';
import { useFieldArray, UseFormRegister } from 'react-hook-form';
import { Control, FormState, UseFormSetValue } from 'react-hook-form/dist/types/form';
import { useTranslation } from 'react-i18next';
import {
Product,
Product, ProductStockMovement,
stockMovementAllReasons, StockMovementIndex, StockMovementIndexFilter,
StockMovementReason,
StockType
@ -20,6 +20,7 @@ import FormatLib from '../../lib/format';
import ProductLib from '../../lib/product';
import { useImmer } from 'use-immer';
import { FabPagination } from '../base/fab-pagination';
import { FormUnsavedList } from '../form/form-unsaved-list';
interface ProductStockFormProps<TContext extends object> {
currentFormValues: Product,
@ -159,6 +160,25 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
}
};
/**
* Render an attribute of an unsaved stock movement
*/
const renderOngoingStockMovement = (movement: ProductStockMovement): ReactNode => (
<>
<div className="group">
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(movement.reason)}`)}</p>
</div>
<div className="group">
<span>{t(`app.admin.store.product_stock_form.${movement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(movement.quantity, movement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(movement.reason))}</p>
</div>
</>
);
return (
<div className='product-stock-form'>
<h4>{t('app.admin.store.product_stock_form.stock_up_to_date')}&nbsp;
@ -178,36 +198,19 @@ export const ProductStockForm = <TContext extends object> ({ currentFormValues,
<span>{t('app.admin.store.product_stock_form.external')}</span>
<p>{currentFormValues?.stock?.external}</p>
</div>
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">Modifier</FabButton>
<FabButton onClick={toggleModal} icon={<PencilSimple size={20} weight="fill" />} className="is-black">{t('app.admin.store.product_stock_form.edit')}</FabButton>
</div>
{fields.length > 0 && <div className="ongoing-stocks">
<span className="title">{t('app.admin.store.product_stock_form.ongoing_operations')}</span>
<span className="save-notice">{t('app.admin.store.product_stock_form.save_reminder')}</span>
{fields.map((newMovement, index) => (
<div key={index} className="unsaved-stock-movement stock-item">
<div className="group">
<p>{t(`app.admin.store.product_stock_form.type_${ProductLib.stockMovementType(newMovement.reason)}`)}</p>
</div>
<div className="group">
<span>{t(`app.admin.store.product_stock_form.${newMovement.stock_type}`)}</span>
<p>{ProductLib.absoluteStockMovement(newMovement.quantity, newMovement.reason)}</p>
</div>
<div className="group">
<span>{t('app.admin.store.product_stock_form.reason')}</span>
<p>{t(ProductLib.stockMovementReasonTrKey(newMovement.reason))}</p>
</div>
<p className="cancel-action" onClick={() => remove(index)}>
{t('app.admin.store.product_stock_form.cancel')}
<X size={20} />
</p>
<FormInput id={`product_stock_movements_attributes.${index}.stock_type`} register={register}
type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.quantity`} register={register} type="hidden" />
<FormInput id={`product_stock_movements_attributes.${index}.reason`} register={register} type="hidden" />
</div>
))}
</div>}
<FormUnsavedList fields={fields}
className="ongoing-stocks"
onRemove={remove}
register={register}
title={t('app.admin.store.product_stock_form.ongoing_operations')}
formAttributeName="product_stock_movements_attributes"
formAttributes={['stock_type', 'quantity', 'reason']}
renderField={renderOngoingStockMovement}
saveReminderLabel={t('app.admin.store.product_stock_form.save_reminder')}
cancelLabel={t('app.admin.store.product_stock_form.cancel')} />
<hr />

View File

@ -111,14 +111,9 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError, uiRouter }) =>
};
/** Delete a product */
const deleteProduct = async (productId: number): Promise<void> => {
try {
await ProductAPI.destroy(productId);
await fetchProducts();
onSuccess(t('app.admin.store.products.successfully_deleted'));
} catch (e) {
onError(t('app.admin.store.products.unable_to_delete') + e);
}
const deleteProduct = async (message: string): Promise<void> => {
await fetchProducts();
onSuccess(message);
};
/** Goto new product page */
@ -244,6 +239,7 @@ const Products: React.FC<ProductsProps> = ({ onSuccess, onError, uiRouter }) =>
<ProductItem
key={product.id}
product={product}
onError={onError}
onEdit={editProduct}
onDelete={deleteProduct}
/>

View File

@ -199,7 +199,7 @@ export const Trainings: React.FC<TrainingsProps> = ({ onError, onSuccess }) => {
onEdit={() => toTrainingEdit(training)}
itemId={training.id}
itemType={t('app.admin.trainings.training')}
apiDestroy={TrainingAPI.destroy}/>
destroy={TrainingAPI.destroy}/>
</div>
</div>
))}

View File

@ -21,11 +21,14 @@
/**
* Controller used in the plan creation form
*/
Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', 'planCategories',
function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, planCategories) {
Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal', 'groups', 'prices', 'partners', 'CSRF', '$state', 'growl', '_t', '$uiRouter',
function ($scope, $uibModal, groups, prices, partners, CSRF, $state, growl, _t, $uiRouter) {
// protection against request forgery
CSRF.setMetaTags();
// the following item is used by the UnsavedFormAlert component to detect a page change
$scope.uiRouter = $uiRouter;
/**
* Shows an error message forwarded from a child component
*/
@ -46,13 +49,16 @@ Application.Controllers.controller('NewPlanController', ['$scope', '$uibModal',
/**
* Controller used in the plan edition form
*/
Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', 'Plan', 'planCategories',
function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, Plan, planCategories) {
Application.Controllers.controller('EditPlanController', ['$scope', 'groups', 'plans', 'planPromise', 'machines', 'spaces', 'prices', 'partners', 'CSRF', '$state', '$transition$', 'growl', '$filter', '_t', '$uiRouter',
function ($scope, groups, plans, planPromise, machines, spaces, prices, partners, CSRF, $state, $transition$, growl, $filter, _t, $uiRouter) {
// protection against request forgery
CSRF.setMetaTags();
$scope.suscriptionPlan = cleanPlan(planPromise);
// the following item is used by the UnsavedFormAlert component to detect a page change
$scope.uiRouter = $uiRouter;
/**
* Shows an error message forwarded from a child component
*/

View File

@ -12,6 +12,16 @@ export interface Partner {
email: string
}
export type LimitableType = 'Machine'|'MachineCategory';
export interface PlanLimitation {
id?: number,
limitable_id: number,
limitable_type: LimitableType,
limit: number,
_modified?: boolean,
_destroy?: boolean,
}
export interface Plan {
id?: number,
base_name: string,
@ -34,8 +44,10 @@ export interface Plan {
plan_file_url?: string,
partner_id?: number,
partnership?: boolean,
limiting?: boolean,
partners?: Array<Partner>,
advanced_accounting_attributes?: AdvancedAccounting
advanced_accounting_attributes?: AdvancedAccounting,
plan_limitations_attributes?: Array<PlanLimitation>
}
export interface PlansDuration {

View File

@ -62,6 +62,7 @@
@import "modules/form/form-checklist";
@import "modules/form/form-file-upload";
@import "modules/form/form-image-upload";
@import "modules/form/form-unsaved-list";
@import "modules/group/change-group";
@import "modules/invoices/invoices-settings-panel";
@import "modules/invoices/vat-settings-modal";
@ -97,6 +98,9 @@
@import "modules/plan-categories/plan-categories-list";
@import "modules/plans/plan-card";
@import "modules/plans/plan-form";
@import "modules/plans/plan-limit-form";
@import "modules/plans/plan-limit-modal";
@import "modules/plans/plan-pricing-form";
@import "modules/plans/plans-filter";
@import "modules/plans/plans-list";
@import "modules/prepaid-packs/packs-summary";

View File

@ -12,16 +12,21 @@
text-align: center;
color: var(--main);
border-bottom: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
&:hover {
background-color: var(--gray-soft-light);
cursor: pointer;
}
&.react-tabs__tab--selected {
color: var(--gray-hard-dark);
border: 1px solid var(--gray-soft-dark);
border-bottom: none;
border-radius: var(--border-radius-sm) var(--border-radius-sm) 0 0;
}
&:hover {
cursor: pointer;
&:hover {
background-color: var(--gray-soft-lightest);
cursor: default;
}
&:focus { outline: none; }
}
}
}

View File

@ -1,6 +1,6 @@
.fab-text-editor {
position: relative;
margin-bottom: 1.6rem;
//margin-bottom: 1.6rem;
padding-bottom: 1.6rem;
background-color: var(--gray-soft-lightest);
border: 1px solid var(--gray-soft-dark);

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}
@ -25,7 +23,7 @@
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2.4rem;
gap: 1.6rem 2.4rem;
.add-price {
max-width: fit-content;
@ -37,6 +35,8 @@
align-items: flex-end;
gap: 0 2.4rem;
.form-item { margin: 0; }
.remove-price {
align-items: center;
display: flex;
@ -61,8 +61,7 @@
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item:first-child {
margin-right: 2.4rem;
}
.form-item { margin: 0; }
.form-item:first-child { margin-right: 2.4rem; }
}
}

View File

@ -0,0 +1,47 @@
.form-unsaved-list {
.save-notice {
@include text-xs;
margin-left: 1rem;
color: var(--alert);
}
.unsaved-field {
background-color: var(--gray-soft-light);
border: 0;
padding: 1.2rem;
margin-top: 1rem;width: 100%;
display: flex;
gap: 4.8rem;
justify-items: flex-start;
align-items: center;
border-radius: var(--border-radius);
& > * { flex: 1 1 45%; }
p {
margin: 0;
@include text-base;
}
.title {
@include text-base(600);
flex: 1 1 100%;
}
.group {
span {
@include text-xs;
color: var(--gray-hard-light);
}
p { @include text-base(600); }
}
.cancel-action {
&:hover {
text-decoration: underline;
cursor: pointer;
}
svg {
margin-left: 1rem;
vertical-align: middle;
}
}
}
}

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -1,25 +1,40 @@
.plan-form {
.plan-sheet {
margin-top: 4rem;
max-width: 1260px;
margin: 2.4rem auto 0;
padding: 0 3rem 6rem;
display: flex;
flex-direction: column;
& > header {
padding-bottom: 0;
@include header($sticky: true);
gap: 2.4rem;
}
.duration {
display: flex;
flex-direction: row;
.form-item:first-child {
margin-right: 32px;
}
}
.partner {
&-content {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3.2rem;
.fab-alert {
width: 100%;
section { @include layout-settings; }
.grp {
display: flex;
flex-direction: column;
@media (min-width: 640px) {flex-direction: row; }
.form-item { margin: 0; }
.form-item:first-child { margin-right: 2.4rem; }
}
.partner {
display: flex;
flex-direction: column-reverse;
align-items: flex-end;
gap: 0 2.4rem;
@media (min-width: 640px) {
flex-direction: row;
button { margin-bottom: 1.6rem; }
}
}
}
.submit-btn {
float: right;
}
}

View File

@ -0,0 +1,85 @@
.plan-limit-form {
display: flex;
flex-direction: column;
gap: 3.2rem 0;
section { @include layout-settings; }
.plan-limit-grp {
header {
@include header();
p {
@include title-base;
margin: 0;
}
}
.form-unsaved-list {
margin-bottom: 6.4rem;
}
.plan-limit-list {
max-height: 65vh;
display: flex;
flex-direction: column;
overflow-y: auto;
& > .title { @include text-base(500); }
.plan-limit-item {
width: 100%;
margin-bottom: 2.4rem;
padding: 1.6rem;
display: flex;
justify-content: space-between;
gap: 3.2rem;
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
background-color: var(--gray-soft-lightest);
span {
@include text-xs;
color: var(--gray-hard-light);
}
p {
margin: 0;
@include text-base(600);
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
}
@media (min-width: 540px) {
.grp {
flex: 1;
display: flex;
justify-content: space-between;
& > * {
flex: 1;
}
}
}
&.is-destroying {
background-color: var(--alert-lightest);
.marker {
text-align: center;
font-weight: 500;
color: var(--alert-dark);
margin: auto;
}
.actions > .cancel-action {
font-weight: normal;
svg {
vertical-align: middle;
margin-left: 1rem;
}
&:hover {
text-decoration: underline;
cursor: pointer;
}
}
}
}
}
}
}

View File

@ -0,0 +1,30 @@
.plan-limit-modal {
.grp {
margin-bottom: 3.2rem;
display: flex;
justify-content: space-between;
align-items: center;
button {
flex: 1;
padding: 1.6rem;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--gray-soft-lightest);
border: 1px solid var(--gray-soft-dark);
color: var(--gray-soft-darkest);
@include text-base;
&.is-active {
border: 1px solid var(--gray-soft-darkest);
background-color: var(--gray-hard-darkest);
color: var(--gray-soft-lightest);
}
}
button:first-of-type {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
button:last-of-type {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
}
}

View File

@ -0,0 +1,12 @@
.plan-pricing-form {
.fab-tabs .tabs li {
margin-bottom: 1.6rem;
&:hover { background-color: var(--gray-soft); }
&.react-tabs__tab--selected:hover { background-color: transparent; }
}
.react-tabs__tab-panel {
max-height: 50vh;
overflow-y: auto;
}
}

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -3,28 +3,6 @@
.ongoing-stocks {
margin: 2.4rem 0;
.save-notice {
@include text-xs;
margin-left: 1rem;
color: var(--alert);
}
.unsaved-stock-movement {
background-color: var(--gray-soft-light);
border: 0;
padding: 1.2rem;
margin-top: 1rem;
.cancel-action {
&:hover {
text-decoration: underline;
cursor: pointer;
}
svg {
margin-left: 1rem;
vertical-align: middle;
}
}
}
}
.store-list {
margin-top: 2.4rem;

View File

@ -15,8 +15,6 @@
flex-direction: column;
gap: 3.2rem;
.fab-alert { margin: 0; }
section { @include layout-settings; }
.save-btn { align-self: flex-start; }
}

View File

@ -29,10 +29,15 @@
}
& > .content {
display: flex;
flex-direction: column;
padding: 1.6rem;
background-color: var(--gray-soft-light);
border: 1px solid var(--gray-soft-dark);
border-radius: var(--border-radius);
& > * { margin-bottom: 0; }
& > *:not(:last-child) { margin-bottom: 3.2rem; }
.fab-alert { margin: 0 0 1.6rem; }
}
@media (min-width: 1024px) {

View File

@ -10,24 +10,7 @@
<h1>{{ 'app.admin.plans.edit.subscription_plan' | translate }} {{ suscriptionPlan.base_name }}</h1>
</section>
</div>
<div class="col-md-3">
<section class="heading-actions wrapper">
<a class="btn btn-lg btn-block btn-default m-t-xs" ui-sref="app.admin.pricing" translate>{{ 'app.shared.buttons.cancel' }}</a>
</section>
</div>
</div>
</section>
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<plan-form action="'update'" plan="suscriptionPlan" on-error="onError" on-success="onSuccess"></plan-form>
</div>
</div>
</div>
</div>
<plan-form action="'update'" plan="suscriptionPlan" on-error="onError" on-success="onSuccess" ui-router="uiRouter"></plan-form>

View File

@ -14,14 +14,4 @@
</div>
</section>
<div class="row no-gutter">
<div class=" col-sm-12 col-md-9 b-r nopadding">
<div class="panel panel-default bg-light m-lg">
<div class="panel-body m-r">
<plan-form action="'create'" on-error="onError" on-success="onSuccess"></plan-form>
</div>
</div>
</div>
</div>
<plan-form action="'create'" on-error="onError" on-success="onSuccess" ui-router="uiRouter"></plan-form>

View File

@ -13,18 +13,22 @@ class CartItem::Reservation < CartItem::BaseItem
nil
end
# @return [Plan,NilClass]
def plan
nil
end
# @return [User]
def operator
operator_profile.user
end
# @return [User]
def customer
customer_profile.user
end
# @return [Hash{Symbol=>Integer,Hash{Symbol=>Array<Hash{Symbol=>Integer,Float,Boolean,Time}>}}]
def price
is_privileged = operator.privileged? && operator.id != customer.id
prepaid = { minutes: PrepaidPackService.minutes_available(customer, reservable) }
@ -48,49 +52,35 @@ class CartItem::Reservation < CartItem::BaseItem
{ elements: elements, amount: amount }
end
# @return [String,NilClass]
def name
reservable&.name
end
# @param all_items [Array<CartItem::BaseItem>]
# @return [Boolean]
def valid?(all_items = [])
pending_subscription = all_items.find { |i| i.is_a?(CartItem::Subscription) }
plan = pending_subscription&.plan || customer&.subscribed_plan
reservation_deadline_minutes = Setting.get('reservation_deadline').to_i
reservation_deadline = reservation_deadline_minutes.minutes.since
unless ReservationLimitService.authorized?(plan, customer, self, all_items)
errors.add(:reservation, I18n.t('cart_item_validation.limit_reached', {
HOURS: ReservationLimitService.limit(plan, reservable),
RESERVABLE: reservable.name
}))
return false
end
cart_item_reservation_slots.each do |sr|
slot = sr.slot
if slot.nil?
errors.add(:slot, I18n.t('cart_item_validation.slot'))
return false
end
availability = slot.availability
if availability.nil?
errors.add(:availability, I18n.t('cart_item_validation.availability'))
return false
end
if slot.full?
errors.add(:slot, I18n.t('cart_item_validation.full'))
return false
end
if slot.start_at < reservation_deadline && !operator.privileged?
errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline_minutes }))
return false
end
next if availability.plan_ids.empty?
next if required_subscription?(availability, pending_subscription)
errors.add(:availability, I18n.t('cart_item_validation.restricted'))
return false
return false unless validate_slot_reservation(sr, pending_subscription, reservation_deadline_minutes, errors)
end
true
end
# @return [Reservation]
def to_object
::Reservation.new(
reservable_id: reservable_id,
@ -107,7 +97,7 @@ class CartItem::Reservation < CartItem::BaseItem
end
# Group the slots by date, if the extended_prices_in_same_day option is set to true
# @return Hash{Symbol => Array<CartItem::ReservationSlot>}
# @return [Hash{Symbol => Array<CartItem::ReservationSlot>}]
def grouped_slots
return { all: cart_item_reservation_slots } unless Setting.get('extended_prices_in_same_day')
@ -125,7 +115,7 @@ class CartItem::Reservation < CartItem::BaseItem
# @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided
# @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default)
# @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer
# @return [Float]
# @return [Float,Integer]
def get_slot_price_from_prices(prices, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
@ -152,7 +142,7 @@ class CartItem::Reservation < CartItem::BaseItem
# @option options [Boolean] :has_credits true if the user still has credits for the given slot, false if not provided
# @option options [Boolean] :is_division false if the slot covers a full availability, true if it is a subdivision (default)
# @option options [Number] :prepaid_minutes number of remaining prepaid minutes for the customer
# @return [Float] price of the slot
# @return [Float,Integer] price of the slot
def get_slot_price(hourly_rate, slot_reservation, is_privileged, options = {})
options = GET_SLOT_PRICE_DEFAULT_OPTS.merge(options)
@ -245,14 +235,50 @@ class CartItem::Reservation < CartItem::BaseItem
cart_item_reservation_slots.map { |sr| { id: sr.slots_reservation_id, slot_id: sr.slot_id, offered: sr.offered } }
end
##
# Check if the given availability requires a valid subscription. If so, check if the current customer
# has the required susbcription, otherwise, check if the operator is privileged
##
# @param availability [Availability]
# @param pending_subscription [CartItem::Subscription, NilClass]
def required_subscription?(availability, pending_subscription)
(customer.subscribed_plan && availability.plan_ids.include?(customer.subscribed_plan.id)) ||
(pending_subscription && availability.plan_ids.include?(pending_subscription.plan.id)) ||
(operator.manager? && customer.id != operator.id) ||
operator.admin?
end
# @param reservation_slot [CartItem::ReservationSlot]
# @param pending_subscription [CartItem::Subscription, NilClass]
# @param reservation_deadline [Integer]
# @param errors [ActiveModel::Errors]
# @return [Boolean]
def validate_slot_reservation(reservation_slot, pending_subscription, reservation_deadline, errors)
slot = reservation_slot.slot
if slot.nil?
errors.add(:slot, I18n.t('cart_item_validation.slot'))
return false
end
availability = slot.availability
if availability.nil?
errors.add(:availability, I18n.t('cart_item_validation.availability'))
return false
end
if slot.full?
errors.add(:slot, I18n.t('cart_item_validation.full'))
return false
end
if slot.start_at < reservation_deadline.minutes.since && !operator.privileged?
errors.add(:slot, I18n.t('cart_item_validation.deadline', { MINUTES: reservation_deadline }))
return false
end
if availability.plan_ids.any? && !required_subscription?(availability, pending_subscription)
errors.add(:availability, I18n.t('cart_item_validation.restricted'))
return false
end
true
end
end

View File

@ -42,6 +42,8 @@ class Machine < ApplicationRecord
belongs_to :machine_category
has_many :plan_limitations, dependent: :destroy, inverse_of: :machine, foreign_type: 'limitable_type', foreign_key: 'limitable_id'
after_create :create_statistic_subtype
after_create :create_machine_prices
after_create :update_gateway_product

View File

@ -4,4 +4,5 @@
class MachineCategory < ApplicationRecord
has_many :machines, dependent: :nullify
accepts_nested_attributes_for :machines, allow_destroy: true
has_many :plan_limitations, dependent: :destroy, inverse_of: :machine_category, foreign_type: 'limitable_type', foreign_key: 'limitable_id'
end

View File

@ -21,6 +21,9 @@ class Plan < ApplicationRecord
has_many :cart_item_subscriptions, class_name: 'CartItem::Subscription', dependent: :destroy
has_many :cart_item_payment_schedules, class_name: 'CartItem::PaymentSchedule', dependent: :destroy
has_many :plan_limitations, dependent: :destroy
accepts_nested_attributes_for :plan_limitations, allow_destroy: true
extend FriendlyId
friendly_id :base_name, use: :slugged

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Allows to set booking limits on some resources, per plan.
class PlanLimitation < ApplicationRecord
belongs_to :plan
belongs_to :limitable, polymorphic: true
belongs_to :machine, foreign_type: 'Machine', foreign_key: 'limitable_id', inverse_of: :plan_limitations
belongs_to :machine_category, foreign_type: 'MachineCategory', foreign_key: 'limitable_id', inverse_of: :plan_limitations
validates :limitable_id, :limitable_type, :limit, :plan_id, presence: true
validates :limitable_id, uniqueness: { scope: %i[limitable_type plan_id] }
# @return [Array<Machine,Event,Space,Training>]
def reservables
return limitable.machines if limitable_type == 'MachineCategory'
[limitable]
end
end

View File

@ -32,6 +32,7 @@ class Reservation < ApplicationRecord
after_commit :notify_member_create_reservation, on: :create
after_commit :notify_admin_member_create_reservation, on: :create
after_commit :extend_subscription, on: :create
after_commit :notify_member_limitation_reached, on: :create
delegate :user, to: :statistic_profile
@ -137,4 +138,14 @@ class Reservation < ApplicationRecord
receiver: User.admins_and_managers,
attached_object: self
end
def notify_member_limitation_reached
date = ReservationLimitService.reached_limit_date(self)
return if date.nil?
NotificationCenter.call type: 'notify_member_reservation_limit_reached',
receiver: user,
attached_object: ReservationLimitService.limit(user.subscribed_plan, reservable),
meta_data: { date: date }
end
end

View File

@ -160,6 +160,7 @@ class ShoppingCart
# Check if the current cart needs the user to have been validated, and if the condition is satisfied.
# Return an array of errors, if any; false otherwise
# @return [Array<String>,FalseClass]
def check_user_validation(items)
user_validation_required = Setting.get('user_validation_required')
user_validation_required_list = Setting.get('user_validation_required_list')

View File

@ -125,27 +125,17 @@ class Availabilities::AvailabilitiesService
# @param range_end [ActiveSupport::TimeWithZone]
# @return ActiveRecord::Relation<Availability>
def availabilities(availabilities, type, user, range_start, range_end)
# who made the request?
# 1) an admin (he can see all availabilities from 1 month ago to anytime in the future)
if @current_user&.admin? || @current_user&.manager?
window_start = [range_start, 1.month.ago].max
availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', range_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, range_end)
# 2) an user (he cannot see past availabilities neither those further than 1 (or 3) months in the future)
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if subscription_year?(user) && type != 'training'
end_at = @maximum_visibility[:year] if show_more_trainings?(user) && type == 'training'
window_end = [end_at, range_end].min
window_start = [range_start, @minimum_visibility].max
availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window_end, window_start, type)
.where('slots.start_at > ? AND slots.end_at < ?', window_start, window_end)
.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil]))
.where(lock: false)
window = Availabilities::VisibilityService.new.visibility(@current_user, type, range_start, range_end)
qry = availabilities.includes(:tags, :slots)
.joins(:slots)
.where('availabilities.start_at <= ? AND availabilities.end_at >= ? AND available_type = ?', window[1], window[0], type)
.where('slots.start_at > ? AND slots.end_at < ?', window[0], window[1])
unless @current_user&.privileged?
# non priviledged users cannot see availabilities with tags different than their own and locked tags
qry = qry.where('availability_tags.tag_id' => user&.tag_ids&.concat([nil]))
.where(lock: false)
end
qry
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
# Return the maximum available visibility for a user
class Availabilities::VisibilityService
def initialize
@maximum_visibility = {
year: Setting.get('visibility_yearly').to_i.months.since,
other: Setting.get('visibility_others').to_i.months.since
}
@minimum_visibility = Setting.get('reservation_deadline').to_i.minutes.since
end
# @param user [User,NilClass]
# @param available_type [String] 'training', 'space', 'machine' or 'event'
# @param range_start [ActiveSupport::TimeWithZone]
# @param range_end [ActiveSupport::TimeWithZone]
# @return [Array<ActiveSupport::TimeWithZone,Date,Time>] as: [start,end]
def visibility(user, available_type, range_start, range_end)
if user&.privileged?
window_start = [range_start, 1.month.ago].max
window_end = range_end
else
end_at = @maximum_visibility[:other]
end_at = @maximum_visibility[:year] if subscription_year?(user) && available_type != 'training'
end_at = @maximum_visibility[:year] if show_more_trainings?(user) && available_type == 'training'
end_at = subscription_visibility(user, available_type) || end_at
window_end = [end_at, range_end].min
window_start = [range_start, @minimum_visibility].max
end
[window_start, window_end]
end
private
# @param user [User,NilClass]
def subscription_year?(user)
user&.subscribed_plan &&
(user&.subscribed_plan&.interval == 'year' ||
(user&.subscribed_plan&.interval == 'month' && user.subscribed_plan.interval_count >= 12))
end
# @param user [User,NilClass]
# @param available_type [String] 'training', 'space', 'machine' or 'event'
# @return [Time,NilClass]
def subscription_visibility(user, available_type)
return nil unless user&.subscribed_plan
return nil unless available_type == 'machine'
machines = user&.subscribed_plan&.machines_visibility
machines&.hours&.since
end
# members must have validated at least 1 training and must have a valid yearly subscription to view
# the trainings further in the futur. This is used to prevent users with a rolling subscription to take
# their first training in a very long delay.
# @param user [User,NilClass]
def show_more_trainings?(user)
user&.trainings&.size&.positive? && subscription_year?(user)
end
end

View File

@ -6,10 +6,9 @@ class CartService
@operator = operator
end
##
# For details about the expected hash format
# @see app/frontend/src/javascript/models/payment.ts > interface ShoppingCart
##
# @return [ShoppingCart]
def from_hash(cart_items)
cart_items.permit! if cart_items.is_a? ActionController::Parameters

View File

@ -73,7 +73,7 @@ class PrepaidPackService
# Total number of prepaid minutes available
# @param user [User]
# @param priceable [Machine,Space]
# @param priceable [Machine,Space,NilClass]
def minutes_available(user, priceable)
return 0 if Setting.get('pack_only_for_subscription') && !user.subscribed_plan

View File

@ -0,0 +1,97 @@
# frozen_string_literal: true
# Check if a user if allowed to book a reservation without exceeding the limits set by his plan
class ReservationLimitService
class << self
# @param plan [Plan,NilClass]
# @param customer [User]
# @param reservation [CartItem::Reservation]
# @param cart_items [Array<CartItem::BaseItem>]
# @return [Boolean]
def authorized?(plan, customer, reservation, cart_items)
return true if plan.nil? || !plan.limiting
return true if reservation.nil? || !reservation.is_a?(CartItem::Reservation)
limit = limit(plan, reservation.reservable)
return true if limit.nil?
reservation.cart_item_reservation_slots.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots|
daily_duration = reservations_duration(customer, date, reservation, cart_items) +
(reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0)
return false if Rational(daily_duration / 3600).to_f > limit.limit
end
true
end
# @param reservation [Reservation]
# @return [Date,NilClass]
def reached_limit_date(reservation)
user = reservation.user
plan = user.subscribed_plan
return nil if plan.nil? || !plan.limiting
limit = limit(plan, reservation.reservable)
return nil if limit.nil?
reservation.slots_reservations.group_by { |sr| sr.slot.start_at.to_date }.each_pair do |date, reservation_slots|
daily_duration = saved_reservations_durations(user, reservation.reservable, date, reservation) +
(reservation_slots.map { |sr| sr.slot.duration }.reduce(:+) || 0)
return date if Rational(daily_duration / 3600).to_f >= limit.limit
end
nil
end
# @param plan [Plan,NilClass]
# @param reservable [Machine,Event,Space,Training]
# @return [PlanLimitation] in hours
def limit(plan, reservable)
return nil unless plan&.limiting
limitations = plan&.plan_limitations&.filter { |limit| limit.reservables.include?(reservable) }
limitations&.find { |limit| limit.limitable_type != 'MachineCategory' } || limitations&.first
end
private
# @param customer [User]
# @param date [Date]
# @param reservation [CartItem::Reservation]
# @param cart_items [Array<CartItem::BaseItem>]
# @return [Integer] in seconds
def reservations_duration(customer, date, reservation, cart_items)
daily_reservations_hours = saved_reservations_durations(customer, reservation.reservable, date)
cart_daily_reservations = cart_items.filter do |item|
item.is_a?(CartItem::Reservation) &&
item != reservation &&
item.reservable == reservation.reservable &&
item.cart_item_reservation_slots
.includes(:slot)
.where("date_trunc('day', slots.start_at) = :date", date: date)
end
daily_reservations_hours +
(cart_daily_reservations.map { |r| r.cart_item_reservation_slots.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0)
end
# @param customer [User]
# @param reservable [Machine,Event,Space,Training]
# @param date [Date]
# @param reservation [Reservation]
# @return [Integer] in seconds
def saved_reservations_durations(customer, reservable, date, reservation = nil)
daily_reservations = customer.reservations
.includes(slots_reservations: :slot)
.where(reservable: reservable)
.where(slots_reservations: { canceled_at: nil })
.where("date_trunc('day', slots.start_at) = :date", date: date)
daily_reservations = daily_reservations.where.not(id: reservation.id) unless reservation.nil?
(daily_reservations.map { |r| r.slots_reservations.map { |sr| sr.slot.duration } }.flatten.reduce(:+) || 0)
end
end
end

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
json.title notification.notification_type
json.description t('.limit_reached',
HOURS: notification.attached_object.limit,
ITEM: notification.attached_object.limitable.name,
DATE: I18n.l(notification.get_meta_data(:date).to_date))

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
json.extract! plan, :id, :base_name, :name, :interval, :interval_count, :group_id, :training_credit_nb, :is_rolling, :description, :type,
:ui_weight, :disabled, :monthly_payment, :plan_category_id
:ui_weight, :disabled, :monthly_payment, :plan_category_id, :limiting, :machines_visibility
json.amount plan.amount / 100.00
json.prices_attributes plan.prices, partial: 'api/prices/price', as: :price
if plan.plan_file
@ -27,3 +27,7 @@ if plan.advanced_accounting
end
end
json.plan_limitations_attributes plan.plan_limitations do |limitation|
json.extract! limitation, :id, :limitable_id, :limitable_type, :limit
end

View File

@ -1 +1,3 @@
# frozen_string_literal: true
json.partial! 'api/plans/plan', plan: @plan

View File

@ -0,0 +1,9 @@
<%= render 'notifications_mailer/shared/hello', recipient: @recipient %>
<p>
<%= t('.body.limit_reached',
HOURS: @attached_object.limit,
ITEM: @attached_object.limitable.name,
DATE: I18n.l(@notification.get_meta_data(:date).to_date)) %>
</p>

View File

@ -2,8 +2,8 @@ de:
app:
admin:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
deleted: "Successfully deleted."
unable_to_delete: "Unable to delete: "
delete_item: "Delete the {TYPE}"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this {TYPE}?"
@ -24,7 +24,7 @@ de:
save: "Save"
successfully_saved: "Your banner was successfully saved."
machine_categories_list:
machine_categories: "Machines categories"
machine_categories: "Maschinen-Kategorien"
add_a_machine_category: "Add a machine category"
name: "Name"
machines_number: "Number of machines"
@ -57,7 +57,7 @@ de:
disabled_help: "When disabled, the machine won't be reservable and won't appear by default in the machines list."
reservable: "Can this machine be reserved?"
reservable_help: "When disabled, the machine will be shown in the default list of machines, but without the reservation button. If you already have created some availability slots for this machine, you may want to remove them: do it from the admin agenda."
save: "Save"
save: "Speichern"
create_success: "The machine was created successfully"
update_success: "The machine was updated successfully"
training_form:
@ -74,7 +74,7 @@ de:
associated_machines_help: "If you associate a machine to this training, the members will need to successfully pass this training before being able to reserve the machine."
default_seats: "Default number of seats"
public_page: "Show in training lists"
public_help: "When unchecked, this option will prevent the training from appearing in the trainings list."
public_help: "Wenn diese Option deaktiviert ist, wird verhindert, dass das Training in der Trainingliste erscheint."
disable_training: "Disable the training"
disabled_help: "When disabled, the training won't be reservable and won't appear by default in the trainings list."
automatic_cancellation: "Automatic cancellation"
@ -90,39 +90,39 @@ de:
validation_rule_info: "Define a rule that cancel an authorisation if the machines associated with the training are not reserved for a specific period of time. This rule prevails over the authorisations validity period."
validation_rule_switch: "Activate the validation rule"
validation_rule_period: "Time limit in months"
save: "Save"
create_success: "The training was created successfully"
update_success: "The training was updated successfully"
save: "Speichern"
create_success: "Die Schulung wurde erfolgreich erstellt"
update_success: "Die Schulung wurde erfolgreich aktualisiert"
space_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} space"
watch_out_when_creating_a_new_space_its_prices_are_initialized_at_0_for_all_subscriptions: "Watch out! When creating a new space, its prices are initialized at 0 for all subscriptions."
consider_changing_its_prices_before_creating_any_reservation_slot: "Consider changing its prices before creating any reservation slot."
ACTION_title: "{ACTION, select, create{Neu} other{Aktualisiere den}} Raum"
watch_out_when_creating_a_new_space_its_prices_are_initialized_at_0_for_all_subscriptions: "Achtung! Beim Erstellen eines neuen Raums wird sein Preis für alle Abonnements mit 0 angelegt."
consider_changing_its_prices_before_creating_any_reservation_slot: "Ändern Sie ggf. die Preise, bevor Sie Reservierungs-Slots erstellen."
name: "Name"
illustration: "Illustration"
description: "Description"
characteristics: "Characteristics"
attachments: "Attachments"
attached_files_pdf: "Attached files (pdf)"
add_an_attachment: "Add an attachment"
settings: "Settings"
default_seats: "Default number of seats"
disable_space: "Disable the space"
disabled_help: "When disabled, the space won't be reservable and won't appear by default in the spaces list."
save: "Save"
create_success: "The space was created successfully"
update_success: "The space was updated successfully"
illustration: "Abbildung"
description: "Beschreibung"
characteristics: "Eigenschaften"
attachments: "Dateianhänge"
attached_files_pdf: "Angehängte Dateien (pdf)"
add_an_attachment: "Anhang hinzufügen"
settings: "Einstellungen"
default_seats: "Standardanzahl der Sitze"
disable_space: "Raum deaktivieren"
disabled_help: "Wenn deaktiviert, ist der Raum nicht reservierbar und erscheint standardmäßig nicht in der Liste der Leerzeichen."
save: "Speichern"
create_success: "Der Raum wurde erfolgreich erstellt"
update_success: "Der Raum wurde erfolgreich aktualisiert"
event_form:
ACTION_title: "{ACTION, select, create{New} other{Update the}} event"
title: "Title"
ACTION_title: "{ACTION, select, create{Neue} other{Aktualisiere die}} Veranstaltung"
title: "Titel"
matching_visual: "Matching visual"
description: "Description"
attachments: "Attachments"
attached_files_pdf: "Attached files (pdf)"
add_a_new_file: "Add a new file"
event_category: "Event category"
event_category: "Veranstaltungskategorie"
dates_and_opening_hours: "Dates and opening hours"
all_day: "All day"
all_day_help: "Will the event last all day or do you want to set times?"
all_day_help: "Wird das Ereignis den ganzen Tag dauern oder möchtest du Zeiten festlegen?"
start_date: "Start date"
end_date: "End date"
start_time: "Start time"
@ -135,29 +135,36 @@ de:
fare_class: "Fare class"
price: "Price"
seats_available: "Seats available"
seats_help: "If you leave this field empty, this event will be available without reservations."
event_themes: "Event themes"
seats_help: "Wenn sie dieses Feld leer lassen, ist diese Veranstaltung ohne Reservierung."
event_themes: "Veranstaltungsthemen"
age_range: "Age range"
add_price: "Add a price"
save: "Save"
create_success: "The event was created successfully"
events_updated: "{COUNT, plural, =1{One event was} other{{COUNT} Events were}} successfully updated"
events_not_updated: "{TOTAL, plural, =1{The event was} other{On {TOTAL} events {COUNT, plural, =1{one was} other{{COUNT} were}}}} not updated."
error_deleting_reserved_price: "Unable to remove the requested price because it is associated with some existing reservations"
other_error: "An unexpected error occurred while updating the event"
create_success: "Die Veranstaltung wurde erfolgreich erstellt"
events_updated: "{COUNT, plural, one {}=1{Eine Veranstaltung wurde} other{{COUNT} Veranstaltungen wurden}} erfolgreich aktualisiert"
events_not_updated: "{TOTAL, plural, =1{Die Veranstaltung war} other{Auf {TOTAL} Veranstaltungen {COUNT, plural, =1{eins war} other{{COUNT} waren}}}} nicht aktualisiert."
error_deleting_reserved_price: "Der angeforderte Preis konnte nicht gelöscht werden, da er mit einigen Reservierungen verknüpft ist"
other_error: "Beim Aktualisieren der Veranstaltung ist ein unerwarteter Fehler aufgetreten"
recurring:
none: "None"
every_days: "Every days"
every_week: "Every week"
every_month: "Every month"
every_year: "Every year"
every_days: "Täglich"
every_week: "Wöchentlich"
every_month: "Monatlich"
every_year: "Jährlich"
plan_form:
general_information: "General information"
ACTION_title: "{ACTION, select, create{New} other{Update the}} plan"
tab_settings: "Einstellungen"
tab_usage_limits: "Usage limits"
description: "Beschreibung"
general_settings: "Generelle Einstellungen"
general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods."
activation_and_payment: "Subscription activation and payment"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
group: "Group"
group: "Gruppe"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
display: "Display"
category: "Category"
category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions."
number_of_periods: "Number of periods"
@ -173,10 +180,9 @@ de:
rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
description: "Description"
information_sheet: "Information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
new_user: "New user"
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
@ -185,9 +191,39 @@ de:
partner_plan: "Partner plan"
partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan."
partner_created: "The partner was successfully created"
ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan"
slots_visibility: "Slots visibility"
slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings."
machines_visibility: "Visibility time limit, in hours (machines)"
save: "Save"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
plan_limit_form:
usage_limitation: "Limitation of use"
usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation."
usage_limitation_switch: "Restrict machine reservations to a number of hours per day."
new_usage_limitation: "Add a limitation of use"
all_limitations: "All limitations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
cancel_deletion: "Cancel"
ongoing_deletion: "Ongoing deletion"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
categories_info: "If you select all machine categories, the limits will apply across the board."
machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten."
max_hours_per_day: "Maximum number of reservation hours per day"
confirm: "Confirm"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
@ -196,16 +232,17 @@ de:
email: "Email address"
plan_pricing_form:
prices: "Prices"
about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour."
copy_prices_from: "Copy prices from"
copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan"
machines: "Machines"
spaces: "Spaces"
update_recurrent_modal:
title: "Periodic event update"
edit_recurring_event: "You're about to update a periodic event. What do you want to update?"
edit_this_event: "Only this event"
edit_recurring_event: "Sie bearbeiten eine wiederkehrende Veranstaltung. Was möchten Sie ändern?"
edit_this_event: "Nur diese Veranstaltung"
edit_this_and_next: "This event and the followings"
edit_all: "All events"
edit_all: "Alle Veranstaltungen"
date_wont_change: "Warning: you have changed the event date. This modification won't be propagated to other occurrences of the periodic event."
confirm: "Update the {MODE, select, single{event} other{events}}"
advanced_accounting_form:
@ -227,7 +264,7 @@ de:
subscriptions: "Subscriptions"
machine: "Machine reservation"
training: "Training reservation"
event: "Event reservation"
event: "Veranstaltungsreservierung"
space: "Space reservation"
prepaid_pack: "Pack of prepaid-hours"
product: "Product of the store"
@ -286,7 +323,7 @@ de:
manage_trainings: "Klicke hier, um Schulungen hinzuzufügen oder zu entfernen."
number_of_tickets: "Anzahl der Tickets: "
adjust_the_opening_hours: "Öffnungszeiten anpassen"
to_time: "bis" #eg. from 18:00 to 21:00
to_time: "bis" #e.g. from 18:00 to 21:00
restrict_options: "Einschränkungsoptionen"
restrict_with_labels: "Diesen Slot mit Labels einschränken"
restrict_for_subscriptions: "Diesen Slot auf Abonnenten einschränken"
@ -508,8 +545,8 @@ de:
on_DATE: "am {DATE}"
from_DATE: "von {DATE}"
from_TIME: "ab {TIME}"
to_date: "bis" #eg: from 01/01 to 01/05
to_time: "bis" #eg. from 18:00 to 21:00
to_date: "bis" #e.g.: from 01/01 to 01/05
to_time: "bis" #e.g. from 18:00 to 21:00
title: "Titel"
dates: "Datum"
booking: "Buchung"
@ -586,13 +623,13 @@ de:
events_settings:
title: "Settings"
generic_text_block: "Editorial text block"
generic_text_block_info: "Displays an editorial block above the list of events visible to members."
generic_text_block_info: "Zeigt einen redaktionellen Block oberhalb der für Mitglieder sichtbaren Veranstaltungsliste."
generic_text_block_switch: "Display editorial block"
cta_switch: "Display a button"
cta_label: "Button label"
cta_url: "url"
save: "Save"
update_success: "The events settings were successfully updated"
update_success: "Die Einstellungen wurden erfolgreich aktualisiert"
#subscriptions, prices, credits and coupons management
pricing:
pricing_management: "Preisverwaltung"
@ -1453,8 +1490,8 @@ de:
statistics: "Statistiken"
evolution: "Entwicklung"
age_filter: "Altersfilter"
from_age: "Von" #eg. from 8 to 40 years old
to_age: "bis" #eg. from 8 to 40 years old
from_age: "Von" #e.g. from 8 to 40 years old
to_age: "bis" #e.g. from 8 to 40 years old
start: "Start:"
end: "Ende:"
custom_filter: "Benutzerderfinierter Filter"
@ -2215,8 +2252,6 @@ de:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
all_products: "All products"
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
@ -2238,6 +2273,7 @@ de:
sort: "Sort:"
visible_only: "Visible products only"
product_item:
product: "product"
visible: "visible"
hidden: "hidden"
stock:
@ -2288,12 +2324,13 @@ de:
low_stock: "Low stock"
threshold_level: "Minimum threshold level"
threshold_alert: "Notify me when the threshold is reached"
events_history: "Events history"
event_type: "Events:"
events_history: "Veranstaltungsverlauf"
event_type: "Veranstaltungen:"
reason: "Reason"
stocks: "Stock:"
internal: "Private stock"
external: "Public stock"
edit: "Edit"
all: "All types"
remaining_stock: "Remaining stock"
type_in: "Add"
@ -2385,7 +2422,7 @@ de:
VAT_rate_machine: "Machine reservation"
VAT_rate_space: "Space reservation"
VAT_rate_training: "Training reservation"
VAT_rate_event: "Event reservation"
VAT_rate_event: "Veranstaltungsreservierung"
VAT_rate_subscription: "Subscription"
VAT_rate_product: "Products (store)"
multi_VAT_notice: "<strong>Please note</strong>: The current general rate is {RATE}%. You can define different VAT rates for each category.<br><br>For example, you can override this value, only for machine reservations, by filling in the corresponding field beside. If you don't fill any value, the general rate will apply."

View File

@ -2,8 +2,8 @@ en:
app:
admin:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
deleted: "Successfully deleted."
unable_to_delete: "Unable to delete: "
delete_item: "Delete the {TYPE}"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this {TYPE}?"
@ -152,12 +152,19 @@ en:
every_month: "Every month"
every_year: "Every year"
plan_form:
general_information: "General information"
ACTION_title: "{ACTION, select, create{New} other{Update the}} plan"
tab_settings: "Settings"
tab_usage_limits: "Usage limits"
description: "Description"
general_settings: "General settings"
general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods."
activation_and_payment: "Subscription activation and payment"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
group: "Group"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
display: "Display"
category: "Category"
category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions."
number_of_periods: "Number of periods"
@ -173,10 +180,9 @@ en:
rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
description: "Description"
information_sheet: "Information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
new_user: "New user"
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
@ -185,9 +191,39 @@ en:
partner_plan: "Partner plan"
partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan."
partner_created: "The partner was successfully created"
ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan"
slots_visibility: "Slots visibility"
slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings."
machines_visibility: "Visibility time limit, in hours (machines)"
save: "Save"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
plan_limit_form:
usage_limitation: "Limitation of use"
usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation."
usage_limitation_switch: "Restrict machine reservations to a number of hours per day."
new_usage_limitation: "Add a limitation of use"
all_limitations: "All limitations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
cancel_deletion: "Cancel"
ongoing_deletion: "Ongoing deletion"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
categories_info: "If you select all machine categories, the limits will apply across the board."
machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten."
max_hours_per_day: "Maximum number of reservation hours per day"
confirm: "Confirm"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
@ -196,6 +232,7 @@ en:
email: "Email address"
plan_pricing_form:
prices: "Prices"
about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour."
copy_prices_from: "Copy prices from"
copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan"
machines: "Machines"
@ -286,7 +323,7 @@ en:
manage_trainings: "Click here to add or remove trainings."
number_of_tickets: "Number of tickets: "
adjust_the_opening_hours: "Adjust the opening hours"
to_time: "to" #eg. from 18:00 to 21:00
to_time: "to" #e.g. from 18:00 to 21:00
restrict_options: "Restriction options"
restrict_with_labels: "Restrict this slot with labels"
restrict_for_subscriptions: "Restrict this slot for subscription users"
@ -508,8 +545,8 @@ en:
on_DATE: "on {DATE}"
from_DATE: "from {DATE}"
from_TIME: "from {TIME}"
to_date: "to" #eg: from 01/01 to 01/05
to_time: "to" #eg. from 18:00 to 21:00
to_date: "to" #e.g.: from 01/01 to 01/05
to_time: "to" #e.g. from 18:00 to 21:00
title: "Title"
dates: "Dates"
booking: "Booking"
@ -1453,8 +1490,8 @@ en:
statistics: "Statistics"
evolution: "Evolution"
age_filter: "Age filter"
from_age: "From" #eg. from 8 to 40 years old
to_age: "to" #eg. from 8 to 40 years old
from_age: "From" #e.g. from 8 to 40 years old
to_age: "to" #e.g. from 8 to 40 years old
start: "Start:"
end: "End:"
custom_filter: "Custom filter"
@ -2215,8 +2252,6 @@ en:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
all_products: "All products"
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
@ -2238,6 +2273,7 @@ en:
sort: "Sort:"
visible_only: "Visible products only"
product_item:
product: "product"
visible: "visible"
hidden: "hidden"
stock:
@ -2294,6 +2330,7 @@ en:
stocks: "Stock:"
internal: "Private stock"
external: "Public stock"
edit: "Edit"
all: "All types"
remaining_stock: "Remaining stock"
type_in: "Add"

View File

@ -2,8 +2,8 @@ es:
app:
admin:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
deleted: "Successfully deleted."
unable_to_delete: "Unable to delete: "
delete_item: "Delete the {TYPE}"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this {TYPE}?"
@ -152,12 +152,19 @@ es:
every_month: "Every month"
every_year: "Every year"
plan_form:
general_information: "General information"
ACTION_title: "{ACTION, select, create{New} other{Update the}} plan"
tab_settings: "Settings"
tab_usage_limits: "Usage limits"
description: "Description"
general_settings: "General settings"
general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods."
activation_and_payment: "Subscription activation and payment"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
group: "Group"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
display: "Display"
category: "Category"
category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions."
number_of_periods: "Number of periods"
@ -173,10 +180,9 @@ es:
rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
description: "Description"
information_sheet: "Information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
new_user: "New user"
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
@ -185,9 +191,39 @@ es:
partner_plan: "Partner plan"
partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan."
partner_created: "The partner was successfully created"
ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan"
slots_visibility: "Slots visibility"
slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings."
machines_visibility: "Visibility time limit, in hours (machines)"
save: "Save"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
plan_limit_form:
usage_limitation: "Limitation of use"
usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation."
usage_limitation_switch: "Restrict machine reservations to a number of hours per day."
new_usage_limitation: "Add a limitation of use"
all_limitations: "All limitations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
cancel_deletion: "Cancel"
ongoing_deletion: "Ongoing deletion"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
categories_info: "If you select all machine categories, the limits will apply across the board."
machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten."
max_hours_per_day: "Maximum number of reservation hours per day"
confirm: "Confirm"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
@ -196,6 +232,7 @@ es:
email: "Email address"
plan_pricing_form:
prices: "Prices"
about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour."
copy_prices_from: "Copy prices from"
copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan"
machines: "Machines"
@ -286,7 +323,7 @@ es:
manage_trainings: "Click here to add or remove trainings."
number_of_tickets: "Número de tickets: "
adjust_the_opening_hours: "Ajustar el horario de apertura"
to_time: "a" #eg. from 18:00 to 21:00
to_time: "a" #e.g. from 18:00 to 21:00
restrict_options: "Restriction options"
restrict_with_labels: "Restringir este horario con etiquetas"
restrict_for_subscriptions: "Restrict this slot for subscription users"
@ -508,8 +545,8 @@ es:
on_DATE: "on {DATE}"
from_DATE: "Desde {DATE}"
from_TIME: "Desde {TIME}"
to_date: "to" #eg: from 01/01 to 01/05
to_time: "to" #eg. from 18:00 to 21:00
to_date: "to" #e.g.: from 01/01 to 01/05
to_time: "to" #e.g. from 18:00 to 21:00
title: "Title"
dates: "Dates"
booking: "Booking"
@ -1453,8 +1490,8 @@ es:
statistics: "Statistics"
evolution: "Evolución"
age_filter: "Filtro de edad"
from_age: "Desde" #eg. from 8 to 40 years old
to_age: "a" #eg. from 8 to 40 years old
from_age: "Desde" #e.g. from 8 to 40 years old
to_age: "a" #e.g. from 8 to 40 years old
start: "Principio:"
end: "Final:"
custom_filter: "Filtro personalizado"
@ -2215,8 +2252,6 @@ es:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
all_products: "All products"
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
@ -2238,6 +2273,7 @@ es:
sort: "Sort:"
visible_only: "Visible products only"
product_item:
product: "product"
visible: "visible"
hidden: "hidden"
stock:
@ -2294,6 +2330,7 @@ es:
stocks: "Stock:"
internal: "Private stock"
external: "Public stock"
edit: "Edit"
all: "All types"
remaining_stock: "Remaining stock"
type_in: "Add"

View File

@ -152,12 +152,19 @@ fr:
every_month: "Chaque mois"
every_year: "Chaque année"
plan_form:
general_information: "Informations générales"
ACTION_title: "{ACTION, select, create{Nouvelle} other{Mettre à jour la}} formule d'abonnement"
tab_settings: "Paramètres"
tab_usage_limits: "Limites d'utilisation"
description: "Description"
general_settings: "Paramètres généraux"
general_settings_info: "Déterminez à quel groupe cet abonnement est dédié. Définissez également son prix et sa durée en périodes."
activation_and_payment: "Activation et paiement de l'abonnement"
name: "Nom"
name_max_length: "Le nom doit faire moins de 24 caractères."
group: "Groupe"
transversal: "Abonnement transversal"
transversal_help: "Si cette option est cochée, une copie de cette formule d'abonnement sera créée pour chaque groupe actuellement activé."
display: "Affichage"
category: "Catégorie"
category_help: "Les catégories vous permettent de regrouper les formules d'abonnement, sur la vue publique des abonnements."
number_of_periods: "Nombre de périodes"
@ -173,7 +180,6 @@ fr:
rolling_subscription_help: "Un abonnement glissant commencera le jour de la première formation. Sinon, il commencera dès qu'il est acheté."
monthly_payment: "Paiement mensuel ?"
monthly_payment_help: "Si le paiement mensuel est activé, les membres pourront choisir entre un paiement unique ou un échéancier de paiement échelonné chaque mois."
description: "Description"
information_sheet: "Fiche descriptive"
notified_partner: "Partenaire notifié"
new_user: "Nouvel utilisateur ..."
@ -185,9 +191,39 @@ fr:
partner_plan: "Abonnement partenaire"
partner_plan_help: "Vous pouvez vendre des abonnements en partenariat avec un autre organisme. Ce faisant, l'autre entité sera informée lorsqu'un membre s'abonne à cette formule d'abonnement."
partner_created: "Le partenaire a bien été créé"
ACTION_plan: "{ACTION, select, create{Créer} other{Mettre à jour}} la formule d'abonnement"
slots_visibility: "Visibilité des créneaux"
slots_visibility_help: "Vous pouvez déterminer combien de temps en avance les abonnés peuvent voir et réserver les créneaux machines. Lorsque ce paramètre est défini, il devient prioritaire sur les paramètres généraux."
machines_visibility: "Délai de visibilité, en heures (machines)"
save: "Enregistrer"
create_success: "Création du/des formule(s) d'abonnement réussie(s). N'oubliez pas de redéfinir les tarifs."
update_success: "La formule d'abonnement a bien été mise à jour"
plan_limit_form:
usage_limitation: "Limite d'usage"
usage_limitation_info: "Définissez un nombre maximum d'heures de réservation par jour et par catégorie de machine. Les catégories de machines qui n'ont aucun paramètre configuré ne seront soumises à aucune limitation."
usage_limitation_switch: "Restreindre les réservations de machines à un certain nombre d'heures par jour."
new_usage_limitation: "Ajouter une limite d'usage"
all_limitations: "Toutes les limitations"
by_category: "Par catégorie de machines"
by_machine: "Par machine"
category: "Catégorie de machines"
machine: "Nom machine"
max_hours_per_day: "Max. heures/jour"
ongoing_limitations: "Limites en cours"
saved_limitations: "Limites enregistrées"
cancel: "Annuler cette limite"
cancel_deletion: "Annuler"
ongoing_deletion: "Suppression en cours"
plan_limit_modal:
title: "Gérer la limite d'usage"
limit_reservations: "Limiter les réservations"
by_category: "Par catégorie de machines"
by_machine: "Par machine"
category: "Catégorie de machines"
machine: "Nom machine"
categories_info: "Si vous sélectionnez toutes les catégories de machines, les limites s'appliqueront à tous les niveaux."
machine_info: "Veuillez noter que si vous avez déjà créé une limite pour la catégorie de machines incluant la machine sélectionnée, elle sera définitivement écrasée."
max_hours_per_day: "Nombre maximum d'heures de réservation par jour"
confirm: "Confirmer"
partner_modal:
title: "Créer un nouveau partenaire"
create_partner: "Créer le partenaire"
@ -196,6 +232,7 @@ fr:
email: "Adresse électronique"
plan_pricing_form:
prices: "Tarifs"
about_prices: "Les prix définis ici s'appliqueront aux membres qui s'abonneront à cette formule d'abonnement, pour les machines et les espaces."
copy_prices_from: "Copier les prix depuis"
copy_prices_from_help: "Cela remplacera tous les prix de cette formule d'abonnement par les prix de la formule sélectionnée"
machines: "Machines"
@ -286,7 +323,7 @@ fr:
manage_trainings: "Cliquez-ici pour ajouter ou supprimer des formations."
number_of_tickets: "Nombre de places : "
adjust_the_opening_hours: "Ajuster l'horaire"
to_time: "à" #eg. from 18:00 to 21:00
to_time: "à" #e.g. from 18:00 to 21:00
restrict_options: "Options de restriction"
restrict_with_labels: "Restreindre ce créneau avec des étiquettes"
restrict_for_subscriptions: "Restreindre ce créneau pour les abonnements"
@ -508,8 +545,8 @@ fr:
on_DATE: "le {DATE}"
from_DATE: "du {DATE}"
from_TIME: "de {TIME}"
to_date: "au" #eg: from 01/01 to 01/05
to_time: "à" #eg. from 18:00 to 21:00
to_date: "au" #e.g.: from 01/01 to 01/05
to_time: "à" #e.g. from 18:00 to 21:00
title: "Titre"
dates: "Dates"
booking: "Réservations"
@ -1453,8 +1490,8 @@ fr:
statistics: "Statistiques"
evolution: "Évolution"
age_filter: "Filtre d'âge"
from_age: "De" #eg. from 8 to 40 years old
to_age: "à" #eg. from 8 to 40 years old
from_age: "De" #e.g. from 8 to 40 years old
to_age: "à" #e.g. from 8 to 40 years old
start: "Début :"
end: "Fin :"
custom_filter: "Filtre personnalisé"
@ -2215,8 +2252,6 @@ fr:
unexpected_error_occurred: "Une erreur inattendue s'est produite. Veuillez réessayer ultérieurement."
all_products: "Tous les produits"
create_a_product: "Créer un produit"
successfully_deleted: "Le produit a bien été supprimé"
unable_to_delete: "Impossible de supprimer le produit : "
filter: "Filter"
filter_clear: "Tout effacer"
filter_apply: "Appliquer"
@ -2238,6 +2273,7 @@ fr:
sort: "Trier :"
visible_only: "Produits visibles uniquement"
product_item:
product: "produit"
visible: "visible"
hidden: "caché"
stock:
@ -2294,6 +2330,7 @@ fr:
stocks: "Stock :"
internal: "Stock interne"
external: "Stock externe"
edit: "Modifier"
all: "Tous types"
remaining_stock: "Stock restant"
type_in: "Ajouter"

View File

@ -2,8 +2,8 @@
app:
admin:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
deleted: "Successfully deleted."
unable_to_delete: "Unable to delete: "
delete_item: "Delete the {TYPE}"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this {TYPE}?"
@ -152,12 +152,19 @@
every_month: "Every month"
every_year: "Every year"
plan_form:
general_information: "General information"
ACTION_title: "{ACTION, select, create{New} other{Update the}} plan"
tab_settings: "Settings"
tab_usage_limits: "Usage limits"
description: "Description"
general_settings: "General settings"
general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods."
activation_and_payment: "Subscription activation and payment"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
group: "Group"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
display: "Display"
category: "Category"
category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions."
number_of_periods: "Number of periods"
@ -173,10 +180,9 @@
rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
description: "Description"
information_sheet: "Information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
new_user: "New user"
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
@ -185,9 +191,39 @@
partner_plan: "Partner plan"
partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan."
partner_created: "The partner was successfully created"
ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan"
slots_visibility: "Slots visibility"
slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings."
machines_visibility: "Visibility time limit, in hours (machines)"
save: "Save"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
plan_limit_form:
usage_limitation: "Limitation of use"
usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation."
usage_limitation_switch: "Restrict machine reservations to a number of hours per day."
new_usage_limitation: "Add a limitation of use"
all_limitations: "All limitations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
cancel_deletion: "Cancel"
ongoing_deletion: "Ongoing deletion"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
categories_info: "If you select all machine categories, the limits will apply across the board."
machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten."
max_hours_per_day: "Maximum number of reservation hours per day"
confirm: "Confirm"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
@ -196,6 +232,7 @@
email: "Email address"
plan_pricing_form:
prices: "Prices"
about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour."
copy_prices_from: "Copy prices from"
copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan"
machines: "Machines"
@ -286,7 +323,7 @@
manage_trainings: "Klikk her for å legge til eller endre opplæring."
number_of_tickets: "Antall billetter: "
adjust_the_opening_hours: "Endre åpningstid"
to_time: "til" #eg. from 18:00 to 21:00
to_time: "til" #e.g. from 18:00 to 21:00
restrict_options: "Alternativer for begrensning"
restrict_with_labels: "Begrens denne reservasjonen med etiketter"
restrict_for_subscriptions: "Begrens denne reservasjoen til medlemmer"
@ -508,8 +545,8 @@
on_DATE: "{DATE}"
from_DATE: "fra {DATE}"
from_TIME: "fra {TIME}"
to_date: "til" #eg: from 01/01 to 01/05
to_time: "til" #eg. from 18:00 to 21:00
to_date: "til" #e.g.: from 01/01 to 01/05
to_time: "til" #e.g. from 18:00 to 21:00
title: "Tittel"
dates: "Datoer"
booking: "Reservasjon"
@ -1453,8 +1490,8 @@
statistics: "Statistikk"
evolution: "Utvikling"
age_filter: "Aldersfilter"
from_age: "Fra" #eg. from 8 to 40 years old
to_age: "til" #eg. from 8 to 40 years old
from_age: "Fra" #e.g. from 8 to 40 years old
to_age: "til" #e.g. from 8 to 40 years old
start: "Start:"
end: "Slutt:"
custom_filter: "Egendefinerte filtre"
@ -2215,8 +2252,6 @@
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
all_products: "All products"
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
@ -2238,6 +2273,7 @@
sort: "Sort:"
visible_only: "Visible products only"
product_item:
product: "product"
visible: "visible"
hidden: "hidden"
stock:
@ -2294,6 +2330,7 @@
stocks: "Stock:"
internal: "Private stock"
external: "Public stock"
edit: "Edit"
all: "All types"
remaining_stock: "Remaining stock"
type_in: "Add"

View File

@ -2,8 +2,8 @@ pt:
app:
admin:
edit_destroy_buttons:
deleted: "The {TYPE} was successfully deleted."
unable_to_delete: "Unable to delete the {TYPE}: "
deleted: "Successfully deleted."
unable_to_delete: "Unable to delete: "
delete_item: "Delete the {TYPE}"
confirm_delete: "Delete"
delete_confirmation: "Are you sure you want to delete this {TYPE}?"
@ -152,12 +152,19 @@ pt:
every_month: "Every month"
every_year: "Every year"
plan_form:
general_information: "General information"
ACTION_title: "{ACTION, select, create{New} other{Update the}} plan"
tab_settings: "Settings"
tab_usage_limits: "Usage limits"
description: "Description"
general_settings: "General settings"
general_settings_info: "Determine to which group this subscription is dedicated. Also set its price and duration in periods."
activation_and_payment: "Subscription activation and payment"
name: "Name"
name_max_length: "Name length must be less than 24 characters."
group: "Group"
transversal: "Transversal plan"
transversal_help: "If this option is checked, a copy of this plan will be created for each currently enabled groups."
display: "Display"
category: "Category"
category_help: "Categories allow you to group the subscription plans, on the public view of the subscriptions."
number_of_periods: "Number of periods"
@ -173,10 +180,9 @@ pt:
rolling_subscription_help: "A rolling subscription will begin the day of the first trainings. Otherwise, it will begin as soon as it is bought."
monthly_payment: "Monthly payment?"
monthly_payment_help: "If monthly payment is enabled, the members will be able to choose between a one-time payment or a payment schedule staged each months."
description: "Description"
information_sheet: "Information sheet"
notified_partner: "Notified partner"
new_user: "New user ..."
new_user: "New user"
alert_partner_notification: "As part of a partner subscription, some notifications may be sent to this user."
disabled: "Disable subscription"
disabled_help: "Beware: disabling this plan won't unsubscribe users having active subscriptions with it."
@ -185,9 +191,39 @@ pt:
partner_plan: "Partner plan"
partner_plan_help: "You can sell subscriptions in partnership with another organization. By doing so, the other organization will be notified when a member subscribes to this subscription plan."
partner_created: "The partner was successfully created"
ACTION_plan: "{ACTION, select, create{Create} other{Update}} the plan"
slots_visibility: "Slots visibility"
slots_visibility_help: "You can determine how far in advance subscribers can view and reserve machine slots. When this setting is set, it takes precedence over the general settings."
machines_visibility: "Visibility time limit, in hours (machines)"
save: "Save"
create_success: "Plan(s) successfully created. Don't forget to redefine prices."
update_success: "The plan was updated successfully"
plan_limit_form:
usage_limitation: "Limitation of use"
usage_limitation_info: "Define a maximum number of reservation hours per day and per machine category. Machine categories that have no parameters configured will not be subject to any limitation."
usage_limitation_switch: "Restrict machine reservations to a number of hours per day."
new_usage_limitation: "Add a limitation of use"
all_limitations: "All limitations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
max_hours_per_day: "Max. hours/day"
ongoing_limitations: "Ongoing limitations"
saved_limitations: "Saved limitations"
cancel: "Cancel this limitation"
cancel_deletion: "Cancel"
ongoing_deletion: "Ongoing deletion"
plan_limit_modal:
title: "Manage limitation of use"
limit_reservations: "Limit reservations"
by_category: "By machines category"
by_machine: "By machine"
category: "Machines category"
machine: "Machine name"
categories_info: "If you select all machine categories, the limits will apply across the board."
machine_info: "Please note that if you have already created a limitation for the machines category including the selected machine, it will be permanently overwritten."
max_hours_per_day: "Maximum number of reservation hours per day"
confirm: "Confirm"
partner_modal:
title: "Create a new partner"
create_partner: "Create the partner"
@ -196,6 +232,7 @@ pt:
email: "Email address"
plan_pricing_form:
prices: "Prices"
about_prices: "The prices defined here will apply to members subscribing to this plan, for machines and spaces. All prices are per hour."
copy_prices_from: "Copy prices from"
copy_prices_from_help: "This will replace all the prices of this plan with the prices of the selected plan"
machines: "Machines"
@ -286,7 +323,7 @@ pt:
manage_trainings: "Clique aqui para adicionar ou remover treinamentos."
number_of_tickets: "Número de vagas: "
adjust_the_opening_hours: "Ajustar o horário de funcionamento"
to_time: "ás" #eg. from 18:00 to 21:00
to_time: "ás" #e.g. from 18:00 to 21:00
restrict_options: "Opções de restrição"
restrict_with_labels: "Restrinja este slot com etiquetas"
restrict_for_subscriptions: "Restringir este slot para os usuários da assinatura"
@ -508,8 +545,8 @@ pt:
on_DATE: "No {DATE}"
from_DATE: "Em {DATE}"
from_TIME: "Ás {TIME}"
to_date: "ás" #eg: from 01/01 to 01/05
to_time: "ás" #eg. from 18:00 to 21:00
to_date: "ás" #e.g.: from 01/01 to 01/05
to_time: "ás" #e.g. from 18:00 to 21:00
title: "Título"
dates: "Datas"
booking: "Reserva"
@ -1453,8 +1490,8 @@ pt:
statistics: "Estatísticas"
evolution: "Evolução"
age_filter: "Filtro de idade"
from_age: "Dos" #eg. from 8 to 40 years old
to_age: "aos" #eg. from 8 to 40 years old
from_age: "Dos" #e.g. from 8 to 40 years old
to_age: "aos" #e.g. from 8 to 40 years old
start: "Início:"
end: "Fim:"
custom_filter: "Filtro customizado"
@ -2215,8 +2252,6 @@ pt:
unexpected_error_occurred: "An unexpected error occurred. Please try again later."
all_products: "All products"
create_a_product: "Create a product"
successfully_deleted: "The product has been successfully deleted"
unable_to_delete: "Unable to delete the product: "
filter: "Filter"
filter_clear: "Clear all"
filter_apply: "Apply"
@ -2238,6 +2273,7 @@ pt:
sort: "Sort:"
visible_only: "Visible products only"
product_item:
product: "product"
visible: "visible"
hidden: "hidden"
stock:
@ -2294,6 +2330,7 @@ pt:
stocks: "Stock:"
internal: "Private stock"
external: "Public stock"
edit: "Edit"
all: "All types"
remaining_stock: "Remaining stock"
type_in: "Add"

View File

@ -2,8 +2,8 @@ zu:
app:
admin:
edit_destroy_buttons:
deleted: "crwdns36793:0{TYPE}crwdne36793:0"
unable_to_delete: "crwdns36795:0{TYPE}crwdne36795:0"
deleted: "crwdns36793:0crwdne36793:0"
unable_to_delete: "crwdns36795:0crwdne36795:0"
delete_item: "crwdns36797:0{TYPE}crwdne36797:0"
confirm_delete: "crwdns36799:0crwdne36799:0"
delete_confirmation: "crwdns36801:0{TYPE}crwdne36801:0"
@ -152,12 +152,19 @@ zu:
every_month: "crwdns31885:0crwdne31885:0"
every_year: "crwdns31887:0crwdne31887:0"
plan_form:
general_information: "crwdns31889:0crwdne31889:0"
ACTION_title: "crwdns37397:0ACTION={ACTION}crwdne37397:0"
tab_settings: "crwdns37399:0crwdne37399:0"
tab_usage_limits: "crwdns37401:0crwdne37401:0"
description: "crwdns31931:0crwdne31931:0"
general_settings: "crwdns37403:0crwdne37403:0"
general_settings_info: "crwdns37405:0crwdne37405:0"
activation_and_payment: "crwdns37407:0crwdne37407:0"
name: "crwdns31891:0crwdne31891:0"
name_max_length: "crwdns31893:0crwdne31893:0"
group: "crwdns31895:0crwdne31895:0"
transversal: "crwdns31897:0crwdne31897:0"
transversal_help: "crwdns31899:0crwdne31899:0"
display: "crwdns37409:0crwdne37409:0"
category: "crwdns31901:0crwdne31901:0"
category_help: "crwdns31903:0crwdne31903:0"
number_of_periods: "crwdns31905:0crwdne31905:0"
@ -173,7 +180,6 @@ zu:
rolling_subscription_help: "crwdns31925:0crwdne31925:0"
monthly_payment: "crwdns31927:0crwdne31927:0"
monthly_payment_help: "crwdns31929:0crwdne31929:0"
description: "crwdns31931:0crwdne31931:0"
information_sheet: "crwdns31933:0crwdne31933:0"
notified_partner: "crwdns31935:0crwdne31935:0"
new_user: "crwdns31937:0crwdne31937:0"
@ -185,9 +191,39 @@ zu:
partner_plan: "crwdns31949:0crwdne31949:0"
partner_plan_help: "crwdns31951:0crwdne31951:0"
partner_created: "crwdns31953:0crwdne31953:0"
ACTION_plan: "crwdns31955:0ACTION={ACTION}crwdne31955:0"
slots_visibility: "crwdns37491:0crwdne37491:0"
slots_visibility_help: "crwdns37493:0crwdne37493:0"
machines_visibility: "crwdns37495:0crwdne37495:0"
save: "crwdns37411:0crwdne37411:0"
create_success: "crwdns31957:0crwdne31957:0"
update_success: "crwdns31959:0crwdne31959:0"
plan_limit_form:
usage_limitation: "crwdns37413:0crwdne37413:0"
usage_limitation_info: "crwdns37415:0crwdne37415:0"
usage_limitation_switch: "crwdns37417:0crwdne37417:0"
new_usage_limitation: "crwdns37419:0crwdne37419:0"
all_limitations: "crwdns37421:0crwdne37421:0"
by_category: "crwdns37477:0crwdne37477:0"
by_machine: "crwdns37425:0crwdne37425:0"
category: "crwdns37427:0crwdne37427:0"
machine: "crwdns37429:0crwdne37429:0"
max_hours_per_day: "crwdns37431:0crwdne37431:0"
ongoing_limitations: "crwdns37433:0crwdne37433:0"
saved_limitations: "crwdns37435:0crwdne37435:0"
cancel: "crwdns37437:0crwdne37437:0"
cancel_deletion: "crwdns37487:0crwdne37487:0"
ongoing_deletion: "crwdns37489:0crwdne37489:0"
plan_limit_modal:
title: "crwdns37445:0crwdne37445:0"
limit_reservations: "crwdns37447:0crwdne37447:0"
by_category: "crwdns37479:0crwdne37479:0"
by_machine: "crwdns37451:0crwdne37451:0"
category: "crwdns37453:0crwdne37453:0"
machine: "crwdns37455:0crwdne37455:0"
categories_info: "crwdns37457:0crwdne37457:0"
machine_info: "crwdns37459:0crwdne37459:0"
max_hours_per_day: "crwdns37461:0crwdne37461:0"
confirm: "crwdns37463:0crwdne37463:0"
partner_modal:
title: "crwdns31961:0crwdne31961:0"
create_partner: "crwdns31963:0crwdne31963:0"
@ -196,6 +232,7 @@ zu:
email: "crwdns31969:0crwdne31969:0"
plan_pricing_form:
prices: "crwdns31971:0crwdne31971:0"
about_prices: "crwdns37465:0crwdne37465:0"
copy_prices_from: "crwdns31973:0crwdne31973:0"
copy_prices_from_help: "crwdns31975:0crwdne31975:0"
machines: "crwdns31977:0crwdne31977:0"
@ -286,7 +323,7 @@ zu:
manage_trainings: "crwdns24132:0crwdne24132:0"
number_of_tickets: "crwdns24134:0crwdne24134:0"
adjust_the_opening_hours: "crwdns24136:0crwdne24136:0"
to_time: "crwdns24138:0crwdne24138:0" #eg. from 18:00 to 21:00
to_time: "crwdns24138:0crwdne24138:0" #e.g. from 18:00 to 21:00
restrict_options: "crwdns24140:0crwdne24140:0"
restrict_with_labels: "crwdns24142:0crwdne24142:0"
restrict_for_subscriptions: "crwdns24144:0crwdne24144:0"
@ -508,8 +545,8 @@ zu:
on_DATE: "crwdns24452:0{DATE}crwdne24452:0"
from_DATE: "crwdns24454:0{DATE}crwdne24454:0"
from_TIME: "crwdns24456:0{TIME}crwdne24456:0"
to_date: "crwdns24458:0crwdne24458:0" #eg: from 01/01 to 01/05
to_time: "crwdns24460:0crwdne24460:0" #eg. from 18:00 to 21:00
to_date: "crwdns24458:0crwdne24458:0" #e.g.: from 01/01 to 01/05
to_time: "crwdns24460:0crwdne24460:0" #e.g. from 18:00 to 21:00
title: "crwdns24462:0crwdne24462:0"
dates: "crwdns24464:0crwdne24464:0"
booking: "crwdns24466:0crwdne24466:0"
@ -1453,8 +1490,8 @@ zu:
statistics: "crwdns26224:0crwdne26224:0"
evolution: "crwdns26226:0crwdne26226:0"
age_filter: "crwdns26228:0crwdne26228:0"
from_age: "crwdns26230:0crwdne26230:0" #eg. from 8 to 40 years old
to_age: "crwdns26232:0crwdne26232:0" #eg. from 8 to 40 years old
from_age: "crwdns26230:0crwdne26230:0" #e.g. from 8 to 40 years old
to_age: "crwdns26232:0crwdne26232:0" #e.g. from 8 to 40 years old
start: "crwdns26234:0crwdne26234:0"
end: "crwdns26236:0crwdne26236:0"
custom_filter: "crwdns26238:0crwdne26238:0"
@ -2215,8 +2252,6 @@ zu:
unexpected_error_occurred: "crwdns31338:0crwdne31338:0"
all_products: "crwdns31340:0crwdne31340:0"
create_a_product: "crwdns31342:0crwdne31342:0"
successfully_deleted: "crwdns31344:0crwdne31344:0"
unable_to_delete: "crwdns31346:0crwdne31346:0"
filter: "crwdns31348:0crwdne31348:0"
filter_clear: "crwdns31350:0crwdne31350:0"
filter_apply: "crwdns31352:0crwdne31352:0"
@ -2238,6 +2273,7 @@ zu:
sort: "crwdns31380:0crwdne31380:0"
visible_only: "crwdns31382:0crwdne31382:0"
product_item:
product: "crwdns37467:0crwdne37467:0"
visible: "crwdns31384:0crwdne31384:0"
hidden: "crwdns31386:0crwdne31386:0"
stock:
@ -2294,6 +2330,7 @@ zu:
stocks: "crwdns31480:0crwdne31480:0"
internal: "crwdns31482:0crwdne31482:0"
external: "crwdns31484:0crwdne31484:0"
edit: "crwdns37469:0crwdne37469:0"
all: "crwdns31486:0crwdne31486:0"
remaining_stock: "crwdns31488:0crwdne31488:0"
type_in: "crwdns31490:0crwdne31490:0"

View File

@ -540,3 +540,6 @@ de:
show_reserved_uniq: "Show only slots with reservations"
machine:
machine_uncategorized: "Uncategorized machines"
form_unsaved_list:
save_reminder: "Do not forget to save your changes"
cancel: "Cancel"

View File

@ -540,3 +540,6 @@ en:
show_reserved_uniq: "Show only slots with reservations"
machine:
machine_uncategorized: "Uncategorized machines"
form_unsaved_list:
save_reminder: "Do not forget to save your changes"
cancel: "Cancel"

View File

@ -540,3 +540,6 @@ es:
show_reserved_uniq: "Show only slots with reservations"
machine:
machine_uncategorized: "Uncategorized machines"
form_unsaved_list:
save_reminder: "Do not forget to save your changes"
cancel: "Cancel"

View File

@ -540,3 +540,6 @@ fr:
show_reserved_uniq: "Afficher uniquement les créneaux avec réservation"
machine:
machine_uncategorized: "Machines non classés"
form_unsaved_list:
save_reminder: "N'oubliez pas d'enregistrer vos modifications"
cancel: "Annuler"

View File

@ -540,3 +540,6 @@
show_reserved_uniq: "Show only slots with reservations"
machine:
machine_uncategorized: "Uncategorized machines"
form_unsaved_list:
save_reminder: "Do not forget to save your changes"
cancel: "Cancel"

View File

@ -540,3 +540,6 @@ pt:
show_reserved_uniq: "Show only slots with reservations"
machine:
machine_uncategorized: "Uncategorized machines"
form_unsaved_list:
save_reminder: "Do not forget to save your changes"
cancel: "Cancel"

View File

@ -540,3 +540,6 @@ zu:
show_reserved_uniq: "crwdns36249:0crwdne36249:0"
machine:
machine_uncategorized: "crwdns36219:0crwdne36219:0"
form_unsaved_list:
save_reminder: "crwdns37471:0crwdne37471:0"
cancel: "crwdns37473:0crwdne37473:0"

View File

@ -433,6 +433,8 @@ de:
schedule_deadline: "Sie müssen den Scheck zur %{DATE} -Frist einlösen, für den Zeitplan %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "Sie müssen das Lastschriftverfahren für die %{DATE} -Frist bestätigen, für Zeitplan %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Supporting document uploaded by member <strong><em>%{NAME}</strong></em>."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ de:
availability: "The availaility doesn't exist"
full: "The slot is already fully reserved"
deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start"
limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription"
restricted: "This availability is restricted for subscribers"
plan: "This subscription plan is disabled"
plan_group: "This subscription plan is reserved for members of group %{GROUP}"

View File

@ -433,6 +433,8 @@ en:
schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Supporting document uploaded by member <strong><em>%{NAME}</strong></em>."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ en:
availability: "The availaility doesn't exist"
full: "The slot is already fully reserved"
deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start"
limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription"
restricted: "This availability is restricted for subscribers"
plan: "This subscription plan is disabled"
plan_group: "This subscription plan is reserved for members of group %{GROUP}"

View File

@ -433,6 +433,8 @@ es:
schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Supporting document uploaded by member <strong><em>%{NAME}</strong></em>."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ es:
availability: "The availaility doesn't exist"
full: "The slot is already fully reserved"
deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start"
limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription"
restricted: "This availability is restricted for subscribers"
plan: "This subscription plan is disabled"
plan_group: "This subscription plan is reserved for members of group %{GROUP}"

View File

@ -433,6 +433,8 @@ fr:
schedule_deadline: "Vous devez encaisser le chèque de l'échéance du %{DATE}, pour l'échéancier %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "Vous devez confirmer le prélèvement bancaire pour l'échéance du %{DATE} , pour l'échéancier %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "Pour le %{DATE}, vous avez atteint votre limite quotidienne de %{HOURS} heures de réservation de la %{ITEM}."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Le membre <strong><em>%{NAME}</strong></em> a téléversé un nouveau justificatif."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ fr:
availability: "La disponibilité n'existe pas"
full: "Le créneau est déjà entièrement réservé"
deadline: "Vous ne pouvez pas réserver un créneau %{MINUTES} minutes avant son début"
limit_reached: "Vous avez atteint la limite de réservation de %{HOURS}H par jour pour la %{RESERVABLE}, pour votre abonnement actuel"
restricted: "Cette disponibilité n'est disponible que pour les abonnés"
plan: "Cette formule d'abonnement est désactivé"
plan_group: "Cette formule d'abonnement est réservée aux membres du groupe %{GROUP}"

View File

@ -375,6 +375,10 @@ de:
remember: "Gemäß Ihrem Zahlungsplan von %{REFERENCE} wurde zum %{DATE} eine Belastung der Karte in Höhe von %{AMOUNT} geplant."
date: "Dies ist eine Erinnerung zur Prüfung, ob das Bankkonto erfolgreich belastet werden konnte."
confirm: "Bitte bestätigen Sie den Erhalt des Guthabens in Ihrer Zahlungsverwaltung, damit die entsprechende Rechnung generiert werden kann."
notify_member_reservation_limit_reached:
subject: "Daily reservation limit reached"
body:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
subject: "Supporting documents uploaded by a member"
body:

View File

@ -375,6 +375,10 @@ en:
remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}."
date: "This is a reminder to verify that the direct bank debit was successfull."
confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated."
notify_member_reservation_limit_reached:
subject: "Daily reservation limit reached"
body:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
subject: "Supporting documents uploaded by a member"
body:

View File

@ -375,6 +375,10 @@ es:
remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}."
date: "This is a reminder to verify that the direct bank debit was successfull."
confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated."
notify_member_reservation_limit_reached:
subject: "Daily reservation limit reached"
body:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
subject: "Supporting documents uploaded by a member"
body:

View File

@ -375,6 +375,10 @@ fr:
remember: "Conformément à l'échéancier de paiement %{REFERENCE}, une échéance de %{AMOUNT} était prévu pour être prélevée le %{DATE}."
date: "Ceci est un rappel pour vérifier que le prélèvement bancaire a bien été effectué."
confirm: "Veuillez confirmer la réception des fonds dans votre interface de gestion des échéanciers de paiement, afin que la facture correspondante soit générée."
notify_member_reservation_limit_reached:
subject: "Limite de réservation quotidienne atteinte"
body:
limit_reached: "Pour le %{DATE}, vous avez atteint votre limite quotidienne de %{HOURS} heures de réservation de la %{ITEM}."
notify_admin_user_supporting_document_files_created:
subject: "Justificatif téléversé par un membre"
body:

View File

@ -375,6 +375,10 @@
remember: "In accordance with your %{REFERENCE} payment schedule, %{AMOUNT} was due to be debited on %{DATE}."
date: "This is a reminder to verify that the direct bank debit was successfull."
confirm: "Please confirm the receipt of funds in your payment schedule management interface, so that the corresponding invoice will be generated."
notify_member_reservation_limit_reached:
subject: "Daily reservation limit reached"
body:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
subject: "Supporting documents uploaded by a member"
body:

View File

@ -375,6 +375,10 @@ pt:
remember: "De acordo com a agenda de pagamento %{REFERENCE}, %{AMOUNT} deveria ser debitado em %{DATE}."
date: "Este é um lembrete para verificar se o débito bancário foi bem sucedido."
confirm: "Não se esqueça de confirmar o recibo na interface de gestão de pagamento, para que a fatura correspondente seja gerada."
notify_member_reservation_limit_reached:
subject: "Daily reservation limit reached"
body:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
subject: "Supporting documents uploaded by a member"
body:

View File

@ -375,6 +375,10 @@ zu:
remember: "crwdns29938:0%{REFERENCE}crwdnd29938:0%{AMOUNT}crwdnd29938:0%{DATE}crwdne29938:0"
date: "crwdns29940:0crwdne29940:0"
confirm: "crwdns29942:0crwdne29942:0"
notify_member_reservation_limit_reached:
subject: "crwdns37483:0crwdne37483:0"
body:
limit_reached: "crwdns37485:0%{DATE}crwdnd37485:0%{HOURS}crwdnd37485:0%{ITEM}crwdne37485:0"
notify_admin_user_supporting_document_files_created:
subject: "crwdns37349:0crwdne37349:0"
body:

View File

@ -433,6 +433,8 @@
schedule_deadline: "You must cash the check for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "You must confirm the bank direct debit for the %{DATE} deadline, for schedule %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Supporting document uploaded by member <strong><em>%{NAME}</strong></em>."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@
availability: "The availaility doesn't exist"
full: "The slot is already fully reserved"
deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start"
limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription"
restricted: "This availability is restricted for subscribers"
plan: "This subscription plan is disabled"
plan_group: "This subscription plan is reserved for members of group %{GROUP}"

View File

@ -433,6 +433,8 @@ pt:
schedule_deadline: "Você deve realizar a verificação para a data limite de %{DATE} para agendar %{REFERENCE}"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "Você deve realizar a verificação do débito para a data limite de %{DATE}, para o agendamento %{REFERENCE}"
notify_member_reservation_limit_reached:
limit_reached: "For %{DATE}, you have reached your daily limit of %{HOURS} hours of %{ITEM} reservation."
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "Supporting document uploaded by member <strong><em>%{NAME}</strong></em>."
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ pt:
availability: "The availaility doesn't exist"
full: "The slot is already fully reserved"
deadline: "You can't reserve a slot %{MINUTES} minutes prior to its start"
limit_reached: "You have reached the booking limit of %{HOURS}H per day for the %{RESERVABLE}, for your current subscription"
restricted: "This availability is restricted for subscribers"
plan: "This subscription plan is disabled"
plan_group: "This subscription plan is reserved for members of group %{GROUP}"

View File

@ -433,6 +433,8 @@ zu:
schedule_deadline: "crwdns21120:0%{DATE}crwdnd21120:0%{REFERENCE}crwdne21120:0"
notify_admin_payment_schedule_transfer_deadline:
schedule_deadline: "crwdns22305:0%{DATE}crwdnd22305:0%{REFERENCE}crwdne22305:0"
notify_member_reservation_limit_reached:
limit_reached: "crwdns37481:0%{DATE}crwdnd37481:0%{HOURS}crwdnd37481:0%{ITEM}crwdne37481:0"
notify_admin_user_supporting_document_files_created:
supporting_document_files_uploaded: "crwdns37341:0%{NAME}crwdne37341:0"
notify_admin_user_supporting_document_files_updated:
@ -519,6 +521,7 @@ zu:
availability: "crwdns36269:0crwdne36269:0"
full: "crwdns36271:0crwdne36271:0"
deadline: "crwdns36273:0%{MINUTES}crwdne36273:0"
limit_reached: "crwdns37475:0%{HOURS}crwdnd37475:0%{RESERVABLE}crwdne37475:0"
restricted: "crwdns36275:0crwdne36275:0"
plan: "crwdns36277:0crwdne36277:0"
plan_group: "crwdns37207:0%{GROUP}crwdne37207:0"

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
# From this migration, any subscription plan can define restrictions on the reservation of resources
class AddLimitingToPlan < ActiveRecord::Migration[5.2]
def change
add_column :plans, :limiting, :boolean
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
# This table saves the restrictions settings, per plan and resource
class CreatePlanLimitations < ActiveRecord::Migration[5.2]
def change
create_table :plan_limitations do |t|
t.references :plan, foreign_key: true, index: true, null: false
t.references :limitable, polymorphic: true, null: false
t.integer :limit, null: false, default: 0
t.timestamps
end
add_index :plan_limitations, %i[plan_id limitable_id limitable_type], unique: true, name: 'index_plan_limitations_on_plan_and_limitable'
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal:true
# From this migration, we add a machines_visibility parameter to plans.
# This parameter determines how far in advance subscribers can view and reserve machine slots.
class AddMachineVisibilityToPlan < ActiveRecord::Migration[5.2]
def change
add_column :plans, :machines_visibility, :integer
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2023_03_09_094535) do
ActiveRecord::Schema.define(version: 2023_03_15_095054) do
# These are extensions that must be enabled in order to support this database
enable_extension "fuzzystrmatch"
@ -19,8 +19,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
enable_extension "unaccent"
create_table "abuses", id: :serial, force: :cascade do |t|
t.integer "signaled_id"
t.string "signaled_type"
t.integer "signaled_id"
t.string "first_name"
t.string "last_name"
t.string "email"
@ -68,8 +68,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
t.string "locality"
t.string "country"
t.string "postal_code"
t.integer "placeable_id"
t.string "placeable_type"
t.integer "placeable_id"
t.datetime "created_at"
t.datetime "updated_at"
end
@ -93,8 +93,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
end
create_table "assets", id: :serial, force: :cascade do |t|
t.integer "viewable_id"
t.string "viewable_type"
t.integer "viewable_id"
t.string "attachment"
t.string "type"
t.datetime "created_at"
@ -164,10 +164,10 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
create_table "cart_item_event_reservation_tickets", force: :cascade do |t|
t.integer "booked"
t.bigint "event_price_category_id"
t.bigint "cart_item_event_reservation_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.bigint "event_price_category_id"
t.index ["cart_item_event_reservation_id"], name: "index_cart_item_tickets_on_cart_item_event_reservation"
t.index ["event_price_category_id"], name: "index_cart_item_tickets_on_event_price_category"
end
@ -282,8 +282,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
end
create_table "credits", id: :serial, force: :cascade do |t|
t.integer "creditable_id"
t.string "creditable_type"
t.integer "creditable_id"
t.integer "plan_id"
t.integer "hours"
t.datetime "created_at"
@ -546,15 +546,15 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
create_table "notifications", id: :serial, force: :cascade do |t|
t.integer "receiver_id"
t.integer "attached_object_id"
t.string "attached_object_type"
t.integer "attached_object_id"
t.integer "notification_type_id"
t.boolean "is_read", default: false
t.datetime "created_at"
t.datetime "updated_at"
t.string "receiver_type"
t.boolean "is_send", default: false
t.jsonb "meta_data", default: {}
t.jsonb "meta_data", default: "{}"
t.index ["notification_type_id"], name: "index_notifications_on_notification_type_id"
t.index ["receiver_id"], name: "index_notifications_on_receiver_id"
end
@ -740,13 +740,14 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
end
create_table "plan_limitations", force: :cascade do |t|
t.bigint "plan_id"
t.string "limitable_type"
t.bigint "limitable_id"
t.bigint "plan_id", null: false
t.string "limitable_type", null: false
t.bigint "limitable_id", null: false
t.integer "limit", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["limitable_type", "limitable_id"], name: "index_plan_limitations_on_limitable_type_and_limitable_id"
t.index ["plan_id", "limitable_id", "limitable_type"], name: "index_plan_limitations_on_plan_and_limitable", unique: true
t.index ["plan_id"], name: "index_plan_limitations_on_plan_id"
end
@ -770,6 +771,7 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
t.boolean "monthly_payment"
t.bigint "plan_category_id"
t.boolean "limiting"
t.integer "machines_visibility"
t.index ["group_id"], name: "index_plans_on_group_id"
t.index ["plan_category_id"], name: "index_plans_on_plan_category_id"
end
@ -811,15 +813,14 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
t.text "conditions"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index "btrim(lower((name)::text))", name: "index_price_categories_on_TRIM_BOTH_FROM_LOWER_name", unique: true
t.index ["name"], name: "index_price_categories_on_name", unique: true
end
create_table "prices", id: :serial, force: :cascade do |t|
t.integer "group_id"
t.integer "plan_id"
t.integer "priceable_id"
t.string "priceable_type"
t.integer "priceable_id"
t.integer "amount"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -983,8 +984,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
t.text "message"
t.datetime "created_at"
t.datetime "updated_at"
t.integer "reservable_id"
t.string "reservable_type"
t.integer "reservable_id"
t.integer "nb_reserve_places"
t.integer "statistic_profile_id"
t.index ["reservable_type", "reservable_id"], name: "index_reservations_on_reservable_type_and_reservable_id"
@ -993,8 +994,8 @@ ActiveRecord::Schema.define(version: 2023_03_09_094535) do
create_table "roles", id: :serial, force: :cascade do |t|
t.string "name"
t.integer "resource_id"
t.string "resource_type"
t.integer "resource_id"
t.datetime "created_at"
t.datetime "updated_at"
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"

View File

@ -23,3 +23,11 @@ unless NotificationType.find_by(name: 'notify_admin_order_is_paid')
is_configurable: true
)
end
unless NotificationType.find_by(name: 'notify_member_reservation_limit_reached')
NotificationType.create!(
name: 'notify_member_reservation_limit_reached',
category: 'agenda',
is_configurable: false
)
end

View File

@ -161,7 +161,7 @@
"react-cool-onclickoutside": "^1.7.0",
"react-custom-events": "^1.1.1",
"react-dom": "^17.0.2",
"react-hook-form": "^7.30.0",
"react-hook-form": "~7.31.3",
"react-i18next": "^11.15.6",
"react-modal": "^3.16.1",
"react-select": "^5.3.2",

View File

@ -34,7 +34,7 @@ list_files() {
update_file() {
# params: FILE_ID, STORAGE_ID
curl -s -X PUT "https://api.crowdin.com/api/v2/projects/$PROJECT_ID/files/$1" -H "$(authorization)" -H "Content-Type: application/json" -d "{ \"storageId\": $2, \"updateOption\": \"keep_translations_and_approvals\" }"
curl -s -X PUT "https://api.crowdin.com/api/v2/projects/$PROJECT_ID/files/$1" -H "$(authorization)" -H "Content-Type: application/json" -d "{ \"storageId\": $2 }"
}
find_file_id() {

View File

@ -1,3 +1,4 @@
# admin without subscription
user_1:
id: 1
username: admin
@ -30,6 +31,7 @@ user_1:
merged_at:
is_allow_newsletter: true
# member without subscription
user_2:
id: 2
username: jdupond
@ -62,6 +64,7 @@ user_2:
merged_at:
is_allow_newsletter: true
# member with 1 month subscription (plan 2/standard)
user_3:
id: 3
username: pdurand
@ -94,6 +97,7 @@ user_3:
merged_at:
is_allow_newsletter: false
# member with 1 month subscription (plan 3/students)
user_4:
id: 4
username: kdumas
@ -126,6 +130,7 @@ user_4:
merged_at:
is_allow_newsletter: false
# member with 10€ on wallet
user_5:
id: 5
username: vlonchamp
@ -158,6 +163,7 @@ user_5:
merged_at:
is_allow_newsletter: true
# partner of plan 2
user_6:
id: 6
username: GilbertPartenaire
@ -190,6 +196,7 @@ user_6:
merged_at:
is_allow_newsletter: true
# member with 255€ on wallet
user_7:
id: 7
username: lseguin
@ -286,6 +293,7 @@ user_9:
merged_at:
is_allow_newsletter: true
# member with 1 year subscription
user_10:
id: 10
username: acamus

View File

@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import plans from '../../__fixtures__/plans';
import machines from '../../__fixtures__/machines';
import { tiptapEvent } from '../../__lib__/tiptap';
import { uiRouter } from '../../__lib__/ui-router';
describe('PlanForm', () => {
const onError = jest.fn();
@ -14,7 +15,7 @@ describe('PlanForm', () => {
const beforeSubmit = jest.fn();
test('render create PlanForm', async () => {
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} />);
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ }));
expect(screen.getByLabelText(/app.admin.plan_form.name/)).toBeInTheDocument();
expect(screen.getByLabelText(/app.admin.plan_form.transversal/)).toBeInTheDocument();
@ -31,11 +32,11 @@ describe('PlanForm', () => {
expect(screen.getByLabelText(/app.admin.plan_form.period/)).toBeInTheDocument();
expect(screen.getByLabelText(/app.admin.plan_form.partner_plan/)).toBeInTheDocument();
expect(screen.queryByTestId('plan-pricing-form')).toBeNull();
expect(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /app.admin.plan_form.save/ })).toBeInTheDocument();
});
test('create new plan', async () => {
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} beforeSubmit={beforeSubmit} />);
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} beforeSubmit={beforeSubmit} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ }));
const user = userEvent.setup();
// base_name
@ -66,7 +67,7 @@ describe('PlanForm', () => {
// advanced_accounting_attributes.analytical_section
fireEvent.change(screen.getByLabelText(/app.admin.advanced_accounting_form.analytical_section/), { target: { value: '9B20A' } });
// send the form
fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ }));
fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.save/ }));
await waitFor(() => {
const expected: Plan = {
base_name: 'Test Plan',
@ -98,7 +99,7 @@ describe('PlanForm', () => {
test('render update PlanForm with partner', async () => {
const plan = plans[1];
render(<PlanForm action="update" plan={plan} onError={onError} onSuccess={onSuccess} />);
render(<PlanForm action="update" plan={plan} onError={onError} onSuccess={onSuccess} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_pricing_form.copy_prices_from/ }));
expect(screen.getByLabelText(/app.admin.plan_form.name/)).toBeInTheDocument();
expect(screen.queryByLabelText(/app.admin.plan_form.transversal/)).toBeNull();
@ -119,18 +120,18 @@ describe('PlanForm', () => {
expect(screen.getByLabelText(/app.admin.plan_form.notified_partner/)).toBeInTheDocument();
expect(screen.getByText(/app.admin.plan_form.alert_partner_notification/)).toBeInTheDocument();
expect(screen.getByTestId('plan-pricing-form')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /app.admin.plan_form.save/ })).toBeInTheDocument();
});
test('selecting transversal plan disables group select', async () => {
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} />);
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ }));
fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.transversal/ }));
expect(screen.queryByRole('combobox', { name: /app.admin.plan_form.group/ })).toBeNull();
});
test('selecting partner plan shows partner selection', async () => {
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} />);
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ }));
fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.partner_plan/ }));
expect(screen.getByLabelText(/app.admin.plan_form.notified_partner/));
@ -138,7 +139,7 @@ describe('PlanForm', () => {
});
test('creating a new partner selects him by default', async () => {
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} />);
render(<PlanForm action="create" onError={onError} onSuccess={onSuccess} uiRouter={uiRouter} />);
await waitFor(() => screen.getByRole('combobox', { name: /app.admin.plan_form.group/ }));
fireEvent.click(screen.getByRole('switch', { name: /app.admin.plan_form.partner_plan/ }));
fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.new_user/ }));
@ -157,12 +158,12 @@ describe('PlanForm', () => {
test('update plan prices', async () => {
const plan = plans[1];
const machine = machines[1];
render(<PlanForm action="update" plan={plan} onError={onError} onSuccess={onSuccess} beforeSubmit={beforeSubmit} />);
render(<PlanForm action="update" plan={plan} onError={onError} onSuccess={onSuccess} beforeSubmit={beforeSubmit} uiRouter={uiRouter} />);
await waitFor(() => screen.getByLabelText(new RegExp(machine.name)));
// update machine price
fireEvent.change(screen.getByLabelText(new RegExp(machine.name)), { target: { value: 42.42 } });
// send the form
fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.ACTION_plan/ }));
fireEvent.click(screen.getByRole('button', { name: /app.admin.plan_form.save/ }));
await waitFor(() => {
const expected = {
prices_attributes: expect.arrayContaining([{

View File

@ -72,7 +72,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}.to_json, headers: default_headers
end
assert_equal 201, response.status
assert_equal 201, response.status, response.body
assert_equal reservations_count + 1, Reservation.count
assert_equal invoices_count + 1, Invoice.count
@ -105,7 +105,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}
}
assert_equal 201, response.status
assert_equal 201, response.status, response.body
# Check the id
availability = json_response(response.body)
@ -141,7 +141,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}.to_json, headers: default_headers
end
assert_equal 422, response.status
assert_equal 422, response.status, response.body
assert_match(/availability is restricted for subscribers/, response.body)
assert_equal reservations_count, Reservation.count
@ -175,7 +175,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}
}
assert_equal 201, response.status
assert_equal 201, response.status, response.body
# Check the id
availability = json_response(response.body)
@ -187,27 +187,25 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
slot = Availability.find(availability[:id]).slots.first
# book a reservation
VCR.use_cassette('reservations_create_for_restricted_slot_forced') do
post '/api/local_payment/confirm_payment',
params: {
customer_id: @jdupont.id,
items: [
{
reservation: {
reservable_id: 2,
reservable_type: 'Machine',
slots_reservations_attributes: [
{
slot_id: slot.id
}
]
}
post '/api/local_payment/confirm_payment',
params: {
customer_id: @jdupont.id,
items: [
{
reservation: {
reservable_id: 2,
reservable_type: 'Machine',
slots_reservations_attributes: [
{
slot_id: slot.id
}
]
}
]
}.to_json, headers: default_headers
end
}
]
}.to_json, headers: default_headers
assert_equal 201, response.status
assert_equal 201, response.status, response.body
# Check the result
result = json_response(response.body)
@ -246,7 +244,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}
}
assert_equal 201, response.status
assert_equal 201, response.status, response.body
# Check the id
availability = json_response(response.body)
@ -287,7 +285,7 @@ class Reservations::RestrictedTest < ActionDispatch::IntegrationTest
}.to_json, headers: default_headers
end
assert_equal 201, response.status
assert_equal 201, response.status, response.body
# Check the result
result = json_response(response.body)

View File

@ -3,7 +3,7 @@
require 'test_helper'
# Test the service returning the availabilities for the given resources
class AvailabilitiesServiceTest < ActiveSupport::TestCase
class Availabilities::AvailabilitiesServiceTest < ActiveSupport::TestCase
setup do
@no_subscription = User.find_by(username: 'jdupond')
@with_subscription = User.find_by(username: 'kdumas')

View File

@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'test_helper'
# Test the service returning the visibility window for availabilities
class Availabilities::VisibilityServiceTest < ActiveSupport::TestCase
setup do
@admin = User.find_by(username: 'admin')
@no_subscription = User.find_by(username: 'jdupond')
@with_subscription = User.find_by(username: 'kdumas')
@with_1y_subscription = User.find_by(username: 'acamus')
# from the fixtures:
# - visibility_others = 1 month
# - visibility_yearly = 3 months
end
test 'admin visibility for the coming month' do
starting = Time.current.beginning_of_day
ending = 1.month.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending)
assert_equal starting, window[0]
assert_equal ending, window[1]
end
test 'admin visibility for the previous month' do
starting = 1.month.ago.end_of_day
ending = Time.current.beginning_of_day
window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending)
assert_equal starting, window[0]
assert_equal ending, window[1]
end
test 'admin visibility for the coming year' do
starting = Time.current.beginning_of_day
ending = 1.year.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@admin, 'space', starting, ending)
assert_equal starting, window[0]
assert_equal ending, window[1]
end
test 'member visibility for the coming month' do
starting = Time.current.beginning_of_day
ending = 1.month.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 1.month.from_now, window[1]
end
test 'member visibility for the previous month' do
starting = 1.month.ago.end_of_day
ending = Time.current.beginning_of_day
window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_equal ending, window[1]
end
test 'member visibility for the coming year' do
starting = Time.current.beginning_of_day
ending = 1.year.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@no_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 1.month.from_now, window[1]
end
test 'subscriber visibility for the coming month' do
starting = Time.current.beginning_of_day
ending = 1.month.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 1.month.from_now, window[1]
end
test 'subscriber visibility for the previous month' do
starting = 1.month.ago.end_of_day
ending = Time.current.beginning_of_day
window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_equal ending, window[1]
end
test 'subscriber visibility for the coming year' do
starting = Time.current.beginning_of_day
ending = 1.year.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 1.month.from_now, window[1]
end
test '1 year subscriber visibility for the coming month' do
starting = Time.current.beginning_of_day
ending = 1.month.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_equal ending, window[1]
end
test '1 year subscriber visibility for the previous month' do
starting = 1.month.ago.end_of_day
ending = Time.current.beginning_of_day
window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_equal ending, window[1]
end
test '1 year subscriber visibility for the coming year' do
starting = Time.current.beginning_of_day
ending = 1.year.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'space', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 3.months.from_now, window[1]
end
test '1 year subscriber visibility for trainings in the coming year' do
starting = Time.current.beginning_of_day
ending = 1.year.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_1y_subscription, 'training', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 1.month.from_now, window[1]
end
test 'subscriber with plan custom visibility' do
plan = @with_subscription.subscribed_plan
plan.update(machines_visibility: 48)
starting = Time.current.beginning_of_day
ending = 1.month.from_now.end_of_day
window = Availabilities::VisibilityService.new.visibility(@with_subscription, 'machine', starting, ending)
assert_datetimes_equal Time.current, window[0]
assert_datetimes_equal 48.hours.from_now, window[1]
end
end

View File

@ -0,0 +1,173 @@
# frozen_string_literal: true
require 'test_helper'
class ReservationLimitServiceTest < ActiveSupport::TestCase
setup do
@acamus = User.find_by(username: 'acamus')
@admin = User.find_by(username: 'admin')
@machine = Machine.first
@plan = Plan.find(1)
end
test 'simple reservation without plan' do
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }]
)
assert ReservationLimitService.authorized?(nil, @acamus, reservation, [])
end
test 'simple reservation with not limiting plan' do
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }]
)
assert ReservationLimitService.authorized?(@plan, @acamus, reservation, [])
end
test 'simple reservation with limiting plan' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot_id: @machine.availabilities.first.slots.first.id }]
)
assert ReservationLimitService.authorized?(@plan, @acamus, reservation, [])
end
test 'reservation exceeds plan limit' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
slots = Availabilities::AvailabilitiesService.new(@acamus)
.machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now })
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[2] }, { slot: slots[3] }, { slot: slots[4] }]
)
assert_not ReservationLimitService.authorized?(@plan, @acamus, reservation, [])
end
test 'second reservation at plan limit' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
slots = Availabilities::AvailabilitiesService.new(@acamus)
.machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now })
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[0] }]
)
reservation2 = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[1] }]
)
assert ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation])
end
test 'second reservation exceeds plan limit' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
slots = Availabilities::AvailabilitiesService.new(@acamus)
.machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now })
reservation = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[0] }, { slot: slots[1] }]
)
reservation2 = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[2] }]
)
assert_not ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation])
end
test 'reservation of other resource should not conflict' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
slots = Availabilities::AvailabilitiesService.new(@acamus)
.machines([@machine], @acamus, { start: Time.current, end: 10.days.from_now })
reservation = CartItem::SpaceReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: Space.first,
cart_item_reservation_slots_attributes: [{ slot: Space.first.availabilities.first.slots.first },
{ slot: Space.first.availabilities.first.slots.last }]
)
reservation2 = CartItem::MachineReservation.new(
customer_profile: @acamus.invoicing_profile,
operator_profile: @acamus.invoicing_profile,
reservable: @machine,
cart_item_reservation_slots_attributes: [{ slot: slots[0] }]
)
assert ReservationLimitService.authorized?(@plan, @acamus, reservation2, [reservation])
end
test 'get plan limit' do
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable_id: @machine.id, limitable_type: 'Machine', limit: 2 }])
assert_equal 2, ReservationLimitService.limit(@plan, @machine).limit
end
test 'get plan without limit' do
assert_nil ReservationLimitService.limit(@plan, @machine)
end
test 'get category limit' do
category = MachineCategory.find(1)
category.update(machine_ids: [@machine.id])
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable: category, limit: 4 }])
assert_equal 4, ReservationLimitService.limit(@plan, @machine).limit
end
test 'machine limit should override the category limit' do
category = MachineCategory.find(1)
category.update(machine_ids: [@machine.id])
@plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 2 }, { limitable: category, limit: 4 }])
limit = ReservationLimitService.limit(@plan, @machine)
assert_equal 2, limit.limit
assert_equal @machine, limit.limitable
end
test 'reservation reaches the limit' do
user = User.find_by(username: 'kdumas')
plan = user.subscribed_plan
plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 1 }])
slots = Availabilities::AvailabilitiesService.new(user)
.machines([@machine], user, { start: Time.current, end: 10.days.from_now })
reservation = Reservation.create!(
statistic_profile: user.statistic_profile,
reservable: @machine,
slots_reservations_attributes: [{ slot: slots.last }]
)
reservation.reload
assert_equal slots.last.start_at.to_date, ReservationLimitService.reached_limit_date(reservation)
end
test 'reservation does not reaches the limit' do
user = User.find_by(username: 'kdumas')
plan = user.subscribed_plan
plan.update(limiting: true, plan_limitations_attributes: [{ limitable: @machine, limit: 2 }])
slots = Availabilities::AvailabilitiesService.new(user)
.machines([@machine], user, { start: Time.current, end: 10.days.from_now })
reservation = Reservation.create!(
statistic_profile: user.statistic_profile,
reservable: @machine,
slots_reservations_attributes: [{ slot: slots.last }]
)
reservation.reload
assert_nil ReservationLimitService.reached_limit_date(reservation)
end
end

View File

@ -111,6 +111,11 @@ class ActiveSupport::TestCase
assert_not_nil actual, msg
assert_equal expected.to_date, actual.to_date, msg
end
def assert_datetimes_equal(expected, actual, msg = nil)
assert_not_nil actual, msg
assert_equal expected.iso8601, actual.iso8601, msg
end
end
class ActionDispatch::IntegrationTest

View File

@ -5,7 +5,7 @@ http_interactions:
uri: https://api.stripe.com/v1/payment_methods
body:
encoding: UTF-8
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -13,12 +13,14 @@ http_interactions:
- Bearer sk_test_testfaketestfaketestfake
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_MQd4Z7i8cW9FYF","request_duration_ms":535}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -31,11 +33,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:07 GMT
- Wed, 15 Mar 2023 11:51:50 GMT
Content-Type:
- application/json
Content-Length:
- '934'
- '930'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -50,19 +52,23 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- 8b19d06b-ed60-406d-8490-3d7062d47f67
Original-Request:
- req_jrCbj5YQwrn3m7
Request-Id:
- req_tlTlxEJC4LyAQv
- req_jrCbj5YQwrn3m7
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '6'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pm_1JZDGd2sOmf47Nz9LCckU76B",
"id": "pm_1Mlsry2sOmf47Nz9g8twwVyn",
"object": "payment_method",
"billing_details": {
"address": {
@ -86,7 +92,7 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"generated_from": null,
@ -102,20 +108,19 @@ http_interactions:
},
"wallet": null
},
"created": 1631532247,
"created": 1678881110,
"customer": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"type": "card"
}
recorded_at: Mon, 13 Sep 2021 11:24:07 GMT
recorded_at: Wed, 15 Mar 2023 11:51:50 GMT
- request:
method: post
uri: https://api.stripe.com/v1/payment_intents
body:
encoding: UTF-8
string: payment_method=pm_1JZDGd2sOmf47Nz9LCckU76B&amount=11500&currency=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt
string: payment_method=pm_1Mlsry2sOmf47Nz9g8twwVyn&amount=11500&currency=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -124,13 +129,13 @@ http_interactions:
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_tlTlxEJC4LyAQv","request_duration_ms":663}}'
- '{"last_request_metrics":{"request_id":"req_jrCbj5YQwrn3m7","request_duration_ms":586}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -143,11 +148,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:09 GMT
- Wed, 15 Mar 2023 11:51:52 GMT
Content-Type:
- application/json
Content-Length:
- '4263'
- '4522'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -162,25 +167,33 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- bceb9ff3-545c-48c9-9f9b-4654dd50641b
Original-Request:
- req_2QEtRciNfNblB1
Request-Id:
- req_4hAutJ5WkAA9ps
- req_2QEtRciNfNblB1
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '10'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pi_3JZDGd2sOmf47Nz91tgWkK3L",
"id": "pi_3Mlsrz2sOmf47Nz90UCTYKFx",
"object": "payment_intent",
"amount": 11500,
"amount_capturable": 0,
"amount_details": {
"tip": {}
},
"amount_received": 11500,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
@ -188,7 +201,7 @@ http_interactions:
"object": "list",
"data": [
{
"id": "ch_3JZDGd2sOmf47Nz91HsbEa5U",
"id": "ch_3Mlsrz2sOmf47Nz9004R5HME",
"object": "charge",
"amount": 11500,
"amount_captured": 11500,
@ -196,7 +209,7 @@ http_interactions:
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3JZDGd2sOmf47Nz91MEnox3F",
"balance_transaction": "txn_3Mlsrz2sOmf47Nz90SLqRLy1",
"billing_details": {
"address": {
"city": null,
@ -212,34 +225,33 @@ http_interactions:
},
"calculated_statement_descriptor": "Stripe",
"captured": true,
"created": 1631532248,
"created": 1678881111,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": null,
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"fraud_details": {},
"invoice": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 12,
"risk_score": 29,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3JZDGd2sOmf47Nz91tgWkK3L",
"payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B",
"payment_intent": "pi_3Mlsrz2sOmf47Nz90UCTYKFx",
"payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn",
"payment_method_details": {
"card": {
"brand": "visa",
@ -250,11 +262,12 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"installments": null,
"last4": "4242",
"mandate": null,
"network": "visa",
"three_d_secure": null,
"wallet": null
@ -263,16 +276,14 @@ http_interactions:
},
"receipt_email": null,
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGd2sOmf47Nz91HsbEa5U/rcpt_KDeZXFrj8mqhXX4v6MKFwawzylc2kPA",
"receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNjixqAGMgZncKXPXn06LBY8nUVkDGBhMzC5bIzAPJWsR_S75nAIhMIDBqALhrU35BgWxXMZpcMss_ty",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"data": [],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3JZDGd2sOmf47Nz91HsbEa5U/refunds"
"url": "/v1/charges/ch_3Mlsrz2sOmf47Nz9004R5HME/refunds"
},
"review": null,
"shipping": null,
@ -287,25 +298,26 @@ http_interactions:
],
"has_more": false,
"total_count": 1,
"url": "/v1/charges?payment_intent=pi_3JZDGd2sOmf47Nz91tgWkK3L"
"url": "/v1/charges?payment_intent=pi_3Mlsrz2sOmf47Nz90UCTYKFx"
},
"client_secret": "pi_3JZDGd2sOmf47Nz91tgWkK3L_secret_DooP6j5YiNN0kzaXPdTGEeKeR",
"client_secret": "pi_3Mlsrz2sOmf47Nz90UCTYKFx_secret_af9V81CCyVZZ2J2sptymbRLES",
"confirmation_method": "manual",
"created": 1631532247,
"created": 1678881111,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": null,
"invoice": null,
"last_payment_error": null,
"latest_charge": "ch_3Mlsrz2sOmf47Nz9004R5HME",
"livemode": false,
"metadata": {
},
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B",
"payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn",
"payment_method_options": {
"card": {
"installments": null,
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
@ -313,6 +325,7 @@ http_interactions:
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
@ -324,13 +337,13 @@ http_interactions:
"transfer_data": null,
"transfer_group": null
}
recorded_at: Mon, 13 Sep 2021 11:24:09 GMT
recorded_at: Wed, 15 Mar 2023 11:51:52 GMT
- request:
method: post
uri: https://api.stripe.com/v1/payment_intents/pi_3JZDGd2sOmf47Nz91tgWkK3L
uri: https://api.stripe.com/v1/payment_intents/pi_3Mlsrz2sOmf47Nz90UCTYKFx
body:
encoding: UTF-8
string: description=Invoice+reference%3A+2109001%2FVL
string: description=Invoice+reference%3A+2303007%2FVL
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -339,13 +352,13 @@ http_interactions:
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_4hAutJ5WkAA9ps","request_duration_ms":1725}}'
- '{"last_request_metrics":{"request_id":"req_2QEtRciNfNblB1","request_duration_ms":1482}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -358,11 +371,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:10 GMT
- Wed, 15 Mar 2023 11:51:52 GMT
Content-Type:
- application/json
Content-Length:
- '4290'
- '4549'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -377,25 +390,33 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- 51790c68-63e7-4b6e-96b1-4b6eef30b42b
Original-Request:
- req_Ms4raQ174jWmPK
Request-Id:
- req_ysWG3JfyCp5xVD
- req_Ms4raQ174jWmPK
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '0'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pi_3JZDGd2sOmf47Nz91tgWkK3L",
"id": "pi_3Mlsrz2sOmf47Nz90UCTYKFx",
"object": "payment_intent",
"amount": 11500,
"amount_capturable": 0,
"amount_details": {
"tip": {}
},
"amount_received": 11500,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
@ -403,7 +424,7 @@ http_interactions:
"object": "list",
"data": [
{
"id": "ch_3JZDGd2sOmf47Nz91HsbEa5U",
"id": "ch_3Mlsrz2sOmf47Nz9004R5HME",
"object": "charge",
"amount": 11500,
"amount_captured": 11500,
@ -411,7 +432,7 @@ http_interactions:
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3JZDGd2sOmf47Nz91MEnox3F",
"balance_transaction": "txn_3Mlsrz2sOmf47Nz90SLqRLy1",
"billing_details": {
"address": {
"city": null,
@ -427,34 +448,33 @@ http_interactions:
},
"calculated_statement_descriptor": "Stripe",
"captured": true,
"created": 1631532248,
"created": 1678881111,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": null,
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"fraud_details": {},
"invoice": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 12,
"risk_score": 29,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3JZDGd2sOmf47Nz91tgWkK3L",
"payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B",
"payment_intent": "pi_3Mlsrz2sOmf47Nz90UCTYKFx",
"payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn",
"payment_method_details": {
"card": {
"brand": "visa",
@ -465,11 +485,12 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"installments": null,
"last4": "4242",
"mandate": null,
"network": "visa",
"three_d_secure": null,
"wallet": null
@ -478,16 +499,14 @@ http_interactions:
},
"receipt_email": null,
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGd2sOmf47Nz91HsbEa5U/rcpt_KDeZXFrj8mqhXX4v6MKFwawzylc2kPA",
"receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNjixqAGMgbuQcWgG5c6LBa-ZSU_-3ZZaF7tt6uBsD2Rs604Am5ssLMpXbt0FvRIOVw-pVEwxmP_5ACg",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"data": [],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3JZDGd2sOmf47Nz91HsbEa5U/refunds"
"url": "/v1/charges/ch_3Mlsrz2sOmf47Nz9004R5HME/refunds"
},
"review": null,
"shipping": null,
@ -502,25 +521,26 @@ http_interactions:
],
"has_more": false,
"total_count": 1,
"url": "/v1/charges?payment_intent=pi_3JZDGd2sOmf47Nz91tgWkK3L"
"url": "/v1/charges?payment_intent=pi_3Mlsrz2sOmf47Nz90UCTYKFx"
},
"client_secret": "pi_3JZDGd2sOmf47Nz91tgWkK3L_secret_DooP6j5YiNN0kzaXPdTGEeKeR",
"client_secret": "pi_3Mlsrz2sOmf47Nz90UCTYKFx_secret_af9V81CCyVZZ2J2sptymbRLES",
"confirmation_method": "manual",
"created": 1631532247,
"created": 1678881111,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": "Invoice reference: 2109001/VL",
"description": "Invoice reference: 2303007/VL",
"invoice": null,
"last_payment_error": null,
"latest_charge": "ch_3Mlsrz2sOmf47Nz9004R5HME",
"livemode": false,
"metadata": {
},
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_1JZDGd2sOmf47Nz9LCckU76B",
"payment_method": "pm_1Mlsry2sOmf47Nz9g8twwVyn",
"payment_method_options": {
"card": {
"installments": null,
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
@ -528,6 +548,7 @@ http_interactions:
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
@ -539,5 +560,5 @@ http_interactions:
"transfer_data": null,
"transfer_group": null
}
recorded_at: Mon, 13 Sep 2021 11:24:10 GMT
recorded_at: Wed, 15 Mar 2023 11:51:53 GMT
recorded_with: VCR 6.0.0

View File

@ -5,7 +5,7 @@ http_interactions:
uri: https://api.stripe.com/v1/payment_methods
body:
encoding: UTF-8
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -13,14 +13,12 @@ http_interactions:
- Bearer sk_test_testfaketestfaketestfake
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_ysWG3JfyCp5xVD","request_duration_ms":520}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -33,11 +31,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:10 GMT
- Wed, 15 Mar 2023 11:51:20 GMT
Content-Type:
- application/json
Content-Length:
- '934'
- '930'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -52,19 +50,23 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- 6a183d91-3220-4ad4-913c-f169a75aa488
Original-Request:
- req_LQKka6p7rniNKT
Request-Id:
- req_7RNGSU2vySHdHz
- req_LQKka6p7rniNKT
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '6'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pm_1JZDGg2sOmf47Nz9pfmMaPtb",
"id": "pm_1MlsrU2sOmf47Nz9voyfBlTb",
"object": "payment_method",
"billing_details": {
"address": {
@ -88,7 +90,7 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"generated_from": null,
@ -104,227 +106,11 @@ http_interactions:
},
"wallet": null
},
"created": 1631532250,
"created": 1678881080,
"customer": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"type": "card"
}
recorded_at: Mon, 13 Sep 2021 11:24:10 GMT
- request:
method: post
uri: https://api.stripe.com/v1/payment_intents
body:
encoding: UTF-8
string: payment_method=pm_1JZDGg2sOmf47Nz9pfmMaPtb&amount=4200&currency=usd&confirmation_method=manual&confirm=true&customer=cus_8Di1wjdVktv5kt
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
Authorization:
- Bearer sk_test_testfaketestfaketestfake
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_7RNGSU2vySHdHz","request_duration_ms":628}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
- "*/*"
response:
status:
code: 200
message: OK
headers:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:12 GMT
Content-Type:
- application/json
Content-Length:
- '4258'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
- 'true'
Access-Control-Allow-Methods:
- GET, POST, HEAD, OPTIONS, DELETE
Access-Control-Allow-Origin:
- "*"
Access-Control-Expose-Headers:
- Request-Id, Stripe-Manage-Version, X-Stripe-External-Auth-Required, X-Stripe-Privileged-Session-Required
Access-Control-Max-Age:
- '300'
Cache-Control:
- no-cache, no-store
Request-Id:
- req_NgGOJxEFd8THv8
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '10'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
body:
encoding: UTF-8
string: |
{
"id": "pi_3JZDGh2sOmf47Nz91FT4yZ2t",
"object": "payment_intent",
"amount": 4200,
"amount_capturable": 0,
"amount_received": 4200,
"application": null,
"application_fee_amount": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
"charges": {
"object": "list",
"data": [
{
"id": "ch_3JZDGh2sOmf47Nz91FoAsBFe",
"object": "charge",
"amount": 4200,
"amount_captured": 4200,
"amount_refunded": 0,
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3JZDGh2sOmf47Nz91yuRvukb",
"billing_details": {
"address": {
"city": null,
"country": null,
"line1": null,
"line2": null,
"postal_code": null,
"state": null
},
"email": null,
"name": null,
"phone": null
},
"calculated_statement_descriptor": "Stripe",
"captured": true,
"created": 1631532251,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": null,
"destination": null,
"dispute": null,
"disputed": false,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"invoice": null,
"livemode": false,
"metadata": {
},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 2,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3JZDGh2sOmf47Nz91FT4yZ2t",
"payment_method": "pm_1JZDGg2sOmf47Nz9pfmMaPtb",
"payment_method_details": {
"card": {
"brand": "visa",
"checks": {
"address_line1_check": null,
"address_postal_code_check": null,
"cvc_check": "pass"
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"installments": null,
"last4": "4242",
"network": "visa",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"receipt_email": null,
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGh2sOmf47Nz91FoAsBFe/rcpt_KDeZ4pRoBzCyvhebh2wUzvr5fmdZdtD",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3JZDGh2sOmf47Nz91FoAsBFe/refunds"
},
"review": null,
"shipping": null,
"source": null,
"source_transfer": null,
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/charges?payment_intent=pi_3JZDGh2sOmf47Nz91FT4yZ2t"
},
"client_secret": "pi_3JZDGh2sOmf47Nz91FT4yZ2t_secret_F3QBmBEtZjcaKblNLMUnLO6hD",
"confirmation_method": "manual",
"created": 1631532251,
"currency": "usd",
"customer": "cus_8Di1wjdVktv5kt",
"description": null,
"invoice": null,
"last_payment_error": null,
"livemode": false,
"metadata": {
},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_1JZDGg2sOmf47Nz9pfmMaPtb",
"payment_method_options": {
"card": {
"installments": null,
"network": null,
"request_three_d_secure": "automatic"
}
},
"payment_method_types": [
"card"
],
"receipt_email": null,
"review": null,
"setup_future_usage": null,
"shipping": null,
"source": null,
"statement_descriptor": null,
"statement_descriptor_suffix": null,
"status": "succeeded",
"transfer_data": null,
"transfer_group": null
}
recorded_at: Mon, 13 Sep 2021 11:24:12 GMT
recorded_at: Wed, 15 Mar 2023 11:51:20 GMT
recorded_with: VCR 6.0.0

View File

@ -5,7 +5,7 @@ http_interactions:
uri: https://api.stripe.com/v1/payment_methods
body:
encoding: UTF-8
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2022&card[cvc]=314
string: type=card&card[number]=4242424242424242&card[exp_month]=4&card[exp_year]=2024&card[cvc]=314
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -13,14 +13,12 @@ http_interactions:
- Bearer sk_test_testfaketestfaketestfake
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_NgGOJxEFd8THv8","request_duration_ms":1772}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -33,11 +31,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:13 GMT
- Wed, 15 Mar 2023 11:51:46 GMT
Content-Type:
- application/json
Content-Length:
- '934'
- '930'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -52,19 +50,23 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- 4a6e0351-64bd-4ede-a018-7c03e320c3f5
Original-Request:
- req_njTGdMZNpa6wpG
Request-Id:
- req_px5zsAdlzgwwSe
- req_njTGdMZNpa6wpG
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '6'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pm_1JZDGj2sOmf47Nz9S8jhZkFt",
"id": "pm_1Mlsru2sOmf47Nz9s3VNHXYt",
"object": "payment_method",
"billing_details": {
"address": {
@ -88,7 +90,7 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"generated_from": null,
@ -104,20 +106,19 @@ http_interactions:
},
"wallet": null
},
"created": 1631532253,
"created": 1678881106,
"customer": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"type": "card"
}
recorded_at: Mon, 13 Sep 2021 11:24:13 GMT
recorded_at: Wed, 15 Mar 2023 11:51:46 GMT
- request:
method: post
uri: https://api.stripe.com/v1/payment_intents
body:
encoding: UTF-8
string: payment_method=pm_1JZDGj2sOmf47Nz9S8jhZkFt&amount=1500&currency=usd&confirmation_method=manual&confirm=true&customer=cus_8CzHcwBJtlA3IL
string: payment_method=pm_1Mlsru2sOmf47Nz9s3VNHXYt&amount=1500&currency=usd&confirmation_method=manual&confirm=true&customer=cus_8CzHcwBJtlA3IL
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -126,13 +127,13 @@ http_interactions:
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_px5zsAdlzgwwSe","request_duration_ms":619}}'
- '{"last_request_metrics":{"request_id":"req_njTGdMZNpa6wpG","request_duration_ms":581}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -145,11 +146,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:15 GMT
- Wed, 15 Mar 2023 11:51:48 GMT
Content-Type:
- application/json
Content-Length:
- '4259'
- '4517'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -164,25 +165,33 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- f50a36a7-97b5-4d67-9c77-43b5cbddccbe
Original-Request:
- req_EocoIWBH6tucS4
Request-Id:
- req_EMDO1Z1Uux0kJb
- req_EocoIWBH6tucS4
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '10'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pi_3JZDGj2sOmf47Nz90n2aOsuM",
"id": "pi_3Mlsrv2sOmf47Nz91hRe49rq",
"object": "payment_intent",
"amount": 1500,
"amount_capturable": 0,
"amount_details": {
"tip": {}
},
"amount_received": 1500,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
@ -190,7 +199,7 @@ http_interactions:
"object": "list",
"data": [
{
"id": "ch_3JZDGj2sOmf47Nz90qQzwGqL",
"id": "ch_3Mlsrv2sOmf47Nz91760imzX",
"object": "charge",
"amount": 1500,
"amount_captured": 1500,
@ -198,7 +207,7 @@ http_interactions:
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3JZDGj2sOmf47Nz90fOwfgv5",
"balance_transaction": "txn_3Mlsrv2sOmf47Nz915VOM7IM",
"billing_details": {
"address": {
"city": null,
@ -214,34 +223,33 @@ http_interactions:
},
"calculated_statement_descriptor": "Stripe",
"captured": true,
"created": 1631532254,
"created": 1678881107,
"currency": "usd",
"customer": "cus_8CzHcwBJtlA3IL",
"description": null,
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"fraud_details": {},
"invoice": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 22,
"risk_score": 7,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3JZDGj2sOmf47Nz90n2aOsuM",
"payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt",
"payment_intent": "pi_3Mlsrv2sOmf47Nz91hRe49rq",
"payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt",
"payment_method_details": {
"card": {
"brand": "visa",
@ -252,11 +260,12 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"installments": null,
"last4": "4242",
"mandate": null,
"network": "visa",
"three_d_secure": null,
"wallet": null
@ -265,16 +274,14 @@ http_interactions:
},
"receipt_email": null,
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGj2sOmf47Nz90qQzwGqL/rcpt_KDeZMofbRKdRjLxxUw4LZO1LIDJP1sR",
"receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNTixqAGMgYuj2m72sQ6LBbKXya9pk9SHbdsAtPEkvHFS51bMvmjrLtHgTEs1uQ8YSkOpfxQRpKaC0PP",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"data": [],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3JZDGj2sOmf47Nz90qQzwGqL/refunds"
"url": "/v1/charges/ch_3Mlsrv2sOmf47Nz91760imzX/refunds"
},
"review": null,
"shipping": null,
@ -289,25 +296,26 @@ http_interactions:
],
"has_more": false,
"total_count": 1,
"url": "/v1/charges?payment_intent=pi_3JZDGj2sOmf47Nz90n2aOsuM"
"url": "/v1/charges?payment_intent=pi_3Mlsrv2sOmf47Nz91hRe49rq"
},
"client_secret": "pi_3JZDGj2sOmf47Nz90n2aOsuM_secret_WR5cdTATWgOShT8ZHInabONRL",
"client_secret": "pi_3Mlsrv2sOmf47Nz91hRe49rq_secret_gLvEqjCexLXGNWDLb7g2NpnfB",
"confirmation_method": "manual",
"created": 1631532253,
"created": 1678881107,
"currency": "usd",
"customer": "cus_8CzHcwBJtlA3IL",
"description": null,
"invoice": null,
"last_payment_error": null,
"latest_charge": "ch_3Mlsrv2sOmf47Nz91760imzX",
"livemode": false,
"metadata": {
},
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt",
"payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt",
"payment_method_options": {
"card": {
"installments": null,
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
@ -315,6 +323,7 @@ http_interactions:
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
@ -326,13 +335,13 @@ http_interactions:
"transfer_data": null,
"transfer_group": null
}
recorded_at: Mon, 13 Sep 2021 11:24:15 GMT
recorded_at: Wed, 15 Mar 2023 11:51:48 GMT
- request:
method: post
uri: https://api.stripe.com/v1/payment_intents/pi_3JZDGj2sOmf47Nz90n2aOsuM
uri: https://api.stripe.com/v1/payment_intents/pi_3Mlsrv2sOmf47Nz91hRe49rq
body:
encoding: UTF-8
string: description=Invoice+reference%3A+2109001%2FVL
string: description=Invoice+reference%3A+2303007%2FVL
headers:
User-Agent:
- Stripe/v1 RubyBindings/5.29.0
@ -341,13 +350,13 @@ http_interactions:
Content-Type:
- application/x-www-form-urlencoded
X-Stripe-Client-Telemetry:
- '{"last_request_metrics":{"request_id":"req_EMDO1Z1Uux0kJb","request_duration_ms":1528}}'
- '{"last_request_metrics":{"request_id":"req_EocoIWBH6tucS4","request_duration_ms":1593}}'
Stripe-Version:
- '2019-08-14'
X-Stripe-Client-User-Agent:
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.3 p62 (2019-04-16)","platform":"x86_64-darwin18","engine":"ruby","publisher":"stripe","uname":"Darwin
MacBook-Pro-Sleede-Peng 20.5.0 Darwin Kernel Version 20.5.0: Sat May 8 05:10:33
PDT 2021; root:xnu-7195.121.3~9/RELEASE_X86_64 x86_64","hostname":"MacBook-Pro-Sleede-Peng"}'
- '{"bindings_version":"5.29.0","lang":"ruby","lang_version":"2.6.10 p210 (2022-04-12)","platform":"x86_64-linux","engine":"ruby","publisher":"stripe","uname":"Linux
version 6.2.5-arch1-1 (linux@archlinux) (gcc (GCC) 12.2.1 20230201, GNU ld
(GNU Binutils) 2.40) #1 SMP PREEMPT_DYNAMIC Sat, 11 Mar 2023 14:28:13 +0000","hostname":"Sylvain-desktop"}'
Accept-Encoding:
- gzip;q=1.0,deflate;q=0.6,identity;q=0.3
Accept:
@ -360,11 +369,11 @@ http_interactions:
Server:
- nginx
Date:
- Mon, 13 Sep 2021 11:24:15 GMT
- Wed, 15 Mar 2023 11:51:49 GMT
Content-Type:
- application/json
Content-Length:
- '4286'
- '4544'
Connection:
- keep-alive
Access-Control-Allow-Credentials:
@ -379,25 +388,33 @@ http_interactions:
- '300'
Cache-Control:
- no-cache, no-store
Idempotency-Key:
- 75c78d8d-9df1-4bb7-af1b-7de3aeaa7056
Original-Request:
- req_MQd4Z7i8cW9FYF
Request-Id:
- req_Xmih0ndHQjzde4
- req_MQd4Z7i8cW9FYF
Stripe-Should-Retry:
- 'false'
Stripe-Version:
- '2019-08-14'
X-Stripe-C-Cost:
- '0'
Strict-Transport-Security:
- max-age=31556926; includeSubDomains; preload
- max-age=63072000; includeSubDomains; preload
body:
encoding: UTF-8
string: |
string: |-
{
"id": "pi_3JZDGj2sOmf47Nz90n2aOsuM",
"id": "pi_3Mlsrv2sOmf47Nz91hRe49rq",
"object": "payment_intent",
"amount": 1500,
"amount_capturable": 0,
"amount_details": {
"tip": {}
},
"amount_received": 1500,
"application": null,
"application_fee_amount": null,
"automatic_payment_methods": null,
"canceled_at": null,
"cancellation_reason": null,
"capture_method": "automatic",
@ -405,7 +422,7 @@ http_interactions:
"object": "list",
"data": [
{
"id": "ch_3JZDGj2sOmf47Nz90qQzwGqL",
"id": "ch_3Mlsrv2sOmf47Nz91760imzX",
"object": "charge",
"amount": 1500,
"amount_captured": 1500,
@ -413,7 +430,7 @@ http_interactions:
"application": null,
"application_fee": null,
"application_fee_amount": null,
"balance_transaction": "txn_3JZDGj2sOmf47Nz90fOwfgv5",
"balance_transaction": "txn_3Mlsrv2sOmf47Nz915VOM7IM",
"billing_details": {
"address": {
"city": null,
@ -429,34 +446,33 @@ http_interactions:
},
"calculated_statement_descriptor": "Stripe",
"captured": true,
"created": 1631532254,
"created": 1678881107,
"currency": "usd",
"customer": "cus_8CzHcwBJtlA3IL",
"description": null,
"destination": null,
"dispute": null,
"disputed": false,
"failure_balance_transaction": null,
"failure_code": null,
"failure_message": null,
"fraud_details": {
},
"fraud_details": {},
"invoice": null,
"livemode": false,
"metadata": {
},
"metadata": {},
"on_behalf_of": null,
"order": null,
"outcome": {
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
"risk_score": 22,
"risk_score": 7,
"seller_message": "Payment complete.",
"type": "authorized"
},
"paid": true,
"payment_intent": "pi_3JZDGj2sOmf47Nz90n2aOsuM",
"payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt",
"payment_intent": "pi_3Mlsrv2sOmf47Nz91hRe49rq",
"payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt",
"payment_method_details": {
"card": {
"brand": "visa",
@ -467,11 +483,12 @@ http_interactions:
},
"country": "US",
"exp_month": 4,
"exp_year": 2022,
"exp_year": 2024,
"fingerprint": "o52jybR7bnmNn6AT",
"funding": "credit",
"installments": null,
"last4": "4242",
"mandate": null,
"network": "visa",
"three_d_secure": null,
"wallet": null
@ -480,16 +497,14 @@ http_interactions:
},
"receipt_email": null,
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/acct_103rE62sOmf47Nz9/ch_3JZDGj2sOmf47Nz90qQzwGqL/rcpt_KDeZMofbRKdRjLxxUw4LZO1LIDJP1sR",
"receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xMDNyRTYyc09tZjQ3Tno5KNXixqAGMgYPhCqQTfg6LBaYDHIkkSjReWHiH0dGP8uhn2RZUgWlfTBRya6YV380PXzkqIYppHOEGGj9",
"refunded": false,
"refunds": {
"object": "list",
"data": [
],
"data": [],
"has_more": false,
"total_count": 0,
"url": "/v1/charges/ch_3JZDGj2sOmf47Nz90qQzwGqL/refunds"
"url": "/v1/charges/ch_3Mlsrv2sOmf47Nz91760imzX/refunds"
},
"review": null,
"shipping": null,
@ -504,25 +519,26 @@ http_interactions:
],
"has_more": false,
"total_count": 1,
"url": "/v1/charges?payment_intent=pi_3JZDGj2sOmf47Nz90n2aOsuM"
"url": "/v1/charges?payment_intent=pi_3Mlsrv2sOmf47Nz91hRe49rq"
},
"client_secret": "pi_3JZDGj2sOmf47Nz90n2aOsuM_secret_WR5cdTATWgOShT8ZHInabONRL",
"client_secret": "pi_3Mlsrv2sOmf47Nz91hRe49rq_secret_gLvEqjCexLXGNWDLb7g2NpnfB",
"confirmation_method": "manual",
"created": 1631532253,
"created": 1678881107,
"currency": "usd",
"customer": "cus_8CzHcwBJtlA3IL",
"description": "Invoice reference: 2109001/VL",
"description": "Invoice reference: 2303007/VL",
"invoice": null,
"last_payment_error": null,
"latest_charge": "ch_3Mlsrv2sOmf47Nz91760imzX",
"livemode": false,
"metadata": {
},
"metadata": {},
"next_action": null,
"on_behalf_of": null,
"payment_method": "pm_1JZDGj2sOmf47Nz9S8jhZkFt",
"payment_method": "pm_1Mlsru2sOmf47Nz9s3VNHXYt",
"payment_method_options": {
"card": {
"installments": null,
"mandate_options": null,
"network": null,
"request_three_d_secure": "automatic"
}
@ -530,6 +546,7 @@ http_interactions:
"payment_method_types": [
"card"
],
"processing": null,
"receipt_email": null,
"review": null,
"setup_future_usage": null,
@ -541,5 +558,5 @@ http_interactions:
"transfer_data": null,
"transfer_group": null
}
recorded_at: Mon, 13 Sep 2021 11:24:15 GMT
recorded_at: Wed, 15 Mar 2023 11:51:49 GMT
recorded_with: VCR 6.0.0

View File

@ -4626,15 +4626,11 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0:
version "1.0.30001397"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001397.tgz"
integrity sha512-SW9N2TbCdLf0eiNDRrrQXx2sOkaakNZbCjgNpPyMJJbiOrU5QzMIrXOVMRM1myBXTD5iTkdrtU/EguCrBocHlA==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001313, caniuse-lite@^1.0.30001332, caniuse-lite@^1.0.30001400:
version "1.0.30001460"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001460.tgz"
integrity sha512-Bud7abqjvEjipUkpLs4D7gR0l8hBYBHoa+tGtKJHvT2AYzLp1z7EmVkUT4ERpVUfca8S2HGIVs883D8pUH1ZzQ==
caniuse-lite@^1.0.30001400:
version "1.0.30001439"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001439.tgz#ab7371faeb4adff4b74dad1718a6fd122e45d9cb"
integrity sha512-1MgUzEkoMO6gKfXflStpYgZDlFM7M/ck/bgfVCACO5vnAf0fXoNVHdWtqGU+MYca+4bL9Z5bpOVmR33cWW9G2A==
chalk@4.1.1:
version "4.1.1"
@ -8965,10 +8961,10 @@ react-dom@^17.0.2:
object-assign "^4.1.1"
scheduler "^0.20.2"
react-hook-form@^7.30.0:
version "7.30.0"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.30.0.tgz#c9e2fd54d3627e43bd94bf38ef549df2e80c1371"
integrity sha512-DzjiM6o2vtDGNMB9I4yCqW8J21P314SboNG1O0obROkbg7KVS0I7bMtwSdKyapnCPjHgnxc3L7E5PEdISeEUcQ==
react-hook-form@~7.31.3:
version "7.31.3"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.31.3.tgz#b61bafb9a7435f91695351a7a9f714d8c4df0121"
integrity sha512-NVZdCWViIWXXXlQ3jxVQH0NuNfwPf8A/0KvuCxrM9qxtP1qYosfR2ZudarziFrVOC7eTUbWbm1T4OyYCwv9oSQ==
react-i18next@^11.15.6:
version "11.15.6"