Added Wizard for Month/Year End Close
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user