diff --git a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml index 0c76383..6d0bf25 100644 --- a/config/sync/core.entity_form_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_form_display.node.ligne_comptable.default.yml @@ -4,6 +4,8 @@ status: true dependencies: config: - 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_entree_liee - field.field.node.ligne_comptable.field_flag @@ -40,6 +42,20 @@ content: size: 60 placeholder: '' 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: type: datetime_default weight: 0 @@ -58,7 +74,7 @@ content: third_party_settings: { } field_flag: type: entity_reference_autocomplete_tags - weight: 11 + weight: 12 region: content settings: match_operator: CONTAINS @@ -75,14 +91,14 @@ content: third_party_settings: { } field_montant_ttc: type: number - weight: 7 + weight: 8 region: content settings: placeholder: '' third_party_settings: { } field_notes: type: string_textarea - weight: 10 + weight: 11 region: content settings: rows: 5 @@ -98,7 +114,7 @@ content: third_party_settings: { } field_repartition: type: paragraphs - weight: 9 + weight: 10 region: content settings: title: Répartition @@ -116,7 +132,7 @@ content: third_party_settings: { } field_tva: type: number - weight: 8 + weight: 9 region: content settings: placeholder: '' diff --git a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml index bb898d5..18556fd 100644 --- a/config/sync/core.entity_view_display.node.ligne_comptable.default.yml +++ b/config/sync/core.entity_view_display.node.ligne_comptable.default.yml @@ -4,6 +4,8 @@ status: true dependencies: config: - 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_entree_liee - field.field.node.ligne_comptable.field_flag @@ -33,6 +35,27 @@ content: third_party_settings: { } weight: 2 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: type: datetime_default label: above @@ -56,7 +79,7 @@ content: settings: link: true third_party_settings: { } - weight: 11 + weight: 12 region: content field_montant_ht: type: number_decimal @@ -78,14 +101,14 @@ content: scale: 2 prefix_suffix: true third_party_settings: { } - weight: 7 + weight: 8 region: content field_notes: type: basic_string label: above settings: { } third_party_settings: { } - weight: 10 + weight: 11 region: content field_numero_facture: type: string @@ -103,7 +126,7 @@ content: view_mode: default link: '' third_party_settings: { } - weight: 9 + weight: 10 region: content field_tva: type: number_decimal @@ -114,7 +137,7 @@ content: scale: 2 prefix_suffix: true third_party_settings: { } - weight: 8 + weight: 9 region: content field_type_ligne: type: list_default diff --git a/config/sync/field.field.node.ligne_comptable.field_cotisation_active.yml b/config/sync/field.field.node.ligne_comptable.field_cotisation_active.yml new file mode 100644 index 0000000..5dd955e --- /dev/null +++ b/config/sync/field.field.node.ligne_comptable.field_cotisation_active.yml @@ -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 diff --git a/config/sync/field.field.node.ligne_comptable.field_cotisation_urssaf.yml b/config/sync/field.field.node.ligne_comptable.field_cotisation_urssaf.yml new file mode 100644 index 0000000..1a3ea64 --- /dev/null +++ b/config/sync/field.field.node.ligne_comptable.field_cotisation_urssaf.yml @@ -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 diff --git a/config/sync/field.storage.node.field_cotisation_active.yml b/config/sync/field.storage.node.field_cotisation_active.yml new file mode 100644 index 0000000..66592d7 --- /dev/null +++ b/config/sync/field.storage.node.field_cotisation_active.yml @@ -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 diff --git a/config/sync/field.storage.node.field_cotisation_urssaf.yml b/config/sync/field.storage.node.field_cotisation_urssaf.yml new file mode 100644 index 0000000..4934a07 --- /dev/null +++ b/config/sync/field.storage.node.field_cotisation_urssaf.yml @@ -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 diff --git a/web/modules/custom/figli_compta_ledger/css/home.css b/web/modules/custom/figli_compta_ledger/css/home.css index eed4eb8..e7fff26 100644 --- a/web/modules/custom/figli_compta_ledger/css/home.css +++ b/web/modules/custom/figli_compta_ledger/css/home.css @@ -457,6 +457,13 @@ html.gin--dark-mode #figli-home-app { #figli-home-app .amount.figli-ttc-col { 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 { width: 3.5%; } diff --git a/web/modules/custom/figli_compta_ledger/css/ledger-form.css b/web/modules/custom/figli_compta_ledger/css/ledger-form.css index 968a660..aa1aa78 100644 --- a/web/modules/custom/figli_compta_ledger/css/ledger-form.css +++ b/web/modules/custom/figli_compta_ledger/css/ledger-form.css @@ -25,7 +25,7 @@ --flform-danger: #b3261e; display: grid; - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); gap: 0.5rem 1rem; max-width: 44rem; margin: 0 auto; @@ -59,15 +59,18 @@ html.gin--dark-mode .figli-ledger-form { display: none; } -/* Row layout, fully explicit (not left to grid auto-placement) -- - there are now two separate 1-col-plus-2-col row groupings (Client + - N° Facture, then Montant HT + TVA + Montant TTC) and relying on DOM - order alone to keep them from bleeding into each other got fragile - once a 4th financial field (TVA) entered the picture. This must stay - in sync with the field weights set in - figli_compta_ledger_update_8004()/_figli_compta_ledger_create_node_type_ligne_comptable(). */ +/* Row layout, fully explicit (not left to grid auto-placement) -- with + Cotisation now in the mix there are three separate row groupings + (Client + N° Facture, the checkbox on its own full-width row, then + Montant HT + Cotisation + TVA + Montant TTC as a clean four-column + row) and relying on DOM order alone to keep them from bleeding into + each other got fragile once that many financial fields entered the + 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-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-notes, .figli-ledger-form > .field--name-field-flag { @@ -77,31 +80,32 @@ html.gin--dark-mode .figli-ledger-form { grid-column: 1 / 2; } .figli-ledger-form > .field--name-field-type-ligne { - grid-column: 2 / 4; + grid-column: 2 / 5; } .figli-ledger-form > .field--name-field-client { - grid-column: 1 / 3; + grid-column: 1 / 4; } .figli-ledger-form > .field--name-field-numero-facture { - grid-column: 3 / 4; + grid-column: 4 / 5; } .figli-ledger-form > .field--name-field-montant-ht { grid-column: 1 / 2; } -/* The field_tva_rate select (added in figli_compta_ledger_form_alter(), - 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 { +.figli-ledger-form > .field--name-field-cotisation-urssaf { grid-column: 2 / 3; } -.figli-ledger-form > .field--name-field-tva { - grid-column: 1 / -1; +/* The field_tva_rate select (added in figli_compta_ledger_form_alter(), + 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 { - grid-column: 3 / 4; + grid-column: 4 / 5; } /* Field basics */ diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install index 8a0e947..0ad9db4 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.install @@ -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 // unaffected either way. _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_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_client', ['type' => 'entity_reference_autocomplete', 'weight' => 2]) ->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_ttc', ['type' => 'number', 'weight' => 7]) - // field_tva sits *after* Montant TTC, not between HT and TTC -- - // figli_compta_ledger_form_alter() inserts a non-field - // "field_tva_rate" select at weight 6 (a select of the official + ->setComponent('field_cotisation_urssaf', ['type' => 'number', 'weight' => 6]) + ->setComponent('field_montant_ttc', ['type' => 'number', 'weight' => 8]) + // field_tva sits *after* Montant TTC, not between Cotisation and + // 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 // field only becomes visible (still at its own weight, on its own // full-width row) when "Autre" is picked there. See // css/ledger-form.css's grid-column rules for both. - ->setComponent('field_tva', ['type' => 'number', 'weight' => 8]) - ->setComponent('field_repartition', ['type' => 'paragraphs', 'weight' => 9, 'settings' => ['title' => 'Répartition', 'title_plural' => 'Répartitions', 'edit_mode' => 'open', 'add_mode' => 'button']]) - ->setComponent('field_notes', ['type' => 'string_textarea', 'weight' => 10]) - ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 11]) + ->setComponent('field_tva', ['type' => 'number', 'weight' => 9]) + ->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' => 11]) + ->setComponent('field_flag', ['type' => 'entity_reference_autocomplete_tags', 'weight' => 12]) ->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_client', ['type' => 'entity_reference_label', 'weight' => 2]) ->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_ttc', ['type' => 'number_decimal', 'weight' => 7]) - ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 8]) - ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 9]) - ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 10]) - ->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 11]) + ->setComponent('field_cotisation_urssaf', ['type' => 'number_decimal', 'weight' => 6]) + ->setComponent('field_montant_ttc', ['type' => 'number_decimal', 'weight' => 8]) + ->setComponent('field_tva', ['type' => 'number_decimal', 'weight' => 9]) + ->setComponent('field_repartition', ['type' => 'entity_reference_revisions_entity_view', 'weight' => 10]) + ->setComponent('field_notes', ['type' => 'basic_string', 'weight' => 11]) + ->setComponent('field_flag', ['type' => 'entity_reference_label', 'weight' => 12]) ->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)."; } + +/** + * 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."; +} diff --git a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module index b68707c..c7ca4e4 100644 --- a/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module +++ b/web/modules/custom/figli_compta_ledger/figli_compta_ledger.module @@ -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.'); } + // 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 // majority of new lines with one click, instead of associates having // 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 // mixed into it. // - // Weight 6: the exact slot Montant HT (5)/Montant TTC (7) leave open - // between them in figli_compta_ledger_update_8004() -- field_tva - // itself has been moved to weight 8 (its own full-width row) so - // that this select forms a clean HT/rate/TTC three-column row + // Weight 7: the slot between field_cotisation_urssaf (6) and Montant + // TTC (8) left open in figli_compta_ledger_update_8006() -- field_tva + // itself sits at weight 9 (its own full-width row) so that this + // select forms a clean HT/Cotisation/rate/TTC four-column row // whether or not "Autre" is currently revealing the real field_tva // input further down. See css/ledger-form.css's grid-column rules // 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'), '#options' => $taux_officiels + ['autre' => t('Autre (préciser)')], '#default_value' => $rate_default, - '#weight' => 6, + '#weight' => 7, ]; $tva_field['#title'] = t('Taux exact (%)'); // 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; $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 HT and TVA (see figli_compta_ledger_update_8004()). Only - // recompute it when HT or TVA actually changed, though, rather than on - // every save unconditionally: the TVA backfilled onto pre-migration - // lines (figli_compta_ledger_update_8005()) is a best-effort - // reconstruction and doesn't reproduce every historical Montant TTC to - // the exact centime, so blindly recomputing on an unrelated edit - // (fixing a typo in Libellé, say) would silently nudge an untouched - // historical value -- exactly what this module's "never correct - // historical data" rule exists to prevent. A genuinely new/changed - // HT or TVA has no such history to protect. + // Montant HT, TVA, and (entrée client only) the cotisation diffuseur + // URSSAF cascade (see figli_compta_ledger_update_8004()/_8006()). Only + // recompute it when HT, TVA, type, or the cotisation toggle actually + // changed, though, rather than on every save unconditionally: the TVA + // backfilled onto pre-migration lines (figli_compta_ledger_update_8005()/ + // _8007()) is a best-effort reconstruction and doesn't reproduce every + // historical Montant TTC to the exact centime, so blindly recomputing + // on an unrelated edit (fixing a typo in Libellé, say) would silently + // nudge an untouched historical value -- exactly what this module's + // "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')) { - $ht_or_tva_changed = $node->isNew(); - if (!$ht_or_tva_changed && isset($node->original)) { + $inputs_changed = $node->isNew(); + if (!$inputs_changed && isset($node->original)) { $original = $node->original; $orig_ht = $original->hasField('field_montant_ht') && !$original->get('field_montant_ht')->isEmpty() ? (float) $original->get('field_montant_ht')->value : NULL; $orig_tva = $original->hasField('field_tva') && !$original->get('field_tva')->isEmpty() ? (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) { - $node->set('field_montant_ttc', round($montant_ht * (1 + $tva / 100), 2)); + if ($inputs_changed) { + 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)); } } diff --git a/web/modules/custom/figli_compta_ledger/js/home.js b/web/modules/custom/figli_compta_ledger/js/home.js index 6084ec3..af0d143 100644 --- a/web/modules/custom/figli_compta_ledger/js/home.js +++ b/web/modules/custom/figli_compta_ledger/js/home.js @@ -369,6 +369,7 @@ facture: attrs.field_numero_facture || null, libelle: attrs.field_notes || attrs.title, 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, montant_ttc: attrs.field_montant_ttc !== null && attrs.field_montant_ttc !== undefined ? parseFloat(attrs.field_montant_ttc) : null, parCompte, diff --git a/web/modules/custom/figli_compta_ledger/js/ledger-form.js b/web/modules/custom/figli_compta_ledger/js/ledger-form.js index f6ac05c..cfc3d1b 100644 --- a/web/modules/custom/figli_compta_ledger/js/ledger-form.js +++ b/web/modules/custom/figli_compta_ledger/js/ledger-form.js @@ -27,6 +27,9 @@ var tva = form.querySelector('[name="field_tva[0][value]"]'); var rate = form.querySelector('[name="field_tva_rate"]'); 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) { return; } @@ -42,6 +45,13 @@ 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() { var htValue = parseFloat(ht.value); if (isNaN(htValue)) { @@ -51,7 +61,17 @@ if (isNaN(tvaValue)) { 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) @@ -60,17 +80,23 @@ // the centime (see figli_compta_ledger_update_8005()'s // docblock) -- showing a recomputed number the instant the // modal opens, before the associate has touched anything, would - // misrepresent what's actually saved. Only recompute once HT, - // TVA, or the rate select are actually edited, matching the - // same "only overwrite Montant TTC when HT/TVA actually - // changed" guard in figli_compta_ledger_node_presave(). Harmless - // no-op on a genuinely new/blank line either way (HT is empty, - // recompute() bails out above). + // misrepresent what's actually saved. Only recompute once an + // actual input happens, matching the same "only overwrite + // Montant TTC when an input actually changed" guard in + // figli_compta_ledger_node_presave(). Harmless no-op on a + // genuinely new/blank line either way (HT is empty, recompute() + // bails out above). ht.addEventListener('input', recompute); tva.addEventListener('input', recompute); if (rate) { rate.addEventListener('change', recompute); } + if (type) { + type.addEventListener('change', recompute); + } + if (cotisationActive) { + cotisationActive.addEventListener('change', recompute); + } }); } }; diff --git a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig index 8de72aa..50468bc 100644 --- a/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig +++ b/web/modules/custom/figli_compta_ledger/templates/figli-compta-home.html.twig @@ -129,6 +129,7 @@ Libellé / Détail Signalement HT + 1,1% TVA TTC {{ c }} @@ -137,13 +138,13 @@ - + Chargement des mois précédents… - + Chargement des mois suivants… @@ -261,6 +263,7 @@ chargement… {{ currentYearTotals ? formatEur(currentYearTotals.montant_ht) : '' }} + {{ currentYearTotals ? formatEur(currentYearTotals.montant_ttc) : '' }} {{ currentYearTotals && currentYearTotals.par_compte[c] !== undefined ? formatEur(currentYearTotals.par_compte[c]) : '' }}