Move cash payment compliance to SQLite accounting mirror

This commit is contained in:
A R R R Associates
2026-09-06 19:48:24 +05:30
parent 1fd15d652b
commit 6c2a2081ed
5 changed files with 422 additions and 520 deletions
@@ -3,15 +3,15 @@
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · Tally</p>
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Tools · Accounting · SQLite Mirror</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">{{ 'Cash Payment Entry Allocation' if page_mode == 'allocation' else 'Cash Payment Compliance Review' }}</h1>
{% if page_mode == 'allocation' %}
<p class="mt-1 max-w-4xl text-sm text-slate-600">Prepare and validate cash payment entry allocation using only actual source payment dates. The tool checks totals, the configured daily limit and the permitted source-date window; it never invents or changes dates.</p>
{% else %}
<p class="mt-1 max-w-4xl text-sm text-slate-600">Read-only Tally review for cash payments above the configured limit, same-day party aggregation and possible split-payment patterns.</p>
<p class="mt-1 max-w-4xl text-sm text-slate-600">Analyse cash-payment compliance directly from the client's local SQLite Accounting Mirror. TallyPrime is not contacted during this review.</p>
{% endif %}
</div>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally</a>
<a href="/tools/tally{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Back to Tally Mirror</a>
</div>
{% if saved %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Cash payment rule settings saved.</div>{% endif %}
@@ -20,9 +20,20 @@
{% if page_mode != 'allocation' %}
<section class="grid gap-4 lg:grid-cols-3">
<div class="rounded-2xl bg-white p-5 shadow-soft lg:col-span-2">
<h2 class="text-lg font-semibold text-slate-900">Cash Payment Compliance Review</h2>
<form id="cash-review-form" method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<input type="hidden" id="cash-csrf-token" value="{{ csrf_token }}">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-lg font-semibold text-slate-900">Review from Accounting Mirror</h2>
<p class="mt-1 text-sm text-slate-500">No Tally connection, company selection, extraction queue or day-by-day cache is used here.</p>
</div>
{% set mirror = (mirror_status.mirror if mirror_status and mirror_status.mirror else {}) %}
{% if mirror.ready %}
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700">SQLite Mirror Ready</span>
{% else %}
<span class="rounded-full bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-800">Mirror Required</span>
{% endif %}
</div>
<form method="get" action="/tools/accounting/cash-payments" class="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Client</label>
<select name="client_id" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required onchange="this.form.submit()">
@@ -30,66 +41,38 @@
{% for client in clients %}<option value="{{ client.id }}" {% if selected_client and selected_client.id == client.id %}selected{% endif %}>{{ client.client_name }}</option>{% endfor %}
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Tally Company</label>
<select id="cash-tally-company" name="tally_guid" class="mt-1 w-full rounded-xl border-slate-300 text-sm" {% if selected_client %}required{% endif %}>
<option value="">Select open Tally company</option>
{% set mapped_guids = [] %}
{% if live_result and live_result.accounting %}
{% for mapping in live_result.accounting.mappings or [] %}
{% if mapping.tally_guid %}{% set _ = mapped_guids.append(mapping.tally_guid) %}{% endif %}
{% endfor %}
{% endif %}
{% if live_result and live_result.tally %}
{% for company in live_result.tally.companies or [] %}
{% set is_mapped = company.guid in mapped_guids %}
<option value="{{ company.guid }}" data-company-name="{{ company.name or '' }}" data-open-company="1" {% if selected_tally_guid == company.guid or (not selected_tally_guid and is_mapped) %}selected{% endif %}>{{ company.name or company.guid }}{% if is_mapped %} • Mapped{% endif %}</option>
{% endfor %}
{% endif %}
{% if live_result and live_result.accounting %}
{% for mapping in live_result.accounting.mappings or [] %}
{% if mapping.tally_guid and mapping.tally_guid not in ((live_result.tally.companies or [])|map(attribute='guid')|list if live_result and live_result.tally else []) %}
<option value="{{ mapping.tally_guid }}" data-company-name="{{ mapping.company_name or mapping.tally_company_name or '' }}" data-open-company="0" {% if selected_tally_guid == mapping.tally_guid %}selected{% endif %}>{{ mapping.company_name or mapping.tally_company_name or mapping.tally_guid }} • Mapped (not open)</option>
{% endif %}
{% endfor %}
{% endif %}
</select>
</div>
<div>
<label class="text-xs font-semibold uppercase text-slate-500">Ledger Scope</label>
<select id="cash-ledger-scope" name="ledger_scope" class="mt-1 w-full rounded-xl border-slate-300 text-xs">
<option value="cash" selected>Ledgers under Cash-in-Hand</option>
<option value="all">All Tally ledgers — manual selection</option>
</select>
<label class="mt-2 block text-xs font-semibold uppercase text-slate-500">Cash Ledger Selection</label>
<select id="cash-ledger-select" name="cash_ledger_name" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required>
<option value="">Select Cash ledger</option>
{% if selected_cash_ledger_name %}<option value="{{ selected_cash_ledger_name }}" selected>{{ selected_cash_ledger_name }}</option>{% endif %}
</select>
<div class="mt-2 flex flex-wrap gap-2">
<button type="button" id="cash-load-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Load Cash Ledgers</button>
<button type="button" id="cash-refresh-ledgers" class="rounded-xl border border-slate-300 bg-white px-3 py-2 text-xs font-semibold text-slate-700">Refresh Master Data</button>
</div>
<p id="cash-ledger-help" class="mt-1 text-xs text-slate-500">Load Cash Ledgers reads the common master from the client .act SQLite database only. Refresh Master Data explicitly refreshes that common master from TallyPrime.</p>
<div id="cash-ledger-progress" class="mt-2 hidden rounded-xl border border-sky-200 bg-sky-50 p-3">
<div class="flex items-center justify-between text-xs font-semibold text-sky-900"><span id="cash-ledger-progress-stage">Preparing Cash ledger list…</span><span id="cash-ledger-progress-pct">0%</span></div>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-sky-100"><div id="cash-ledger-progress-bar" class="h-full rounded-full bg-sky-600 transition-all duration-500" style="width:0%"></div></div>
</div>
<div class="mt-3 grid grid-cols-2 gap-2 text-[11px] sm:grid-cols-4">
<div id="cash-master-step-1" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">1. Local Master</div><div class="mt-1 text-slate-500">.act SQLite</div></div>
<div id="cash-master-step-2" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">2. Cash Filter</div><div class="mt-1 text-slate-500">Cash-in-Hand</div></div>
<div id="cash-master-step-3" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">3. Transfer to VPS</div><div class="mt-1 text-slate-500">Tunnel result</div></div>
<div id="cash-master-step-4" class="rounded-lg border border-slate-200 bg-slate-50 px-2 py-2 text-center"><div class="font-semibold">4. Ledger Ready</div><div class="mt-1 text-slate-500">Dropdown</div></div>
</div>
<div id="cash-master-meta" class="mt-2 text-[11px] text-slate-500">Master source: local .act SQLite. Normal loading never queries TallyPrime.</div>
</div>
<div><label class="text-xs font-semibold uppercase text-slate-500">From</label><input type="date" name="date_from" value="{{ date_from }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div><label class="text-xs font-semibold uppercase text-slate-500">To</label><input type="date" name="date_to" value="{{ date_to }}" class="mt-1 w-full rounded-xl border-slate-300 text-sm" required></div>
<div class="md:col-span-2 xl:col-span-5"><button type="button" id="cash-review-button" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white" {% if not selected_client %}disabled{% endif %}>Review Cash Transactions</button><p class="mt-2 text-xs text-slate-500">Select the Tally company and Cash ledger from the local .act master snapshot. Only the selected ledger’s relevant transactions are then extracted from Tally into SQLite; compliance analysis runs from SQLite. Existing threshold rules, allocation workflow and dual VPS/Local Agent progress are preserved.</p></div>
<div class="flex items-end">
<button type="submit" name="analyze" value="1" class="w-full rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50" {% if not selected_client or not mirror.ready %}disabled{% endif %}>Review Cash Payments</button>
</div>
</form>
{% if selected_client %}
<div class="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Company</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.company.company_name if mirror.ready and mirror.company else 'Not mirrored' }}</div>
</div>
<div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Mirror Period</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}</div>
</div>
<div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Vouchers Available</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher if mirror.ready and mirror.counts else 0 }}</div>
</div>
<div class="rounded-xl bg-slate-50 p-3">
<div class="text-xs font-semibold uppercase text-slate-500">Voucher Lines</div>
<div class="mt-1 text-sm font-semibold text-slate-900">{{ mirror.counts.voucher_ledger_entry if mirror.ready and mirror.counts else 0 }}</div>
</div>
</div>
{% if mirror.ready %}
<p class="mt-3 break-all text-xs text-slate-500">Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}</p>
{% else %}
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">Mirror this client first from <strong>Tally Accounting → Mirror Tally to SQLite</strong>. After that, Cash Payment Review works even if TallyPrime is closed.</div>
{% endif %}
{% endif %}
</div>
<div class="rounded-2xl bg-white p-5 shadow-soft">
@@ -106,25 +89,13 @@
</div>
</section>
{% if cache_job %}
<section class="rounded-2xl border border-sky-200 bg-sky-50 p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="font-semibold text-sky-950">Local Tally SQLite Cache</h3>
<p class="mt-1 text-sm text-sky-800">Status: <strong>{{ cache_job.status }}</strong>{% if cache_job.stage %} · {{ cache_job.stage }}{% endif %}</p>
</div>
<div class="text-right text-sm text-sky-900"><div>{{ cache_job.completed_days }}/{{ cache_job.total_days }} day(s)</div><div>{{ cache_job.vouchers_cached }} Cash-ledger voucher(s)</div>{% if cache_job.cash_ledgers %}<div>Ledger: {{ cache_job.cash_ledgers|join(', ') }}</div>{% endif %}</div>
</div>
<div class="mt-3 h-2 overflow-hidden rounded-full bg-sky-100"><div class="h-full bg-sky-600" style="width:{{ cache_job.percent or 0 }}%"></div></div>
{% if cache_job.error %}<p class="mt-3 text-sm font-medium text-amber-900">{{ cache_job.error }}</p>{% endif %}
</section>
{% endif %}
{% if review %}
<section class="space-y-4">
{% if review.sqlite_cache %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">Analysis completed from the Local Agent SQLite cache. TallyPrime was used only for the slow background extraction and was released before this analysis ran. {{ review.tally_request_count or 0 }} Tally request(s), {{ review.tally_retry_count or 0 }} retry/retries, and approximately {{ review.tally_pause_ms or 0 }} ms cooling between successful day reads.</div>
{% endif %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted.
{% if review.cash_ledgers %} Cash ledger(s): <strong>{{ review.cash_ledgers|join(', ') }}</strong>.{% endif %}
{% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
</div>
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %}
<div class="rounded-2xl bg-white p-4 shadow-soft"><div class="text-xs font-semibold uppercase text-slate-500">{{ label }}</div><div class="mt-2 text-2xl font-semibold text-slate-900">{{ value }}</div></div>
@@ -147,8 +118,6 @@
</div>
</section>
{% endif %}
{% endif %}
{% if page_mode == 'allocation' %}
@@ -177,394 +146,5 @@
{% endif %}
</section>
{% endif %}
<div id="cash-review-progress" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-950/40 p-4 backdrop-blur-sm">
<div class="w-full max-w-2xl rounded-2xl bg-white p-6 shadow-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-semibold text-slate-900">Cash Ledger Extraction & Compliance Progress</h3>
<p id="cash-progress-text" class="mt-1 text-sm text-slate-600">Submitting a background extraction job to the ERP Local Agent…</p>
</div>
<div id="cash-progress-percent" class="text-lg font-semibold text-brand-700">0%</div>
</div>
<div class="mt-5 h-3 overflow-hidden rounded-full bg-slate-200">
<div id="cash-progress-bar" class="h-full rounded-full bg-brand-600 transition-all duration-500" style="width:0%"></div>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Days</div><div id="cash-progress-days" class="mt-1 font-semibold">0 / 0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Cash vouchers</div><div id="cash-progress-vouchers" class="mt-1 font-semibold">0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Tally requests</div><div id="cash-progress-requests" class="mt-1 font-semibold">0</div></div>
<div class="rounded-xl bg-slate-50 p-3"><div class="text-xs uppercase text-slate-500">Current date</div><div id="cash-progress-date" class="mt-1 font-semibold">—</div></div>
</div>
<div class="mt-4 grid gap-2 text-xs md:grid-cols-5">
<div id="erp-cash-step-1" class="rounded-lg bg-slate-100 px-3 py-2">1. Connect</div>
<div id="erp-cash-step-2" class="rounded-lg bg-slate-100 px-3 py-2">2. Confirm ledger</div>
<div id="erp-cash-step-3" class="rounded-lg bg-slate-100 px-3 py-2">3. Extract</div>
<div id="erp-cash-step-4" class="rounded-lg bg-slate-100 px-3 py-2">4. Analyse</div>
<div id="erp-cash-step-5" class="rounded-lg bg-slate-100 px-3 py-2">5. Complete</div>
</div>
<p class="mt-4 text-xs text-slate-500">The confirmed Cash ledger comes from the common local .act master snapshot. The Local Agent then reads Tally in safe one-day batches and stores only vouchers containing that ledger in SQLite. Analysis runs from SQLite. You can close this ERP page; the background job continues and remains visible on the Local Agent dashboard.</p>
<div id="cash-progress-error" class="mt-4 hidden rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900"></div>
<div class="mt-4 flex justify-end gap-2">
<button type="button" id="cash-progress-cancel" class="hidden rounded-xl border border-amber-300 bg-amber-50 px-4 py-2 text-sm font-semibold text-amber-900">Cancel Extraction</button>
<button type="button" id="cash-progress-close" class="hidden rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700">Close</button>
</div>
</div>
</div>
<script>
(function () {
const form = document.getElementById('cash-review-form');
const button = document.getElementById('cash-review-button');
if (!form || !button) return;
const overlay = document.getElementById('cash-review-progress');
const bar = document.getElementById('cash-progress-bar');
const pct = document.getElementById('cash-progress-percent');
const text = document.getElementById('cash-progress-text');
const days = document.getElementById('cash-progress-days');
const vouchers = document.getElementById('cash-progress-vouchers');
const requests = document.getElementById('cash-progress-requests');
const currentDate = document.getElementById('cash-progress-date');
const errorBox = document.getElementById('cash-progress-error');
const closeButton = document.getElementById('cash-progress-close');
const cancelButton = document.getElementById('cash-progress-cancel');
let pollTimer = null;
let activeJobId = '';
let activeClientId = '';
let activeDateFrom = '';
const ledgerButton = document.getElementById('cash-load-ledgers');
const ledgerSelect = document.getElementById('cash-ledger-select');
const ledgerScope = document.getElementById('cash-ledger-scope');
const ledgerHelp = document.getElementById('cash-ledger-help');
let allLedgerRows = [];
let cashLedgerRows = [];
let lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
const ledgerRefreshButton = document.getElementById('cash-refresh-ledgers');
const ledgerProgress = document.getElementById('cash-ledger-progress');
const ledgerProgressBar = document.getElementById('cash-ledger-progress-bar');
const ledgerProgressPct = document.getElementById('cash-ledger-progress-pct');
const ledgerProgressStage = document.getElementById('cash-ledger-progress-stage');
const masterStep1 = document.getElementById('cash-master-step-1');
const masterStep2 = document.getElementById('cash-master-step-2');
const masterStep3 = document.getElementById('cash-master-step-3');
const masterStep4 = document.getElementById('cash-master-step-4');
const masterMeta = document.getElementById('cash-master-meta');
let ledgerPollTimer = null;
function paintMasterSteps(step) {
[masterStep1, masterStep2, masterStep3, masterStep4].forEach(function(el, idx) {
if (!el) return;
el.className = 'rounded-lg border px-2 py-2 text-center ' + (idx < step
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
: 'border-slate-200 bg-slate-50 text-slate-700');
});
}
function renderLedgerOptions() {
const previous = ledgerSelect.value;
const rows = (ledgerScope && ledgerScope.value === 'all') ? allLedgerRows : cashLedgerRows;
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
rows.forEach(function(row){
const opt=document.createElement('option');
opt.value=row.name;
opt.textContent=row.name + (row.parent ? ' — '+row.parent : '') + (row.is_cash_candidate ? ' • Cash-in-Hand' : '');
if (previous && previous===row.name) opt.selected=true;
ledgerSelect.appendChild(opt);
});
if (previous && !rows.some(row => row.name === previous)) ledgerSelect.value='';
const company = lastLedgerMeta.company_name || 'Tally';
if (ledgerScope && ledgerScope.value === 'all') {
ledgerHelp.textContent = String(rows.length)+' of '+String(lastLedgerMeta.ledger_count || allLedgerRows.length)+' ledger(s) available from '+company+'. Select the required ledger manually.';
} else if (rows.length) {
ledgerHelp.textContent = String(rows.length)+' Cash-in-Hand ledger(s) found in '+company+'. Select the ledger whose transactions should be reviewed.';
} else if (allLedgerRows.length) {
ledgerHelp.textContent = 'No Cash-in-Hand ledger was identified from Tally grouping. Choose “All Tally ledgers — manual selection” to select the required ledger.';
} else {
ledgerHelp.textContent = 'No ledgers are available yet for the selected Tally company.';
}
}
function populateLedgers(data) {
allLedgerRows = Array.isArray(data.ledgers) ? data.ledgers.slice() : [];
cashLedgerRows = Array.isArray(data.cash_candidates) ? data.cash_candidates.slice() : allLedgerRows.filter(row => row.is_cash_candidate);
lastLedgerMeta = {
ledger_count: Number(data.ledger_count || allLedgerRows.length || 0),
company_name: String(data.company_name || ''),
from_cache: Boolean(data.from_cache)
};
renderLedgerOptions();
paintMasterSteps(4);
if (masterMeta) {
const synced = data.master_synced_at_utc ? new Date(data.master_synced_at_utc).toLocaleString() : 'time not available';
const source = data.master_refreshed ? 'TallyPrime → local .act SQLite (refreshed now)' : 'local .act SQLite';
masterMeta.textContent = 'Master source: ' + source + ' · Last master sync: ' + synced + ' · ' + String(data.total_ledgers_examined || data.ledger_count || 0) + ' ledger(s) in common snapshot.';
}
}
function updateLedgerProgress(job) {
const p=Math.max(0,Math.min(100,Number(job.percent||0)));
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width=p+'%';
ledgerProgressPct.textContent=Math.round(p)+'%';
ledgerProgressStage.textContent=job.stage||job.status||'Working…';
if (p >= 100) paintMasterSteps(4);
else if (p >= 90) paintMasterSteps(3);
else if (p >= 60) paintMasterSteps(2);
else if (p >= 15) paintMasterSteps(1);
}
async function pollLedgers(jobId, clientId, dateFrom, tallyGuid, companyName, selectedScope, forceRefresh) {
try {
const url=new URL('/tools/accounting/cash-payments/ledgers/progress',window.location.origin);
url.searchParams.set('client_id',clientId);
url.searchParams.set('job_id',jobId);
url.searchParams.set('date_from',dateFrom);
url.searchParams.set('tally_guid',tallyGuid || '');
url.searchParams.set('company_name',companyName || '');
url.searchParams.set('ledger_scope',selectedScope || 'cash');
url.searchParams.set('force_refresh',forceRefresh ? '1' : '0');
const response=await fetch(url.toString(),{headers:{'Accept':'application/json'},cache:'no-store'});
const data=await response.json();
if(!response.ok||!data.ok) throw new Error(data.error||'Could not read Cash ledger transfer progress.');
const job=data.job||{};
updateLedgerProgress(job);
if(data.transport_pending){
ledgerHelp.textContent='Local Agent work is queued/running. Waiting for the tunnel to deliver the ledger result to the VPS…';
}
if(job.status==='completed' && Array.isArray(data.ledgers) && data.ledgers.length >= 0){
populateLedgers(data);
ledgerButton.disabled=false;
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='100%';
ledgerProgressPct.textContent='100%';
ledgerProgressStage.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) transmitted to VPS';
paintMasterSteps(4);
ledgerHelp.textContent=String(data.cash_ledger_count || 0)+' Cash-in-Hand ledger(s) received by VPS and ready for selection.';
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1800);
return;
}
if(job.status==='failed') throw new Error(job.error||'Cash ledger preparation failed.');
ledgerPollTimer=window.setTimeout(
()=>pollLedgers(jobId,clientId,dateFrom,tallyGuid,companyName,selectedScope,forceRefresh),
1500
);
} catch(err) {
ledgerHelp.textContent=err&&err.message?err.message:String(err);
ledgerButton.disabled=false;
if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgressStage.textContent=ledgerHelp.textContent;
}
}
async function loadCashLedgers(forceRefresh) {
const fd = new FormData(form);
if (!fd.get('client_id') || !fd.get('tally_guid')) { ledgerHelp.textContent = 'Select a client and Tally company first.'; return; }
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
const companySelect = form.querySelector('select[name="tally_guid"]');
const companyOption = companySelect && companySelect.options[companySelect.selectedIndex];
const companyName = companyOption ? String(companyOption.dataset.companyName || companyOption.textContent || '').trim() : '';
if (companyName) fd.set('company_name', companyName);
const selectedScope = ledgerScope ? String(ledgerScope.value || 'cash') : 'cash';
fd.set('ledger_scope', selectedScope);
if(forceRefresh) fd.set('force_refresh','1');
ledgerButton.disabled = true; if(ledgerRefreshButton) ledgerRefreshButton.disabled=true;
paintMasterSteps(0);
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='15%'; ledgerProgressPct.textContent='15%';
if (forceRefresh) {
ledgerProgressStage.textContent='Refreshing common accounting masters from TallyPrime…';
ledgerHelp.textContent='Refreshing the shared .act master snapshot once, then filtering the Cash-in-Hand hierarchy locally.';
if (masterMeta) masterMeta.textContent='Master source: refreshing TallyPrime → local .act SQLite.';
} else {
ledgerProgressStage.textContent='Reading common ledger master from local SQLite…';
ledgerHelp.textContent = selectedScope === 'all'
? 'Reading all ledgers from the common local .act master snapshot…'
: 'Reading local .act masters and selecting Cash-in-Hand ledgers…';
if (masterMeta) masterMeta.textContent='Master source: local .act SQLite. TallyPrime is not queried for this normal load.';
}
try {
const response = await fetch('/tools/accounting/cash-payments/ledgers', {method:'POST', body:fd, headers:{'Accept':'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not load Tally ledgers.');
if (Array.isArray(data.ledgers)) {
populateLedgers(data);
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
ledgerProgress.classList.remove('hidden');
ledgerProgressBar.style.width='100%'; ledgerProgressPct.textContent='100%';
ledgerProgressStage.textContent = data.cash_only
? String(data.ledger_count || 0)+' Cash-in-Hand ledger(s) ready from SQLite'
: String(data.ledger_count || 0)+' ledger(s) ready from SQLite';
paintMasterSteps(4);
window.setTimeout(()=>ledgerProgress.classList.add('hidden'),1200);
return;
}
const job=data.job||{};
if(!job.job_id) throw new Error('Local Agent did not return a ledger-sync job id.');
updateLedgerProgress(job);
if(data.transport_pending){ ledgerHelp.textContent='Command sent. Waiting for the Local Agent result to cross the tunnel back to VPS…'; }
pollLedgers(
job.job_id,
String(fd.get('client_id')||''),
String(fd.get('date_from')||''),
String(fd.get('tally_guid')||''),
companyName,
selectedScope,
Boolean(forceRefresh)
);
} catch(err) {
ledgerHelp.textContent = err && err.message ? err.message : String(err);
ledgerButton.disabled=false; if(ledgerRefreshButton) ledgerRefreshButton.disabled=false;
}
}
if (ledgerButton) ledgerButton.addEventListener('click', function(){loadCashLedgers(false);});
if (ledgerRefreshButton) ledgerRefreshButton.addEventListener('click', function(){loadCashLedgers(true);});
const companySelect = document.getElementById('cash-tally-company');
if (ledgerScope) ledgerScope.addEventListener('change', function(){
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
if (companySelect && companySelect.value) loadCashLedgers(false);
});
if (companySelect) companySelect.addEventListener('change', function(){
allLedgerRows = []; cashLedgerRows = []; lastLedgerMeta = {ledger_count:0, company_name:'', from_cache:false};
ledgerSelect.innerHTML = '<option value="">Select Cash ledger</option>';
ledgerProgress.classList.add('hidden');
if (this.value) {
ledgerHelp.textContent = 'Loading Cash-in-Hand ledgers for the selected Tally company…';
loadCashLedgers(false);
} else {
ledgerHelp.textContent = 'Select an open Tally company first.';
}
});
if (companySelect && companySelect.value) {
window.setTimeout(function(){ loadCashLedgers(false); }, 250);
}
function show() {
overlay.classList.remove('hidden');
overlay.classList.add('flex');
errorBox.classList.add('hidden');
closeButton.classList.add('hidden');
cancelButton.classList.add('hidden');
}
function stopPolling() {
if (pollTimer) window.clearTimeout(pollTimer);
pollTimer = null;
}
function fail(message) {
stopPolling();
button.disabled = false;
text.textContent = 'Extraction paused safely.';
errorBox.textContent = message || 'The extraction did not complete.';
errorBox.classList.remove('hidden');
closeButton.classList.remove('hidden');
cancelButton.classList.add('hidden');
}
function update(job) {
const value = Math.max(0, Math.min(100, Number(job.percent || 0)));
bar.style.width = value + '%';
pct.textContent = value + '%';
text.textContent = job.stage || job.status || 'Working…';
days.textContent = String(job.completed_days || 0) + ' / ' + String(job.total_days || 0);
vouchers.textContent = String(job.vouchers_cached || 0);
requests.textContent = String(job.tally_requests || 0);
currentDate.textContent = job.current_date || '—';
const stage = String(job.stage || '').toLowerCase();
const status = String(job.status || '').toLowerCase();
const steps = [1,2,3,4,5].map(n => document.getElementById('erp-cash-step-'+n));
steps.forEach(e => { if(e) e.className='rounded-lg bg-slate-100 px-3 py-2'; });
function done(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-emerald-50 px-3 py-2 font-semibold text-emerald-700'; }
function running(n){ if(steps[n-1]) steps[n-1].className='rounded-lg bg-sky-50 px-3 py-2 font-semibold text-sky-700 ring-1 ring-sky-200'; }
if (value > 0) running(1); if (value >= 10) {done(1); running(2);} if (value >= 20) {done(2); running(3);} if (stage.includes('analys')) {done(3); running(4);} if (status === 'completed' && stage.includes('analysis complete')) {done(1);done(2);done(3);done(4);done(5);} else if (status === 'completed') {done(1);done(2);done(3);running(4);}
if (job.can_cancel) cancelButton.classList.remove('hidden'); else cancelButton.classList.add('hidden');
}
async function poll(jobId, clientId, dateFrom, tallyGuid, dateTo) {
try {
const url = new URL('/tools/accounting/cash-payments/cache/progress', window.location.origin);
url.searchParams.set('client_id', clientId);
url.searchParams.set('job_id', jobId);
url.searchParams.set('date_from', dateFrom);
const response = await fetch(url.toString(), {headers: {'Accept': 'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not read extraction progress.');
const job = data.job || {};
update(job);
if (job.status === 'completed') {
text.textContent = 'SQLite extraction complete. Running cash-payment analysis locally…';
bar.style.width = '100%'; pct.textContent = '100%';
const target = new URL('/tools/accounting/cash-payments', window.location.origin);
target.searchParams.set('client_id', clientId);
target.searchParams.set('tally_guid', tallyGuid);
target.searchParams.set('date_from', dateFrom);
target.searchParams.set('date_to', dateTo);
target.searchParams.set('job_id', jobId);
target.searchParams.set('cash_ledger_name', String(document.getElementById('cash-ledger-select').value || ''));
target.searchParams.set('analyze', '1');
window.location.assign(target.toString());
return;
}
if (job.status === 'cancelled') {
fail('Extraction was cancelled safely. Already cached days remain available and can be resumed later.');
return;
}
if (job.status === 'paused' || job.status === 'failed') {
fail(job.error || 'Tally extraction paused. Restart/verify TallyPrime and click Review Cash Transactions again to resume.');
return;
}
pollTimer = window.setTimeout(() => poll(jobId, clientId, dateFrom, tallyGuid, dateTo), 2000);
} catch (err) {
fail(err && err.message ? err.message : String(err));
}
}
button.addEventListener('click', async function () {
if (!form.reportValidity()) return;
const fd = new FormData(form);
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
const selectedCompany = document.getElementById('cash-tally-company');
const selectedCompanyOption = selectedCompany && selectedCompany.options[selectedCompany.selectedIndex];
const selectedCompanyName = selectedCompanyOption ? String(selectedCompanyOption.dataset.companyName || selectedCompanyOption.textContent || '').replace(' • Mapped','').replace(' • Mapped (not open)','').trim() : '';
if (selectedCompanyName) fd.set('company_name', selectedCompanyName);
button.disabled = true;
show();
try {
const response = await fetch('/tools/accounting/cash-payments/cache/start', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not start extraction.');
const job = data.job || {};
activeJobId = job.job_id || '';
activeClientId = String(fd.get('client_id') || '');
activeDateFrom = String(fd.get('date_from') || '');
update(job);
poll(job.job_id, fd.get('client_id'), fd.get('date_from'), fd.get('tally_guid'), fd.get('date_to'));
} catch (err) {
fail(err && err.message ? err.message : String(err));
}
});
cancelButton.addEventListener('click', async function () {
if (!activeJobId || !activeClientId) return;
cancelButton.disabled = true;
text.textContent = 'Requesting a safe stop after the current Tally request…';
try {
const fd = new FormData();
fd.set('csrf_token', document.getElementById('cash-csrf-token').value);
fd.set('client_id', activeClientId);
fd.set('job_id', activeJobId);
fd.set('date_from', activeDateFrom);
const response = await fetch('/tools/accounting/cash-payments/cache/cancel', {method: 'POST', body: fd, headers: {'Accept': 'application/json'}});
const data = await response.json();
if (!response.ok || !data.ok) throw new Error(data.error || 'Could not cancel extraction.');
if (data.job) update(data.job);
cancelButton.classList.add('hidden');
} catch (err) {
errorBox.textContent = err && err.message ? err.message : String(err);
errorBox.classList.remove('hidden');
cancelButton.disabled = false;
}
});
closeButton.addEventListener('click', function () {
overlay.classList.add('hidden'); overlay.classList.remove('flex');
});
})();
</script>
</div>
{% endblock %}