← Files VeraARCHIVED FILE

modules/browser-automation/scripts/econs_processing.mjs

15.3 KB · Oct 3, 2026 · 06:30 UTC

↓ Download file

/**
 * Execute the bounded ECONS mapping/posting procedure from reviewed phase bindings.
 * Exact identity, checkbox state and arithmetic are mechanical contracts from
 * CR-42. The host model reviews account/tax meaning and supplies current approval.
 */
import { canonicalJson, executeCapability, sha256Text } from './capability_runtime.mjs';

const PHASES = ['select', 'map', 'journal', 'post', 'verify', 'exit'];
const text = (value) => typeof value === 'string' && value.trim().length > 0;
class EconsProcessingError extends Error {}
function need(value, code) { if (!value) throw new EconsProcessingError(code); }
const actions = (phase) => phase.milestones.flatMap((milestone) => milestone.actions);

export function validateEconsProcessingProfile(profile) {
  need(profile?.schema_version === 'econs-processing-profile/v1' && text(profile.complete_status) && text(profile.non_posted_view) &&
    Object.keys(profile.phases ?? {}).length === PHASES.length && PHASES.every((name) => profile.phases[name]), 'invalid_processing_profile');
  let origins;
  for (const name of PHASES) {
    const phase = profile.phases[name];
    need(['discovered', 'validated_local'].includes(phase.status), 'processing_requires_reviewed_discovery');
    const current = canonicalJson([...phase.site.allowed_origins].sort());
    origins ??= current;
    need(current === origins, 'processing_origins_must_match');
    const requiredInputs = ['company-code', 'invoice-id', ...(name === 'select' ? ['line-id', 'checked'] : name === 'map' ? ['anchor-line-id', 'checked'] : [])];
    need(requiredInputs.every((key) => phase.inputs.some((input) => input.name === key && input.required && input.type === (key === 'checked' ? 'boolean' : 'text'))), 'missing_processing_context_inputs');
    need(phase.outputs.every((output) => output.delivery === 'model_and_artifact'), 'processing_outputs_must_be_reviewable');
    const requiredOutputs = name === 'journal' ? ['journal'] : name === 'post' ? ['posting'] : name === 'verify' ? ['company', 'invoices', 'invoice-count'] : [];
    need(requiredOutputs.every((key) => phase.outputs.some((output) => output.name === key) && phase.completion.required_outputs.includes(key)), 'missing_processing_verification_outputs');
    for (const action of actions(phase)) {
      need(['read_only', 'reversible', 'consequential'].includes(action.effect), 'unknown_action_effect');
      need(['click', 'wait_for', 'extract', 'set_checked'].includes(action.operation), 'unsupported_processing_operation');
      if (name === 'post') {
        need(action.operation !== 'set_checked', 'post_must_not_change_mapping');
      } else {
        need(action.effect !== 'consequential' && action.confirmation === 'none', 'consequential_action_outside_post');
        need(['select', 'map'].includes(name) || action.operation !== 'set_checked', 'unexpected_checkbox_write');
      }
    }
  }
  const selection = actions(profile.phases.select).filter((action) => action.operation === 'set_checked');
  need(selection.length === 1 && selection[0].input_ref === 'checked', 'selection_requires_set_checked');
  const mapping = actions(profile.phases.map);
  const checkbox = mapping.findIndex((action) => action.id === 'associate-all');
  const confirmation = mapping.findIndex((action) => action.id === 'confirm-mapping');
  need(checkbox >= 0 && mapping[checkbox].operation === 'set_checked' && mapping[checkbox].input_ref === 'checked' &&
    confirmation > checkbox && mapping[confirmation].operation === 'click', 'mapping_requires_checkbox_then_confirmation');
  const posting = actions(profile.phases.post).filter((action) => action.effect === 'consequential');
  need(posting.length === 1 && posting[0].id === 'confirm-registration' && posting[0].operation === 'click' &&
    posting[0].confirmation === 'action_time', 'posting_requires_exact_confirmation');
}

