Cotisation diffuseur URSSAF (1,1%) : nouveau champ, TTC calculé en cascade

Architecture métier corrigée : la SAS devise HT, ajoute (quand
applicable -- pas systématique) 1,1% de cotisation diffuseur URSSAF
pour l'usage de freelances, puis applique la TVA sur ce montant
augmenté -- pas sur le HT brut. La répartition entre comptes reste
basée sur le HT seul, inchangée.

- field_cotisation_active (case à cocher, "Entrée client" uniquement,
  cochée par défaut sur une nouvelle ligne -- optionnelle puisque tous
  les devis ne l'incluent pas historiquement).
- field_cotisation_urssaf ("1,1%", montant calculé HT × 1,011, jamais
  éditable).
- Montant TTC = round(cotisation × (1 + TVA/100), 2) quand la
  cotisation s'applique, round(HT × (1 + TVA/100), 2) sinon --
  inchangé pour tous les types hors "Entrée client".

Migration (update hooks 8006/8007) : toutes les lignes "Entrée client"
de field_tva rétro-calculées par figli_compta_ledger_update_8005()
étaient fausses dès que la cotisation s'appliquait (taux mixte HT->TTC,
pas le vrai taux de TVA) -- corrigées avec la même prudence que la
migration précédente : 2021 laissée de côté (pratique non confirmée
sur cette année), et par ligne, un taux déjà "propre" (0/2,1/5,5/10/20
%) signifie qu'aucune cotisation n'a été appliquée (laissé tel quel) ;
sinon le nouveau taux recalculé n'est retenu que s'il retombe lui-même
sur un taux officiel, sinon la ligne reste inchangée plutôt que de
deviner. Montant TTC historique jamais réécrit, comme pour toute
migration de ce module. Résultat : 154 lignes corrigées (cotisation
active), 90 déjà correctes (cotisation inactive), 40 ambiguës laissées
telles quelles, 43 lignes 2021 ignorées.

