diff --git a/CHANGELOG.md b/CHANGELOG.md
index a04f2ce4e..657b71988 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,11 @@ Tous les changements notables sur le projet sont documentés dans ce fichier.
Ce projet adhère au principe du [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## 0.23.2 (2024-02-12)
+
+- Sur le calendrier, la vérification du matériel manquant est différée pour optimiser les temps de chargement.
+- Dans les devis et factures, le calcul de la remise s'applique sur le montant total, non plus sur le total journalier.
+
## 0.23.1 (2023-12-16)
- Les fiches de sorties des événements peuvent être éditées même en l'absence d'un bénéficiaire.
diff --git a/VERSION b/VERSION
index 610e28725..fda96dcf6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.23.1
+0.23.2
diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js
index edae620f0..ec62cb5de 100644
--- a/client/src/locale/en/common.js
+++ b/client/src/locale/en/common.js
@@ -286,10 +286,10 @@ export default {
'time': "time",
'delete-selected': "Delete-selected",
+ "daily-total-without-tax": "Daily total excl. tax:",
"daily-total": "Daily total:",
- "daily-total-discountable": "Daily total discountable:",
- "daily-total-without-tax-after-discount": "Daily total excl. tax after discount:",
- "daily-total-after-discount": "Daily Total after discount:",
+ "total-discountable": "Total discountable:",
+ "total-after-discount": "Total with discount:",
"total-without-taxes": "Total excl. tax:",
"total-taxes": "Taxes:",
"total-with-taxes": "Total incl. taxes:",
diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js
index d6da238e2..d859a04ca 100644
--- a/client/src/locale/fr/common.js
+++ b/client/src/locale/fr/common.js
@@ -285,10 +285,10 @@ export default {
'time': "heure",
'delete-selected': "Effacer la selection",
+ "daily-total-without-tax": "Total H.T.\u00A0/\u00A0jour\u00A0:",
"daily-total": "Total\u00A0/\u00A0jour\u00A0:",
- "daily-total-discountable": "Total remisable\u00A0/\u00A0jour\u00A0:",
- "daily-total-without-tax-after-discount": "Total H.T.\u00A0/\u00A0jour après remise\u00A0:",
- "daily-total-after-discount": "Total\u00A0/\u00A0jour après remise\u00A0:",
+ "total-discountable": "Total remisable\u00A0:",
+ "total-after-discount": "Total après remise\u00A0:",
"total-without-taxes": "Total H.T.\u00A0:",
"total-taxes": "T.V.A.\u00A0:",
"total-with-taxes": "Total T.T.C.\u00A0:",
diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts
index c1a4c2989..08608ab08 100644
--- a/client/src/stores/api/events.ts
+++ b/client/src/stores/api/events.ts
@@ -106,12 +106,10 @@ export type RawEvent<
degressive_rate: DecimalType,
discount_rate: DecimalType,
vat_rate: DecimalType,
- daily_total_without_discount: DecimalType,
- daily_total_discountable: DecimalType,
- daily_total_discount: DecimalType,
- daily_total_without_taxes: DecimalType,
- daily_total_taxes: DecimalType,
- daily_total_with_taxes: DecimalType,
+ daily_total: DecimalType,
+ total_without_discount: DecimalType,
+ total_discountable: DecimalType,
+ total_discount: DecimalType,
total_without_taxes: DecimalType,
total_taxes: DecimalType,
total_with_taxes: DecimalType,
@@ -193,12 +191,10 @@ export const normalize = (rawEvent: RawEvent): Event => {
vat_rate: new Decimal(event.vat_rate),
degressive_rate: new Decimal(event.degressive_rate),
discount_rate: new Decimal(event.discount_rate),
- daily_total_without_discount: new Decimal(event.daily_total_without_discount),
- daily_total_discountable: new Decimal(event.daily_total_discountable),
- daily_total_discount: new Decimal(event.daily_total_discount),
- daily_total_without_taxes: new Decimal(event.daily_total_without_taxes),
- daily_total_taxes: new Decimal(event.daily_total_taxes),
- daily_total_with_taxes: new Decimal(event.daily_total_with_taxes),
+ daily_total: new Decimal(event.daily_total),
+ total_without_discount: new Decimal(event.total_without_discount),
+ total_discountable: new Decimal(event.total_discountable),
+ total_discount: new Decimal(event.total_discount),
total_without_taxes: new Decimal(event.total_without_taxes),
total_taxes: new Decimal(event.total_taxes),
total_with_taxes: new Decimal(event.total_with_taxes),
diff --git a/client/src/themes/default/components/EventTotals/index.scss b/client/src/themes/default/components/EventTotals/index.scss
index fd4d67670..92f69787c 100644
--- a/client/src/themes/default/components/EventTotals/index.scss
+++ b/client/src/themes/default/components/EventTotals/index.scss
@@ -41,6 +41,11 @@
text-align: right;
}
+ &--secondary {
+ margin-bottom: globals.$spacing-medium;
+ color: globals.$text-light-color;
+ }
+
&--grand-total {
margin-bottom: globals.$spacing-medium;
diff --git a/client/src/themes/default/components/EventTotals/index.tsx b/client/src/themes/default/components/EventTotals/index.tsx
index a6c96ad7c..97ac37d3e 100644
--- a/client/src/themes/default/components/EventTotals/index.tsx
+++ b/client/src/themes/default/components/EventTotals/index.tsx
@@ -39,7 +39,7 @@ const EventTotals = defineComponent({
}
const { vat_rate: vatRate } = this.event;
- return vatRate.toNumber() > 0;
+ return !vatRate.isZero();
},
hasDiscount(): boolean {
@@ -49,28 +49,28 @@ const EventTotals = defineComponent({
}
const { discount_rate: discountRate } = this.event;
- return discountRate.toNumber() > 0;
+ return !discountRate.isZero();
},
- discountableDifferentFromTotal(): boolean {
+ isNotFullyDiscountable(): boolean {
const { is_billable: isBillable } = this.event;
if (!isBillable) {
return false;
}
const {
- daily_total_discountable: dailyTotalDiscountable,
- daily_total_without_taxes: dailyTotalWithoutTaxes,
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
} = this.event;
- return dailyTotalDiscountable.toNumber() !== dailyTotalWithoutTaxes.toNumber();
+ return !totalDiscountable.eq(totalWithoutDiscount);
},
},
created() {
this.$store.dispatch('categories/fetch');
},
render() {
- const { $t: __, event, itemsCount, useTaxes, hasDiscount, discountableDifferentFromTotal } = this;
+ const { $t: __, event, itemsCount, useTaxes, hasDiscount, isNotFullyDiscountable } = this;
const {
is_billable: isBillable,
duration,
@@ -107,11 +107,11 @@ const EventTotals = defineComponent({
const {
degressive_rate: degressiveRate,
vat_rate: vatRate,
+ daily_total: dailyTotal,
+ total_without_discount: totalWithoutDiscount,
discount_rate: discountRate,
- daily_total_without_discount: dailyTotalWithoutDiscount,
- daily_total_discountable: dailyTotalDiscountable,
- daily_total_discount: dailyTotalDiscount,
- daily_total_without_taxes: dailyTotalWithoutTaxes,
+ total_discountable: totalDiscountable,
+ total_discount: totalDiscount,
total_without_taxes: totalWithoutTaxes,
total_taxes: totalTaxes,
total_with_taxes: totalWithTaxes,
@@ -123,10 +123,10 @@ const EventTotals = defineComponent({
- {__('daily-total')}
+ {useTaxes ? __('daily-total-without-tax') : __('daily-total')}
- {formatAmount(dailyTotalWithoutDiscount, currency)}
+ {formatAmount(dailyTotal, currency)}
@@ -142,18 +142,18 @@ const EventTotals = defineComponent({
{useTaxes ? __('total-without-taxes') : __('total')}
- {formatAmount(totalWithoutTaxes, currency)}
+ {formatAmount(totalWithoutDiscount, currency)}
{hasDiscount && (
- {discountableDifferentFromTotal && (
+ {isNotFullyDiscountable && (
- {__('daily-total-discountable')}
+ {__('total-discountable')}
- {formatAmount(dailyTotalDiscountable, currency)}
+ {formatAmount(totalDiscountable, currency)}
)}
@@ -162,15 +162,15 @@ const EventTotals = defineComponent({
{__('discount-rate', { rate: discountRate })}
- - {formatAmount(dailyTotalDiscount, currency)}
+ - {formatAmount(totalDiscount, currency)}
- {useTaxes ? __('daily-total-without-tax-after-discount') : __('daily-total-after-discount')}
+ {__('total-after-discount')}
- {formatAmount(dailyTotalWithoutTaxes, currency)}
+ {formatAmount(totalWithoutTaxes, currency)}
@@ -195,6 +195,16 @@ const EventTotals = defineComponent({
)}
+ {(!hasDiscount && isNotFullyDiscountable) && (
+
+
+ {__('total-discountable')}
+
+
+ {formatAmount(totalDiscountable, currency)}
+
+
+ )}
);
diff --git a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js
index 202753e63..14f82ddd1 100644
--- a/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js
+++ b/client/src/themes/default/layouts/Default/components/Sidebar/Menu/index.js
@@ -7,10 +7,6 @@ import Item from './Item';
const DefaultLayoutSidebarMenu = defineComponent({
name: 'DefaultLayoutSidebarMenu',
computed: {
- reservationsEnabled() {
- return this.$store.state.settings.reservation.enabled;
- },
-
links() {
const links = [
{ ident: 'calendar', url: '/', icon: 'calendar-alt', exact: true },
diff --git a/client/src/themes/default/modals/EventDetails/components/BillingForm/index.js b/client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx
similarity index 60%
rename from client/src/themes/default/modals/EventDetails/components/BillingForm/index.js
rename to client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx
index 53c6865fb..d69c29958 100644
--- a/client/src/themes/default/modals/EventDetails/components/BillingForm/index.js
+++ b/client/src/themes/default/modals/EventDetails/components/BillingForm/index.tsx
@@ -1,46 +1,118 @@
import './index.scss';
+import { defineComponent } from '@vue/composition-api';
import Decimal from 'decimal.js';
import config from '@/globals/config';
import Fragment from '@/components/Fragment';
import FormField from '@/themes/default/components/FormField';
import Button from '@/themes/default/components/Button';
-// @vue/component
-export default {
+import type { PropType } from '@vue/composition-api';
+import type { Beneficiary } from '@/stores/api/beneficiaries';
+
+type Props = {
+ /**
+ * Le taux de remise, en pourcent.
+ * Doit être un nombre compris entre 0 et 100.
+ */
+ discountRate: Decimal,
+
+ /**
+ * Le montant total voulu après remise.
+ * Doit être un nombre compris entre 0 (ou la valeur de minAmount)
+ * et le montant total sans remise (ou la valeur de maxAmount).
+ */
+ discountTarget: Decimal,
+
+ /**
+ * Le taux maximum de la remise, en pourcent.
+ *
+ * @default 100
+ */
+ maxRate?: Decimal,
+
+ /**
+ * Le montant total minimum applicable, pour limiter la saisie dans le champ.
+ * Doit être un nombre compris entre 0 et le montant total sans remise.
+ */
+ minAmount?: Decimal,
+
+ /**
+ * Le montant total maximum applicable, pour limiter la saisie dans le champ.
+ * Doit être un nombre compris entre 0 et le montant total sans remise.
+ */
+ maxAmount?: Decimal,
+
+ /**
+ * Le devis (ou la facture) est en cours de création ?
+ *
+ * @default false
+ */
+ loading?: boolean,
+
+ /** Le bénéficiaire, si on veut afficher son nom. */
+ beneficiary?: Beneficiary,
+
+ /**
+ * Le texte à afficher dans le bouton de sauvegarde.
+ *
+ * @default "Sauvegarder" (traduit)
+ */
+ saveLabel?: string,
+};
+
+/** Formulaire permettant de créer un devis ou une facture. */
+const EventDetailsBillingForm = defineComponent({
name: 'EventDetailsBillingForm',
props: {
- discountRate: { type: Number, required: true },
- discountTarget: { type: Number, required: true },
- maxRate: { type: Object, default: () => new Decimal(100) },
- minAmount: { type: Object, default: undefined },
- maxAmount: { type: Object, default: undefined },
- loading: { type: Boolean, default: false },
- beneficiary: { type: Object, default: undefined },
+ discountRate: {
+ type: Object as PropType,
+ required: true,
+ },
+ discountTarget: {
+ type: Object as PropType,
+ required: true,
+ },
+ maxRate: {
+ type: Object as PropType['maxRate']>,
+ default: () => new Decimal(100),
+ },
+ minAmount: {
+ type: Object as PropType,
+ default: undefined,
+ },
+ maxAmount: {
+ type: Object as PropType,
+ default: undefined,
+ },
+ loading: {
+ type: Boolean as PropType['loading']>,
+ default: false,
+ },
+ beneficiary: {
+ type: Object as PropType,
+ default: undefined,
+ },
saveLabel: {
- type: String,
+ type: String as PropType['saveLabel']>,
default() {
const { $t: __ } = this;
return __('save');
},
},
},
+ emits: ['change', 'submit', 'cancel'],
computed: {
- isDiscountable() {
+ isDiscountable(): boolean {
const { maxRate } = this;
return maxRate.greaterThan(0);
},
- targetAmount() {
- const { discountTarget } = this;
- return (new Decimal(discountTarget)).toFixed(2);
- },
-
- currency() {
+ currency(): string {
return config.currency.symbol;
},
},
methods: {
- handleChangeRate(givenValue) {
+ handleChangeRate(givenValue: string) {
const rate = new Decimal(givenValue);
if (rate.isNaN() || !rate.isFinite()) {
return;
@@ -50,7 +122,7 @@ export default {
this.$emit('change', { field: 'rate', value });
},
- handleChangeAmount(givenValue) {
+ handleChangeAmount(givenValue: string) {
const amount = new Decimal(givenValue);
if (amount.isNaN() || !amount.isFinite()) {
return;
@@ -60,7 +132,7 @@ export default {
this.$emit('change', { field: 'amount', value });
},
- handleSubmit(e) {
+ handleSubmit(e: SubmitEvent) {
e.preventDefault();
this.$emit('submit');
},
@@ -81,7 +153,7 @@ export default {
maxAmount,
isDiscountable,
discountRate,
- targetAmount,
+ discountTarget,
handleSubmit,
handleCancel,
handleChangeRate,
@@ -95,7 +167,7 @@ export default {
];
return (
-
);
},
-};
+});
+
+export default EventDetailsBillingForm;
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js b/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js
index cf19a5d32..b2b3a329b 100644
--- a/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js
+++ b/client/src/themes/default/modals/EventDetails/tabs/Estimates/index.js
@@ -5,7 +5,6 @@ import { defineComponent } from '@vue/composition-api';
import { Group } from '@/stores/api/groups';
import apiEvents from '@/stores/api/events';
import getEventDiscountRate from '@/utils/getEventDiscountRate';
-import { round } from '@/utils/decimalRound';
import Fragment from '@/components/Fragment';
import Icon from '@/themes/default/components/Icon';
import Button from '@/themes/default/components/Button';
@@ -47,30 +46,29 @@ const EventDetailsEstimates = defineComponent({
return this.$store.getters['auth/is']([Group.ADMIN, Group.MEMBER]);
},
- totalDiscountable() {
+ maxDiscountRate() {
const {
- degressive_rate: degressiveRate,
- daily_total_discountable: dailyTotalDiscountable,
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
} = this.event;
- return dailyTotalDiscountable.times(degressiveRate);
- },
-
- maxDiscountRate() {
- const { event, totalDiscountable } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ if (totalWithoutDiscount > 0) {
+ return (totalDiscountable.times(100))
+ .div(totalWithoutDiscount)
+ .toDecimalPlaces(4, Decimal.ROUND_UP);
+ }
- return totalWithoutTaxes > 0
- ? (totalDiscountable.times(100)).div(totalWithoutTaxes)
- : new Decimal(0);
+ return new Decimal(0);
},
minTotalAmount() {
- const { event, totalDiscountable } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const {
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
+ } = this.event;
- return totalWithoutTaxes > 0
- ? totalWithoutTaxes.sub(totalDiscountable)
+ return totalWithoutDiscount > 0
+ ? totalWithoutDiscount.sub(totalDiscountable)
: new Decimal(0);
},
@@ -80,38 +78,46 @@ const EventDetailsEstimates = defineComponent({
if (unsavedDiscountRate !== null) {
return this.unsavedDiscountRate;
}
- return Math.min(getEventDiscountRate(event), maxDiscountRate.toNumber());
+
+ const eventDiscountRate = new Decimal(getEventDiscountRate(event));
+ return Decimal.min(eventDiscountRate, maxDiscountRate);
},
set(value) {
- this.unsavedDiscountRate = Math.min(value, this.maxDiscountRate.toNumber());
+ this.unsavedDiscountRate = Decimal.min(new Decimal(value), this.maxDiscountRate);
},
},
discountTarget: {
get() {
const { event, discountRate, minTotalAmount } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const { total_without_discount: totalWithoutDiscount } = event;
- const discountAmount = totalWithoutTaxes.times(discountRate / 100);
- const totalAmount = totalWithoutTaxes.sub(discountAmount).toNumber();
- return Math.max(totalAmount, minTotalAmount.toNumber());
+ const discountAmount = totalWithoutDiscount.times(discountRate / 100);
+ const totalAmount = totalWithoutDiscount.sub(discountAmount);
+ return Decimal.max(totalAmount, minTotalAmount);
},
set(value) {
- const { event, totalDiscountable, maxDiscountRate } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const { event, maxDiscountRate } = this;
+ const {
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
+ } = event;
- if (totalWithoutTaxes <= 0 || totalDiscountable === 0) {
+ if (totalWithoutDiscount <= 0 || totalDiscountable.isZero()) {
this.unsavedDiscountRate = 0;
return;
}
- let discountAmount = totalWithoutTaxes.sub(value);
+ let discountAmount = totalWithoutDiscount.sub(value);
if (discountAmount.greaterThan(totalDiscountable)) {
discountAmount = totalDiscountable;
}
- const rate = discountAmount.times(100).div(totalWithoutTaxes).toNumber();
- this.unsavedDiscountRate = Math.min(round(rate, 4), maxDiscountRate.toNumber());
+ const rate = discountAmount.times(100)
+ .div(totalWithoutDiscount)
+ .toDecimalPlaces(4, Decimal.ROUND_UP);
+
+ this.unsavedDiscountRate = Decimal.min(rate, maxDiscountRate);
},
},
},
diff --git a/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js b/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js
index ed926c471..e1524afbc 100644
--- a/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js
+++ b/client/src/themes/default/modals/EventDetails/tabs/Invoices/index.js
@@ -3,7 +3,6 @@ import invariant from 'invariant';
import Decimal from 'decimal.js';
import { defineComponent } from '@vue/composition-api';
import getEventDiscountRate from '@/utils/getEventDiscountRate';
-import { round } from '@/utils/decimalRound';
import apiEvents from '@/stores/api/events';
import { Group } from '@/stores/api/groups';
import Fragment from '@/components/Fragment';
@@ -56,30 +55,29 @@ const EventDetailsInvoices = defineComponent({
return this.$store.getters['auth/is']([Group.ADMIN, Group.MEMBER]);
},
- totalDiscountable() {
+ maxDiscountRate() {
const {
- degressive_rate: degressiveRate,
- daily_total_discountable: dailyTotalDiscountable,
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
} = this.event;
- return dailyTotalDiscountable.times(degressiveRate);
- },
-
- maxDiscountRate() {
- const { event, totalDiscountable } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ if (totalWithoutDiscount > 0) {
+ return (totalDiscountable.times(100))
+ .div(totalWithoutDiscount)
+ .toDecimalPlaces(4, Decimal.ROUND_UP);
+ }
- return totalWithoutTaxes > 0
- ? (totalDiscountable.times(100)).div(totalWithoutTaxes)
- : new Decimal(0);
+ return new Decimal(0);
},
minTotalAmount() {
- const { event, totalDiscountable } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const {
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
+ } = this.event;
- return totalWithoutTaxes > 0
- ? totalWithoutTaxes.sub(totalDiscountable)
+ return totalWithoutDiscount > 0
+ ? totalWithoutDiscount.sub(totalDiscountable)
: new Decimal(0);
},
@@ -89,38 +87,46 @@ const EventDetailsInvoices = defineComponent({
if (unsavedDiscountRate !== null) {
return this.unsavedDiscountRate;
}
- return Math.min(getEventDiscountRate(event), maxDiscountRate.toNumber());
+
+ const eventDiscountRate = new Decimal(getEventDiscountRate(event));
+ return Decimal.min(eventDiscountRate, maxDiscountRate);
},
set(value) {
- this.unsavedDiscountRate = Math.min(value, this.maxDiscountRate.toNumber());
+ this.unsavedDiscountRate = Decimal.min(new Decimal(value), this.maxDiscountRate);
},
},
discountTarget: {
get() {
const { event, discountRate, minTotalAmount } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const { total_without_discount: totalWithoutDiscount } = event;
- const discountAmount = totalWithoutTaxes.times(discountRate / 100);
- const totalAmount = totalWithoutTaxes.sub(discountAmount).toNumber();
- return Math.max(totalAmount, minTotalAmount.toNumber());
+ const discountAmount = totalWithoutDiscount.times(discountRate / 100);
+ const totalAmount = totalWithoutDiscount.sub(discountAmount);
+ return Decimal.max(totalAmount, minTotalAmount);
},
set(value) {
- const { event, totalDiscountable, maxDiscountRate } = this;
- const { total_without_taxes: totalWithoutTaxes } = event;
+ const { event, maxDiscountRate } = this;
+ const {
+ total_without_discount: totalWithoutDiscount,
+ total_discountable: totalDiscountable,
+ } = event;
- if (totalWithoutTaxes <= 0 || totalDiscountable === 0) {
+ if (totalWithoutDiscount <= 0 || totalDiscountable.isZero()) {
this.unsavedDiscountRate = 0;
return;
}
- let discountAmount = totalWithoutTaxes.sub(value);
+ let discountAmount = totalWithoutDiscount.sub(value);
if (discountAmount.greaterThan(totalDiscountable)) {
discountAmount = totalDiscountable;
}
- const rate = discountAmount.times(100).div(totalWithoutTaxes).toNumber();
- this.unsavedDiscountRate = Math.min(round(rate, 4), maxDiscountRate.toNumber());
+ const rate = discountAmount.times(100)
+ .div(totalWithoutDiscount)
+ .toDecimalPlaces(4, Decimal.ROUND_UP);
+
+ this.unsavedDiscountRate = Decimal.min(rate, maxDiscountRate);
},
},
},
diff --git a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss
index b7fc9d5dc..e7c195c8d 100644
--- a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss
+++ b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.scss
@@ -86,4 +86,10 @@
background-color: #913410;
}
}
+
+ &--warning {
+ #{$block}__booking__icon {
+ color: globals.$text-danger-color;
+ }
+ }
}
diff --git a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx
index c17546399..ee4f4a6bb 100644
--- a/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx
+++ b/client/src/themes/default/pages/BeneficiaryView/components/BookingsItem/index.tsx
@@ -152,7 +152,7 @@ const BeneficiaryViewBookingsItem = defineComponent({
},
},
render() {
- const { duration } = this.booking;
+ const { duration, has_missing_materials: hasMissingMaterials } = this.booking;
const {
$t: __,
icon,
@@ -172,6 +172,7 @@ const BeneficiaryViewBookingsItem = defineComponent({
'BeneficiaryViewBookingsItem--future': !isPast,
'BeneficiaryViewBookingsItem--current': isOngoing,
'BeneficiaryViewBookingsItem--confirmed': isConfirmed,
+ 'BeneficiaryViewBookingsItem--warning': hasMissingMaterials,
}];
return (
diff --git a/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx b/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx
index 4fd065fe5..ef6ade740 100644
--- a/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx
+++ b/client/src/themes/default/pages/BeneficiaryView/tabs/Borrowings/index.tsx
@@ -6,6 +6,8 @@ import { BookingEntity } from '@/stores/api/bookings';
import formatTimelineBooking from '@/utils/formatTimelineBooking';
import showModal from '@/utils/showModal';
import apiBeneficiaries from '@/stores/api/beneficiaries';
+import apiEvents from '@/stores/api/events';
+import Queue from 'p-queue';
import EventDetails from '@/themes/default/modals/EventDetails';
import CriticalError from '@/themes/default/components/CriticalError';
import Loading from '@/themes/default/components/Loading';
@@ -28,6 +30,7 @@ type Props = {
type InstanceProperties = {
cancelOngoingFetch: (() => void) | undefined,
nowTimer: ReturnType | undefined,
+ fetchMissingMaterialsQueue: Queue | undefined,
};
type Data = {
@@ -39,6 +42,13 @@ type Data = {
now: number,
};
+/**
+ * Nombre de requêtes simultanées maximum pour la récupération du
+ * matériel manquant (au delà, elles seront placées dans une file d'attente).
+ */
+const MAX_CONCURRENT_FETCHES = 5;
+
+/** Nombre de millisecondes pour faire un jour entier. */
const ONE_DAY = 1000 * 3600 * 24;
/* Contenu de l'onglet "emprunts" de la page de détails d'un bénéficiaire. */
@@ -53,6 +63,7 @@ const BeneficiaryViewBorrowings = defineComponent({
setup: (): InstanceProperties => ({
cancelOngoingFetch: undefined,
nowTimer: undefined,
+ fetchMissingMaterialsQueue: undefined,
}),
data: (): Data => ({
bookings: [],
@@ -98,6 +109,9 @@ const BeneficiaryViewBorrowings = defineComponent({
}));
},
},
+ created() {
+ this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES });
+ },
mounted() {
this.fetchData();
@@ -107,6 +121,9 @@ const BeneficiaryViewBorrowings = defineComponent({
beforeDestroy() {
this.cancelOngoingFetch?.();
+ // - Vide la file d'attente des requêtes.
+ this.fetchMissingMaterialsQueue?.clear();
+
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -209,6 +226,9 @@ const BeneficiaryViewBorrowings = defineComponent({
async fetchData() {
const { beneficiary } = this;
+ // - Vide la file d'attente des requêtes avant de la re-peupler.
+ this.fetchMissingMaterialsQueue?.clear();
+
// - Annule la récupération en cours, s'il y en a une.
this.cancelOngoingFetch?.();
@@ -229,6 +249,35 @@ const BeneficiaryViewBorrowings = defineComponent({
} else {
this.bookings.push(...data);
}
+
+ const promises = data
+ .filter((booking: BookingSummary) => (
+ !booking.is_archived &&
+ !(
+ moment(booking.end_date).isBefore(this.now, 'day') &&
+ booking.is_return_inventory_done
+ )
+ ))
+ .map((booking: BookingSummary) => async () => {
+ const missingMaterials = await apiEvents.missingMaterials(booking.id);
+
+ const originalBookingIndex = this.bookings.findIndex(
+ ({ entity, id }: BookingSummary) => (
+ entity === booking.entity && id === booking.id
+ ),
+ );
+ if (originalBookingIndex === -1) {
+ return;
+ }
+
+ this.$set(this.bookings, originalBookingIndex, {
+ ...booking,
+ has_missing_materials: missingMaterials.length > 0,
+ });
+ });
+
+ await this.fetchMissingMaterialsQueue?.addAll(promises);
+
this.isPartiallyFetched = true;
this.isFullyFetched = false;
diff --git a/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx b/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx
index 8de9ed9fc..46b0c7109 100644
--- a/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx
+++ b/client/src/themes/default/pages/BeneficiaryView/tabs/Infos/NextBookings/index.tsx
@@ -5,6 +5,8 @@ import { defineComponent } from '@vue/composition-api';
import showModal from '@/utils/showModal';
import { BookingEntity } from '@/stores/api/bookings';
import apiBeneficiaries from '@/stores/api/beneficiaries';
+import apiEvents from '@/stores/api/events';
+import Queue from 'p-queue';
import Loading from '@/themes/default/components/Loading';
import EventDetails from '@/themes/default/modals/EventDetails';
import Item from '../../../components/BookingsItem';
@@ -20,6 +22,7 @@ type Props = {
type InstanceProperties = {
cancelOngoingFetch: (() => void) | undefined,
+ fetchMissingMaterialsQueue: Queue | undefined,
};
type Data = {
@@ -29,6 +32,12 @@ type Data = {
isFullyFetched: boolean,
};
+/**
+ * Nombre de requêtes simultanées maximum pour la récupération du
+ * matériel manquant (au delà, elles seront placées dans une file d'attente).
+ */
+const MAX_CONCURRENT_FETCHES = 5;
+
/* Liste des emprunts en cours ou futurs d'un bénéficiaire. */
const BeneficiaryViewNextBookings = defineComponent({
name: 'BeneficiaryViewNextBookings',
@@ -40,6 +49,7 @@ const BeneficiaryViewNextBookings = defineComponent({
},
setup: (): InstanceProperties => ({
cancelOngoingFetch: undefined,
+ fetchMissingMaterialsQueue: undefined,
}),
data: (): Data => ({
bookings: [],
@@ -48,10 +58,15 @@ const BeneficiaryViewNextBookings = defineComponent({
isFullyFetched: false,
}),
created() {
+ this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES });
+
this.fetchData();
},
beforeDestroy() {
this.cancelOngoingFetch?.();
+
+ // - Vide la file d'attente des requêtes.
+ this.fetchMissingMaterialsQueue?.clear();
},
methods: {
// ------------------------------------------------------
@@ -98,6 +113,9 @@ const BeneficiaryViewNextBookings = defineComponent({
const { id } = this;
const now = moment();
+ // - Vide la file d'attente des requêtes avant de la re-peupler.
+ this.fetchMissingMaterialsQueue?.clear();
+
// - Annule la récupération en cours, s'il y en a une.
this.cancelOngoingFetch?.();
@@ -119,6 +137,27 @@ const BeneficiaryViewNextBookings = defineComponent({
} else {
this.bookings.push(...data);
}
+
+ const promises = data.map((booking: BookingSummary) => async () => {
+ const missingMaterials = await apiEvents.missingMaterials(booking.id);
+
+ const originalBookingIndex = this.bookings.findIndex(
+ ({ entity, id: searchId }: BookingSummary) => (
+ entity === booking.entity && searchId === booking.id
+ ),
+ );
+ if (originalBookingIndex === -1) {
+ return;
+ }
+
+ this.$set(this.bookings, originalBookingIndex, {
+ ...booking,
+ has_missing_materials: missingMaterials.length > 0,
+ });
+ });
+
+ await this.fetchMissingMaterialsQueue?.addAll(promises);
+
this.isPartiallyFetched = true;
this.isFullyFetched = false;
diff --git a/client/src/themes/default/pages/Calendar/index.js b/client/src/themes/default/pages/Calendar/index.js
index 034fe02f4..9875a4064 100644
--- a/client/src/themes/default/pages/Calendar/index.js
+++ b/client/src/themes/default/pages/Calendar/index.js
@@ -6,6 +6,7 @@ import { Group } from '@/stores/api/groups';
import { DATE_DB_FORMAT } from '@/globals/constants';
import apiBookings, { BookingEntity } from '@/stores/api/bookings';
import apiEvents from '@/stores/api/events';
+import Queue from 'p-queue';
import EventDetails from '@/themes/default/modals/EventDetails';
import Page from '@/themes/default/components/Page';
import CriticalError from '@/themes/default/components/CriticalError';
@@ -21,9 +22,18 @@ const ONE_DAY = 1000 * 3600 * 24;
const FETCH_DELTA_DAYS = 3;
const MAX_ZOOM_MONTH = 3;
+/**
+ * Nombre de requêtes simultanées maximum pour la récupération du
+ * matériel manquant (au delà, elles seront placées dans une file d'attente).
+ */
+const MAX_CONCURRENT_FETCHES = 5;
+
/** Page du calendrier des réservations / événements. */
const Calendar = defineComponent({
name: 'Calendar',
+ setup: () => ({
+ fetchMissingMaterialsQueue: undefined,
+ }),
data() {
const parkFilter = this.$route.query.park;
const { start, end } = getDefaultPeriod();
@@ -120,11 +130,17 @@ const Calendar = defineComponent({
});
},
},
+ created() {
+ this.fetchMissingMaterialsQueue = new Queue({ concurrency: MAX_CONCURRENT_FETCHES });
+ },
mounted() {
// - Actualise le timestamp courant toutes les minutes.
this.nowTimer = setInterval(() => { this.now = Date.now(); }, 60_000);
},
beforeDestroy() {
+ // - Vide la file d'attente des requêtes.
+ this.fetchMissingMaterialsQueue?.clear();
+
if (this.nowTimer) {
clearInterval(this.nowTimer);
}
@@ -346,8 +362,31 @@ const Calendar = defineComponent({
this.isLoading = true;
this.isModalOpened = false;
+ // - Vide la file d'attente des requêtes avant de la re-peupler.
+ this.fetchMissingMaterialsQueue?.clear();
+
try {
this.bookings = await apiBookings.all(this.fetchStart, this.fetchEnd);
+
+ const promises = this.bookings
+ .filter((booking) => (
+ !booking.is_archived &&
+ !(
+ moment(booking.end_date).isBefore(this.now, 'day') &&
+ booking.is_return_inventory_done
+ )
+ ))
+ .map((booking) => async () => {
+ const missingMaterials = await apiEvents.missingMaterials(booking.id);
+
+ this.handleUpdatedBooking({
+ ...booking,
+ has_missing_materials: missingMaterials.length > 0,
+ });
+ });
+
+ await this.fetchMissingMaterialsQueue?.addAll(promises);
+
this.isFetched = true;
} catch (error) {
if (isRequestErrorStatusCode(error, HttpCode.ClientErrorRangeNotSatisfiable)) {
diff --git a/client/src/utils/decimalRound.js b/client/src/utils/decimalRound.js
deleted file mode 100644
index 941b68193..000000000
--- a/client/src/utils/decimalRound.js
+++ /dev/null
@@ -1,7 +0,0 @@
-export const round = (value, depth = 2) => (
- Math.round((value + Number.EPSILON) * 10 ** depth) / 10 ** depth
-);
-
-export const floor = (value, depth = 2) => (
- Math.floor((value + Number.EPSILON) * 10 ** depth) / 10 ** depth
-);
diff --git a/client/tests/specs/utils/decimalRound.js b/client/tests/specs/utils/decimalRound.js
deleted file mode 100644
index 25a34b620..000000000
--- a/client/tests/specs/utils/decimalRound.js
+++ /dev/null
@@ -1,59 +0,0 @@
-import { round, floor } from '@/utils/decimalRound';
-
-describe('decimalRound', () => {
- describe('round()', () => {
- it('return the value rounded to 2 decimals', () => {
- expect(round(1)).toEqual(1);
- expect(round(1.1)).toEqual(1.1);
- expect(round(1.008)).toEqual(1.01);
- expect(round(1.014)).toEqual(1.01);
- expect(round(1.015)).toEqual(1.02);
- expect(round(1.0001)).toEqual(1);
- });
-
- it('return the value rounded to 4 decimals', () => {
- expect(round(1, 4)).toEqual(1);
- expect(round(1.1, 4)).toEqual(1.1);
- expect(round(1.0001, 4)).toEqual(1.0001);
- expect(round(1.000_08, 4)).toEqual(1.0001);
- });
-
- it('return 0 if value is falsy', () => {
- expect(round(null)).toEqual(0);
- expect(round(false)).toEqual(0);
- });
-
- it('return NaN if value is not a number', () => {
- expect(round('NotANumber')).toEqual(NaN);
- expect(round({ a: 1 })).toEqual(NaN);
- });
- });
-
- describe('floor()', () => {
- it('return the value rounded to 2 decimals', () => {
- expect(floor(1)).toEqual(1);
- expect(floor(1.1)).toEqual(1.1);
- expect(floor(1.008)).toEqual(1);
- expect(floor(1.014)).toEqual(1.01);
- expect(floor(1.015)).toEqual(1.01);
- expect(floor(1.0001)).toEqual(1);
- });
-
- it('return the value rounded to 4 decimals', () => {
- expect(floor(1, 4)).toEqual(1);
- expect(floor(1.1, 4)).toEqual(1.1);
- expect(floor(1.0001, 4)).toEqual(1.0001);
- expect(floor(1.000_08, 4)).toEqual(1);
- });
-
- it('return 0 if value is falsy', () => {
- expect(floor(null)).toEqual(0);
- expect(floor(false)).toEqual(0);
- });
-
- it('return NaN if value is not a number', () => {
- expect(floor('NotANumber')).toEqual(NaN);
- expect(floor({ a: 1 })).toEqual(NaN);
- });
- });
-});
diff --git a/server/src/App/Controllers/BookingController.php b/server/src/App/Controllers/BookingController.php
index 57719143e..f45c82c5d 100644
--- a/server/src/App/Controllers/BookingController.php
+++ b/server/src/App/Controllers/BookingController.php
@@ -6,7 +6,6 @@
use Fig\Http\Message\StatusCodeInterface as StatusCode;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
-use Loxya\Contracts\PeriodInterface;
use Loxya\Errors\Enums\ApiErrorCode;
use Loxya\Errors\Exception\ApiBadRequestException;
use Loxya\Errors\Exception\HttpUnprocessableEntityException;
@@ -14,7 +13,6 @@
use Loxya\Models\Event;
use Loxya\Models\Park;
use Loxya\Support\Database\QueryAggregator;
-use Loxya\Support\Period;
use Psr\Http\Message\ResponseInterface;
use Slim\Exception\HttpBadRequestException;
use Slim\Exception\HttpException;
@@ -61,33 +59,6 @@ public function getAll(Request $request, Response $response): ResponseInterface
if ($bookings->isEmpty()) {
return $response->withJson([], StatusCode::STATUS_OK);
}
-
- $period = $bookings->reduce(
- fn(?Period $currentPeriod, PeriodInterface $booking) => (
- $currentPeriod === null
- ? new Period($booking)
- : $currentPeriod->merge($booking)
- )
- );
-
- // - NOTE : Ne pas prefetch le materiel des bookables via `->with()`,
- // car cela peut surcharger la mémoire rapidement.
- $allConcurrentBookables = (new Collection())
- // - Événements.
- ->concat(
- Event::inPeriod($period)->get()
- );
-
- foreach ($bookings as $booking) {
- $booking->__cachedConcurrentBookables = $allConcurrentBookables
- ->filter(fn($otherBookable) => (
- !$booking->is($otherBookable) &&
- $booking->getStartDate() <= $otherBookable->getEndDate() &&
- $booking->getEndDate() >= $otherBookable->getStartDate()
- ))
- ->values();
- }
-
$useMultipleParks = Park::count() > 1;
$data = $bookings
diff --git a/server/src/App/Models/Estimate.php b/server/src/App/Models/Estimate.php
index fe06c0087..58a9e0e29 100644
--- a/server/src/App/Models/Estimate.php
+++ b/server/src/App/Models/Estimate.php
@@ -4,6 +4,7 @@
namespace Loxya\Models;
use Brick\Math\BigDecimal as Decimal;
+use Brick\Math\RoundingMode;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
@@ -40,12 +41,10 @@
* @property Decimal $degressive_rate
* @property Decimal $discount_rate
* @property Decimal $vat_rate
- * @property Decimal $daily_total_without_discount
- * @property Decimal $daily_total_discountable
- * @property Decimal $daily_total_discount
- * @property Decimal $daily_total_without_taxes
- * @property Decimal $daily_total_taxes
- * @property Decimal $daily_total_with_taxes
+ * @property Decimal $daily_total
+ * @property Decimal $total_without_discount
+ * @property Decimal $total_discountable
+ * @property Decimal $total_discount
* @property Decimal $total_without_taxes
* @property Decimal $total_taxes
* @property Decimal $total_with_taxes
@@ -84,12 +83,10 @@ public function __construct(array $attributes = [])
'degressive_rate' => V::custom([$this, 'checkDegressiveRate']),
'discount_rate' => V::custom([$this, 'checkDiscountRate']),
'vat_rate' => V::custom([$this, 'checkVatRate']),
- 'daily_total_without_discount' => V::custom([$this, 'checkAmount']),
- 'daily_total_discountable' => V::custom([$this, 'checkAmount']),
- 'daily_total_discount' => V::custom([$this, 'checkAmount']),
- 'daily_total_without_taxes' => V::custom([$this, 'checkAmount']),
- 'daily_total_taxes' => V::custom([$this, 'checkAmount']),
- 'daily_total_with_taxes' => V::custom([$this, 'checkAmount']),
+ 'daily_total' => V::custom([$this, 'checkAmount']),
+ 'total_without_discount' => V::custom([$this, 'checkAmount']),
+ 'total_discountable' => V::custom([$this, 'checkAmount']),
+ 'total_discount' => V::custom([$this, 'checkAmount']),
'total_without_taxes' => V::custom([$this, 'checkAmount']),
'total_taxes' => V::custom([$this, 'checkAmount']),
'total_with_taxes' => V::custom([$this, 'checkAmount']),
@@ -163,11 +160,37 @@ public function checkDiscountRate($value)
V::floatVal()->check($value);
$value = Decimal::of($value);
- return (
- $value->isGreaterThanOrEqualTo(0) &&
- $value->isLessThan(100) &&
- $value->getScale() <= 4
- );
+ $totalWithoutDiscountRaw = $this->getAttributeFromArray('total_without_discount');
+ if (!$this->validation['total_without_discount']->validate($totalWithoutDiscountRaw)) {
+ return true;
+ }
+ $totalWithoutDiscount = Decimal::of($totalWithoutDiscountRaw);
+
+ // - Si le total sans remise est à 0, la remise est forcément à 0 aussi.
+ if ($totalWithoutDiscount->isZero()) {
+ return $value->isZero();
+ }
+
+ $totalDiscountableRaw = $this->getAttributeFromArray('total_discountable');
+ if ($totalDiscountableRaw === null) {
+ return $value->isLessThan(100) ?: 'discount-rate-exceeds-maximum';
+ }
+
+ if (!$this->validation['total_discountable']->validate($totalDiscountableRaw)) {
+ return true;
+ }
+ $totalDiscountable = Decimal::of($totalDiscountableRaw);
+
+ // - Si le total remisable est à 0, il ne peut pas y avoir de remise.
+ if ($totalDiscountable->isZero()) {
+ return $value->isZero();
+ }
+
+ $maxDiscountRate = $totalDiscountable
+ ->multipliedBy(100)
+ ->dividedBy($totalWithoutDiscount, 4, RoundingMode::HALF_UP);
+
+ return !$value->isGreaterThan($maxDiscountRate) ?: 'discount-rate-exceeds-maximum';
}
public function checkVatRate($value)
@@ -262,12 +285,10 @@ public function author()
'degressive_rate' => AsDecimal::class,
'discount_rate' => AsDecimal::class,
'vat_rate' => AsDecimal::class,
- 'daily_total_without_discount' => AsDecimal::class,
- 'daily_total_discountable' => AsDecimal::class,
- 'daily_total_discount' => AsDecimal::class,
- 'daily_total_without_taxes' => AsDecimal::class,
- 'daily_total_taxes' => AsDecimal::class,
- 'daily_total_with_taxes' => AsDecimal::class,
+ 'daily_total' => AsDecimal::class,
+ 'total_without_discount' => AsDecimal::class,
+ 'total_discountable' => AsDecimal::class,
+ 'total_discount' => AsDecimal::class,
'total_without_taxes' => AsDecimal::class,
'total_taxes' => AsDecimal::class,
'total_with_taxes' => AsDecimal::class,
@@ -384,13 +405,12 @@ protected function getPdfData(): array
'degressiveRate' => $this->degressive_rate,
'discountRate' => $this->discount_rate->dividedBy(100, 6),
'vatRate' => $this->vat_rate->dividedBy(100, 4),
- 'dailyTotalWithoutDiscount' => $this->daily_total_without_discount,
- 'dailyTotalDiscountable' => $this->daily_total_discountable,
- 'dailyTotalDiscount' => $this->daily_total_discount,
- 'dailyTotalWithoutTaxes' => $this->daily_total_without_taxes,
- 'dailyTotalTaxes' => $this->daily_total_taxes,
- 'dailyTotalWithTaxes' => $this->daily_total_with_taxes,
+ 'dailyTotal' => $this->daily_total,
+ 'totalWithoutDiscount' => $this->total_without_discount,
+ 'totalDiscountable' => $this->total_discountable,
+ 'totalDiscount' => $this->total_discount,
'totalWithoutTaxes' => $this->total_without_taxes,
+ 'totalTaxes' => $this->total_taxes,
'totalWithTaxes' => $this->total_with_taxes,
'categoriesSubTotals' => $categoriesTotals,
'materials' => (
@@ -414,12 +434,10 @@ protected function getPdfData(): array
'degressive_rate',
'discount_rate',
'vat_rate',
- 'daily_total_without_discount',
- 'daily_total_discountable',
- 'daily_total_discount',
- 'daily_total_without_taxes',
- 'daily_total_taxes',
- 'daily_total_with_taxes',
+ 'daily_total',
+ 'total_without_discount',
+ 'total_discountable',
+ 'total_discount',
'total_without_taxes',
'total_taxes',
'total_with_taxes',
@@ -472,15 +490,13 @@ public static function createFromBooking(Event $booking, User $creator): Estimat
'discount_rate' => $booking->discount_rate,
'vat_rate' => $booking->vat_rate,
+ // - Total / jour.
+ 'daily_total' => $booking->daily_total,
+
// - Remise.
- 'daily_total_without_discount' => $booking->daily_total_without_discount,
- 'daily_total_discountable' => $booking->daily_total_discountable,
- 'daily_total_discount' => $booking->daily_total_discount,
- 'daily_total_without_taxes' => $booking->daily_total_without_taxes,
-
- // - Taxes.
- 'daily_total_taxes' => $booking->daily_total_taxes,
- 'daily_total_with_taxes' => $booking->daily_total_with_taxes,
+ 'total_without_discount' => $booking->total_without_discount,
+ 'total_discountable' => $booking->total_discountable,
+ 'total_discount' => $booking->total_discount,
// - Totaux.
'total_without_taxes' => $booking->total_without_taxes,
diff --git a/server/src/App/Models/Event.php b/server/src/App/Models/Event.php
index 94f6ab33b..23a9b9980 100644
--- a/server/src/App/Models/Event.php
+++ b/server/src/App/Models/Event.php
@@ -46,12 +46,10 @@
* @property-read Decimal|null $degressive_rate
* @property Decimal|null $discount_rate
* @property-read Decimal|null $vat_rate
- * @property-read Decimal|null $daily_total_without_discount
- * @property-read Decimal|null $daily_total_discountable
- * @property-read Decimal|null $daily_total_discount
- * @property-read Decimal|null $daily_total_without_taxes
- * @property-read Decimal|null $daily_total_taxes
- * @property-read Decimal|null $daily_total_with_taxes
+ * @property-read Decimal|null $daily_total
+ * @property-read Decimal|null $total_discountable
+ * @property-read Decimal|null $total_discount
+ * @property-read Decimal|null $total_without_discount
* @property-read Decimal|null $total_without_taxes
* @property-read Decimal|null $total_taxes
* @property-read Decimal|null $total_with_taxes
@@ -560,10 +558,10 @@ function ($totals, $material) {
}
//
- // - Daily totals.
+ // - Daily total.
//
- public function getDailyTotalWithoutDiscountAttribute(): ?Decimal
+ public function getDailyTotalAttribute(): ?Decimal
{
if (!$this->is_billable) {
return null;
@@ -579,72 +577,55 @@ public function getDailyTotalWithoutDiscountAttribute(): ?Decimal
->toScale(2, RoundingMode::UNNECESSARY);
}
- public function getDailyTotalDiscountableAttribute(): ?Decimal
- {
- if (!$this->is_billable) {
- return null;
- }
-
- return $this->materials->pluck('pivot')
- ->reduce(
- fn (Decimal $currentTotal, EventMaterial $material) => (
- $material->is_discountable
- ? $currentTotal->plus($material->total_price)
- : $currentTotal
- ),
- Decimal::zero()
- )
- ->toScale(2, RoundingMode::UNNECESSARY);
- }
+ //
+ // - Discount.
+ //
- public function getDailyTotalDiscountAttribute(): ?Decimal
+ public function getTotalWithoutDiscountAttribute(): ?Decimal
{
if (!$this->is_billable) {
return null;
}
- return $this->daily_total_without_discount
- ->multipliedBy($this->discount_rate->dividedBy(100, 6))
+ return $this->daily_total
+ ->multipliedBy($this->degressive_rate)
// @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price
// @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules
->toScale(2, RoundingMode::HALF_UP);
}
- public function getDailyTotalWithoutTaxesAttribute(): ?Decimal
+ public function getTotalDiscountableAttribute(): ?Decimal
{
if (!$this->is_billable) {
return null;
}
- return $this->daily_total_without_discount
- ->minus($this->daily_total_discount)
- ->toScale(2, RoundingMode::UNNECESSARY);
+ return $this->materials->pluck('pivot')
+ ->reduce(
+ fn (Decimal $currentTotal, EventMaterial $material) => (
+ $material->is_discountable
+ ? $currentTotal->plus($material->total_price)
+ : $currentTotal
+ ),
+ Decimal::zero()
+ )
+ ->multipliedBy($this->degressive_rate)
+ ->toScale(2, RoundingMode::HALF_UP);
}
- public function getDailyTotalTaxesAttribute(): ?Decimal
+ public function getTotalDiscountAttribute(): ?Decimal
{
if (!$this->is_billable) {
return null;
}
- return $this->daily_total_without_taxes
- ->multipliedBy($this->vat_rate->dividedBy(100, 4))
+ return $this->total_without_discount
+ ->multipliedBy($this->discount_rate->dividedBy(100, 6))
// @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price
// @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules
->toScale(2, RoundingMode::HALF_UP);
}
- public function getDailyTotalWithTaxesAttribute(): ?Decimal
- {
- if (!$this->is_billable) {
- return null;
- }
-
- return $this->daily_total_without_taxes
- ->plus($this->daily_total_taxes)
- ->toScale(2, RoundingMode::UNNECESSARY);
- }
-
//
// - Totals.
//
@@ -655,8 +636,8 @@ public function getTotalWithoutTaxesAttribute(): ?Decimal
return null;
}
- return $this->daily_total_without_taxes
- ->multipliedBy($this->degressive_rate)
+ return $this->total_without_discount
+ ->minus($this->total_discount)
// @see https://www.ibm.com/docs/en/order-management-sw/9.2.1?topic=rounding-price
// @see https://wiki.dolibarr.org/index.php?title=VAT_setup,_calculation_and_rounding_rules
->toScale(2, RoundingMode::HALF_UP);
@@ -1773,7 +1754,6 @@ public function serialize(string $format = self::SERIALIZE_DEFAULT): array
'duration',
'technicians',
'beneficiaries',
- 'has_missing_materials',
'has_not_returned_materials',
'parks',
]);
@@ -1800,12 +1780,10 @@ public function serialize(string $format = self::SERIALIZE_DEFAULT): array
'degressive_rate',
'vat_rate',
'discount_rate',
- 'daily_total_without_discount',
- 'daily_total_discountable',
- 'daily_total_discount',
- 'daily_total_without_taxes',
- 'daily_total_taxes',
- 'daily_total_with_taxes',
+ 'daily_total',
+ 'total_without_discount',
+ 'total_discountable',
+ 'total_discount',
'total_without_taxes',
'total_taxes',
'total_with_taxes',
diff --git a/server/src/App/Models/Invoice.php b/server/src/App/Models/Invoice.php
index 691336148..5f7830247 100644
--- a/server/src/App/Models/Invoice.php
+++ b/server/src/App/Models/Invoice.php
@@ -4,6 +4,7 @@
namespace Loxya\Models;
use Brick\Math\BigDecimal as Decimal;
+use Brick\Math\RoundingMode;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
@@ -41,12 +42,10 @@
* @property Decimal $degressive_rate
* @property Decimal $discount_rate
* @property Decimal $vat_rate
- * @property Decimal $daily_total_without_discount
- * @property Decimal $daily_total_discountable
- * @property Decimal $daily_total_discount
- * @property Decimal $daily_total_without_taxes
- * @property Decimal $daily_total_taxes
- * @property Decimal $daily_total_with_taxes
+ * @property Decimal $daily_total
+ * @property Decimal $total_without_discount
+ * @property Decimal $total_discountable
+ * @property Decimal $total_discount
* @property Decimal $total_without_taxes
* @property Decimal $total_taxes
* @property Decimal $total_with_taxes
@@ -66,7 +65,7 @@ final class Invoice extends BaseModel implements Serializable
use Serializer;
use SoftDeletable;
- protected const PDF_TEMPLATE = 'invoice-default';
+ public const PDF_TEMPLATE = 'invoice-default';
public function __construct(array $attributes = [])
{
@@ -86,12 +85,10 @@ public function __construct(array $attributes = [])
'degressive_rate' => V::custom([$this, 'checkDegressiveRate']),
'discount_rate' => V::custom([$this, 'checkDiscountRate']),
'vat_rate' => V::custom([$this, 'checkVatRate']),
- 'daily_total_without_discount' => V::custom([$this, 'checkAmount']),
- 'daily_total_discountable' => V::custom([$this, 'checkAmount']),
- 'daily_total_discount' => V::custom([$this, 'checkAmount']),
- 'daily_total_without_taxes' => V::custom([$this, 'checkAmount']),
- 'daily_total_taxes' => V::custom([$this, 'checkAmount']),
- 'daily_total_with_taxes' => V::custom([$this, 'checkAmount']),
+ 'daily_total' => V::custom([$this, 'checkAmount']),
+ 'total_without_discount' => V::custom([$this, 'checkAmount']),
+ 'total_discountable' => V::custom([$this, 'checkAmount']),
+ 'total_discount' => V::custom([$this, 'checkAmount']),
'total_without_taxes' => V::custom([$this, 'checkAmount']),
'total_taxes' => V::custom([$this, 'checkAmount']),
'total_with_taxes' => V::custom([$this, 'checkAmount']),
@@ -183,11 +180,37 @@ public function checkDiscountRate($value)
V::floatVal()->check($value);
$value = Decimal::of($value);
- return (
- $value->isGreaterThanOrEqualTo(0) &&
- $value->isLessThan(100) &&
- $value->getScale() <= 4
- );
+ $totalWithoutDiscountRaw = $this->getAttributeFromArray('total_without_discount');
+ if (!$this->validation['total_without_discount']->validate($totalWithoutDiscountRaw)) {
+ return true;
+ }
+ $totalWithoutDiscount = Decimal::of($totalWithoutDiscountRaw);
+
+ // - Si le total sans remise est à 0, la remise est forcément à 0 aussi.
+ if ($totalWithoutDiscount->isZero()) {
+ return $value->isZero();
+ }
+
+ $totalDiscountableRaw = $this->getAttributeFromArray('total_discountable');
+ if ($totalDiscountableRaw === null) {
+ return $value->isLessThan(100) ?: 'discount-rate-exceeds-maximum';
+ }
+
+ if (!$this->validation['total_discountable']->validate($totalDiscountableRaw)) {
+ return true;
+ }
+ $totalDiscountable = Decimal::of($totalDiscountableRaw);
+
+ // - Si le total remisable est à 0, il ne peut pas y avoir de remise.
+ if ($totalDiscountable->isZero()) {
+ return $value->isZero();
+ }
+
+ $maxDiscountRate = $totalDiscountable
+ ->multipliedBy(100)
+ ->dividedBy($totalWithoutDiscount, 4, RoundingMode::HALF_UP);
+
+ return !$value->isGreaterThan($maxDiscountRate) ?: 'discount-rate-exceeds-maximum';
}
public function checkVatRate($value)
@@ -283,12 +306,10 @@ public function author()
'degressive_rate' => AsDecimal::class,
'discount_rate' => AsDecimal::class,
'vat_rate' => AsDecimal::class,
- 'daily_total_without_discount' => AsDecimal::class,
- 'daily_total_discountable' => AsDecimal::class,
- 'daily_total_discount' => AsDecimal::class,
- 'daily_total_without_taxes' => AsDecimal::class,
- 'daily_total_taxes' => AsDecimal::class,
- 'daily_total_with_taxes' => AsDecimal::class,
+ 'daily_total' => AsDecimal::class,
+ 'total_without_discount' => AsDecimal::class,
+ 'total_discountable' => AsDecimal::class,
+ 'total_discount' => AsDecimal::class,
'total_without_taxes' => AsDecimal::class,
'total_taxes' => AsDecimal::class,
'total_with_taxes' => AsDecimal::class,
@@ -406,13 +427,12 @@ protected function getPdfData(): array
'degressiveRate' => $this->degressive_rate,
'discountRate' => $this->discount_rate->dividedBy(100, 6),
'vatRate' => $this->vat_rate->dividedBy(100, 4),
- 'dailyTotalWithoutDiscount' => $this->daily_total_without_discount,
- 'dailyTotalDiscountable' => $this->daily_total_discountable,
- 'dailyTotalDiscount' => $this->daily_total_discount,
- 'dailyTotalWithoutTaxes' => $this->daily_total_without_taxes,
- 'dailyTotalTaxes' => $this->daily_total_taxes,
- 'dailyTotalWithTaxes' => $this->daily_total_with_taxes,
+ 'dailyTotal' => $this->daily_total,
+ 'totalWithoutDiscount' => $this->total_without_discount,
+ 'totalDiscountable' => $this->total_discountable,
+ 'totalDiscount' => $this->total_discount,
'totalWithoutTaxes' => $this->total_without_taxes,
+ 'totalTaxes' => $this->total_taxes,
'totalWithTaxes' => $this->total_with_taxes,
'categoriesSubTotals' => $categoriesTotals,
'materials' => (
@@ -437,12 +457,10 @@ protected function getPdfData(): array
'degressive_rate',
'discount_rate',
'vat_rate',
- 'daily_total_without_discount',
- 'daily_total_discountable',
- 'daily_total_discount',
- 'daily_total_without_taxes',
- 'daily_total_taxes',
- 'daily_total_with_taxes',
+ 'daily_total',
+ 'total_without_discount',
+ 'total_discountable',
+ 'total_discount',
'total_without_taxes',
'total_taxes',
'total_with_taxes',
@@ -496,15 +514,13 @@ public static function createFromBooking(Event $booking, User $creator): Invoice
'discount_rate' => $booking->discount_rate,
'vat_rate' => $booking->vat_rate,
+ // - Total / jour.
+ 'daily_total' => $booking->daily_total,
+
// - Remise.
- 'daily_total_without_discount' => $booking->daily_total_without_discount,
- 'daily_total_discountable' => $booking->daily_total_discountable,
- 'daily_total_discount' => $booking->daily_total_discount,
- 'daily_total_without_taxes' => $booking->daily_total_without_taxes,
-
- // - Taxes.
- 'daily_total_taxes' => $booking->daily_total_taxes,
- 'daily_total_with_taxes' => $booking->daily_total_with_taxes,
+ 'total_without_discount' => $booking->total_without_discount,
+ 'total_discountable' => $booking->total_discountable,
+ 'total_discount' => $booking->total_discount,
// - Totaux.
'total_without_taxes' => $booking->total_without_taxes,
diff --git a/server/src/locales/en/common.php b/server/src/locales/en/common.php
index 97eda2960..1e31762da 100644
--- a/server/src/locales/en/common.php
+++ b/server/src/locales/en/common.php
@@ -116,6 +116,9 @@
"invoice-note-detail-next-page"
=> "Note: you'll find the detail of materials in next pages.",
+ "amount"
+ => "Amount",
+
"daily-amount"
=> "Daily price",
@@ -131,12 +134,15 @@
"discountable"
=> "Discountable",
- "discount-rate"
+ "discount"
=> "Discount",
"no-discount"
=> "No discount",
+ "discount-of-amount"
+ => "%s of %s",
+
"totals"
=> "Totals",
diff --git a/server/src/locales/en/validation.php b/server/src/locales/en/validation.php
index 9c8b6cda8..c50834f79 100644
--- a/server/src/locales/en/validation.php
+++ b/server/src/locales/en/validation.php
@@ -1214,6 +1214,9 @@
"invalid-csv-delimiter"
=> "The CSV delimiter is invalid.",
+ "discount-rate-exceeds-maximum"
+ => "The discount rate exceeds the maximum.",
+
//
// - Existence messages
//
diff --git a/server/src/locales/fr/common.php b/server/src/locales/fr/common.php
index 9d90d2c7a..2ecbd3290 100644
--- a/server/src/locales/fr/common.php
+++ b/server/src/locales/fr/common.php
@@ -116,6 +116,9 @@
"invoice-note-detail-next-page"
=> "Remarque\xc2\xa0: vous trouverez le détail du matériel aux pages suivantes.",
+ "amount"
+ => "Montant",
+
"daily-amount"
=> "Tarif\xc2\xa0/\xc2\xa0jour",
@@ -131,12 +134,15 @@
"discountable"
=> "Remisable",
- "discount-rate"
+ "discount"
=> "Remise",
"no-discount"
=> "Sans remise",
+ "discount-of-amount"
+ => "%s de %s",
+
"totals"
=> "Totaux",
diff --git a/server/src/locales/fr/validation.php b/server/src/locales/fr/validation.php
index 51214d455..c85cb06f0 100644
--- a/server/src/locales/fr/validation.php
+++ b/server/src/locales/fr/validation.php
@@ -1217,6 +1217,9 @@
"invalid-csv-delimiter"
=> "Le délimiteur CSV est invalide.",
+ "discount-rate-exceeds-maximum"
+ => "Le taux de remise dépasse le maximum.",
+
//
// - Existence messages
//
diff --git a/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php b/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php
new file mode 100644
index 000000000..1e59a7d71
--- /dev/null
+++ b/server/src/migrations/20240118115610_fix_estimates_and_invoices_discount_fields.php
@@ -0,0 +1,179 @@
+table($tableName);
+ $table
+ ->addColumn('total_without_discount', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_with_taxes',
+ ])
+ ->addColumn('total_discountable', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'total_without_discount',
+ ])
+ ->addColumn('total_discount', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'total_discountable',
+ ])
+ ->save();
+
+ // - Re-calcul des remises pour les devis existants.
+ $allEntries = $this->fetchAll(sprintf("SELECT * FROM `%s%s`", $prefix, $tableName));
+ foreach ($allEntries as $entry) {
+ $degressiveRate = Decimal::of($entry['degressive_rate']);
+ $totalWithoutDiscount = Decimal::of($entry['daily_total_without_discount'])
+ ->multipliedBy($degressiveRate)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $totalDiscountable = Decimal::of($entry['daily_total_discountable'])
+ ->multipliedBy($degressiveRate)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $totalDiscount = Decimal::of($entry['daily_total_discount'])
+ ->multipliedBy($degressiveRate)
+ ->toScale(2, RoundingMode::HALF_UP);
+
+ $this->execute(
+ sprintf(
+ "UPDATE `%s%s` SET " .
+ "`total_without_discount` = ?, `total_discountable` = ?, `total_discount` = ? " .
+ "WHERE `id` = ?",
+ $prefix,
+ $tableName,
+ ),
+ [$totalWithoutDiscount, $totalDiscountable, $totalDiscount, $entry['id']],
+ );
+ }
+
+ $table
+ ->renameColumn('daily_total_without_discount', 'daily_total')
+ ->removeColumn('daily_total_discountable')
+ ->removeColumn('daily_total_discount')
+ ->removeColumn('daily_total_without_taxes')
+ ->removeColumn('daily_total_taxes')
+ ->removeColumn('daily_total_with_taxes')
+ ->save();
+ }
+ }
+
+ public function down(): void
+ {
+ $prefix = Config::get('db.prefix');
+
+ foreach (['invoices', 'estimates'] as $tableName) {
+ $table = $this->table($tableName);
+ $table
+ ->addColumn('daily_total_without_discount', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'vat_rate',
+ ])
+ ->addColumn('daily_total_discountable', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_without_discount',
+ ])
+ ->addColumn('daily_total_discount', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_discountable',
+ ])
+ ->addColumn('daily_total_without_taxes', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_discount',
+ ])
+ ->addColumn('daily_total_taxes', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_without_taxes',
+ ])
+ ->addColumn('daily_total_with_taxes', 'decimal', [
+ 'precision' => 14,
+ 'scale' => 2,
+ 'null' => true,
+ 'after' => 'daily_total_taxes',
+ ])
+ ->save();
+
+ $allEntries = $this->fetchAll(sprintf("SELECT * FROM `%s%s`", $prefix, $tableName));
+ foreach ($allEntries as $entry) {
+ $degressiveRate = Decimal::of($entry['degressive_rate']);
+ $vatRate = Decimal::of($entry['vat_rate']);
+ $vatRate = $vatRate->isZero()
+ ? Decimal::of(1)
+ : $vatRate->dividedBy(100, 4, RoundingMode::UNNECESSARY);
+
+ $dailyTotalWithoutDiscount = Decimal::of($entry['daily_total'])
+ ->toScale(2, RoundingMode::HALF_UP);
+ $dailyTotalDiscountable = Decimal::of($entry['total_discountable'])
+ ->dividedBy($degressiveRate, 2, RoundingMode::HALF_UP)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $dailyTotalDiscount = Decimal::of($entry['total_discount'])
+ ->dividedBy($degressiveRate, 2, RoundingMode::HALF_UP)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $dailyTotalWithoutTaxes = $dailyTotalWithoutDiscount
+ ->minus($dailyTotalDiscount)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $dailyTotalTaxes = $dailyTotalWithoutTaxes
+ ->dividedBy($vatRate, 2, RoundingMode::HALF_UP)
+ ->toScale(2, RoundingMode::HALF_UP);
+ $dailyTotalWithTaxes = $dailyTotalWithoutTaxes
+ ->plus($dailyTotalTaxes)
+ ->toScale(2, RoundingMode::HALF_UP);
+
+ $this->execute(
+ sprintf(
+ "UPDATE `%s%s` SET " .
+ "`daily_total_without_discount` = ?, " .
+ "`daily_total_discountable` = ?, " .
+ "`daily_total_discount` = ?, " .
+ "`daily_total_without_taxes` = ?, " .
+ "`daily_total_taxes` = ?, " .
+ "`daily_total_with_taxes` = ? " .
+ "WHERE `id` = ?",
+ $prefix,
+ $tableName,
+ ),
+ [
+ $dailyTotalWithoutDiscount,
+ $dailyTotalDiscountable,
+ $dailyTotalDiscount,
+ $dailyTotalWithoutTaxes,
+ $dailyTotalTaxes,
+ $dailyTotalWithTaxes,
+ $entry['id'],
+ ]
+ );
+ }
+
+ $table
+ ->removeColumn('daily_total')
+ ->removeColumn('total_without_discount')
+ ->removeColumn('total_discountable')
+ ->removeColumn('total_discount')
+ ->save();
+ }
+ }
+}
diff --git a/server/src/views/pdf/base.twig b/server/src/views/pdf/base.twig
index da9f7b1fa..0477a428c 100644
--- a/server/src/views/pdf/base.twig
+++ b/server/src/views/pdf/base.twig
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/src/views/pdf/estimate-default.twig b/server/src/views/pdf/estimate-default.twig
index 7df8e59c3..9ccd9f874 100644
--- a/server/src/views/pdf/estimate-default.twig
+++ b/server/src/views/pdf/estimate-default.twig
@@ -75,106 +75,89 @@
-
+
- {{ translate(hasVat ? 'daily-amount-excl-tax' : 'daily-amount') }} |
- {{ translate('discount-rate') }} |
{{ translate('daily-total') }} |
{{ translate('degressive-rate') }} |
+ {% if hasDiscount -%}
+ {{ translate('discount') }} |
+ {% endif -%}
{{ translate('totals') }} |
-
+ |
- {{ dailyTotalWithoutDiscount|format_currency(currency, locale=locale) }}
-
-
- {{ translate('discountable') }}
- {{ dailyTotalDiscountable|format_currency(currency, locale=locale) }}
+ {{ dailyTotal|format_currency(currency, locale=locale) }}
|
-
+ |
- {% if hasDiscount %}
- {{ discountRate|format_percent_number({fraction_digit: 4}, locale=locale) }}
- - {{ dailyTotalDiscount|format_currency(currency, locale=locale) }}
- {% else %}
- {{ translate('no-discount') }}
- {% endif %}
+ × {{ degressiveRate|format_number(locale=locale) }}
+ ({{ plural('number-of-days', booking.duration.days) }})
|
-
- {% if hasVat %}
-
- {{ translate('excl-tax') }}
- {{ dailyTotalWithoutTaxes|format_currency(currency, locale=locale) }}
-
-
- {{ translate('taxes') }} ({{ vatRate|format_percent_number({fraction_digit: 1}, locale=locale) }})
- {{ dailyTotalTaxes|format_currency(currency, locale=locale) }}
+ {% if hasDiscount -%}
+
+
+ {{ translate('discount-of-amount', [
+ discountRate|format_percent_number({max_fraction_digit: 4}, locale=locale),
+ totalWithoutDiscount|format_currency(currency, locale=locale),
+ ]) }}
- {% endif %}
-
- {% if hasVat %}{{ translate('incl-tax') }} {% endif %}
- {{ dailyTotalWithTaxes|format_currency(currency, locale=locale) }}
+
+ - {{ totalDiscount|format_currency(currency, locale=locale) }}
|
-
-
- × {{ degressiveRate|format_number(locale=locale) }}
-
- ({{ plural('number-of-days', booking.duration.days) }})
-
+ {% endif -%}
+
+
+ {% if hasVat -%}
+ {{ translate('total-excl-tax') }}
+ {% else -%}
+ {{ translate('total-due') }}
+ {{ translate('tax-not-applicable') }}
+ {% endif -%}
|
- {% if hasVat %}
-
- {{ translate('total-excl-tax') }}
- |
-
+ |
{{ totalWithoutTaxes|format_currency(currency, locale=locale) }}
|
- {% endif %}
- {% if not hasVat %}
-
-
- {{ translate('total-due') }}
-
-
- {{ translate('tax-not-applicable') }}
-
- |
-
-
- {{ totalWithTaxes|format_currency(currency, locale=locale) }}
+ | | |
+
+
+
+ = {{ totalWithoutDiscount|format_currency(currency, locale=locale) }}
|
- {% endif %}
-
- {% if hasVat %}
-
+ {% if hasVat -%}
- {{ translate('total-incl-taxes') }}
+
+ {{ translate('taxes') }}
+ ({{ vatRate|format_percent_number({max_fraction_digit: 2}, locale=locale) }})
+
|
- {{ totalWithTaxes|format_currency(currency, locale=locale) }}
+ {{ totalTaxes|format_currency(currency, locale=locale) }}
|
+ {% endif -%}
+ {% if hasVat -%}
-
+
+ {{ translate('total-incl-taxes') }}
{{ translate('total-due') }}
|
-
+
{{ totalWithTaxes|format_currency(currency, locale=locale) }}
|
diff --git a/server/src/views/pdf/invoice-default.twig b/server/src/views/pdf/invoice-default.twig
index 656ddfb9e..a4aab1e9f 100644
--- a/server/src/views/pdf/invoice-default.twig
+++ b/server/src/views/pdf/invoice-default.twig
@@ -75,106 +75,89 @@
-
+
- {{ translate(hasVat ? 'daily-amount-excl-tax' : 'daily-amount') }} |
- {{ translate('discount-rate') }} |
{{ translate('daily-total') }} |
{{ translate('degressive-rate') }} |
+ {% if hasDiscount -%}
+ {{ translate('discount') }} |
+ {% endif -%}
{{ translate('totals') }} |
-
+ |
- {{ dailyTotalWithoutDiscount|format_currency(currency, locale=locale) }}
-
-
- {{ translate('discountable') }}
- {{ dailyTotalDiscountable|format_currency(currency, locale=locale) }}
+ {{ dailyTotal|format_currency(currency, locale=locale) }}
|
-
+ |
- {% if hasDiscount %}
- {{ discountRate|format_percent_number({fraction_digit: 4}, locale=locale) }}
- - {{ dailyTotalDiscount|format_currency(currency, locale=locale) }}
- {% else %}
- {{ translate('no-discount') }}
- {% endif %}
+ × {{ degressiveRate|format_number(locale=locale) }}
+ ({{ plural('number-of-days', booking.duration.days) }})
|
-
- {% if hasVat %}
-
- {{ translate('excl-tax') }}
- {{ dailyTotalWithoutTaxes|format_currency(currency, locale=locale) }}
-
-
- {{ translate('taxes') }} ({{ vatRate|format_percent_number({fraction_digit: 1}, locale=locale) }})
- {{ dailyTotalTaxes|format_currency(currency, locale=locale) }}
+ {% if hasDiscount -%}
+
+
+ {{ translate('discount-of-amount', [
+ discountRate|format_percent_number({max_fraction_digit: 4}, locale=locale),
+ totalWithoutDiscount|format_currency(currency, locale=locale),
+ ]) }}
- {% endif %}
-
- {% if hasVat %}{{ translate('incl-tax') }} {% endif %}
- {{ dailyTotalWithTaxes|format_currency(currency, locale=locale) }}
+
+ - {{ totalDiscount|format_currency(currency, locale=locale) }}
|
-
-
- × {{ degressiveRate|format_number(locale=locale) }}
-
- ({{ plural('number-of-days', booking.duration.days) }})
-
+ {% endif -%}
+
+
+ {% if hasVat -%}
+ {{ translate('total-excl-tax') }}
+ {% else -%}
+ {{ translate('total-due') }}
+ {{ translate('tax-not-applicable') }}
+ {% endif -%}
|
- {% if hasVat %}
-
- {{ translate('total-excl-tax') }}
- |
-
+ |
{{ totalWithoutTaxes|format_currency(currency, locale=locale) }}
|
- {% endif %}
- {% if not hasVat %}
-
-
- {{ translate('total-due') }}
-
-
- {{ translate('tax-not-applicable') }}
-
- |
-
-
- {{ totalWithTaxes|format_currency(currency, locale=locale) }}
+ | | |
+
+
+
+ = {{ totalWithoutDiscount|format_currency(currency, locale=locale) }}
|
- {% endif %}
-
- {% if hasVat %}
-
+ {% if hasVat -%}
- {{ translate('total-incl-taxes') }}
+
+ {{ translate('taxes') }}
+ ({{ vatRate|format_percent_number({max_fraction_digit: 2}, locale=locale) }})
+
|
- {{ totalWithTaxes|format_currency(currency, locale=locale) }}
+ {{ totalTaxes|format_currency(currency, locale=locale) }}
|
+ {% endif -%}
+ {% if hasVat -%}
-
+
+ {{ translate('total-incl-taxes') }}
{{ translate('total-due') }}
|
-
+
{{ totalWithTaxes|format_currency(currency, locale=locale) }}
|
diff --git a/server/tests/endpoints/EstimatesTest.php b/server/tests/endpoints/EstimatesTest.php
index 8f5847b22..1fa58cff1 100644
--- a/server/tests/endpoints/EstimatesTest.php
+++ b/server/tests/endpoints/EstimatesTest.php
@@ -15,9 +15,9 @@ public static function data(int $id)
'id' => 1,
'date' => '2021-01-30 14:00:00',
'url' => 'http://loxya.test/estimates/1/pdf',
- 'discount_rate' => '50.0000',
- 'total_with_taxes' => '662.56',
- 'total_without_taxes' => '552.13',
+ 'discount_rate' => '5.0000',
+ 'total_without_taxes' => '550.28',
+ 'total_with_taxes' => '660.34',
'currency' => 'EUR',
],
]);
diff --git a/server/tests/endpoints/EventsTest.php b/server/tests/endpoints/EventsTest.php
index 9932014a2..d3b9ff880 100644
--- a/server/tests/endpoints/EventsTest.php
+++ b/server/tests/endpoints/EventsTest.php
@@ -33,12 +33,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT)
'discount_rate' => '0',
'vat_rate' => '20.00',
'currency' => 'EUR',
- 'daily_total_without_discount' => '341.45',
- 'daily_total_discountable' => '41.45',
- 'daily_total_discount' => '0.00',
- 'daily_total_without_taxes' => '341.45',
- 'daily_total_taxes' => '68.29',
- 'daily_total_with_taxes' => '409.74',
+ 'daily_total' => '341.45',
+ 'total_without_discount' => '597.54',
+ 'total_discountable' => '72.54',
+ 'total_discount' => '0.00',
'total_without_taxes' => '597.54',
'total_taxes' => '119.51',
'total_with_taxes' => '717.05',
@@ -137,12 +135,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT)
'discount_rate' => '0',
'vat_rate' => '20.00',
'currency' => 'EUR',
- 'daily_total_without_discount' => '951.00',
- 'daily_total_discountable' => '51.00',
- 'daily_total_discount' => '0.00',
- 'daily_total_without_taxes' => '951.00',
- 'daily_total_taxes' => '190.20',
- 'daily_total_with_taxes' => '1141.20',
+ 'daily_total' => '951.00',
+ 'total_without_discount' => '1664.25',
+ 'total_discountable' => '89.25',
+ 'total_discount' => '0.00',
'total_without_taxes' => '1664.25',
'total_taxes' => '332.85',
'total_with_taxes' => '1997.10',
@@ -425,12 +421,10 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT)
'discount_rate' => '0',
'vat_rate' => '20.00',
'currency' => 'EUR',
- 'daily_total_without_discount' => '1031.88',
- 'daily_total_discountable' => '31.90',
- 'daily_total_discount' => '0.00',
- 'daily_total_without_taxes' => '1031.88',
- 'daily_total_taxes' => '206.38',
- 'daily_total_with_taxes' => '1238.26',
+ 'daily_total' => '1031.88',
+ 'total_without_discount' => '3353.61',
+ 'total_discountable' => '103.68',
+ 'total_discount' => '0.00',
'total_without_taxes' => '3353.61',
'total_taxes' => '670.72',
'total_with_taxes' => '4024.33',
@@ -544,7 +538,7 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT)
Event::SERIALIZE_BOOKING_DEFAULT => $events->map(fn($event) => (
Arr::except($event, ['parks', 'categories'])
)),
- Event::SERIALIZE_BOOKING_SUMMARY => $events->map(fn($event) => (
+ Event::SERIALIZE_BOOKING_SUMMARY => $events->map(fn ($event) => (
Arr::only($event, [
'id',
'title',
@@ -562,7 +556,6 @@ public static function data(?int $id, string $format = Event::SERIALIZE_DEFAULT)
'is_archived',
'is_departure_inventory_done',
'is_return_inventory_done',
- 'has_missing_materials',
'has_not_returned_materials',
'parks',
'categories',
@@ -622,12 +615,10 @@ public function testCreateEvent(): void
'discount_rate' => '0',
'vat_rate' => '20.00',
'currency' => 'EUR',
- 'daily_total_without_discount' => '0.00',
- 'daily_total_discountable' => '0.00',
- 'daily_total_discount' => '0.00',
- 'daily_total_without_taxes' => '0.00',
- 'daily_total_taxes' => '0.00',
- 'daily_total_with_taxes' => '0.00',
+ 'daily_total' => '0.00',
+ 'total_without_discount' => '0.00',
+ 'total_discountable' => '0.00',
+ 'total_discount' => '0.00',
'total_without_taxes' => '0.00',
'total_taxes' => '0.00',
'total_with_taxes' => '0.00',
@@ -686,12 +677,10 @@ public function testCreateEvent(): void
$this->assertResponseData(array_replace($expected, [
'id' => 9,
'title' => "Encore un événement",
- 'daily_total_without_taxes' => '357.40',
- 'daily_total_discountable' => '57.40',
- 'daily_total_discount' => '0.00',
- 'daily_total_without_discount' => '357.40',
- 'daily_total_taxes' => '71.48',
- 'daily_total_with_taxes' => '428.88',
+ 'daily_total' => '357.40',
+ 'total_without_discount' => '893.50',
+ 'total_discountable' => '143.50',
+ 'total_discount' => '0.00',
'total_without_taxes' => '893.50',
'total_taxes' => '178.70',
'total_with_taxes' => '1072.20',
@@ -790,12 +779,10 @@ public function testUpdateEvent(): void
'hours' => 984,
],
'has_missing_materials' => true,
- 'daily_total_discount' => '0.00',
- 'daily_total_discountable' => '0.00',
- 'daily_total_taxes' => '260.00',
- 'daily_total_with_taxes' => '1559.98',
- 'daily_total_without_discount' => '1299.98',
- 'daily_total_without_taxes' => '1299.98',
+ 'daily_total' => '1299.98',
+ 'total_without_discount' => '40299.38',
+ 'total_discountable' => '0.00',
+ 'total_discount' => '0.00',
'degressive_rate' => '31.00',
'discount_rate' => '0',
'vat_rate' => '20.00',
@@ -843,12 +830,10 @@ public function testUpdateEvent(): void
'hours' => 984,
],
'has_missing_materials' => true,
- 'daily_total_discount' => '0.00',
- 'daily_total_discountable' => '149.85',
- 'daily_total_taxes' => '149.97',
- 'daily_total_with_taxes' => '899.82',
- 'daily_total_without_discount' => '749.85',
- 'daily_total_without_taxes' => '749.85',
+ 'daily_total' => '749.85',
+ 'total_without_discount' => '23245.35',
+ 'total_discountable' => '4645.35',
+ 'total_discount' => '0.00',
'degressive_rate' => '31.00',
'discount_rate' => '0',
'vat_rate' => '20.00',
@@ -953,6 +938,8 @@ public function testDuplicateEvent(): void
'hours' => 72,
],
'degressive_rate' => '2.50',
+ 'total_without_discount' => '853.63',
+ 'total_discountable' => '103.63',
'total_taxes' => '170.73',
'total_with_taxes' => '1024.36',
'total_without_taxes' => '853.63',
@@ -2258,8 +2245,17 @@ public function testCreateInvoiceWithDiscount(): void
{
Carbon::setTestNow(Carbon::create(2020, 10, 22, 18, 42, 36));
+ // - Test avec un taux de remise dépassant le taux maximum (5.3628 %).
$this->client->post('/api/events/2/invoices', [
- 'discountRate' => 50.0,
+ 'discountRate' => '5.3629',
+ ]);
+ $this->assertApiValidationError([
+ 'discount_rate' => ['The discount rate exceeds the maximum.'],
+ ]);
+
+ // - Test avec le taux de remise maximum possible.
+ $this->client->post('/api/events/2/invoices', [
+ 'discountRate' => '5.3628',
]);
$this->assertStatusCode(StatusCode::STATUS_CREATED);
$this->assertResponseData([
@@ -2267,9 +2263,9 @@ public function testCreateInvoiceWithDiscount(): void
'number' => '2020-00002',
'date' => '2020-10-22 18:42:36',
'url' => 'http://loxya.test/invoices/2/pdf',
- 'discount_rate' => '50.0000',
- 'total_without_taxes' => '832.13',
- 'total_with_taxes' => '998.56',
+ 'discount_rate' => '5.3628',
+ 'total_without_taxes' => '1575.00',
+ 'total_with_taxes' => '1890.00',
'currency' => 'EUR',
]);
}
@@ -2295,17 +2291,26 @@ public function testCreateEstimateWithDiscount(): void
{
Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36));
+ // - Test avec un taux de remise dépassant le taux maximum (5.3628 %).
+ $this->client->post('/api/events/2/estimates', [
+ 'discountRate' => '5.3629',
+ ]);
+ $this->assertApiValidationError([
+ 'discount_rate' => ['The discount rate exceeds the maximum.'],
+ ]);
+
+ // - Test avec le taux de remise maximum possible.
$this->client->post('/api/events/2/estimates', [
- 'discountRate' => 50.0,
+ 'discountRate' => '5.3628',
]);
$this->assertStatusCode(StatusCode::STATUS_CREATED);
$this->assertResponseData([
'id' => 2,
'date' => '2022-10-22 18:42:36',
'url' => 'http://loxya.test/estimates/2/pdf',
- 'discount_rate' => '50.0000',
- 'total_without_taxes' => '832.13',
- 'total_with_taxes' => '998.56',
+ 'discount_rate' => '5.3628',
+ 'total_without_taxes' => '1575.00',
+ 'total_with_taxes' => '1890.00',
'currency' => 'EUR',
]);
}
diff --git a/server/tests/endpoints/InvoicesTest.php b/server/tests/endpoints/InvoicesTest.php
index 3ec8a5005..a3d8712e9 100644
--- a/server/tests/endpoints/InvoicesTest.php
+++ b/server/tests/endpoints/InvoicesTest.php
@@ -15,9 +15,9 @@ public static function data(int $id)
'number' => '2020-00001',
'date' => '2020-01-30 14:00:00',
'url' => 'http://loxya.test/invoices/1/pdf',
- 'discount_rate' => '50.0000',
- 'total_without_taxes' => '547.31',
- 'total_with_taxes' => '658.52',
+ 'discount_rate' => '4.4766',
+ 'total_without_taxes' => '544.13',
+ 'total_with_taxes' => '652.96',
'currency' => 'EUR',
],
]);
diff --git a/server/tests/fixtures/seed/estimates.json b/server/tests/fixtures/seed/estimates.json
index cc0a87576..31a12286c 100644
--- a/server/tests/fixtures/seed/estimates.json
+++ b/server/tests/fixtures/seed/estimates.json
@@ -9,18 +9,16 @@
"booking_end_date": "2018-12-18 23:59:59",
"beneficiary_id": 1,
"degressive_rate": 1.75,
- "discount_rate": 50,
+ "discount_rate": 5,
"vat_rate": 20,
- "daily_total_without_discount": 331,
- "daily_total_discountable": 31,
- "daily_total_discount": 15.5,
- "daily_total_without_taxes": 315.5,
- "daily_total_taxes": 63.1,
- "daily_total_with_taxes": 378.6,
- "total_without_taxes": 552.13,
- "total_taxes": 110.43,
- "total_with_taxes": 662.56,
- "total_replacement": 21769.9,
+ "daily_total": 331,
+ "total_without_discount": 579.25,
+ "total_discountable": 54.25,
+ "total_discount": 28.97,
+ "total_without_taxes": 550.28,
+ "total_taxes": 110.06,
+ "total_with_taxes": 660.34,
+ "total_replacement": 19769.9,
"currency": "EUR",
"author_id": null,
"created_at": "2021-01-30 14:00:00",
diff --git a/server/tests/fixtures/seed/invoices.json b/server/tests/fixtures/seed/invoices.json
index 2b3b3f23c..f6f746bcd 100644
--- a/server/tests/fixtures/seed/invoices.json
+++ b/server/tests/fixtures/seed/invoices.json
@@ -10,17 +10,15 @@
"booking_end_date": "2018-12-18 23:59:59",
"beneficiary_id": 1,
"degressive_rate": 1.75,
- "discount_rate": 50,
+ "discount_rate": 4.4766,
"vat_rate": 20,
- "daily_total_without_discount": 325.5,
- "daily_total_discountable": 25.5,
- "daily_total_discount": 12.75,
- "daily_total_without_taxes": 312.75,
- "daily_total_taxes": 63.55,
- "daily_total_with_taxes": 376.3,
- "total_without_taxes": 547.31,
- "total_taxes": 111.21,
- "total_with_taxes": 658.52,
+ "daily_total": 325.5,
+ "total_without_discount": 569.63,
+ "total_discountable": 25.5,
+ "total_discount": 25.5,
+ "total_without_taxes": 544.13,
+ "total_taxes": 108.82,
+ "total_with_taxes": 652.96,
"total_replacement": 19749.9,
"currency": "EUR",
"author_id": 1,
diff --git a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html
index 5031e1f98..237decad3 100644
--- a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html
+++ b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -203,82 +207,75 @@
-
+
- Tarif H.T. / jour |
- Remise |
Total / jour |
Coef. dégressif |
+ Remise |
Totaux |
-
+ |
331,00 €
-
- Remisable
- 31,00 €
-
|
-
+ |
- 50,0000 %
- - 15,50 €
-
- |
-
-
- H.T.
- 315,50 €
-
-
- T.V.A. (20,0 %)
- 63,10 €
-
-
- T.T.C. 378,60 €
+ × 1,75
+ (2 jours)
|
-
+ |
- × 1,75
-
- (2 jours)
-
+ 5 % de 579,25 €
+
+
+ - 28,97 €
|
-
- Total H.T.
+ |
+
+ Total H.T.
+
|
-
+ |
- 552,13 €
+ 550,28 €
+
+ |
+
+
+
+
+ = 579,25 €
|
-
-
- Total T.T.C.
+
+ T.V.A.
+ (20 %)
+
|
- 662,56 €
+ 110,06 €
|
-
+
-
+
+ Total T.T.C.
Montant net à payer
|
- |
diff --git a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html
index f29ef3808..0fc5c1f46 100644
--- a/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html
+++ b/server/tests/fixtures/snapshots/EstimateTest__testToPdf__2.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -209,11 +213,9 @@
-
+
- Tarif H.T. / jour |
- Remise |
Total / jour |
Coef. dégressif |
Totaux |
@@ -221,68 +223,55 @@
-
-
- 926,45 €
-
-
- Remisable
- 26,45 €
-
- |
-
+ |
- Sans remise
-
- |
-
-
- H.T.
926,45 €
-
- T.V.A. (20,0 %)
- 185,29 €
-
-
- T.T.C. 1 111,74 €
-
|
-
+ |
- × 3,8
-
- (4 jours)
-
+ × 3,8
+ (4 jours)
|
-
- Total H.T.
+ |
+
+ Total H.T.
+
|
-
+ |
3 520,51 €
|
-
-
+
+
+
+
+ = 3 520,51 €
+
+ |
- Total T.T.C.
+
+ T.V.A.
+ (20 %)
+
|
- 4 224,61 €
+ 704,10 €
|
-
+
-
+
+ Total T.T.C.
Montant net à payer
|
- |
diff --git a/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html
index be6e35f49..b56b05ae2 100644
--- a/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html
+++ b/server/tests/fixtures/snapshots/EstimatesTest__testDownloadPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -203,82 +207,75 @@
-
+
- Daily price excl. VAT |
- Discount |
Total / day |
Degressive rate |
+ Discount |
Totals |
-
+ |
€331.00
-
- Discountable
- €31.00
-
|
-
+ |
- 50.0000%
- - €15.50
-
- |
-
-
- Excl. VAT
- €315.50
-
-
- V.A.T. (20.0%)
- €63.10
-
-
- Incl. VAT €378.60
+ × 1.75
+ (2 days)
|
-
+ |
- × 1.75
-
- (2 days)
-
+ 5% of €579.25
+
+
+ - €28.97
|
-
- Total excl. VAT
+ |
+
+ Total excl. VAT
+
|
-
+ |
- €552.13
+ €550.28
+
+ |
+
+
+
+
+ = €579.25
|
-
-
- Total incl. VAT
+
+ V.A.T.
+ (20%)
+
|
- €662.56
+ €110.06
|
-
+
-
+
+ Total incl. VAT
Amount due
|
- |
diff --git a/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html b/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html
index 3b2e2103a..5f64fd40c 100644
--- a/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html
+++ b/server/tests/fixtures/snapshots/EventTest__testToPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html b/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html
index a6c80ca37..a9553e824 100644
--- a/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html
+++ b/server/tests/fixtures/snapshots/EventTest__testToPdf__2.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html
index f41f84abf..73c444d38 100644
--- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html
+++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html
index a6c80ca37..a9553e824 100644
--- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html
+++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__2.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html
index 673f3d921..c09fe4ef3 100644
--- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html
+++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__3.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html
index 9313e8212..004fc64c8 100644
--- a/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html
+++ b/server/tests/fixtures/snapshots/EventsTest__testDownloadPdf__4.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html b/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html
new file mode 100644
index 000000000..4bb778cdd
--- /dev/null
+++ b/server/tests/fixtures/snapshots/InvoiceTest__testCreateFromEvent__1.html
@@ -0,0 +1,349 @@
+
+
+
+
+ Facture n°2022-00001
+
+
+
+
+
+
+ Testing corp. 5 rue des tests 05555 Testville France
+SIRET: 543 210 080 20145 APE: 947A
+Tél. : +33123456789 E-mail : jean@testing-corp.dev
+N° TVA Intracom. : FR11223344556600
+ |
+
+ Facture en Euro, N° 2022-00001
+
+
+ Le 22/10/2022 |
+ Page 1 |
+
+
+
+ Bénéficiaire
+
+
+ Client Benef 156 bis, avenue des tests poussés
+88080 Wazzaville (Réf. 0003) Tél. : +33123456789 E-mail : client@beneficiaires.com
+
+ |
+
+
+
+
+
Événement : Second événement
+
+ Du mardi 18 décembre 2018 au mercredi 19 décembre 2018,
+ À Lyon
+
+
+
+
+ Catégorie |
+ Quantité |
+ Total H.T. / jour |
+
+
+
+
+
+ Son
+ |
+ 5 articles |
+
+ 951,00 €
+ |
+
+
+
+
Remarque : vous trouverez le détail du matériel aux pages suivantes.
+
+
+
+
+
+
+ Total / jour |
+ Coef. dégressif |
+ Remise |
+ Totaux |
+
+
+
+
+
+
+ 951,00 €
+
+ |
+
+
+ × 1,75
+ (2 jours)
+
+ |
+
+
+ 1,3923 % de 1 664,25 €
+
+
+ - 23,17 €
+
+ |
+
+
+ Total H.T.
+
+ |
+
+
+ 1 641,08 €
+
+ |
+
+
+
+
+ = 1 664,25 €
+
+ |
+
+
+ T.V.A.
+ (20 %)
+
+ |
+
+
+ 328,22 €
+
+ |
+
+
+
+
+ Total T.T.C.
+ Montant net à payer
+
+ |
+
+
+ 1 969,30 €
+
+ |
+
+
+
+
+
+
+
+
+ Testing corp. 5 rue des tests 05555 Testville France
+Tél. : +33123456789 E-mail : jean@testing-corp.dev
+ |
+
+ Détails de la facture N° 2022-00001
+
+
+ Le 22/10/2022 |
+ Page 2 |
+
+
+ |
+
+
+
+
+
+
+
+ Réf. |
+ Désignation |
+ Qté |
+ P.U. H.T. |
+ Val. Remp. |
+ Total H.T. |
+
+
+
+
+
+ Son - Mixeurs
+ |
+
+
+ CL3 |
+ Console Yamaha CL3 |
+ 3 |
+
+ 300,00 €
+ |
+
+ 19 400,00 €
+ |
+
+ 900,00 €
+ |
+
+
+
+ Son - Processeurs
+ |
+
+
+ DBXPA2 |
+ Processeur DBX PA2 |
+ 2 |
+
+ 25,50 €
+ |
+
+ 349,90 €
+ |
+
+ 51,00 €
+ |
+
+
+
+
+
diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html
index 58b3a09ad..7b0fde4da 100644
--- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html
+++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -194,82 +198,75 @@
-
+
- Tarif H.T. / jour |
- Remise |
Total / jour |
Coef. dégressif |
+ Remise |
Totaux |
-
+ |
325,50 €
-
- Remisable
- 25,50 €
-
|
-
+ |
- 50,0000 %
- - 12,75 €
-
- |
-
-
- H.T.
- 312,75 €
-
-
- T.V.A. (20,0 %)
- 63,55 €
-
-
- T.T.C. 376,30 €
+ × 1,75
+ (2 jours)
|
-
+ |
- × 1,75
-
- (2 jours)
-
+ 4,4766 % de 569,63 €
+
+
+ - 25,50 €
|
-
- Total H.T.
+ |
+
+ Total H.T.
+
|
-
+ |
- 547,31 €
+ 544,13 €
+
+ |
+
+
+
+
+ = 569,63 €
|
-
-
- Total T.T.C.
+
+ T.V.A.
+ (20 %)
+
|
- 658,52 €
+ 108,82 €
|
-
+
-
+
+ Total T.T.C.
Montant net à payer
|
- |
diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html
index 5f5e4e1f7..fb5c4c4c9 100644
--- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html
+++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__2.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -194,82 +198,75 @@
-
+
- Tarif H.T. / jour |
- Remise |
Total / jour |
Coef. dégressif |
+ Remise |
Totaux |
-
+ |
325.50 €
-
- Remisable
- 25.50 €
-
|
-
+ |
- 50,0000%
- - 12.75 €
-
- |
-
-
- H.T.
- 312.75 €
-
-
- T.V.A. (20,0%)
- 63.55 €
-
-
- T.T.C. 376.30 €
+ × 1,75
+ (2 jours)
|
-
+ |
- × 1,75
-
- (2 jours)
-
+ 4,4766% de 569.63 €
+
+
+ - 25.50 €
|
-
- Total H.T.
+ |
+
+ Total H.T.
+
|
-
+ |
- 547.31 €
+ 544.13 €
+
+ |
+
+
+
+
+ = 569.63 €
|
-
-
- Total T.T.C.
+
+ T.V.A.
+ (20%)
+
|
- 658.52 €
+ 108.82 €
|
-
+
-
+
+ Total T.T.C.
Montant net à payer
|
- |
diff --git a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html
index 41bb35e44..bddb165f8 100644
--- a/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html
+++ b/server/tests/fixtures/snapshots/InvoiceTest__testToPdf__3.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -209,11 +213,9 @@
-
+
- Tarif H.T. / jour |
- Remise |
Total / jour |
Coef. dégressif |
Totaux |
@@ -221,68 +223,55 @@
-
-
- 926,45 €
-
-
- Remisable
- 26,45 €
-
- |
-
+ |
- Sans remise
-
- |
-
-
- H.T.
926,45 €
-
- T.V.A. (20,0 %)
- 185,29 €
-
-
- T.T.C. 1 111,74 €
-
|
-
+ |
- × 3,8
-
- (4 jours)
-
+ × 3,8
+ (4 jours)
|
-
- Total H.T.
+ |
+
+ Total H.T.
+
|
-
+ |
3 520,51 €
|
-
-
+
+
+
+
+ = 3 520,51 €
+
+ |
- Total T.T.C.
+
+ T.V.A.
+ (20 %)
+
|
- 4 224,61 €
+ 704,10 €
|
-
+
-
+
+ Total T.T.C.
Montant net à payer
|
- |
diff --git a/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html b/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html
index ebab09f25..61625c58c 100644
--- a/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html
+++ b/server/tests/fixtures/snapshots/InvoicesTest__testDownloadPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
@@ -194,82 +198,75 @@
-
+
- Daily price excl. VAT |
- Discount |
Total / day |
Degressive rate |
+ Discount |
Totals |
-
+ |
€325.50
-
- Discountable
- €25.50
-
|
-
+ |
- 50.0000%
- - €12.75
-
- |
-
-
- Excl. VAT
- €312.75
-
-
- V.A.T. (20.0%)
- €63.55
-
-
- Incl. VAT €376.30
+ × 1.75
+ (2 days)
|
-
+ |
- × 1.75
-
- (2 days)
-
+ 4.4766% of €569.63
+
+
+ - €25.50
|
-
- Total excl. VAT
+ |
+
+ Total excl. VAT
+
|
-
+ |
- €547.31
+ €544.13
+
+ |
+
+
+
+
+ = €569.63
|
-
-
- Total incl. VAT
+
+ V.A.T.
+ (20%)
+
|
- €658.52
+ €108.82
|
-
+
-
+
+ Total incl. VAT
Amount due
|
- |
diff --git a/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html b/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html
index d48015e03..2fdea3d84 100644
--- a/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html
+++ b/server/tests/fixtures/snapshots/MaterialsTest__testGetAllPdf__1.html
@@ -100,6 +100,10 @@
.listing-table > tbody > tr > td:last-child {
border-right: 1pt solid #888;
}
+ .totals-table > tbody > tr > td {
+ border-bottom: 1pt solid #888;
+ vertical-align: middle;
+ }
.inset {
padding: 5mm 3mm 8mm !important;
border: 1pt solid #999 !important;
diff --git a/server/tests/models/EstimateTest.php b/server/tests/models/EstimateTest.php
index 33f9d2dbe..fe85131a0 100644
--- a/server/tests/models/EstimateTest.php
+++ b/server/tests/models/EstimateTest.php
@@ -5,6 +5,7 @@
use Brick\Math\BigDecimal as Decimal;
use Illuminate\Support\Carbon;
+use Loxya\Errors\Exception\ValidationException;
use Loxya\Models\Beneficiary;
use Loxya\Models\Estimate;
use Loxya\Models\Event;
@@ -21,7 +22,6 @@ public function testValidation(): void
'booking_start_date' => '',
'booking_end_date' => '',
'degressive_rate' => 100_000.0,
- 'discount_rate' => 101.5,
'vat_rate' => -5.0,
'total_without_taxes' => 1_000_000_000_000,
'total_replacement' => -20,
@@ -34,7 +34,7 @@ public function testValidation(): void
$expectedErrors = [
'date' => ["Ce champ est obligatoire.", "Cette date est invalide."],
'degressive_rate' => ["Ce champ est invalide."],
- 'discount_rate' => ["Ce champ est invalide."],
+ 'discount_rate' => ["Ce champ doit contenir un chiffre à virgule."],
'vat_rate' => ["Ce champ est invalide."],
'total_replacement' => ["Ce champ est invalide."],
'currency' => [
@@ -44,17 +44,55 @@ public function testValidation(): void
],
'booking_start_date' => ["Cette date est invalide."],
'booking_end_date' => ["Cette date est invalide."],
- 'daily_total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."],
- 'daily_total_discountable' => ["Ce champ doit contenir un chiffre à virgule."],
- 'daily_total_discount' => ["Ce champ doit contenir un chiffre à virgule."],
- 'daily_total_without_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
- 'daily_total_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
- 'daily_total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'daily_total' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_discountable' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_discount' => ["Ce champ doit contenir un chiffre à virgule."],
'total_without_taxes' => ["Ce champ est invalide."],
'total_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
'total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
];
$this->assertEquals($expectedErrors, $errors);
+
+ // - Test de validation du taux de remise.
+ $estimate = new Estimate([
+ 'date' => '2024-01-19 16:00:00',
+ 'booking_start_date' => '2018-12-17 00:00:00',
+ 'booking_end_date' => '2018-12-18 23:59:59',
+ 'degressive_rate' => 1.75,
+ 'discount_rate' => 50.0,
+ 'vat_rate' => 20.0,
+ 'daily_total' => 1000.0,
+ 'total_without_discount' => 1750.0,
+ 'total_discountable' => 437.5, // => 25% de remise max.
+ 'total_discount' => 875.0,
+ 'total_without_taxes' => 875.0,
+ 'total_taxes' => 175.0,
+ 'total_with_taxes' => 1050.0,
+ 'currency' => 'EUR',
+ 'total_replacement' => 2000,
+ ]);
+ $estimate->booking()->associate(Event::findOrFail(1));
+ $estimate->beneficiary()->associate(Beneficiary::findOrFail(1));
+ $errors = $estimate->validationErrors();
+
+ $expectedErrors = [
+ 'discount_rate' => ["Le taux de remise dépasse le maximum."],
+ ];
+ $this->assertEquals($expectedErrors, $errors);
+ }
+
+ public function testCreateFromEventBadDiscountRate(): void
+ {
+ Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36));
+
+ $event = tap(Event::findOrFail(2), function ($event) {
+ // - Pour cet événement, le taux de remise maximum est de 5.6328 %
+ $event->discount_rate = Decimal::of('5.3629');
+ });
+
+ $this->expectException(ValidationException::class);
+ Estimate::createFromBooking($event, User::findOrFail(1));
}
public function testCreateFromEvent(): void
@@ -107,14 +145,13 @@ public function testCreateFromEvent(): void
'discount_rate' => '1.3923',
'vat_rate' => '20.00',
- 'daily_total_without_discount' => '951.00',
- 'daily_total_discountable' => '51.00',
- 'daily_total_discount' => '13.24',
- 'daily_total_without_taxes' => '937.76',
+ // - Total / jour.
+ 'daily_total' => '951.00',
- // - Taxes.
- 'daily_total_taxes' => '187.55',
- 'daily_total_with_taxes' => '1125.31',
+ // - Remise.
+ 'total_without_discount' => '1664.25',
+ 'total_discountable' => '89.25',
+ 'total_discount' => '23.17',
// - Totaux.
'total_without_taxes' => '1641.08',
diff --git a/server/tests/models/EventTest.php b/server/tests/models/EventTest.php
index dbddf6f3c..9cd35e22e 100644
--- a/server/tests/models/EventTest.php
+++ b/server/tests/models/EventTest.php
@@ -234,14 +234,14 @@ public function testHasNotReturnedMaterials(): void
$this->assertSame(null, $result->hasNotReturnedMaterials);
}
- public function testDailyAmountWithoutDiscount(): void
+ public function testTotalWithoutDiscount(): void
{
- $this->assertEquals('341.45', (string) Event::find(1)->daily_total_without_discount);
+ $this->assertEquals('597.54', (string) Event::find(1)->total_without_discount);
}
- public function testDailyTotalDiscountable(): void
+ public function testTotalDiscountable(): void
{
- $this->assertEquals('41.45', (string) Event::find(1)->daily_total_discountable);
+ $this->assertEquals('72.54', (string) Event::find(1)->total_discountable);
}
public function testTotalReplacement(): void
diff --git a/server/tests/models/InvoiceTest.php b/server/tests/models/InvoiceTest.php
index 25a2ea11a..76aa4b8bc 100644
--- a/server/tests/models/InvoiceTest.php
+++ b/server/tests/models/InvoiceTest.php
@@ -5,6 +5,8 @@
use Brick\Math\BigDecimal as Decimal;
use Illuminate\Support\Carbon;
+use Loxya\Errors\Exception\ValidationException;
+use Loxya\Models\Beneficiary;
use Loxya\Models\Event;
use Loxya\Models\Invoice;
use Loxya\Models\User;
@@ -13,6 +15,90 @@
final class InvoiceTest extends TestCase
{
+ public function testValidation(): void
+ {
+ $invoice = new Invoice([
+ 'number' => '',
+ 'date' => '',
+ 'booking_start_date' => '',
+ 'booking_end_date' => '',
+ 'degressive_rate' => 100_000.0,
+ 'vat_rate' => -5.0,
+ 'total_without_taxes' => 1_000_000_000_000,
+ 'total_replacement' => -20,
+ 'currency' => 'a',
+ ]);
+ $invoice->booking()->associate(Event::findOrFail(1));
+ $invoice->beneficiary()->associate(Beneficiary::findOrFail(1));
+ $errors = $invoice->validationErrors();
+
+ $expectedErrors = [
+ 'number' => ["Ce champ est obligatoire."],
+ 'date' => ["Ce champ est obligatoire.", "Cette date est invalide."],
+ 'degressive_rate' => ["Ce champ est invalide."],
+ 'discount_rate' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'vat_rate' => ["Ce champ est invalide."],
+ 'total_replacement' => ["Ce champ est invalide."],
+ 'currency' => [
+ "Toutes les règles requises doivent être validées\xc2\xa0:",
+ "Ce champ doit être en majuscule.",
+ "3 caractères attendus.",
+ ],
+ 'booking_start_date' => ["Cette date est invalide."],
+ 'booking_end_date' => ["Cette date est invalide."],
+ 'daily_total' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_without_discount' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_discountable' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_discount' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_without_taxes' => ["Ce champ est invalide."],
+ 'total_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
+ 'total_with_taxes' => ["Ce champ doit contenir un chiffre à virgule."],
+ ];
+ $this->assertEquals($expectedErrors, $errors);
+
+ // - Test de validation du numéro de facture et du taux de remise.
+ $invoice = new Invoice([
+ 'number' => '2020-00001',
+ 'date' => '2024-01-19 16:00:00',
+ 'booking_start_date' => '2018-12-17 00:00:00',
+ 'booking_end_date' => '2018-12-18 23:59:59',
+ 'degressive_rate' => 1.75,
+ 'discount_rate' => 50.0,
+ 'vat_rate' => 20.0,
+ 'daily_total' => 1000.0,
+ 'total_without_discount' => 1750.0,
+ 'total_discountable' => 437.5, // => 25% de remise max.
+ 'total_discount' => 875.0,
+ 'total_without_taxes' => 875.0,
+ 'total_taxes' => 175.0,
+ 'total_with_taxes' => 1050.0,
+ 'currency' => 'EUR',
+ 'total_replacement' => 2000,
+ ]);
+ $invoice->booking()->associate(Event::findOrFail(1));
+ $invoice->beneficiary()->associate(Beneficiary::findOrFail(1));
+ $errors = $invoice->validationErrors();
+
+ $expectedErrors = [
+ 'number' => ["Une facture existe déjà avec ce numéro."],
+ 'discount_rate' => ["Le taux de remise dépasse le maximum."],
+ ];
+ $this->assertEquals($expectedErrors, $errors);
+ }
+
+ public function testCreateFromEventBadDiscountRate(): void
+ {
+ Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36));
+
+ $event = tap(Event::findOrFail(2), function ($event) {
+ // - Pour cet événement, le taux de remise maximum est de 5.6328 %
+ $event->discount_rate = Decimal::of('5.3629');
+ });
+
+ $this->expectException(ValidationException::class);
+ Invoice::createFromBooking($event, User::findOrFail(1));
+ }
+
public function testCreateFromEvent(): void
{
Carbon::setTestNow(Carbon::create(2022, 10, 22, 18, 42, 36));
@@ -64,14 +150,13 @@ public function testCreateFromEvent(): void
'discount_rate' => '1.3923',
'vat_rate' => '20.00',
- 'daily_total_without_discount' => '951.00',
- 'daily_total_discountable' => '51.00',
- 'daily_total_discount' => '13.24',
- 'daily_total_without_taxes' => '937.76',
+ // - Total / jour.
+ 'daily_total' => '951.00',
- // - Taxes.
- 'daily_total_taxes' => '187.55',
- 'daily_total_with_taxes' => '1125.31',
+ // - Remise.
+ 'total_without_discount' => '1664.25',
+ 'total_discountable' => '89.25',
+ 'total_discount' => '23.17',
// - Totaux.
'total_without_taxes' => '1641.08',