Added Wizard for Month/Year End Close

This commit is contained in:
2026-06-09 18:48:52 -05:00
parent 9a9f890494
commit 599465bdac
15 changed files with 1374 additions and 25 deletions

View File

@@ -1881,6 +1881,91 @@ async function main() {
const logForbidden = await fetch(`${baseUrl}/api/app-logs`, { headers: limitedAuth });
if (logForbidden.status !== 403) throw new Error('Application logs should require the admin.logs capability');
// ---- Period close (month-end / year-end) -----------------------------------
// Run the whole close in company A explicitly (bare `auth` resolves to the admin's first company,
// which isn't deterministically company A). Seed a depreciating GAAP asset so depreciation is non-zero.
const aHeaders = companyHeaders(companyA);
await request('/api/assets', {
method: 'POST', headers: aHeaders,
body: JSON.stringify({
asset_id: `CLZ-${suffix}`, description: 'Close-test machine', category: 'Computer Equipment',
acquisition_cost: 12000, acquired_date: '2026-01-10', in_service_date: '2026-01-10', useful_life_months: 60,
books: [{ book_type: 'GAAP', active: true, depreciation_method: 'straight_line', convention: 'half_year', useful_life_months: 60, cost: 12000 }]
})
});
const startClose = await request('/api/close-periods/start', {
method: 'POST', headers: aHeaders,
body: JSON.stringify({ book: 'GAAP', period_type: 'month', fiscal_year: 2026, period_month: 3 })
});
const closeId = startClose.period.id;
if (startClose.period.status !== 'in_progress') throw new Error('Close period did not start');
const postedSource = `close:${closeId}`;
const closeEntries = async () => (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries.filter((e) => e.source === postedSource);
// Post depreciation → balanced JE (Dr expense = Cr accumulated), tagged close:<id>.
const dep = (await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders })).summary;
if (!(dep.debit > 0) || dep.debit !== dep.credit) throw new Error(`Depreciation JE not balanced: ${JSON.stringify(dep)}`);
const firstPost = await closeEntries();
if (!firstPost.length) throw new Error('Close did not post GL entries');
// Idempotent re-post: same entry count, not doubled.
await request(`/api/close-periods/${closeId}/post-depreciation`, { method: 'POST', headers: aHeaders });
if ((await closeEntries()).length !== firstPost.length) throw new Error('Depreciation re-post was not idempotent');
// Reconcile, then mark the reconcile step done (acknowledging any opening variance).
const recon = (await request(`/api/close-periods/${closeId}/reconcile`, { method: 'POST', headers: aHeaders })).reconciliation;
if (!recon.lines || recon.lines.length !== 3) throw new Error('Reconciliation result malformed');
await request(`/api/close-periods/${closeId}/steps/reconcile`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true, notes: 'opening balance' }) });
// Finalize → period closed.
const finalized = (await request(`/api/close-periods/${closeId}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) })).period;
if (finalized.status !== 'closed') throw new Error('Finalize did not close the period');
// Lock: a backdated GL entry into the closed month is rejected (409).
const lockedPost = await fetch(`${baseUrl}/api/books/GAAP/gl-entries`, {
method: 'POST', headers: { ...aHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 })
});
if (lockedPost.status !== 409) throw new Error(`Backdated GL post into a closed period should be 409, got ${lockedPost.status}`);
// Reopen → close JEs reversed, status in_progress, and the backdated write now succeeds.
const reopened = (await request(`/api/close-periods/${closeId}/reopen`, { method: 'POST', headers: aHeaders })).period;
if (reopened.status !== 'in_progress') throw new Error('Reopen did not reset status');
if ((await closeEntries()).length !== 0) throw new Error('Reopen did not reverse the close postings');
await request('/api/books/GAAP/gl-entries', { method: 'POST', headers: aHeaders, body: JSON.stringify({ entry_date: '2026-03-15', account: '6400', debit: 10, credit: 0 }) });
// Every close action is audited.
const closeAudit = await request('/api/audit-logs?entity_type=close_period', { headers: auth });
for (const action of ['start', 'post_depreciation', 'finalize', 'reopen']) {
if (!closeAudit.rows.some((r) => r.action === action)) throw new Error(`Close action not audited: ${action}`);
}
// Isolation: company B cannot read company A's close period.
const crossClose = await fetch(`${baseUrl}/api/close-periods/${closeId}`, { headers: companyHeaders(companyB) });
if (crossClose.status !== 404) throw new Error('Cross-company close period should 404');
// Capitalize WIP: a Construction-in-progress asset moves into service + posts a JE.
const cip = (await request('/api/assets', {
method: 'POST', headers: aHeaders,
body: JSON.stringify({ asset_id: `WIP-${suffix}`, description: 'Build in progress', category: 'Computer Equipment', acquisition_cost: 5000, status: 'cip', acquired_date: '2026-02-01' })
})).asset;
const capped = await request(`/api/close-periods/${closeId}/wip/${cip.id}/capitalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ in_service_date: '2026-06-01' }) });
if (capped.asset.status !== 'in_service') throw new Error('WIP capitalization did not place the asset in service');
// Year close: finalize runs the roll-forward to Retained Earnings.
const yearClose = (await request('/api/close-periods/start', { method: 'POST', headers: aHeaders, body: JSON.stringify({ book: 'GAAP', period_type: 'year', fiscal_year: 2026 }) })).period;
await request(`/api/close-periods/${yearClose.id}/post-depreciation`, { method: 'POST', headers: aHeaders });
await request(`/api/close-periods/${yearClose.id}/reconcile`, { method: 'POST', headers: aHeaders });
for (const key of ['final_month_close', 'reconcile', 'physical_inventory']) {
await request(`/api/close-periods/${yearClose.id}/steps/${key}`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ done: true, acknowledged: true }) });
}
await request(`/api/close-periods/${yearClose.id}/finalize`, { method: 'POST', headers: aHeaders, body: JSON.stringify({ acknowledge_variance: true }) });
const reEntries = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: aHeaders })).entries
.filter((e) => e.source === `close:${yearClose.id}` && e.account_type === 'Retained earnings');
if (!reEntries.length) throw new Error('Year-end roll-forward did not post a Retained Earnings entry');
console.log('Smoke test passed');
}