Files
Investidor-V2/resources/js/lib/iva-secondhand.ts
T

580 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { acquisitionScope, readAcquisitionStore } from './iva-acquisitions.ts';
export const secondhandVersion = 'secondhand-individual-margin/1';
export const secondhandSource =
'CIVA3(3)g/3(6),15(2)f/g,Lei13/2016PDF7/13;Lei22/2022PDF3mantém15f/g efixa17(1)16%;Lei10/2025não altera15/17,revoga regimes35/42. RIVADecreto7/2008arts47–49,BR16-I16/04/2008PDF8–9/p106–107:revenda porbem,margem positiva dividida por1.16 ebase arredondada à unidade;sem compensar perdas entrebens. Leilão:facturado−líquidocomitente−IVApróprio identificado,líquido=adjudicação−comissão. ClassificaçãoP125 separada da baseP092/P093. Sem nova dedução/campo11,isenção automática,IVA calculado ou totaisModeloA.';
export const secondhandTreatments = [
{
id: 'reseller_margin',
branch: 'P092',
label: 'Revendedor — margem individual',
},
{
id: 'auction_margin',
branch: 'P093',
label: 'Organizador emnome próprio — venda em leilão',
},
] as const;
export const secondhandCategories = [
{ id: 'used_goods', label: 'Bem móvel usado3(6)a' },
{ id: 'art', label: 'Objecto de arte3(6)b' },
{ id: 'collection', label: 'Objecto de colecção3(6)c' },
{ id: 'antique', label: 'Antiguidade3(6)d' },
] as const;
export const secondhandConditions = [
[
'goodsIdentity',
'Bem individual identificado,com aquisição,transmissão e referências correspondentes',
],
[
'operatorRole',
'Sujeito passivo revendedor ou organizador de leilão identificado,no exercício da actividade epercurso escolhido',
],
] as const;
export const categoryConditions = {
used_goods: [
[
'reusableUsed',
'Bem móvel usado susceptível de reutilização no estado ou após reparação',
],
[
'notRenewedTransformed',
'Não é renovado nem transformado:renovado se materiais de reparação excedem aquisição+mão-de-obra;transformado se reparação modifica características essenciais',
],
[
'notPreciousExcluded',
'Não são pedras/metais preciosos excluídos3(6)a;moedas/artefactos desses materiais não são excluídos só por esse motivo',
],
],
art: [
[
'artistOriginal',
'Obra da autoria do artista,no âmbito3(6)b;sem desenho industrial,ourivesaria ou joalharia',
],
],
collection: [
[
'collectionNature',
'Objecto de colecção no âmbito3(6)c,com natureza e características identificadas',
],
],
antique: [
[
'notArtCollection',
'Não é objecto de arte ou de colecção já classificado nessas categorias',
],
],
} as const;
export function secondhandCategoryConditions(
category: string,
): ReadonlyArray<readonly [string, string]> {
return (
categoryConditions[category as keyof typeof categoryConditions] ?? []
);
}
type Choice = '' | 'yes' | 'no';
export type SecondhandRow = {
id: string;
treatment: string;
category: string;
description: string;
period: string;
origin: 'projected' | 'effective';
currency: string;
operationDate: string;
operationRef: string;
documentRef: string;
documentDate: string;
goodsRef: string;
operatorRef: string;
categoryRef: string;
artSubtype: string;
editionCount: string;
ceramicUniqueSigned: string;
collectionSubtype: string;
postalNotCirculating: string;
ageYears: string;
sourceType: string;
sourceRef: string;
domesticPurchase: string;
regimeChoice: string;
domesticTaxable: string;
grossSale: string;
purchasePrice: string;
purchaseRef: string;
grossInvoice: string;
hammerPrice: string;
principalCommission: string;
auctionRef: string;
principalRef: string;
ownVat: string;
ownVatRef: string;
scope: string;
evidence: string;
conditions: Partial<Record<string, Choice>>;
};
type LineResult = {
id: string;
status: 'determined' | 'not_applicable' | 'indeterminate';
classification: 'qualified_goods' | 'not_applicable' | 'indeterminate';
baseAmount: string | null;
grossMargin: string | null;
principalNet: string | null;
baseDivisorRate: string | null;
vat: null;
rate: null;
reason: string;
};
export type SecondhandCalculation = {
version: string;
revision: number;
calculatedAt: string;
opportunity: string;
location: string;
sourceKey: string;
source: string;
inputs: SecondhandRow[];
rows: LineResult[];
};
export type SecondhandBook = {
rows: SecondhandRow[];
result?: SecondhandCalculation;
history?: SecondhandCalculation[];
};
type Store = ReturnType<typeof readAcquisitionStore> & {
regulatedSecondhands?: Record<string, SecondhandBook>;
};
export function newSecondhand(id: string, currency: string): SecondhandRow {
return {
id,
treatment: '',
category: '',
description: '',
period: '',
origin: 'projected',
currency,
operationDate: '',
operationRef: '',
documentRef: '',
documentDate: '',
goodsRef: '',
operatorRef: '',
categoryRef: '',
artSubtype: '',
editionCount: '',
ceramicUniqueSigned: '',
collectionSubtype: '',
postalNotCirculating: '',
ageYears: '',
sourceType: '',
sourceRef: '',
domesticPurchase: '',
regimeChoice: '',
domesticTaxable: '',
grossSale: '',
purchasePrice: '',
purchaseRef: '',
grossInvoice: '',
hammerPrice: '',
principalCommission: '',
auctionRef: '',
principalRef: '',
ownVat: '',
ownVatRef: '',
scope: '',
evidence: '',
conditions: {},
};
}
export function switchSecondhandTreatment(
row: SecondhandRow,
treatment: string,
): SecondhandRow {
return {
...row,
treatment,
conditions: {
...row.conditions,
regimeConfirmed: '',
compositionConfirmed: '',
},
};
}
export function switchSecondhandCategory(
row: SecondhandRow,
category: string,
): SecondhandRow {
return {
...row,
category,
conditions: Object.fromEntries(
secondhandConditions.map(([key]) => [
key,
row.conditions[key] ?? '',
]),
),
};
}
function readStore(draft: Record<string, unknown>): Store {
const store = readAcquisitionStore(draft.modelo_a_snapshot) as Store;
if (
store.regulatedSecondhands !== undefined &&
(!store.regulatedSecondhands ||
typeof store.regulatedSecondhands !== 'object' ||
Array.isArray(store.regulatedSecondhands))
)
throw new Error(
'Afectações de bens não recuperáveis;originais preservados.',
);
const fields = Object.keys(newSecondhand('', '')).filter(
(key) => key !== 'conditions',
);
for (const book of Object.values(store.regulatedSecondhands ?? {}))
if (
!book ||
!Array.isArray(book.rows) ||
(book.history !== undefined && !Array.isArray(book.history)) ||
book.rows.some(
(row) =>
!row ||
fields.some(
(key) =>
typeof row[key as keyof SecondhandRow] !== 'string',
) ||
!row.conditions ||
typeof row.conditions !== 'object' ||
Array.isArray(row.conditions),
)
)
throw new Error(
'Linhas de afectações de bens não recuperáveis;originais preservados,sem substituição por lista vazia.',
);
return store;
}
export function secondhandBook(draft: Record<string, unknown>): SecondhandBook {
return (
readStore(draft).regulatedSecondhands?.[acquisitionScope(draft)] ?? {
rows: [],
}
);
}
export function saveSecondhandBook(
draft: Record<string, unknown>,
book: SecondhandBook,
): string {
const current = readStore(draft),
encoded = JSON.stringify({
...current,
regulatedSecondhands: {
...current.regulatedSecondhands,
[acquisitionScope(draft)]: book,
},
});
readStore({ ...draft, modelo_a_snapshot: encoded });
return encoded;
}
function decimal(raw: unknown): bigint | null {
if (typeof raw !== 'string' || !/^\d{1,15}(\.\d{1,6})?$/.test(raw.trim()))
return null;
const [whole, fraction = ''] = raw.trim().split('.');
return BigInt(whole) * 1000000n + BigInt(fraction.padEnd(6, '0'));
}
function money(n: bigint): string {
return `${n / 1000000n}.${(n % 1000000n).toString().padStart(6, '0').replace(/0+$/, '').padEnd(2, '0')}`;
}
function validDate(value: string): boolean {
const d = new Date(value + 'T00:00:00Z');
return (
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
Number.isFinite(d.getTime()) &&
d.toISOString().slice(0, 10) === value
);
}
export function evaluateSecondhand(
row: SecondhandRow,
currency: unknown,
): LineResult {
const result: LineResult = {
id: row.id,
status: 'indeterminate',
classification: 'indeterminate',
baseAmount: null,
grossMargin: null,
principalNet: null,
baseDivisorRate: null,
vat: null,
rate: null,
reason: '',
};
const stop = (
reason: string,
status: LineResult['status'] = 'indeterminate',
) => ({ ...result, status, reason });
if (
!secondhandTreatments.some((t) => t.id === row.treatment) ||
!secondhandCategories.some((c) => c.id === row.category)
)
return stop(
'Identifique revendedor/leilão e categoria do bem;sem regime ou base por defeito.',
);
if (
!/^2026-(0[1-9]|1[0-2])$/.test(row.period) ||
!['projected', 'effective'].includes(row.origin) ||
!validDate(row.operationDate) ||
row.operationDate.slice(0, 7) !== row.period
)
return stop(
'Identifique mês2026,data correspondente e natureza efectiva/projectada dos dados.',
);
const required = [
...secondhandConditions,
...secondhandCategoryConditions(row.category),
];
if (required.some(([k]) => row.conditions[k] === 'no'))
return {
...stop(
'Definição/estatuto deste percurso não correspondem aos factos;rever classificação3(6),sem basezero ou isenção presumida.',
'not_applicable',
),
classification: 'not_applicable',
};
if (required.some(([k]) => row.conditions[k] !== 'yes'))
return stop(
'Natureza do bem ou estatuto do operador por esclarecer;desconhecido não é zero.',
);
if (
![
row.description,
row.operationRef,
row.documentRef,
row.goodsRef,
row.operatorRef,
row.categoryRef,
row.scope,
row.evidence,
].every((v) => !!v.trim()) ||
!validDate(row.documentDate)
)
return stop(
'Identifique bem individual,operador,categoria,operação,documento/data,âmbito e factos ou pressupostos expressos;sem certificado/aprovador novo.',
);
if (row.category === 'art') {
if (
!['original', 'print', 'sculpture', 'ceramic'].includes(
row.artSubtype,
)
)
return stop(
'Identifique pintura/desenho original,gravura/estampa/litografia,escultura/estatuária ou cerâmica.',
);
if (
row.artSubtype === 'print' &&
(!/^\d+$/.test(row.editionCount) || BigInt(row.editionCount) < 1n)
)
return stop('Identifique tiragem da gravura/estampa/litografia.');
if (row.artSubtype === 'print' && BigInt(row.editionCount) > 200n)
return {
...stop(
'Tiragem superior a200 não corresponde a esta definição de arte3(6)b.',
'not_applicable',
),
classification: 'not_applicable',
};
if (row.artSubtype === 'ceramic' && row.ceramicUniqueSigned !== 'yes')
return stop(
'Esclareça se a cerâmica é exemplar único executado eassinado pelo artista3(6)b.',
);
}
if (
row.category === 'collection' &&
(!['postal', 'scientific', 'historical'].includes(
row.collectionSubtype,
) ||
(row.collectionSubtype === 'postal' &&
row.postalNotCirculating !== 'yes'))
)
return stop(
'Identifique colecção postal não emcirculação/nem destinada acircular,colecções/espécimes científicos ou interesse histórico/arqueológico/paleontológico/etnográfico/numismático3(6)c.',
);
if (row.category === 'antique') {
const age = decimal(row.ageYears);
if (age === null) return stop('Identifique idade emanos do bem.');
if (age <= 100000000n)
return {
...stop(
'A definição de antiguidade3(6)d exige mais de100anos.',
'not_applicable',
),
classification: 'not_applicable',
};
}
result.classification = 'qualified_goods';
if (row.domesticPurchase !== 'yes' || !row.sourceRef.trim())
return stop(
'P125identificado;P092/P093 por esclarecer:origem nacional equalidade do fornecedor/comitente comreferência48(1)/49(1).',
);
if (row.sourceType === 'legacy_35_42')
return stop(
'P125identificado;referência histórica aos regimes35/42 não activa margem em2026:Lei10/2025revogou essas subsecções. Conferir situação aplicável sem reactivar regime revogado.',
);
if (
!['private', 'margin_reseller', 'exempt_9_12_e'].includes(
row.sourceType,
)
)
return stop(
'P125identificado;origem elegível particular,revendedor na margem ou transmissão9(12)e não demonstrada. Outro regime permanece por esclarecer.',
);
if (row.treatment === 'reseller_margin' && row.regimeChoice === 'general')
return stop(
'P125identificado;opção pelo regime geral47(1)não aplica esta fórmula de margem.',
'not_applicable',
);
if (row.treatment === 'reseller_margin' && row.regimeChoice !== 'margin')
return stop(
'P125identificado;esclareça opção margem/regime geral do revendedor47(1).',
);
if (
row.domesticTaxable !== 'yes' ||
row.conditions.regimeConfirmed !== 'yes' ||
row.conditions.compositionConfirmed !== 'yes'
)
return stop(
'P125identificado;base por esclarecer:transmissão interna tributável,natureza/regime elegível ecomposição confirmados;exportação13/48(7)tem percurso próprio.',
);
if (currency !== 'MZN' || row.currency !== currency)
return stop(
'P125identificado;base fora do recorteMZN ou moeda divergente,sem conversão silenciosa.',
);
if (row.treatment === 'reseller_margin') {
const sale = decimal(row.grossSale),
purchase = decimal(row.purchasePrice);
if (sale === null || purchase === null || !row.purchaseRef.trim())
return stop(
'Identifique contraprestação total devida pelocliente comIVA incluído epreço compra justificado do mesmo bem;desconhecido não é zero.',
);
const diff = sale - purchase,
positive = diff > 0n ? diff : 0n,
denominator = 116000000n;
const whole = (positive * 100n + denominator / 2n) / denominator;
return {
...result,
status: 'determined',
baseAmount: money(whole * 1000000n),
grossMargin: diff < 0n ? '-' + money(-diff) : money(diff),
baseDivisorRate: '16%',
reason: `P092:48(2)/(3)/(6),margem individual=${money(sale)}−${money(purchase)}=${diff < 0n ? '-' + money(-diff) : money(diff)}MZN;parcela positiva÷1.16,base arredondada à unidade=${money(whole * 1000000n)}MZN. ${diff < 0n ? 'Excesso do preço compra registado neste bem,sem compensar outras transmissões;sem margem positiva tributável.' : ''} Taxa16% utilizada só no divisor da base17(1)/48(6)b;não há IVA ou dedução calculados. Factura com menção IVA-Bens emSegundaMão,sem discriminarIVA48(8)/(9);registos separados48(10)/(11).`,
};
}
const invoice = decimal(row.grossInvoice),
hammer = decimal(row.hammerPrice),
commission = decimal(row.principalCommission),
tax = decimal(row.ownVat);
if (
invoice === null ||
hammer === null ||
commission === null ||
tax === null ||
![row.auctionRef, row.principalRef, row.ownVatRef].every(
(v) => !!v.trim(),
)
)
return stop(
'Identifique montante facturado,adjudicação,comissão do comitente eIVApróprio relativo à transmissão,comreferências;IVA desconhecido não é zero.',
);
if (commission > hammer)
return stop(
'Comissão excede adjudicação;conferir componentes antes de determinar líquido comitente49(3).',
);
const net = hammer - commission;
if (invoice < net + tax)
return stop(
'Facturado inferior ao líquido comitente+IVAindicado;conferir composição,sem base negativa ou zero substituto.',
);
return {
...result,
status: 'determined',
baseAmount: money(invoice - net - tax),
principalNet: money(net),
reason: `P093:15(2)g/49(2)/(3),líquido comitente=${money(hammer)}−${money(commission)}=${money(net)}MZN;base=${money(invoice)}facturado−${money(net)}líquido−${money(tax)}IVApróprio identificado=${money(invoice - net - tax)}MZN. IVAindicado é componente referenciada,não calculada aqui. Factura IVA-Regime específico devendas emleilão,sem discriminarIVA49(5);relatório ao comitente em5dias úteis49(6)/(7),sem calendário de feriados presumido;contas deterceiros49(8). Sem nova dedução/campo11 ou totaisModeloA.`,
};
}
function key(draft: Record<string, unknown>, book: SecondhandBook): string {
return JSON.stringify({
version: secondhandVersion,
scope: acquisitionScope(draft),
location: draft.selection_labels ?? {},
currency: draft.currency,
rows: book.rows,
});
}
export function secondhandCurrent(
draft: Record<string, unknown>,
book: SecondhandBook,
): boolean {
return !!book.result && book.result.sourceKey === key(draft, book);
}
export function recordSecondhands(
draft: Record<string, unknown>,
book: SecondhandBook,
): SecondhandBook {
if (
!book.rows.length ||
new Set(book.rows.map((row) => row.id)).size !== book.rows.length
)
throw new Error(
'Indique operações com identificadores distintos;originais conservados.',
);
const result: SecondhandCalculation = {
version: secondhandVersion,
revision: (book.result?.revision ?? 0) + 1,
calculatedAt: new Date().toISOString(),
opportunity: acquisitionScope(draft),
location: JSON.stringify(draft.selection_labels ?? {}),
sourceKey: key(draft, book),
source: secondhandSource,
inputs: structuredClone(book.rows),
rows: book.rows.map((row) => evaluateSecondhand(row, draft.currency)),
};
const next = {
...book,
result,
history: [
...(book.history ?? []),
...(book.result ? [book.result] : []),
],
};
saveSecondhandBook(draft, next);
return next;
}
export function secondhandReportRows(
draft: Record<string, unknown>,
): Array<[string, string, string, string]> {
try {
const book = secondhandBook(draft),
result = book.result;
if (!result) return [];
return result.inputs.map((row, i) => {
const line = result.rows[i],
rule = secondhandTreatments.find((t) => t.id === row.treatment);
return [
'IVA mensal',
`Bem P125/base${rule?.branch ?? 'margem'} — ${row.period} — ${row.id}`,
`${row.description};P125:${line.classification === 'qualified_goods' ? 'Categoria/estatuto identificados' : line.classification === 'not_applicable' ? 'Percurso não aplicável' : 'Indeterminado'};base:${line.baseAmount ?? 'Indeterminada'} ${row.currency};IVA: Indeterminado;${line.reason}`,
`${result.opportunity};${result.location};${secondhandCurrent(draft, book) ? 'Resultado actual' : 'Entradas alteradas;resultado histórico conservado'};versão${result.revision};${result.calculatedAt};${result.source};${row.origin === 'projected' ? 'Projecção' : 'Dados efectivos declarados'};${rule?.label};categoria ${row.category},${row.categoryRef};critérios ${row.category === 'art' ? `${row.artSubtype},tiragem ${row.editionCount},cerâmica única/assinada ${row.ceramicUniqueSigned}` : row.category === 'collection' ? `${row.collectionSubtype},postal fora de circulação ${row.postalNotCirculating}` : row.category === 'antique' ? `idade ${row.ageYears} anos` : JSON.stringify(row.conditions)};bem ${row.goodsRef};operador ${row.operatorRef};origem ${row.sourceType},${row.sourceRef};operação ${row.operationRef},${row.operationDate};documento ${row.documentRef},${row.documentDate};${row.treatment === 'reseller_margin' ? `venda comIVA ${row.grossSale};compra ${row.purchasePrice},${row.purchaseRef};margem ${line.grossMargin ?? 'indeterminada'};divisor ${line.baseDivisorRate ?? 'indeterminado'}` : `facturado ${row.grossInvoice};adjudicação ${row.hammerPrice};comissão comitente ${row.principalCommission};líquido ${line.principalNet ?? 'indeterminado'},${row.principalRef};IVAcomponente ${row.ownVat},${row.ownVatRef};leilão ${row.auctionRef}`};âmbito ${row.scope};fundamento ${row.evidence};sem nova dedução/campo11/totaisModeloA.`,
];
});
} catch {
return [
[
'IVA mensal',
'Bens usados/margem',
'Dados não recuperáveis',
'Originais preservados;sem base por defeito.',
],
];
}
}