/** Strict Italian money parsing avoids guessing whether a dot is a decimal. */
export function italianCents(value) {
  need(typeof value === 'string' && /^-?(?:\d+|\d{1,3}(?:\.\d{3})+)(?:,\d{1,2})?$/.test(value), 'invalid_italian_amount');
  const negative = value.startsWith('-');
  const [whole, fraction = ''] = value.replace('-', '').replaceAll('.', '').split(',');
  const cents = BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0'));
  return negative ? -cents : cents;
}

/** Require the complete independent population before planning any line writes. */
export function planEconsMapping(detail) {
  const lines = detail.lines;
  need(Array.isArray(lines) && lines.length > 0 && typeof detail['line-count'] === 'string' &&
    /^\d+$/.test(detail['line-count']) && Number(detail['line-count']) === lines.length, 'incomplete_line_population');
  need(new Set(lines.map((line) => line['line-id'])).size === lines.length &&
    lines.every((line) => text(line['line-id']) && text(line.description) && text(line['vat-code']) &&
      (typeof line.account === 'string' || line.account === null)), 'incomplete_line_fields');
  lines.forEach((line) => italianCents(line.amount));
  const assigned = lines.filter((line) => text(line.account));
  need(assigned.length > 0 && new Set(assigned.map((line) => line.account)).size === 1, 'discordant_or_missing_accounts');
  need(new Set(lines.map((line) => line['vat-code'])).size === 1, 'different_vat_rates');
  const missing = lines.filter((line) => !text(line.account));
  need(!missing.length || assigned.length >= 2, 'two_concordant_lines_required');
  return { account: assigned[0].account, vat: assigned[0]['vat-code'], anchor: assigned[0]['line-id'],
    lineIds: lines.map((line) => line['line-id']), needsMapping: missing.length > 0 };
}

/** Check the taught two-anchor exception without classifying invoice meaning. */
export function hasEconsMappingException(detail) {
  try { return planEconsMapping(detail).needsMapping; }
  catch (error) {
    if (error instanceof EconsProcessingError) return false;
    throw error;
  }
}

function identity(record, expected) {
  need(record && ['company-code', 'invoice-id', 'invoice-number', 'supplier'].every((key) => record[key] === expected[key]), 'wrong_company_or_invoice');
}

/** Validate the displayed journal, never compute or apply tax treatment. */
export function verifyEconsJournal(journal, invoice, detail, plan, review) {
  identity(journal, invoice);
  need(review?.approved === true && text(review.reason) && review.company_code === invoice['company-code'] &&
    text(review.treatment_source) && typeof review.vat_nondeductible_percent === 'number' &&
    review.vat_nondeductible_percent >= 0 && review.vat_nondeductible_percent <= 100, 'professional_treatment_review_required');
  need(journal.account === plan.account, 'journal_account_changed');
  const debit = italianCents(journal.debit), credit = italianCents(journal.credit), total = italianCents(journal.total);
  need(debit === credit && debit === total && total > 0n, 'journal_does_not_balance');
  const cost = italianCents(journal.cost), vat = italianCents(journal.vat);
  need(cost + vat === total, 'journal_components_do_not_match');
  if (review.vat_nondeductible_percent === 100) need(cost === total && vat === 0n, 'non_deductible_vat_not_in_cost');
  const sum = detail.lines.reduce((amount, line) => amount + italianCents(line.amount), 0n);
  if (sum !== italianCents(journal.net)) need(text(review.rounding_explanation), 'unexplained_rounding_difference');
}

/**
 * Caller supplies already validated phase files, private storage and a durable
 * save callback. No external action precedes saving an ambiguous-attempt state.
 * An exception after posting is unverified and is never automatically retried.
 */
