kotsu-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,52 @@
1
+ """Geração de parcelas (D2, D8, D11, D17). Puro: o grupo vem de fora."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import datetime as dt
7
+
8
+ from money_cli.dominio.linha import LinhaInterpretada
9
+ from money_cli.dominio.mes import Mes
10
+ from money_cli.dominio.tipos import NovoLancamento, Parcela
11
+
12
+
13
+ def data_da_parcela(primeira: dt.date, indice: int) -> dt.date:
14
+ """Data da parcela `indice` (0 = primeira): mesmo dia, `indice` meses depois.
15
+
16
+ Dia que o mês não tem vira o último dia do mês (31/01 -> 28/02).
17
+ """
18
+ mes = Mes.de(primeira).somar(indice)
19
+ ultimo = calendar.monthrange(mes.ano, mes.mes)[1]
20
+ return dt.date(mes.ano, mes.mes, min(primeira.day, ultimo))
21
+
22
+
23
+ def gerar(
24
+ linha: LinhaInterpretada,
25
+ conta_id: int,
26
+ categoria_id: int | None,
27
+ grupo: str | None,
28
+ ) -> list[NovoLancamento]:
29
+ """Transforma a linha em lançamentos: um, ou um por parcela em meses seguidos.
30
+
31
+ O valor digitado é o de cada parcela (D11). `grupo` é obrigatório se há parcelas.
32
+ """
33
+ if linha.parcelas == 1:
34
+ return [
35
+ NovoLancamento(
36
+ linha.data, linha.descricao, linha.valor, linha.tipo, conta_id, categoria_id
37
+ )
38
+ ]
39
+ if not grupo:
40
+ raise ValueError("parcelas precisam de um grupo")
41
+ return [
42
+ NovoLancamento(
43
+ data_da_parcela(linha.data, i),
44
+ linha.descricao,
45
+ linha.valor,
46
+ linha.tipo,
47
+ conta_id,
48
+ categoria_id,
49
+ Parcela(grupo, i + 1, linha.parcelas),
50
+ )
51
+ for i in range(linha.parcelas)
52
+ ]
@@ -0,0 +1,20 @@
1
+ """Datas de lançamentos recorrentes (D36). Puro: sem banco."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import calendar
6
+ import datetime as dt
7
+
8
+ from money_cli.dominio.mes import Mes
9
+
10
+
11
+ def data_no_mes(dia: int, mes: Mes) -> dt.date:
12
+ """O `dia` no mês; dia que o mês não tem vira o último (31 -> 28/02)."""
13
+ ultimo = calendar.monthrange(mes.ano, mes.mes)[1]
14
+ return dt.date(mes.ano, mes.mes, min(dia, ultimo))
15
+
16
+
17
+ def meses_a_gerar(gerada_ate: Mes, ate: Mes) -> list[Mes]:
18
+ """Meses depois de `gerada_ate` até `ate`, inclusive. Vazio se já está gerado."""
19
+ quantos = (ate.ano * 12 + ate.mes) - (gerada_ate.ano * 12 + gerada_ate.mes)
20
+ return [gerada_ate.somar(i) for i in range(1, quantos + 1)]
@@ -0,0 +1,95 @@
1
+ """Tipos do domínio. Dinheiro é sempre em centavos inteiros (BRL)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from dataclasses import dataclass
7
+ from enum import StrEnum
8
+
9
+
10
+ class Tipo(StrEnum):
11
+ RECEITA = "receita"
12
+ DESPESA = "despesa"
13
+
14
+
15
+ class Alcance(StrEnum):
16
+ """Resposta a "só esta ou todas?" ao mexer numa parcela (D12). Padrão: todas."""
17
+
18
+ SO_ESTA = "so_esta"
19
+ TODAS = "todas"
20
+ PROXIMAS = "proximas" # recorrente: este mês e os seguintes (D36)
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Conta:
25
+ """Etiqueta colorida (D9): não acumula saldo entre meses."""
26
+
27
+ id: int
28
+ nome: str
29
+ cor: str
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Categoria:
34
+ id: int
35
+ nome: str
36
+ recorrente: bool = False # lançamentos nela se repetem todo mês (D36)
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class Parcela:
41
+ """Posição de um lançamento dentro de uma compra parcelada (D2, D8)."""
42
+
43
+ grupo: str
44
+ numero: int
45
+ total: int
46
+
47
+ def __post_init__(self) -> None:
48
+ if not 1 <= self.numero <= self.total:
49
+ raise ValueError(f"parcela {self.numero}/{self.total} inválida")
50
+
51
+ @property
52
+ def rotulo(self) -> str:
53
+ return f"{self.numero}/{self.total}"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class NovoLancamento:
58
+ """Lançamento ainda não salvo. `valor` é positivo; o sinal vem de `tipo`.
59
+
60
+ Todo lançamento tem conta (D21); categoria é opcional.
61
+ """
62
+
63
+ data: dt.date
64
+ descricao: str
65
+ valor: int
66
+ tipo: Tipo
67
+ conta_id: int
68
+ categoria_id: int | None = None
69
+ parcela: Parcela | None = None
70
+ grupo_recorrencia: str | None = None # série mensal sem fim (D36)
71
+
72
+ def __post_init__(self) -> None:
73
+ if not isinstance(self.valor, int) or isinstance(self.valor, bool):
74
+ raise TypeError("valor deve ser int em centavos")
75
+ if self.valor <= 0:
76
+ raise ValueError("valor deve ser positivo; o tipo diz se é receita ou despesa")
77
+ if not isinstance(self.conta_id, int) or isinstance(self.conta_id, bool):
78
+ raise ValueError("todo lançamento precisa de uma conta")
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class Lancamento:
83
+ id: int
84
+ data: dt.date
85
+ descricao: str
86
+ valor: int
87
+ tipo: Tipo
88
+ conta_id: int
89
+ categoria_id: int | None
90
+ parcela: Parcela | None
91
+ grupo_recorrencia: str | None = None
92
+
93
+ @property
94
+ def valor_com_sinal(self) -> int:
95
+ return self.valor if self.tipo is Tipo.RECEITA else -self.valor
@@ -0,0 +1,17 @@
1
+ """Totais do mês. Nunca gravados: o armazenamento calcula e entrega aqui."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class TotaisMes:
10
+ receitas: int = 0
11
+ despesas: int = 0
12
+ quantidade: int = 0 # lançamentos do mês (ou do filtro)
13
+
14
+ @property
15
+ def saldo(self) -> int:
16
+ """Receitas menos despesas do mês (D8)."""
17
+ return self.receitas - self.despesas
@@ -0,0 +1 @@
1
+ """Camada de tela: app Textual, cores e atalhos. Nenhuma regra financeira aqui."""