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.
- kotsu_cli-0.1.0.dist-info/METADATA +252 -0
- kotsu_cli-0.1.0.dist-info/RECORD +33 -0
- kotsu_cli-0.1.0.dist-info/WHEEL +4 -0
- kotsu_cli-0.1.0.dist-info/entry_points.txt +3 -0
- kotsu_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- money_cli/__init__.py +3 -0
- money_cli/__main__.py +197 -0
- money_cli/armazenamento/__init__.py +4 -0
- money_cli/armazenamento/backup.py +70 -0
- money_cli/armazenamento/banco.py +525 -0
- money_cli/armazenamento/caminhos.py +72 -0
- money_cli/armazenamento/migracoes/0001_inicial.sql +35 -0
- money_cli/armazenamento/migracoes/0002_recorrencia.sql +21 -0
- money_cli/armazenamento/migracoes/0003_mesclagem.sql +54 -0
- money_cli/armazenamento/migracoes/__init__.py +32 -0
- money_cli/armazenamento/preferencias.py +31 -0
- money_cli/armazenamento/trava.py +123 -0
- money_cli/armazenamento/troca.py +285 -0
- money_cli/dominio/__init__.py +5 -0
- money_cli/dominio/dinheiro.py +10 -0
- money_cli/dominio/filtro.py +63 -0
- money_cli/dominio/linha.py +179 -0
- money_cli/dominio/mes.py +57 -0
- money_cli/dominio/mescla.py +440 -0
- money_cli/dominio/parcelas.py +52 -0
- money_cli/dominio/recorrencia.py +20 -0
- money_cli/dominio/tipos.py +95 -0
- money_cli/dominio/totais.py +17 -0
- money_cli/tela/__init__.py +1 -0
- money_cli/tela/app.py +756 -0
- money_cli/tela/cores.py +31 -0
- money_cli/tela/perguntas.py +144 -0
- money_cli/tela/temas.py +74 -0
|
@@ -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."""
|