export async function processEconsInvoice({ tab, profile, invoice, detail, entry, readDetail,
  phaseDirectory, save, reviewInvoice, reviewJournal, approvePosting, environment = {} }) {
  validateEconsProcessingProfile(profile);
  let dispatched = false;
  let phaseIndex = 0;
  async function phase(name, values) {
    const capability = profile.phases[name];
    const inputs = Object.fromEntries(capability.inputs.map((input) => [input.name, values[input.name]]));
    const result = await executeCapability({ tab, capability, inputs,
      runDirectory: await phaseDirectory(`${name}-${++phaseIndex}`), runId: `econs-${name}-${phaseIndex}`,
      approvedConsequentialActions: name === 'post' ? ['confirm-registration'] : [], environment });
    need(result.result === 'passed', `processing_${name}_failed`);
    entry.evidence.push({ label: `Verifica ${name}`, value: result.receipt_path, source: 'Ricevuta locale del runtime browser' });
    return result.delivered_outputs;
  }
  const values = { ...invoice, checked: true };
  try {
    // Re-read the current invoice: a saved collection is not authority to write.
    const current = await readDetail();
    identity(current.invoice, invoice);
    need(canonicalJson(current) === canonicalJson(detail), 'invoice_changed_since_acquisition');
    const plan = planEconsMapping(current);
    // Non-empty text cannot establish that a description is complete. The host
    // model reviews the observed full values; code binds that review to this
    // exact invoice and persists it before mapping or opening the journal.
    const detailHash = sha256Text(canonicalJson(current));
    const invoiceReview = await reviewInvoice(structuredClone({ invoice, detail: current, detail_sha256: detailHash }));
    if (text(invoiceReview?.reason)) entry.evidence.push({ label: 'Revisione descrizioni complete', value: invoiceReview.reason, source: `Revisione del modello sulla fattura · ${detailHash}` });
    need(invoiceReview?.approved === true && invoiceReview.descriptions_complete === true &&
      invoiceReview.company_code === invoice['company-code'] && invoiceReview.invoice_id === invoice['invoice-id'] &&
      invoiceReview.detail_sha256 === detailHash && text(invoiceReview.reason), 'complete_invoice_review_required');
    entry.proposed = [{ label: 'Conto e IVA', value: `${plan.account} · ${plan.vat}`, source: 'Righe concordanti della fattura completa' }];
    await save();
    if (plan.needsMapping) {
      entry.status = 'unverified'; entry.question = 'Verificare il risultato della mappatura prima di ripeterla.';
      entry.outcome = 'Associazione preparata; invio ed esito non ancora confermati.';
      await save();
      for (const id of plan.lineIds) await phase('select', { ...values, 'line-id': id });
      await phase('map', { ...values, 'anchor-line-id': plan.anchor });
      detail = await readDetail();
      identity(detail.invoice, invoice);
      const mapped = planEconsMapping(detail);
      need(detail.invoice.status === profile.complete_status && !mapped.needsMapping && mapped.account === plan.account && mapped.vat === plan.vat &&
        canonicalJson(detail.lines.map((line) => ({ ...line, account: '' }))) === canonicalJson(current.lines.map((line) => ({ ...line, account: '' }))), 'mapping_not_verified');
      entry.actual.push({ label: 'Associazione', value: `${plan.account} · ${plan.vat} · tutte le righe verificate`, source: 'Rilettura completa ECONS dopo Conferma del popup' });
      entry.status = 'pending'; entry.question = ''; entry.outcome = 'Mappatura verificata; registrazione ancora da eseguire.';
      await save();
    }
    const output = await phase('journal', values);
    const journal = output.journal;
    identity(journal, invoice);
    entry.proposed.push(...['account', 'net', 'cost', 'vat', 'total', 'debit', 'credit'].map((key) => ({ label: key, value: journal[key], source: 'Prima nota ECONS prima della registrazione' })));
    entry.evidence.push({ label: 'Contabilizza', value: 'Prima nota aperta; Conferma reg. non ancora eseguita.', source: 'Fase journal completata; nessun protocollo di registrazione' });
    entry.outcome = 'Prima nota acquisita e salvata; revisione e Conferma reg. ancora da eseguire.';
    await save();
    const review = await reviewJournal(structuredClone({ invoice, detail, journal }));
    verifyEconsJournal(journal, invoice, detail, plan, review);
    entry.reason = review.reason;
    entry.evidence.push({ label: 'Trattamento IVA della ditta', value: `${review.vat_nondeductible_percent}% indetraibile · ${review.reason}`, source: review.treatment_source });
    if (review.rounding_explanation) entry.evidence.push({ label: 'Arrotondamento', value: review.rounding_explanation, source: 'Revisione professionale della differenza righe / imponibile' });
    await save();
    // Bind host approval to the exact current journal; callbacks cannot mutate it.
    need(await approvePosting(structuredClone({ invoice, journal, journal_sha256: sha256Text(canonicalJson(journal)), action: 'confirm-registration' })) === true, 'posting_not_approved');
    const currentJournal = (await phase('journal', values)).journal;
    need(canonicalJson(currentJournal) === canonicalJson(journal), 'journal_changed_after_approval');
    entry.status = 'unverified'; entry.question = 'Controllare protocollo e lista Non contab. della stessa ditta prima di qualsiasi nuovo tentativo.';
    entry.outcome = 'Conferma reg. preparata; invio ed esito non ancora confermati.';
    await save();
    dispatched = true;
    const posted = (await phase('post', values)).posting;
    need(posted?.['company-code'] === invoice['company-code'] && posted?.['invoice-id'] === invoice['invoice-id'] && text(posted.protocol), 'missing_posting_protocol');
    entry.posting_reference = posted.protocol;
    await save();
    const verified = await phase('verify', values);
    need(verified.company?.['company-code'] === invoice['company-code'] && verified.company?.view === profile.non_posted_view, 'wrong_posting_verification_context');
    need(Array.isArray(verified.invoices) && typeof verified['invoice-count'] === 'string' && /^\d+$/.test(verified['invoice-count']) &&
      Number(verified['invoice-count']) === verified.invoices.length &&
      verified.invoices.every((item) => text(item['invoice-id'])) &&
      new Set(verified.invoices.map((item) => item['invoice-id'])).size === verified.invoices.length, 'incomplete_posting_verification_population');
    need(!verified.invoices.some((item) => item['invoice-id'] === invoice['invoice-id']), 'invoice_still_non_posted');
    entry.actual.push(...entry.proposed.slice(1), { label: 'Protocollo', value: posted.protocol, source: 'Conferma reg. ECONS e assenza verificata in Non contab. della stessa ditta' });
    entry.status = 'completed'; entry.question = ''; entry.outcome = 'Registrazione verificata; protocollo conservato e documento assente da Non contab. della stessa ditta.';
    await save();
  } catch (error) {
    entry.status = dispatched ? 'unverified' : 'set_aside';
    entry.outcome = dispatched ? 'Registrazione tentata; risultato da riconciliare prima di riprovare.' : 'Documento sospeso; nessuna registrazione eseguita.';
    entry.question = dispatched ? 'Verificare protocollo e stato esterno prima di ripetere Conferma reg.' : 'Verificare mappatura, popolazione completa e trattamento della ditta prima di riprendere.';
    if (error instanceof EconsProcessingError) entry.evidence.push({ label: 'Controllo non superato', value: error.message, source: 'Verifica meccanica ECONS; leggere il dato e la ricevuta indicati' });
    entry.evidence.push({ label: 'Interruzione', value: sha256Text(String(error)), source: 'Impronta errore; nessun contenuto browser grezzo' });
    await save();
    // Leave the failed document through its reviewed reversible return phase.
    // If returning fails, stop the batch instead of acting in an unknown context.
    await phase('exit', values);
  }
  return entry.status;
}

SHA-256: 9115b543d05a83757d6efb54eaecf6282774cefe678db7b48916e7b70875ccc5