Files
Investidor-V2/resources/js/lib/simulation-contract.ts
T
nala 1106509116
tests / ci (push) Canceled after 0s
vrsao 2
2026-10-08 14:02:01 +02:00

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;
}