Formulaire réorganisé en grille à 4 colonnes (HT | 1,1% | TVA | TTC),
case à cocher masquée hors "Entrée client" via #states réel (fiable
ici, contrairement au select TVA -- watch sur field_type_ligne, un
vrai champ, pas un élément ajouté à la main). Colonne "1,1%" ajoutée
au grand livre entre HT et TVA.
This commit is contained in:
2026-09-07 14:33:20 +02:00
parent 134c97ac9d
commit 6c33bc0098
13 changed files with 478 additions and 74 deletions
@@ -4,6 +4,8 @@ status: true
dependencies: dependencies:
config: config:
- field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne - field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag - field.field.node.ligne_comptable.field_flag
@@ -40,6 +42,20 @@ content:
size: 60 size: 60
placeholder: '' placeholder: ''
third_party_settings: { } third_party_settings: { }
field_cotisation_active:
type: boolean_checkbox
weight: 4
region: content
settings:
display_label: true
third_party_settings: { }
field_cotisation_urssaf:
type: number
weight: 6
region: content
settings:
placeholder: ''
third_party_settings: { }
field_date_ligne: field_date_ligne:
type: datetime_default type: datetime_default
weight: 0 weight: 0
@@ -58,7 +74,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_flag: field_flag:
type: entity_reference_autocomplete_tags type: entity_reference_autocomplete_tags
weight: 11 weight: 12
region: content region: content
settings: settings:
match_operator: CONTAINS match_operator: CONTAINS
@@ -75,14 +91,14 @@ content:
third_party_settings: { } third_party_settings: { }
field_montant_ttc: field_montant_ttc:
type: number type: number
weight: 7 weight: 8
region: content region: content
settings: settings:
placeholder: '' placeholder: ''
third_party_settings: { } third_party_settings: { }
field_notes: field_notes:
type: string_textarea type: string_textarea
weight: 10 weight: 11
region: content region: content
settings: settings:
rows: 5 rows: 5
@@ -98,7 +114,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_repartition: field_repartition:
type: paragraphs type: paragraphs
weight: 9 weight: 10
region: content region: content
settings: settings:
title: Répartition title: Répartition
@@ -116,7 +132,7 @@ content:
third_party_settings: { } third_party_settings: { }
field_tva: field_tva:
type: number type: number
weight: 8 weight: 9
region: content region: content
settings: settings:
placeholder: '' placeholder: ''
@@ -4,6 +4,8 @@ status: true
dependencies: dependencies:
config: config:
- field.field.node.ligne_comptable.field_client - field.field.node.ligne_comptable.field_client
- field.field.node.ligne_comptable.field_cotisation_active
- field.field.node.ligne_comptable.field_cotisation_urssaf
- field.field.node.ligne_comptable.field_date_ligne - field.field.node.ligne_comptable.field_date_ligne
- field.field.node.ligne_comptable.field_entree_liee - field.field.node.ligne_comptable.field_entree_liee
- field.field.node.ligne_comptable.field_flag - field.field.node.ligne_comptable.field_flag
@@ -33,6 +35,27 @@ content:
third_party_settings: { } third_party_settings: { }
weight: 2 weight: 2
region: content region: content
field_cotisation_active:
type: boolean
label: above
settings:
format: default
format_custom_false: ''
format_custom_true: ''
third_party_settings: { }
weight: 4
region: content
field_cotisation_urssaf:
type: number_decimal
label: above
settings:
thousand_separator: ''
decimal_separator: .
scale: 2
prefix_suffix: true
third_party_settings: { }
weight: 6
region: content
field_date_ligne: field_date_ligne:
type: datetime_default type: datetime_default
label: above label: above
@@ -56,7 +79,7 @@ content:
settings: settings:
link: true link: true
third_party_settings: { } third_party_settings: { }
weight: 11 weight: 12
region: content region: content
field_montant_ht: field_montant_ht:
type: number_decimal type: number_decimal
@@ -78,14 +101,14 @@ content:
scale: 2 scale: 2
prefix_suffix: true prefix_suffix: true
third_party_settings: { } third_party_settings: { }
weight: 7 weight: 8
region: content region: content
field_notes: field_notes:
type: basic_string type: basic_string
label: above label: above
settings: { } settings: { }
third_party_settings: { } third_party_settings: { }
weight: 10 weight: 11
region: content region: content
field_numero_facture: field_numero_facture:
type: string type: string
@@ -103,7 +126,7 @@ content:
view_mode: default view_mode: default
link: '' link: ''
third_party_settings: { } third_party_settings: { }
weight: 9 weight: 10
region: content region: content
field_tva: field_tva:
type: number_decimal type: number_decimal
@@ -114,7 +137,7 @@ content:
scale: 2 scale: 2
prefix_suffix: true prefix_suffix: true
third_party_settings: { } third_party_settings: { }
weight: 8 weight: 9
region: content region: content
field_type_ligne: field_type_ligne:
type: list_default type: list_default
@@ -0,0 +1,23 @@
uuid: 3defc3d6-42b6-4340-a7cf-fecadb919934
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_active
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
bundle: ligne_comptable
label: 'Cotisation diffuseur URSSAF (1,1%)'
description: ''
required: false
translatable: true
default_value:
-
value: 1
default_value_callback: ''
settings:
on_label: 'On'
off_label: 'Off'
field_type: boolean
@@ -0,0 +1,23 @@
uuid: 309c0b94-be32-42a3-b990-cb04858afb8d
langcode: en
status: true
dependencies:
config:
- field.storage.node.field_cotisation_urssaf
- node.type.ligne_comptable
id: node.ligne_comptable.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
bundle: ligne_comptable
label: '1,1%'
description: ''
required: false
translatable: true
default_value: { }
default_value_callback: ''
settings:
min: null
max: null
prefix: ''
suffix: ''
field_type: decimal
@@ -0,0 +1,18 @@
uuid: 28c4ae5a-011c-4f31-bca5-36874c6c182b
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_active
field_name: field_cotisation_active
entity_type: node
type: boolean
settings: { }
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -0,0 +1,20 @@
uuid: 486554c0-7686-4c52-ab33-eb09b5573be5
langcode: en
status: true
dependencies:
module:
- node
id: node.field_cotisation_urssaf
field_name: field_cotisation_urssaf
entity_type: node
type: decimal
settings:
precision: 12
scale: 2
module: core
locked: false
cardinality: 1
translatable: true
indexes: { }
persist_with_no_fields: false
custom_storage: false
@@ -457,6 +457,13 @@ html.gin--dark-mode #figli-home-app {
#figli-home-app .amount.figli-ttc-col { #figli-home-app .amount.figli-ttc-col {
width: 5%; width: 5%;
} }
/* Cotisation diffuseur URSSAF (1,1%) -- blank for every non-"Entrée
client" row (see figli_compta_ledger_node_presave()), same width
budget as HT/TTC since it's a real € amount, not a short rate like
TVA. */
#figli-home-app .amount.figli-cotisation-col {
width: 5%;
}
#figli-home-app .amount.figli-tva-col { #figli-home-app .amount.figli-tva-col {
width: 3.5%; width: 3.5%;
} }
@@ -25,7 +25,7 @@
--flform-danger: #b3261e; --flform-danger: #b3261e;
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 0.5rem 1rem; gap: 0.5rem 1rem;
max-width: 44rem; max-width: 44rem;
margin: 0 auto; margin: 0 auto;
@@ -59,15 +59,18 @@ html.gin--dark-mode .figli-ledger-form {
display: none; display: none;
} }
/* Row layout, fully explicit (not left to grid auto-placement) -- /* Row layout, fully explicit (not left to grid auto-placement) -- with
there are now two separate 1-col-plus-2-col row groupings (Client + Cotisation now in the mix there are three separate row groupings
N° Facture, then Montant HT + TVA + Montant TTC) and relying on DOM (Client + N° Facture, the checkbox on its own full-width row, then
order alone to keep them from bleeding into each other got fragile Montant HT + Cotisation + TVA + Montant TTC as a clean four-column
once a 4th financial field (TVA) entered the picture. This must stay row) and relying on DOM order alone to keep them from bleeding into
in sync with the field weights set in each other got fragile once that many financial fields entered the
figli_compta_ledger_update_8004()/_figli_compta_ledger_create_node_type_ligne_comptable(). */ picture. This must stay in sync with the field weights set in
figli_compta_ledger_update_8006()/_figli_compta_ledger_create_node_type_ligne_comptable(). */
.figli-ledger-form > .field--name-title, .figli-ledger-form > .field--name-title,
.figli-ledger-form > .field--name-field-entree-liee, .figli-ledger-form > .field--name-field-entree-liee,
.figli-ledger-form > .field--name-field-cotisation-active,
.figli-ledger-form > .field--name-field-tva,
.figli-ledger-form > .field--name-field-repartition, .figli-ledger-form > .field--name-field-repartition,
.figli-ledger-form > .field--name-field-notes, .figli-ledger-form > .field--name-field-notes,
.figli-ledger-form > .field--name-field-flag { .figli-ledger-form > .field--name-field-flag {
@@ -77,31 +80,32 @@ html.gin--dark-mode .figli-ledger-form {
grid-column: 1 / 2; grid-column: 1 / 2;
} }
.figli-ledger-form > .field--name-field-type-ligne { .figli-ledger-form > .field--name-field-type-ligne {
grid-column: 2 / 4; grid-column: 2 / 5;
} }
.figli-ledger-form > .field--name-field-client { .figli-ledger-form > .field--name-field-client {
grid-column: 1 / 3; grid-column: 1 / 4;
} }
.figli-ledger-form > .field--name-field-numero-facture { .figli-ledger-form > .field--name-field-numero-facture {
grid-column: 3 / 4; grid-column: 4 / 5;
} }
.figli-ledger-form > .field--name-field-montant-ht { .figli-ledger-form > .field--name-field-montant-ht {
grid-column: 1 / 2; grid-column: 1 / 2;
} }
/* The field_tva_rate select (added in figli_compta_ledger_form_alter(), .figli-ledger-form > .field--name-field-cotisation-urssaf {
not a real field so it gets no field--name-* class) fills the middle
slot of the HT/TVA/TTC row instead -- see that function's #weight
comment for why this specific ordering (weight 6, between HT's 5 and
TTC's 7) matters, not just matching column numbers. field_tva itself
only appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
grid-column: 2 / 3; grid-column: 2 / 3;
} }
.figli-ledger-form > .field--name-field-tva { /* The field_tva_rate select (added in figli_compta_ledger_form_alter(),
grid-column: 1 / -1; not a real field so it gets no field--name-* class) fills the third
slot of the HT/Cotisation/TVA/TTC row -- see that function's #weight
comment for why this specific ordering (weight 7, between Cotisation's
6 and TTC's 8) matters, not just matching column numbers. field_tva
itself (grouped with the other always-full-width fields above) only
appears -- full width, its own row -- when "Autre" is picked. */
.figli-ledger-form > .js-form-item-field-tva-rate {
grid-column: 3 / 4;
} }
.figli-ledger-form > .field--name-field-montant-ttc { .figli-ledger-form > .field--name-field-montant-ttc {
grid-column: 3 / 4; grid-column: 4 / 5;
} }
/* Field basics */ /* Field basics */
@@ -218,6 +218,13 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
// behind that choice. A user typing a plain rate like "20" is // behind that choice. A user typing a plain rate like "20" is
// unaffected either way. // unaffected either way.
_figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]); _figli_field('node', 'ligne_comptable', 'field_tva', 'TVA (%)', 'decimal', ['precision' => 8, 'scale' => 4, 'min' => 0]);
// Cotisation diffuseur URSSAF (1,1%), entrée client lines only -- see
// figli_compta_ledger_update_8006()'s docblock for the full
// explanation. field_cotisation_active is the editable on/off toggle
// (a devis doesn't always include it); field_cotisation_urssaf is the
// computed amount (Montant HT * 1.011), never entered by hand.
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]); _figli_field('node', 'ligne_comptable', 'field_montant_ttc', 'Montant TTC (€)', 'decimal', ['precision' => 12, 'scale' => 2]);
_figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long'); _figli_field('node', 'ligne_comptable', 'field_notes', 'Notes / détail', 'string_long');
@@ -242,19 +249,30 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1]) ->setComponent('field_type_ligne', ['type' => 'options_select', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string_textfield', 'weight' => 3])
// Cotisation diffuseur URSSAF (1,1%) -- only "Entrée client" lines
// go through a client devis, the only place this cotisation is
// ever invoiced (see figli_compta_ledger_update_8006()'s
// docblock). Optional even there: not every entrée historically
// included it, so a checkbox rather than an automatic
// type-based assumption. Weight 4, tied with field_entree_liee --
// the two types that show each are mutually exclusive
// (entrée vs. versement/achat/hébergement/sous_traitant), so
// which one wins the tie never matters.
->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]])
->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5]) ->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5])
->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 7]) ->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6])
// field_tva sits *after* Montant TTC, not between HT and TTC -- ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8])
// figli_compta_ledger_form_alter() inserts a non-field // field_tva sits *after* Montant TTC, not between Cotisation and
// "field_tva_rate" select at weight 6 (a select of the official // TTC -- figli_compta_ledger_form_alter() inserts a non-field
// "field_tva_rate" select at weight 7 (a select of the official
// French VAT rates) to fill that visual slot instead; this real // French VAT rates) to fill that visual slot instead; this real
// field only becomes visible (still at its own weight, on its own // field only becomes visible (still at its own weight, on its own
// full-width row) when "Autre" is picked there. See // full-width row) when "Autre" is picked there. See
// css/ledger-form.css's grid-column rules for both. // css/ledger-form.css's grid-column rules for both.
->setComponent('field_tva', ['type' => 'number', 'weight' => 8]) ->setComponent('field_tva', ['type' => 'number', 'weight' => 9])
->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 9, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 10, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']])
->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 10]) ->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 11]) ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12])
->save(); ->save();
} }
@@ -269,12 +287,14 @@ function _figli_compta_ledger_create_node_type_ligne_comptable() {
->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1]) ->setComponent('field_type_ligne', ['type' => 'list_default', 'weight' => 1])
->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2]) ->setComponent('field_client', ['type' => 'entity_reference_label', 'weight' => 2])
->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3]) ->setComponent('field_numero_facture', ['type' => 'string', 'weight' => 3])
->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4])
->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5]) ->setComponent('field_montant_ht', ['type' => 'number_decimal', 'weight' => 5])
->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 7]) ->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6])
->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8]) ->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 8])
->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 9]) ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 9])
->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 10]) ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 10])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 11]) ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 11])
->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 12])
->save(); ->save();
} }
} }
@@ -493,3 +513,175 @@ function figli_compta_ledger_update_8005() {
return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul)."; return "TVA calculée pour $filled lignes, $skipped laissées vides (pas de Montant TTC renseigné ou Montant HT nul).";
} }
/**
* Adds the cotisation diffuseur URSSAF (1,1%) fields to ligne_comptable,
* and switches Montant TTC's computation for "Entrée client" lines when
* the cotisation applies: TTC = round(cotisation * (1 + TVA/100), 2)
* instead of straight from Montant HT.
*
* The SAS invoices clients a HT amount, then -- when the devis includes
* it, not always -- adds a 1,1% "cotisation diffuseur" the SAS owes
* URSSAF for using freelancers, *then* applies TVA on top of that
* increased amount, not on the raw HT. Only "Entrée client" lines ever
* go through a client devis at all; every other type (versement, achat,
* charge...) is an outgoing/internal line with nothing to invoice a
* cotisation on, so both fields below are #states-hidden outside that
* type (see figli_compta_ledger_form_alter()) and
* figli_compta_ledger_node_presave() never applies the cascade to them.
*
* field_tva as backfilled by figli_compta_ledger_update_8005() didn't
* know about this cascade and is wrong for every entrée line that did
* have the cotisation applied (it computed straight from HT, so what it
* stored is actually the *blended* HT->TTC rate, not the real TVA
* rate) -- corrected separately in figli_compta_ledger_update_8007(),
* which also backfills field_cotisation_active/field_cotisation_urssaf
* for existing content.
*/
function figli_compta_ledger_update_8006() {
_figli_field('node', 'ligne_comptable', 'field_cotisation_active', 'Cotisation diffuseur URSSAF (1,1%)', 'boolean');
_figli_field('node', 'ligne_comptable', 'field_cotisation_urssaf', '1,1%', 'decimal', ['precision' => 12, 'scale' => 2]);
// New entrée lines default to the cotisation being applied -- the
// associates' normal practice going forward, even though it was only
// roughly half of existing content (figli_compta_ledger_update_8007()
// detects each historical line on its own rather than assuming either
// way for what's already there).
$field_active = FieldConfig::loadByName('node', 'ligne_comptable', 'field_cotisation_active');
if ($field_active && $field_active->getDefaultValueLiteral() === []) {
$field_active->setDefaultValue(1)->save();
}
// See figli_compta_ledger_update_8004()'s comment on why every weight
// here is a plain integer (fractional weights silently truncate).
$form_display = EntityFormDisplay::load('node.ligne_comptable.default');
if ($form_display) {
$form_display->setComponent('field_cotisation_active', ['type' => 'boolean_checkbox', 'weight' => 4, 'settings' => ['display_label' => TRUE]]);
$form_display->setComponent('field_montant_ht', ['type' => 'number', 'weight' => 5] + (array) $form_display->getComponent('field_montant_ht'));
$form_display->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]);
// Weight 7 (between Cotisation and Montant TTC) is the non-field
// "field_tva_rate" select figli_compta_ledger_form_alter() adds.
$form_display->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8] + (array) $form_display->getComponent('field_montant_ttc'));
$form_display->setComponent('field_tva', ['type' => 'number', 'weight' => 9] + (array) $form_display->getComponent('field_tva'));
$form_display->setComponent('field_repartition', ['weight' => 10] + (array) $form_display->getComponent('field_repartition'));
$form_display->setComponent('field_notes', ['weight' => 11] + (array) $form_display->getComponent('field_notes'));
$form_display->setComponent('field_flag', ['weight' => 12] + (array) $form_display->getComponent('field_flag'));
$form_display->save();
}
$view_display = EntityViewDisplay::load('node.ligne_comptable.default');
if ($view_display) {
$view_display->setComponent('field_cotisation_active', ['type' => 'boolean', 'weight' => 4]);
$view_display->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6]);
$view_display->setComponent('field_montant_ttc', ['weight' => 8] + (array) $view_display->getComponent('field_montant_ttc'));
$view_display->setComponent('field_tva', ['weight' => 9] + (array) $view_display->getComponent('field_tva'));
$view_display->setComponent('field_repartition', ['weight' => 10] + (array) $view_display->getComponent('field_repartition'));
$view_display->setComponent('field_notes', ['weight' => 11] + (array) $view_display->getComponent('field_notes'));
$view_display->setComponent('field_flag', ['weight' => 12] + (array) $view_display->getComponent('field_flag'));
$view_display->save();
}
}
/**
* Backfills field_cotisation_active/field_cotisation_urssaf and
* corrects field_tva for existing "Entrée client" lines, now that the
* HT -> +1,1% -> +TVA cascade explained in
* figli_compta_ledger_update_8006()'s docblock is understood. 2021 is
* left untouched entirely (explicit instruction -- that year's
* cotisation practice isn't confirmed), and Montant TTC itself is never
* written here, same "never correct historical data" rule as every
* other migration in this module.
*
* Per entrée line (2022+, both Montant HT and Montant TTC present):
* - old_rate = (TTC/HT - 1) * 100 -- what
* figli_compta_ledger_update_8005() already stored in field_tva.
* - If old_rate already lands on an official French VAT rate (0, 2.1,
* 5.5, 10, 20 -- ±0.05 points, allowing for rounding through 2-decimal
* HT/TTC storage), the cotisation was never applied to this line:
* field_tva is already correct as migrated, left alone, cotisation
* marked inactive.
* - Otherwise, new_rate = (TTC / (HT * 1.011) - 1) * 100. If *that*
* lands on an official rate, the cotisation was applied and old_rate
* was the wrong (blended HT->TTC) figure all along -- field_tva
* corrected to new_rate, cotisation marked active, field_cotisation_
* urssaf backfilled to HT * 1.011.
* - If neither lands cleanly, this is an ambiguous/blended case (a
* handful of genuinely multi-rate invoices collapsed into one line,
* same category figli_compta_ledger_update_8005() already found at 4-
* decimal precision) -- field_tva left untouched, cotisation marked
* inactive, rather than guess.
*
* Checked empirically before writing this (287 entrée lines with both
* amounts, 2022+): 90 already official (left alone), 154 corrected, 40
* still ambiguous both ways (left alone), 3 missing HT or TTC.
*/
function figli_compta_ledger_update_8007() {
$official = [0.0, 2.1, 5.5, 10.0, 20.0];
$epsilon = 0.05;
$matches_official = function ($rate) use ($official, $epsilon) {
foreach ($official as $o) {
if (abs($rate - $o) < $epsilon) {
return TRUE;
}
}
return FALSE;
};
$storage = \Drupal::entityTypeManager()->getStorage('node');
$nids = $storage->getQuery()
->accessCheck(FALSE)
->condition('type', 'ligne_comptable')
->condition('field_type_ligne', 'entree')
->execute();
$skipped2021 = 0;
$noData = 0;
$alreadyOfficial = 0;
$corrected = 0;
$stillAmbiguous = 0;
\Drupal::state()->set('figli_compta_ledger.skip_validation', TRUE);
foreach ($storage->loadMultiple($nids) as $node) {
$date = $node->hasField('field_date_ligne') ? $node->get('field_date_ligne')->value : NULL;
if ($date && substr($date, 0, 4) === '2021') {
$skipped2021++;
continue;
}
$ht = $node->hasField('field_montant_ht') && !$node->get('field_montant_ht')->isEmpty()
? (float) $node->get('field_montant_ht')->value : NULL;
$ttc = $node->hasField('field_montant_ttc') && !$node->get('field_montant_ttc')->isEmpty()
? (float) $node->get('field_montant_ttc')->value : NULL;
if ($ht === NULL || $ttc === NULL || abs($ht) < 0.0001) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$noData++;
continue;
}
$old_rate = round(($ttc / $ht - 1) * 100, 4);
if ($matches_official($old_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$alreadyOfficial++;
continue;
}
$new_rate = round(($ttc / ($ht * 1.011) - 1) * 100, 4);
if (!$matches_official($new_rate)) {
$node->set('field_cotisation_active', FALSE);
$node->save();
$stillAmbiguous++;
continue;
}
$node->set('field_cotisation_active', TRUE);
$node->set('field_cotisation_urssaf', round($ht * 1.011, 2));
$node->set('field_tva', $new_rate);
$node->save();
$corrected++;
}
\Drupal::state()->delete('figli_compta_ledger.skip_validation');
return "Cotisation/TVA : $corrected lignes corrigées (cotisation active), $alreadyOfficial déjà correctes (cotisation inactive), $stillAmbiguous laissées telles quelles (taux non standard des deux façons), $noData sans HT/TTC, $skipped2021 lignes 2021 ignorées.";
}
@@ -119,6 +119,27 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
$form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.'); $form['field_montant_ttc']['widget'][0]['value']['#description'] = t('Calculé automatiquement à partir du montant HT et de la TVA.');
} }
// Cotisation diffuseur URSSAF (1,1%) -- only ever relevant for
// "Entrée client" lines (the only type invoiced to a client via a
// devis; see figli_compta_ledger_update_8006()'s docblock), so both
// fields are hidden otherwise. Real #states on real fields watching
// the real field_type_ligne select -- unlike field_tva_rate below,
// this is the same working pattern already used a few fields up on
// field_entree_liee, not the custom select that #states silently
// failed to toggle.
if (isset($form['field_cotisation_active'])) {
$form['field_cotisation_active']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
}
if (isset($form['field_cotisation_urssaf']['widget'][0]['value'])) {
$form['field_cotisation_urssaf']['#states'] = [
'visible' => [':input[name="field_type_ligne"]' => ['value' => 'entree']],
];
$form['field_cotisation_urssaf']['widget'][0]['value']['#disabled'] = TRUE;
$form['field_cotisation_urssaf']['widget'][0]['value']['#description'] = t('Calculé automatiquement (Montant HT + 1,1%) si la cotisation ci-dessus est cochée.');
}
// TVA: a select of the official French rates covers the overwhelming // TVA: a select of the official French rates covers the overwhelming
// majority of new lines with one click, instead of associates having // majority of new lines with one click, instead of associates having
// to know/type "20" or "5.5" by heart. "Autre" reveals the real // to know/type "20" or "5.5" by heart. "Autre" reveals the real
@@ -162,10 +183,10 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
// delta-indexed value array, and chokes on an unrelated sibling key // delta-indexed value array, and chokes on an unrelated sibling key
// mixed into it. // mixed into it.
// //
// Weight 6: the exact slot Montant HT (5)/Montant TTC (7) leave open // Weight 7: the slot between field_cotisation_urssaf (6) and Montant
// between them in figli_compta_ledger_update_8004() -- field_tva // TTC (8) left open in figli_compta_ledger_update_8006() -- field_tva
// itself has been moved to weight 8 (its own full-width row) so // itself sits at weight 9 (its own full-width row) so that this
// that this select forms a clean HT/rate/TTC three-column row // select forms a clean HT/Cotisation/rate/TTC four-column row
// whether or not "Autre" is currently revealing the real field_tva // whether or not "Autre" is currently revealing the real field_tva
// input further down. See css/ledger-form.css's grid-column rules // input further down. See css/ledger-form.css's grid-column rules
// for both -- the row layout there depends on this exact ordering, // for both -- the row layout there depends on this exact ordering,
@@ -175,7 +196,7 @@ function figli_compta_ledger_form_alter(&$form, FormStateInterface $form_state,
'#title' => t('TVA'), '#title' => t('TVA'),
'#options' => $taux_officiels + ['autre' => t('Autre (préciser)')], '#options' => $taux_officiels + ['autre' => t('Autre (préciser)')],
'#default_value' => $rate_default, '#default_value' => $rate_default,
'#weight' => 6, '#weight' => 7,
]; ];
$tva_field['#title'] = t('Taux exact (%)'); $tva_field['#title'] = t('Taux exact (%)');
// Not #states: this element sits outside the field system (see the // Not #states: this element sits outside the field system (see the
@@ -338,30 +359,57 @@ function figli_compta_ledger_node_presave(NodeInterface $node) {
$montant_ht = (float) $node->get('field_montant_ht')->value; $montant_ht = (float) $node->get('field_montant_ht')->value;
$tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0; $tva = $node->hasField('field_tva') && !$node->get('field_tva')->isEmpty() ? (float) $node->get('field_tva')->value : 0.0;
$type = $node->hasField('field_type_ligne') ? $node->get('field_type_ligne')->value : NULL;
$cotisation_active = $node->hasField('field_cotisation_active') && !$node->get('field_cotisation_active')->isEmpty()
? (bool) $node->get('field_cotisation_active')->value : FALSE;
// Only "Entrée client" lines ever go through a client devis -- the
// only place the cotisation diffuseur is invoiced at all (see
// figli_compta_ledger_update_8006()'s docblock) -- so the checkbox is
// ignored for every other type even if it somehow ended up checked
// (e.g. a line's type changed after the fact).
$cotisation_applies = $type === 'entree' && $cotisation_active;
// Montant TTC is no longer entered by hand -- it's a pure function of // Montant TTC is no longer entered by hand -- it's a pure function of
// Montant HT and TVA (see figli_compta_ledger_update_8004()). Only // Montant HT, TVA, and (entrée client only) the cotisation diffuseur
// recompute it when HT or TVA actually changed, though, rather than on // URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only
// every save unconditionally: the TVA backfilled onto pre-migration // recompute it when HT, TVA, type, or the cotisation toggle actually
// lines (figli_compta_ledger_update_8005()) is a best-effort // changed, though, rather than on every save unconditionally: the TVA
// reconstruction and doesn't reproduce every historical Montant TTC to // backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/
// the exact centime, so blindly recomputing on an unrelated edit // _8007()) is a best-effort reconstruction and doesn't reproduce every
// (fixing a typo in Libellé, say) would silently nudge an untouched // historical Montant TTC to the exact centime, so blindly recomputing
// historical value -- exactly what this module's "never correct // on an unrelated edit (fixing a typo in Libellé, say) would silently
// historical data" rule exists to prevent. A genuinely new/changed // nudge an untouched historical value -- exactly what this module's
// HT or TVA has no such history to protect. // "never correct historical data" rule exists to prevent. A genuinely
// new/changed HT, TVA, type, or cotisation toggle has no such history
// to protect.
if ($node->hasField('field_montant_ttc')) { if ($node->hasField('field_montant_ttc')) {
$ht_or_tva_changed = $node->isNew(); $inputs_changed = $node->isNew();
if (!$ht_or_tva_changed && isset($node->original)) { if (!$inputs_changed && isset($node->original)) {
$original = $node->original; $original = $node->original;
$orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty() $orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty()
? (float) $original->get('field_montant_ht')->value : NULL; ? (float) $original->get('field_montant_ht')->value : NULL;
$orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty() $orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty()
? (float) $original->get('field_tva')->value : 0.0; ? (float) $original->get('field_tva')->value : 0.0;
$ht_or_tva_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005; $orig_type = $original->hasField('field_type_ligne') ? $original->get('field_type_ligne')->value : NULL;
$orig_cotisation_active = $original->hasField('field_cotisation_active') && !$original->get('field_cotisation_active')->isEmpty()
? (bool) $original->get('field_cotisation_active')->value : FALSE;
$inputs_changed = $orig_ht !== $montant_ht || abs($orig_tva - $tva) > 0.00005
|| $orig_type !== $type || $orig_cotisation_active !== $cotisation_active;
} }
if ($ht_or_tva_changed) { if ($inputs_changed) {
$node->set('field_montant_ttc', round($montant_ht * (1 + $tva / 100), 2)); if ($cotisation_applies) {
$base = round($montant_ht * 1.011, 2);
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', $base);
}
}
else {
$base = $montant_ht;
if ($node->hasField('field_cotisation_urssaf')) {
$node->set('field_cotisation_urssaf', NULL);
}
}
$node->set('field_montant_ttc', round($base * (1 + $tva / 100), 2));
} }
} }
@@ -369,6 +369,7 @@
facture: attrs.field_numero_facture || null, facture: attrs.field_numero_facture || null,
libelle: attrs.field_notes || attrs.title, libelle: attrs.field_notes || attrs.title,
montant_ht: montantHt, montant_ht: montantHt,
cotisation: attrs.field_cotisation_urssaf !== null && attrs.field_cotisation_urssaf !== undefined ? parseFloat(attrs.field_cotisation_urssaf) : null,
tva: attrs.field_tva !== null && attrs.field_tva !== undefined ? parseFloat(attrs.field_tva) : null, tva: attrs.field_tva !== null && attrs.field_tva !== undefined ? parseFloat(attrs.field_tva) : null,
montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null, montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null,
parCompte, parCompte,
@@ -27,6 +27,9 @@
var tva = form.querySelector('[name="field_tva[0][value]"]'); var tva = form.querySelector('[name="field_tva[0][value]"]');
var rate = form.querySelector('[name="field_tva_rate"]'); var rate = form.querySelector('[name="field_tva_rate"]');
var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]'); var ttc = form.querySelector('[name="field_montant_ttc[0][value]"]');
var type = form.querySelector('[name="field_type_ligne"]');
var cotisationActive = form.querySelector('[name="field_cotisation_active[value]"]');
var cotisationAmount = form.querySelector('[name="field_cotisation_urssaf[0][value]"]');
if (!tva || !ttc) { if (!tva || !ttc) {
return; return;
} }
@@ -42,6 +45,13 @@
return parseFloat(tva.value); return parseFloat(tva.value);
} }
// Mirrors figli_compta_ledger_node_presave()'s $cotisation_applies:
// only "Entrée client" lines with the checkbox on ever go through
// the +1,1% step before TVA.
function cotisationApplies() {
return !!type && type.value === 'entree' && !!cotisationActive && cotisationActive.checked;
}
function recompute() { function recompute() {
var htValue = parseFloat(ht.value); var htValue = parseFloat(ht.value);
if (isNaN(htValue)) { if (isNaN(htValue)) {
@@ -51,7 +61,17 @@
if (isNaN(tvaValue)) { if (isNaN(tvaValue)) {
tvaValue = 0; tvaValue = 0;
} }
ttc.value = (htValue * (1 + tvaValue / 100)).toFixed(2); var base = htValue;
if (cotisationApplies()) {
base = Math.round(htValue * 1.011 * 100) / 100;
if (cotisationAmount) {
cotisationAmount.value = base.toFixed(2);
}
}
else if (cotisationAmount) {
cotisationAmount.value = '';
}
ttc.value = (base * (1 + tvaValue / 100)).toFixed(2);
} }
// Deliberately not called once up front on an existing (edit) // Deliberately not called once up front on an existing (edit)
@@ -60,17 +80,23 @@
// the centime (see figli_compta_ledger_update_8005()'s // the centime (see figli_compta_ledger_update_8005()'s
// docblock) -- showing a recomputed number the instant the // docblock) -- showing a recomputed number the instant the
// modal opens, before the associate has touched anything, would // modal opens, before the associate has touched anything, would
// misrepresent what's actually saved. Only recompute once HT, // misrepresent what's actually saved. Only recompute once an
// TVA, or the rate select are actually edited, matching the // actual input happens, matching the same "only overwrite
// same "only overwrite Montant TTC when HT/TVA actually // Montant TTC when an input actually changed" guard in
// changed" guard in figli_compta_ledger_node_presave(). Harmless // figli_compta_ledger_node_presave(). Harmless no-op on a
// no-op on a genuinely new/blank line either way (HT is empty, // genuinely new/blank line either way (HT is empty, recompute()
// recompute() bails out above). // bails out above).
ht.addEventListener('input', recompute); ht.addEventListener('input', recompute);
tva.addEventListener('input', recompute); tva.addEventListener('input', recompute);
if (rate) { if (rate) {
rate.addEventListener('change', recompute); rate.addEventListener('change', recompute);
} }
if (type) {
type.addEventListener('change', recompute);
}
if (cotisationActive) {
cotisationActive.addEventListener('change', recompute);
}
}); });
} }
}; };
@@ -129,6 +129,7 @@
<th>Libellé / Détail</th> <th>Libellé / Détail</th>
<th>Signalement</th> <th>Signalement</th>
<th class="amount figli-ht-col">HT</th> <th class="amount figli-ht-col">HT</th>
<th class="amount figli-cotisation-col">1,1%</th>
<th class="amount figli-tva-col">TVA</th> <th class="amount figli-tva-col">TVA</th>
<th class="amount figli-ttc-col">TTC</th> <th class="amount figli-ttc-col">TTC</th>
<th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th> <th v-for="c in allComptes" :key="c" class="amount compte-col">{{ c }}</th>
@@ -137,13 +138,13 @@
</thead> </thead>
<tbody> <tbody>
<tr ref="topSentinel" class="figli-sentinel-row"> <tr ref="topSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 4"> <td :colspan="7 + allComptes.length + 5">
<span v-if="loadingOlder">Chargement des mois précédents…</span> <span v-if="loadingOlder">Chargement des mois précédents…</span>
</td> </td>
</tr> </tr>
<template v-for="item in groupedRows" :key="item.key"> <template v-for="item in groupedRows" :key="item.key">
<tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year"> <tr v-if="item.isGroup" class="figli-group-row" :data-year="item.year">
<td :colspan="7 + allComptes.length + 4">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td> <td :colspan="7 + allComptes.length + 5">{{ item.label }} <span class="figli-note">({{ item.count }} lignes)</span></td>
</tr> </tr>
<tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null"> <tr v-else :class="{'figli-flag-row': item.hasFlag}" :data-year="item.date ? item.date.slice(0, 4) : null">
<td class="actions-col"> <td class="actions-col">
@@ -241,6 +242,7 @@
</span> </span>
</td> </td>
<td class="amount figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td> <td class="amount figli-ht-col" :class="montantClass(item.montant_ht)">{{ formatEur(item.montant_ht) }}</td>
<td class="amount figli-cotisation-col">{{ formatEur(item.cotisation) }}</td>
<td class="amount figli-tva-col">{{ formatPct(item.tva) }}</td> <td class="amount figli-tva-col">{{ formatPct(item.tva) }}</td>
<td class="amount figli-ttc-col">{{ formatEur(item.montant_ttc) }}</td> <td class="amount figli-ttc-col">{{ formatEur(item.montant_ttc) }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td> <td v-for="c in allComptes" :key="c" class="amount compte-col">{{ item.parCompte[c] !== undefined ? formatEur(item.parCompte[c]) : '' }}</td>
@@ -248,7 +250,7 @@
</tr> </tr>
</template> </template>
<tr ref="bottomSentinel" class="figli-sentinel-row"> <tr ref="bottomSentinel" class="figli-sentinel-row">
<td :colspan="7 + allComptes.length + 4"> <td :colspan="7 + allComptes.length + 5">
<span v-if="loadingNewer">Chargement des mois suivants…</span> <span v-if="loadingNewer">Chargement des mois suivants…</span>
</td> </td>
</tr> </tr>
@@ -261,6 +263,7 @@
<span v-if="currentYearLoading" class="figli-note">chargement…</span> <span v-if="currentYearLoading" class="figli-note">chargement…</span>
</td> </td>
<td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td> <td class="amount figli-ht-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }}</td>
<td class="amount figli-cotisation-col"></td>
<td class="amount figli-tva-col"></td> <td class="amount figli-tva-col"></td>
<td class="amount figli-ttc-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td> <td class="amount figli-ttc-col">{{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }}</td>
<td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td> <td v-for="c in allComptes" :key="c" class="amount compte-col" :class="currentYearTotals ? soldeClass(currentYearTotals.par_compte[c]) : ''">{{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}</td>