695 lines
23 KiB
TypeScript
695 lines
23 KiB
TypeScript
/** Shared vocabulary for simulator values. Decimal amounts stay strings at API boundaries. */
|
|
export type DecimalString = string;
|
|
|
|
export type ResultStatus =
|
|
| 'calculated'
|
|
| 'estimated'
|
|
| 'indeterminate'
|
|
| 'not_applicable';
|
|
export const fiscalComponentCodes = [
|
|
'IRPS',
|
|
'INSS',
|
|
'IVA',
|
|
'IRPC',
|
|
'ISPC',
|
|
'RETENCOES',
|
|
] as const;
|
|
export type FiscalComponentCode = (typeof fiscalComponentCodes)[number];
|
|
|
|
export type SimulationContext = {
|
|
contractVersion: 1;
|
|
referenceDate: string;
|
|
currency: string | null;
|
|
currencyFractionDigits: number | null;
|
|
scenarioId: string;
|
|
engineVersion: string;
|
|
rulesetVersion: string | null;
|
|
};
|
|
|
|
export type SimulationResult<TValue = DecimalString> = {
|
|
status: ResultStatus;
|
|
value: TValue | null;
|
|
unit: string | null;
|
|
explanation: string;
|
|
missingInputs: string[];
|
|
sourceIds: string[];
|
|
};
|
|
|
|
/** Exact JSON envelope returned by the v1 API (wire keys stay snake_case). */
|
|
export type ApiSimulationResultV1 = {
|
|
status: ResultStatus;
|
|
value: DecimalString | null;
|
|
unit: string | null;
|
|
explanation: string;
|
|
missing_inputs: string[];
|
|
source_ids: string[];
|
|
};
|
|
|
|
export type ApiSimulationResultContextV1 = {
|
|
reference_date: string;
|
|
currency: string | null;
|
|
currency_fraction_digits: number | null;
|
|
scenario_id: string;
|
|
scenario_version: number;
|
|
engine_version: string;
|
|
ruleset_version: string | null;
|
|
};
|
|
|
|
export type ApiSimulationResultSnapshotV1 = {
|
|
contract_version: 1;
|
|
context: ApiSimulationResultContextV1;
|
|
result: ApiSimulationResultV1;
|
|
indicative_total: DecimalString | null;
|
|
legal_interpretation: false;
|
|
formula_version: string;
|
|
};
|
|
|
|
const apiDecimalPattern = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/;
|
|
const uuidPattern =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/** Runtime guard for untrusted API JSON; zero remains a valid decimal value. */
|
|
export function isApiSimulationResultSnapshotV1(
|
|
value: unknown,
|
|
): value is ApiSimulationResultSnapshotV1 {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return false;
|
|
const snapshot = value as Record<string, unknown>;
|
|
const context = snapshot.context;
|
|
const result = snapshot.result;
|
|
if (
|
|
snapshot.contract_version !== 1 ||
|
|
snapshot.legal_interpretation !== false ||
|
|
typeof snapshot.formula_version !== 'string' ||
|
|
!snapshot.formula_version.trim() ||
|
|
!(
|
|
snapshot.indicative_total === null ||
|
|
(typeof snapshot.indicative_total === 'string' &&
|
|
apiDecimalPattern.test(snapshot.indicative_total))
|
|
) ||
|
|
!context ||
|
|
typeof context !== 'object' ||
|
|
Array.isArray(context) ||
|
|
!result ||
|
|
typeof result !== 'object' ||
|
|
Array.isArray(result)
|
|
)
|
|
return false;
|
|
|
|
const meta = context as Record<string, unknown>;
|
|
const output = result as Record<string, unknown>;
|
|
const currencyValid =
|
|
meta.currency === null ||
|
|
(typeof meta.currency === 'string' && /^[A-Z]{3}$/.test(meta.currency));
|
|
const currencyDigitsValid =
|
|
meta.currency_fraction_digits === null ||
|
|
(Number.isInteger(meta.currency_fraction_digits) &&
|
|
Number(meta.currency_fraction_digits) >= 0 &&
|
|
Number(meta.currency_fraction_digits) <= 6);
|
|
if (
|
|
!isIsoDate(meta.reference_date) ||
|
|
!uuidPattern.test(String(meta.scenario_id)) ||
|
|
!Number.isSafeInteger(meta.scenario_version) ||
|
|
Number(meta.scenario_version) < 1 ||
|
|
typeof meta.engine_version !== 'string' ||
|
|
!meta.engine_version.trim() ||
|
|
!(
|
|
meta.ruleset_version === null ||
|
|
typeof meta.ruleset_version === 'string'
|
|
) ||
|
|
!currencyValid ||
|
|
!currencyDigitsValid ||
|
|
(meta.currency === null) !== (meta.currency_fraction_digits === null) ||
|
|
![
|
|
'calculated',
|
|
'estimated',
|
|
'indeterminate',
|
|
'not_applicable',
|
|
].includes(String(output.status)) ||
|
|
!(
|
|
output.value === null ||
|
|
(typeof output.value === 'string' &&
|
|
apiDecimalPattern.test(output.value))
|
|
) ||
|
|
output.value !== snapshot.indicative_total ||
|
|
!(output.unit === null || typeof output.unit === 'string') ||
|
|
typeof output.explanation !== 'string' ||
|
|
!output.explanation.trim() ||
|
|
!Array.isArray(output.missing_inputs) ||
|
|
!output.missing_inputs.every(
|
|
(item) => typeof item === 'string' && item.length > 0,
|
|
) ||
|
|
!Array.isArray(output.source_ids) ||
|
|
!output.source_ids.every(
|
|
(item) => typeof item === 'string' && item.length > 0,
|
|
)
|
|
)
|
|
return false;
|
|
|
|
if (
|
|
(output.status === 'calculated' || output.status === 'estimated') &&
|
|
output.value === null
|
|
)
|
|
return false;
|
|
if (
|
|
output.status === 'indeterminate' &&
|
|
(output.value !== null || output.missing_inputs.length === 0)
|
|
)
|
|
return false;
|
|
if (output.status === 'not_applicable' && output.value !== null)
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
export const apiSimulationExecutionStatuses = [
|
|
'queued',
|
|
'running',
|
|
'completed',
|
|
'failed',
|
|
'cancelled',
|
|
] as const;
|
|
export type ApiSimulationExecutionStatus =
|
|
(typeof apiSimulationExecutionStatuses)[number];
|
|
|
|
/** Minimum execution fields consumed by the public form and result reader. */
|
|
export type ApiSimulationExecutionV1 = {
|
|
id: string;
|
|
status: ApiSimulationExecutionStatus;
|
|
version_no: number;
|
|
result_snapshot?: Record<string, unknown> | null;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export function isApiSimulationExecutionV1(
|
|
value: unknown,
|
|
): value is ApiSimulationExecutionV1 {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return false;
|
|
const execution = value as Record<string, unknown>;
|
|
const hasSnapshot = Object.prototype.hasOwnProperty.call(
|
|
execution,
|
|
'result_snapshot',
|
|
);
|
|
const snapshot = execution.result_snapshot;
|
|
const snapshotValid =
|
|
snapshot === undefined || snapshot === null
|
|
? execution.status !== 'completed'
|
|
: !!snapshot &&
|
|
typeof snapshot === 'object' &&
|
|
!Array.isArray(snapshot) &&
|
|
(isApiSimulationResultSnapshotV1(snapshot) ||
|
|
(typeof (snapshot as Record<string, unknown>)
|
|
.indicative_total === 'string' &&
|
|
/^(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(
|
|
(snapshot as Record<string, string>).indicative_total,
|
|
) &&
|
|
!Object.prototype.hasOwnProperty.call(
|
|
snapshot,
|
|
'contract_version',
|
|
)));
|
|
|
|
return (
|
|
typeof execution.id === 'string' &&
|
|
uuidPattern.test(execution.id) &&
|
|
apiSimulationExecutionStatuses.includes(
|
|
execution.status as ApiSimulationExecutionStatus,
|
|
) &&
|
|
Number.isSafeInteger(execution.version_no) &&
|
|
Number(execution.version_no) > 0 &&
|
|
snapshotValid &&
|
|
(execution.status !== 'completed' || hasSnapshot)
|
|
);
|
|
}
|
|
|
|
export type ApiSimulationCreateResponseV1 = {
|
|
data: ApiSimulationExecutionV1;
|
|
replayed: boolean;
|
|
access_token?: string;
|
|
};
|
|
|
|
export function isApiSimulationCreateResponseV1(
|
|
value: unknown,
|
|
): value is ApiSimulationCreateResponseV1 {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return false;
|
|
const response = value as Record<string, unknown>;
|
|
return (
|
|
isApiSimulationExecutionV1(response.data) &&
|
|
typeof response.replayed === 'boolean' &&
|
|
(response.access_token === undefined ||
|
|
(typeof response.access_token === 'string' &&
|
|
response.access_token.length >= 32 &&
|
|
response.access_token.length <= 256))
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationReadResponseV1(
|
|
value: unknown,
|
|
): value is { data: ApiSimulationExecutionV1 } {
|
|
return (
|
|
!!value &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
isApiSimulationExecutionV1((value as Record<string, unknown>).data)
|
|
);
|
|
}
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
|
|
const isIsoDateTime = (value: unknown): value is string =>
|
|
typeof value === 'string' &&
|
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
value,
|
|
) &&
|
|
Number.isFinite(Date.parse(value));
|
|
|
|
const isIsoDate = (value: unknown): value is string =>
|
|
typeof value === 'string' &&
|
|
(() => {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) return false;
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
if (year < 1 || month < 1 || month > 12 || day < 1) return false;
|
|
const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
const daysInMonth = [
|
|
31,
|
|
leap ? 29 : 28,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
31,
|
|
30,
|
|
31,
|
|
30,
|
|
31,
|
|
];
|
|
return day <= daysInMonth[month - 1];
|
|
})();
|
|
|
|
export function isApiErrorV1(value: unknown): value is {
|
|
message?: string;
|
|
errors?: Record<string, string[]>;
|
|
[key: string]: unknown;
|
|
} {
|
|
if (!isPlainObject(value)) return false;
|
|
if (value.message !== undefined && typeof value.message !== 'string')
|
|
return false;
|
|
if (value.errors !== undefined) {
|
|
if (!isPlainObject(value.errors)) return false;
|
|
if (
|
|
!Object.values(value.errors).every(
|
|
(messages) =>
|
|
Array.isArray(messages) &&
|
|
messages.every((message) => typeof message === 'string'),
|
|
)
|
|
)
|
|
return false;
|
|
}
|
|
return typeof value.message === 'string' || value.errors !== undefined;
|
|
}
|
|
|
|
export function isApiQuestionnaireReadResponseV1(value: unknown): value is {
|
|
data: {
|
|
id: string;
|
|
code: string;
|
|
version_no: number;
|
|
locale: 'pt-MZ';
|
|
schema_snapshot: Record<string, unknown>;
|
|
status: 'published';
|
|
valid_from: string | null;
|
|
valid_to: string | null;
|
|
};
|
|
} {
|
|
if (!isPlainObject(value) || !isPlainObject(value.data)) return false;
|
|
const data = value.data;
|
|
return (
|
|
typeof data.id === 'string' &&
|
|
uuidPattern.test(data.id) &&
|
|
typeof data.code === 'string' &&
|
|
/^[a-z0-9][a-z0-9-]{0,79}$/.test(data.code) &&
|
|
Number.isSafeInteger(data.version_no) &&
|
|
Number(data.version_no) > 0 &&
|
|
data.locale === 'pt-MZ' &&
|
|
isPlainObject(data.schema_snapshot) &&
|
|
data.status === 'published' &&
|
|
(data.valid_from === null || isIsoDate(data.valid_from)) &&
|
|
(data.valid_to === null || isIsoDate(data.valid_to))
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationReportReadResponseV1(value: unknown): value is {
|
|
data: {
|
|
id: string;
|
|
simulation_id: string;
|
|
version_no: number;
|
|
status: 'queued' | 'ready' | 'failed';
|
|
report_snapshot: Record<string, unknown> | null;
|
|
report_sha256: string | null;
|
|
};
|
|
} {
|
|
if (!isPlainObject(value) || !isPlainObject(value.data)) return false;
|
|
const data = value.data;
|
|
const shapeValid =
|
|
typeof data.id === 'string' &&
|
|
uuidPattern.test(data.id) &&
|
|
typeof data.simulation_id === 'string' &&
|
|
uuidPattern.test(data.simulation_id) &&
|
|
Number.isSafeInteger(data.version_no) &&
|
|
Number(data.version_no) > 0 &&
|
|
(data.status === 'queued' ||
|
|
data.status === 'ready' ||
|
|
data.status === 'failed') &&
|
|
(data.report_snapshot === null ||
|
|
isPlainObject(data.report_snapshot)) &&
|
|
(data.report_sha256 === null ||
|
|
(typeof data.report_sha256 === 'string' &&
|
|
/^[a-f0-9]{64}$/i.test(data.report_sha256)));
|
|
if (!shapeValid) return false;
|
|
if (data.status === 'ready') {
|
|
return (
|
|
isPlainObject(data.report_snapshot) &&
|
|
typeof data.report_sha256 === 'string' &&
|
|
/^[a-f0-9]{64}$/i.test(data.report_sha256)
|
|
);
|
|
}
|
|
|
|
return data.report_snapshot === null && data.report_sha256 === null;
|
|
}
|
|
|
|
export type ApiSimulationJobV1 = {
|
|
id: string;
|
|
simulation_id: string;
|
|
job_type: 'execute' | 'report';
|
|
status: 'pending' | 'running' | 'succeeded' | 'failed';
|
|
attempts: number;
|
|
available_at: string;
|
|
started_at: string | null;
|
|
finished_at: string | null;
|
|
error_code: string | null;
|
|
};
|
|
|
|
export function isApiSimulationJobsReadResponseV1(value: unknown): value is {
|
|
data: ApiSimulationJobV1[];
|
|
} {
|
|
if (!isPlainObject(value) || !Array.isArray(value.data)) return false;
|
|
return value.data.every((job: unknown) => {
|
|
if (!isPlainObject(job)) return false;
|
|
return (
|
|
typeof job.id === 'string' &&
|
|
uuidPattern.test(job.id) &&
|
|
typeof job.simulation_id === 'string' &&
|
|
uuidPattern.test(job.simulation_id) &&
|
|
(job.job_type === 'execute' || job.job_type === 'report') &&
|
|
(job.status === 'pending' ||
|
|
job.status === 'running' ||
|
|
job.status === 'succeeded' ||
|
|
job.status === 'failed') &&
|
|
Number.isSafeInteger(job.attempts) &&
|
|
Number(job.attempts) >= 0 &&
|
|
Number(job.attempts) <= 3 &&
|
|
isIsoDateTime(job.available_at) &&
|
|
(job.started_at === null || isIsoDateTime(job.started_at)) &&
|
|
(job.finished_at === null || isIsoDateTime(job.finished_at)) &&
|
|
(job.error_code === null || typeof job.error_code === 'string')
|
|
);
|
|
});
|
|
}
|
|
|
|
export type ApiSimulationHistoryEntryV1 = {
|
|
id: string;
|
|
scope: 'personal' | 'organization';
|
|
organization_name: string | null;
|
|
status: ApiSimulationExecutionStatus;
|
|
version_no: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
has_ready_report: boolean;
|
|
};
|
|
|
|
export type ApiSimulationHistoryResponseV1 = {
|
|
data: ApiSimulationHistoryEntryV1[];
|
|
meta: {
|
|
current_page: number;
|
|
last_page: number;
|
|
per_page: number;
|
|
total: number;
|
|
};
|
|
};
|
|
|
|
export function isApiSimulationHistoryResponseV1(
|
|
value: unknown,
|
|
): value is ApiSimulationHistoryResponseV1 {
|
|
if (
|
|
!isPlainObject(value) ||
|
|
!Array.isArray(value.data) ||
|
|
!isPlainObject(value.meta)
|
|
)
|
|
return false;
|
|
const meta = value.meta;
|
|
if (
|
|
![meta.current_page, meta.last_page, meta.per_page, meta.total].every(
|
|
(item) => Number.isSafeInteger(item) && Number(item) >= 0,
|
|
) ||
|
|
Number(meta.current_page) < 1 ||
|
|
Number(meta.last_page) < 1 ||
|
|
Number(meta.per_page) < 1
|
|
)
|
|
return false;
|
|
|
|
return value.data.every((entry: unknown) => {
|
|
if (!isPlainObject(entry)) return false;
|
|
return (
|
|
typeof entry.id === 'string' &&
|
|
uuidPattern.test(entry.id) &&
|
|
(entry.scope === 'personal' || entry.scope === 'organization') &&
|
|
(entry.organization_name === null ||
|
|
typeof entry.organization_name === 'string') &&
|
|
apiSimulationExecutionStatuses.includes(
|
|
entry.status as ApiSimulationExecutionStatus,
|
|
) &&
|
|
Number.isSafeInteger(entry.version_no) &&
|
|
Number(entry.version_no) > 0 &&
|
|
(entry.created_at === null || isIsoDateTime(entry.created_at)) &&
|
|
(entry.updated_at === null || isIsoDateTime(entry.updated_at)) &&
|
|
typeof entry.has_ready_report === 'boolean'
|
|
);
|
|
});
|
|
}
|
|
|
|
export type ApiSimulationDraftV1 = {
|
|
id: string;
|
|
version_no: number;
|
|
snapshot_sha256: string;
|
|
personal_snapshot: Record<string, unknown> | [];
|
|
economic_snapshot: Record<string, unknown>;
|
|
simulation_sources: ApiSimulationSourceV1[];
|
|
updated_at: string | null;
|
|
expires_at?: string | null;
|
|
};
|
|
|
|
export type ApiSimulationDraftSummaryV1 = {
|
|
id: string;
|
|
title: string;
|
|
updated_at: string | null;
|
|
expires_at: string | null;
|
|
version_no: number;
|
|
};
|
|
|
|
export type ApiSimulationSourceV1 = {
|
|
simulation_id: string;
|
|
source_version_no: number;
|
|
result_snapshot: Record<string, unknown>;
|
|
report_sha256: string;
|
|
consented_at: string;
|
|
};
|
|
|
|
function isApiSimulationSourceV1(
|
|
value: unknown,
|
|
): value is ApiSimulationSourceV1 {
|
|
if (!isPlainObject(value)) return false;
|
|
return (
|
|
typeof value.simulation_id === 'string' &&
|
|
uuidPattern.test(value.simulation_id) &&
|
|
Number.isSafeInteger(value.source_version_no) &&
|
|
Number(value.source_version_no) > 0 &&
|
|
isPlainObject(value.result_snapshot) &&
|
|
typeof value.report_sha256 === 'string' &&
|
|
/^[a-f0-9]{64}$/i.test(value.report_sha256) &&
|
|
isIsoDateTime(value.consented_at)
|
|
);
|
|
}
|
|
|
|
function isApiSimulationDraftV1(value: unknown): value is ApiSimulationDraftV1 {
|
|
if (!isPlainObject(value)) return false;
|
|
return (
|
|
typeof value.id === 'string' &&
|
|
uuidPattern.test(value.id) &&
|
|
Number.isSafeInteger(value.version_no) &&
|
|
Number(value.version_no) > 0 &&
|
|
typeof value.snapshot_sha256 === 'string' &&
|
|
/^[a-f0-9]{64}$/i.test(value.snapshot_sha256) &&
|
|
(isPlainObject(value.personal_snapshot) ||
|
|
(Array.isArray(value.personal_snapshot) &&
|
|
value.personal_snapshot.length === 0)) &&
|
|
isPlainObject(value.economic_snapshot) &&
|
|
Array.isArray(value.simulation_sources) &&
|
|
value.simulation_sources.every(isApiSimulationSourceV1) &&
|
|
(value.updated_at === null || isIsoDateTime(value.updated_at)) &&
|
|
(value.expires_at === undefined ||
|
|
value.expires_at === null ||
|
|
isIsoDateTime(value.expires_at))
|
|
);
|
|
}
|
|
|
|
function isApiSimulationDraftSummaryV1(
|
|
value: unknown,
|
|
): value is ApiSimulationDraftSummaryV1 {
|
|
return (
|
|
isPlainObject(value) &&
|
|
typeof value.id === 'string' &&
|
|
uuidPattern.test(value.id) &&
|
|
typeof value.title === 'string' &&
|
|
(value.updated_at === null || isIsoDateTime(value.updated_at)) &&
|
|
(value.expires_at === null || isIsoDateTime(value.expires_at)) &&
|
|
Number.isSafeInteger(value.version_no) &&
|
|
Number(value.version_no) > 0
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationDraftListResponseV1(
|
|
value: unknown,
|
|
): value is { data: ApiSimulationDraftSummaryV1[] } {
|
|
return (
|
|
isPlainObject(value) &&
|
|
Array.isArray(value.data) &&
|
|
value.data.every(isApiSimulationDraftSummaryV1)
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationDraftClaimPreviewV1(
|
|
value: unknown,
|
|
): value is { data: ApiSimulationDraftSummaryV1 | null } {
|
|
return (
|
|
isPlainObject(value) &&
|
|
(value.data === null || isApiSimulationDraftSummaryV1(value.data))
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationDraftReadResponseV1(
|
|
value: unknown,
|
|
): value is { data: ApiSimulationDraftV1 | null } {
|
|
if (!isPlainObject(value)) return false;
|
|
return value.data === null || isApiSimulationDraftV1(value.data);
|
|
}
|
|
|
|
export function isApiSimulationDraftWriteResponseV1(value: unknown): value is {
|
|
data: { id: string; version_no: number; replayed?: boolean };
|
|
expires_at?: string | null;
|
|
} {
|
|
if (!isPlainObject(value) || !isPlainObject(value.data)) return false;
|
|
const data = value.data;
|
|
return (
|
|
typeof data.id === 'string' &&
|
|
uuidPattern.test(data.id) &&
|
|
Number.isSafeInteger(data.version_no) &&
|
|
Number(data.version_no) > 0 &&
|
|
(value.expires_at === undefined ||
|
|
value.expires_at === null ||
|
|
isIsoDateTime(value.expires_at)) &&
|
|
(data.replayed === undefined || typeof data.replayed === 'boolean')
|
|
);
|
|
}
|
|
|
|
export function isApiSimulationDraftAttachResponseV1(
|
|
value: unknown,
|
|
): value is { data: ApiSimulationDraftV1; replayed: boolean } {
|
|
return (
|
|
isPlainObject(value) &&
|
|
isApiSimulationDraftV1(value.data) &&
|
|
typeof value.replayed === 'boolean'
|
|
);
|
|
}
|
|
|
|
export function indeterminateFiscalComponent(
|
|
component: FiscalComponentCode,
|
|
referenceDate: string,
|
|
): SimulationResult<DecimalString> & {
|
|
component: FiscalComponentCode;
|
|
referenceDate: string;
|
|
} {
|
|
return {
|
|
component,
|
|
referenceDate,
|
|
status: 'indeterminate',
|
|
value: null,
|
|
unit: null,
|
|
explanation: `A componente ${component} não tem regra oficial aprovada e vigente associada neste simulador.`,
|
|
missingInputs: [
|
|
'approved_rule',
|
|
'effective_parameters',
|
|
'applicable_classification',
|
|
],
|
|
sourceIds: [],
|
|
};
|
|
}
|
|
|
|
export type SimulationSnapshot = {
|
|
personal_snapshot: Record<string, unknown>;
|
|
economic_snapshot: Record<string, unknown>;
|
|
context: SimulationContext;
|
|
};
|
|
|
|
const intl = Intl as unknown as {
|
|
supportedValuesOf?: (key: 'currency') => string[];
|
|
};
|
|
export function simulationCurrencyFractionDigits(value: string): number | null {
|
|
try {
|
|
const digits = new Intl.NumberFormat('en', {
|
|
style: 'currency',
|
|
currency: value,
|
|
}).resolvedOptions().maximumFractionDigits;
|
|
return digits !== undefined && Number.isInteger(digits) ? digits : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
export const simulationCurrencyOptions = [
|
|
...new Set([
|
|
'MZN',
|
|
'USD',
|
|
'EUR',
|
|
'ZAR',
|
|
...(intl.supportedValuesOf?.('currency') ?? []),
|
|
]),
|
|
]
|
|
.filter((code) => simulationCurrencyFractionDigits(code) === 2)
|
|
.sort();
|
|
export const isSimulationCurrency = (value: string): boolean =>
|
|
simulationCurrencyOptions.includes(value);
|
|
|
|
/** Rejects contradictory states so missing/unknown values cannot silently become zero. */
|
|
export function validateSimulationResult<TValue>(
|
|
result: SimulationResult<TValue>,
|
|
): string[] {
|
|
const errors: string[] = [];
|
|
if (!result.explanation.trim()) errors.push('explanation');
|
|
if (result.status === 'calculated' && result.value === null)
|
|
errors.push('calculated_value');
|
|
if (
|
|
result.status === 'indeterminate' &&
|
|
(result.value !== null || result.missingInputs.length === 0)
|
|
)
|
|
errors.push('indeterminate_inputs');
|
|
if (result.status === 'not_applicable' && result.value !== null)
|
|
errors.push('not_applicable_value');
|
|
if (result.status === 'estimated' && result.value === null)
|
|
errors.push('estimated_value');
|
|
return errors;
|
|
}
|