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,525 @@
|
|
|
1
|
+
"""Conexão SQLite, migrações e gravação atômica.
|
|
2
|
+
|
|
3
|
+
Toda escrita passa por `Banco.transacao()`: ou grava tudo, ou nada.
|
|
4
|
+
Antes de migrar um banco que já tem dados, uma cópia é feita ao lado.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
import sqlite3
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from money_cli.armazenamento import migracoes
|
|
18
|
+
from money_cli.dominio.filtro import SEM_FILTRO, Filtro
|
|
19
|
+
from money_cli.dominio.mes import Mes
|
|
20
|
+
from money_cli.dominio.mescla import carimbo, uid_do_mes
|
|
21
|
+
from money_cli.dominio.recorrencia import data_no_mes, meses_a_gerar
|
|
22
|
+
from money_cli.dominio.tipos import (
|
|
23
|
+
Alcance,
|
|
24
|
+
Categoria,
|
|
25
|
+
Conta,
|
|
26
|
+
Lancamento,
|
|
27
|
+
NovoLancamento,
|
|
28
|
+
Parcela,
|
|
29
|
+
Tipo,
|
|
30
|
+
)
|
|
31
|
+
from money_cli.dominio.totais import TotaisMes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _conectar(caminho: Path | str) -> sqlite3.Connection:
|
|
35
|
+
conexao = sqlite3.connect(caminho, isolation_level=None) # transações explícitas
|
|
36
|
+
conexao.execute("PRAGMA foreign_keys = ON")
|
|
37
|
+
conexao.execute("PRAGMA synchronous = FULL")
|
|
38
|
+
return conexao
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def versao_do_esquema(conexao: sqlite3.Connection) -> int:
|
|
42
|
+
return conexao.execute("PRAGMA user_version").fetchone()[0]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def copiar_banco(origem: sqlite3.Connection, destino: Path) -> None:
|
|
46
|
+
"""Cópia consistente do banco aberto (API de backup do SQLite)."""
|
|
47
|
+
alvo = sqlite3.connect(destino)
|
|
48
|
+
try:
|
|
49
|
+
origem.backup(alvo)
|
|
50
|
+
finally:
|
|
51
|
+
alvo.close()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def copiar_arquivo_banco(origem: Path, destino: Path) -> None:
|
|
55
|
+
"""Copia um banco fechado para um caminho novo, de forma consistente."""
|
|
56
|
+
conexao = sqlite3.connect(origem)
|
|
57
|
+
try:
|
|
58
|
+
copiar_banco(conexao, destino)
|
|
59
|
+
finally:
|
|
60
|
+
conexao.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def migrar(
|
|
64
|
+
conexao: sqlite3.Connection,
|
|
65
|
+
caminho: Path | None,
|
|
66
|
+
lista: Sequence[migracoes.Migracao] | None = None,
|
|
67
|
+
) -> int:
|
|
68
|
+
"""Aplica as migrações pendentes, cada uma numa transação. Devolve a versão final."""
|
|
69
|
+
lista = migracoes.carregar() if lista is None else lista
|
|
70
|
+
atual = versao_do_esquema(conexao)
|
|
71
|
+
pendentes = [m for m in lista if m.versao > atual]
|
|
72
|
+
if atual > len(lista):
|
|
73
|
+
raise RuntimeError(
|
|
74
|
+
f"banco na versão {atual}, mas este Money CLI só conhece até {len(lista)}"
|
|
75
|
+
)
|
|
76
|
+
if pendentes and atual > 0 and caminho is not None:
|
|
77
|
+
copiar_banco(conexao, caminho.with_name(f"{caminho.stem}.antes-v{atual + 1}.sqlite"))
|
|
78
|
+
for migracao in pendentes:
|
|
79
|
+
conexao.execute("BEGIN IMMEDIATE")
|
|
80
|
+
try:
|
|
81
|
+
for comando in _comandos(migracao.sql):
|
|
82
|
+
conexao.execute(comando)
|
|
83
|
+
conexao.execute(f"PRAGMA user_version = {migracao.versao}")
|
|
84
|
+
conexao.execute("COMMIT")
|
|
85
|
+
except BaseException:
|
|
86
|
+
conexao.execute("ROLLBACK")
|
|
87
|
+
raise
|
|
88
|
+
return versao_do_esquema(conexao)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _comandos(sql: str) -> list[str]:
|
|
92
|
+
"""Divide um script em comandos completos (sem usar executescript, que faz COMMIT)."""
|
|
93
|
+
comandos, atual = [], ""
|
|
94
|
+
for linha in sql.splitlines(keepends=True):
|
|
95
|
+
atual += linha
|
|
96
|
+
if sqlite3.complete_statement(atual):
|
|
97
|
+
if atual.strip():
|
|
98
|
+
comandos.append(atual)
|
|
99
|
+
atual = ""
|
|
100
|
+
resto = [
|
|
101
|
+
linha
|
|
102
|
+
for linha in atual.splitlines()
|
|
103
|
+
if linha.strip() and not linha.strip().startswith("--")
|
|
104
|
+
]
|
|
105
|
+
if resto:
|
|
106
|
+
raise ValueError("migração termina com comando incompleto")
|
|
107
|
+
return comandos
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Banco:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
conexao: sqlite3.Connection,
|
|
114
|
+
caminho: Path | None,
|
|
115
|
+
pasta_backups: Path | None = None,
|
|
116
|
+
) -> None:
|
|
117
|
+
self._conexao = conexao
|
|
118
|
+
self.caminho = caminho
|
|
119
|
+
self.pasta_backups = pasta_backups # None: `backups/` ao lado do banco
|
|
120
|
+
# Relógio de parede em milissegundos; os testes trocam por um relógio fixo.
|
|
121
|
+
self.agora_ms: Callable[[], int] = lambda: time.time_ns() // 1_000_000
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def abrir(cls, caminho: Path, pasta_backups: Path | None = None) -> Banco:
|
|
125
|
+
caminho.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
conexao = _conectar(caminho)
|
|
127
|
+
try:
|
|
128
|
+
migrar(conexao, caminho)
|
|
129
|
+
except BaseException:
|
|
130
|
+
conexao.close()
|
|
131
|
+
raise
|
|
132
|
+
return cls(conexao, caminho, pasta_backups)
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def em_memoria(cls) -> Banco:
|
|
136
|
+
conexao = _conectar(":memory:")
|
|
137
|
+
migrar(conexao, None)
|
|
138
|
+
return cls(conexao, None)
|
|
139
|
+
|
|
140
|
+
def fechar(self) -> None:
|
|
141
|
+
self._conexao.close()
|
|
142
|
+
|
|
143
|
+
def backup_manual(self, agora: dt.datetime | None = None) -> Path | None:
|
|
144
|
+
from money_cli.armazenamento.backup import backup_manual
|
|
145
|
+
|
|
146
|
+
if self.caminho is None:
|
|
147
|
+
return None
|
|
148
|
+
try:
|
|
149
|
+
return backup_manual(
|
|
150
|
+
self._conexao, self.caminho, agora or dt.datetime.now(), self.pasta_backups
|
|
151
|
+
)
|
|
152
|
+
except sqlite3.Error:
|
|
153
|
+
raise OSError("não foi possível copiar o banco SQLite") from None
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def versao(self) -> int:
|
|
157
|
+
return versao_do_esquema(self._conexao)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def conexao(self) -> sqlite3.Connection:
|
|
161
|
+
"""Para leituras de outros módulos de `armazenamento`. Escrita: `transacao()`."""
|
|
162
|
+
return self._conexao
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def replica(self) -> str:
|
|
166
|
+
"""Identificador deste banco entre aparelhos (D39); desempata carimbos."""
|
|
167
|
+
return self._meta("replica")
|
|
168
|
+
|
|
169
|
+
def _meta(self, chave: str) -> str:
|
|
170
|
+
return self._conexao.execute("SELECT valor FROM meta WHERE chave = ?", (chave,)).fetchone()[
|
|
171
|
+
0
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def carimbar(self, c: sqlite3.Connection, visto: str | None = None) -> str:
|
|
175
|
+
"""Carimbo novo para as alterações desta transação (D39): sempre maior que os já
|
|
176
|
+
dados aqui e que `visto` (o maior carimbo de um arquivo mesclado)."""
|
|
177
|
+
ultimo = max(self._meta("relogio"), visto or "")
|
|
178
|
+
novo = carimbo(self.agora_ms(), ultimo, self.replica)
|
|
179
|
+
c.execute("UPDATE meta SET valor = ? WHERE chave = 'relogio'", (novo,))
|
|
180
|
+
return novo
|
|
181
|
+
|
|
182
|
+
@contextmanager
|
|
183
|
+
def transacao(self) -> Iterator[sqlite3.Connection]:
|
|
184
|
+
"""Tudo dentro do bloco é gravado junto, ou nada é gravado."""
|
|
185
|
+
self._conexao.execute("BEGIN IMMEDIATE")
|
|
186
|
+
try:
|
|
187
|
+
yield self._conexao
|
|
188
|
+
except BaseException:
|
|
189
|
+
self._conexao.execute("ROLLBACK")
|
|
190
|
+
raise
|
|
191
|
+
else:
|
|
192
|
+
self._conexao.execute("COMMIT")
|
|
193
|
+
|
|
194
|
+
# --- contas e categorias -------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def criar_conta(self, nome: str, cor: str) -> Conta:
|
|
197
|
+
with self.transacao() as c:
|
|
198
|
+
cursor = c.execute(
|
|
199
|
+
"INSERT INTO conta (nome, cor, uid, alterado_em) VALUES (?, ?, ?, ?)",
|
|
200
|
+
(nome.strip(), cor, uuid.uuid4().hex, self.carimbar(c)),
|
|
201
|
+
)
|
|
202
|
+
return Conta(cursor.lastrowid, nome.strip(), cor)
|
|
203
|
+
|
|
204
|
+
def listar_contas(self) -> list[Conta]:
|
|
205
|
+
linhas = self._conexao.execute("SELECT id, nome, cor FROM conta ORDER BY nome")
|
|
206
|
+
return [Conta(*linha) for linha in linhas]
|
|
207
|
+
|
|
208
|
+
def criar_categoria(self, nome: str, recorrente: bool = False) -> Categoria:
|
|
209
|
+
with self.transacao() as c:
|
|
210
|
+
cursor = c.execute(
|
|
211
|
+
"INSERT INTO categoria (nome, recorrente, uid, alterado_em) VALUES (?, ?, ?, ?)",
|
|
212
|
+
(nome.strip(), int(recorrente), uuid.uuid4().hex, self.carimbar(c)),
|
|
213
|
+
)
|
|
214
|
+
return Categoria(cursor.lastrowid, nome.strip(), recorrente)
|
|
215
|
+
|
|
216
|
+
def listar_categorias(self) -> list[Categoria]:
|
|
217
|
+
linhas = self._conexao.execute("SELECT id, nome, recorrente FROM categoria ORDER BY nome")
|
|
218
|
+
return [Categoria(id_, nome, bool(rec)) for id_, nome, rec in linhas]
|
|
219
|
+
|
|
220
|
+
def definir_recorrente(self, categoria_id: int, recorrente: bool) -> None:
|
|
221
|
+
"""Marca ou desmarca a categoria (D36). Séries já em andamento não mudam."""
|
|
222
|
+
with self.transacao() as c:
|
|
223
|
+
c.execute(
|
|
224
|
+
"UPDATE categoria SET recorrente = ?, alterado_em = ? WHERE id = ?",
|
|
225
|
+
(int(recorrente), self.carimbar(c), categoria_id),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def conta_por_nome(self, nome: str) -> Conta | None:
|
|
229
|
+
"""Busca ignorando maiúsculas, inclusive acentuadas (Itaú = ITAÚ)."""
|
|
230
|
+
alvo = nome.strip().casefold()
|
|
231
|
+
return next((c for c in self.listar_contas() if c.nome.casefold() == alvo), None)
|
|
232
|
+
|
|
233
|
+
def categoria_por_nome(self, nome: str) -> Categoria | None:
|
|
234
|
+
alvo = nome.strip().casefold()
|
|
235
|
+
return next((c for c in self.listar_categorias() if c.nome.casefold() == alvo), None)
|
|
236
|
+
|
|
237
|
+
# --- lançamentos ---------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def novo_grupo_parcela() -> str:
|
|
241
|
+
return uuid.uuid4().hex
|
|
242
|
+
|
|
243
|
+
def inserir_lancamentos(self, novos: Sequence[NovoLancamento]) -> list[int]:
|
|
244
|
+
"""Insere vários lançamentos (ex.: todas as parcelas) numa transação só."""
|
|
245
|
+
ids = []
|
|
246
|
+
with self.transacao() as c:
|
|
247
|
+
agora = self.carimbar(c)
|
|
248
|
+
for n in novos:
|
|
249
|
+
if n.grupo_recorrencia:
|
|
250
|
+
c.execute(
|
|
251
|
+
"INSERT INTO recorrencia (grupo, dia, descricao, valor, tipo, conta_id,"
|
|
252
|
+
" categoria_id, gerada_ate, alterado_em)"
|
|
253
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
254
|
+
(
|
|
255
|
+
n.grupo_recorrencia,
|
|
256
|
+
n.data.day,
|
|
257
|
+
n.descricao,
|
|
258
|
+
n.valor,
|
|
259
|
+
n.tipo.value,
|
|
260
|
+
n.conta_id,
|
|
261
|
+
n.categoria_id,
|
|
262
|
+
Mes.de(n.data).inicio.isoformat(),
|
|
263
|
+
agora,
|
|
264
|
+
),
|
|
265
|
+
)
|
|
266
|
+
ids.append(_inserir(c, n, agora))
|
|
267
|
+
return ids
|
|
268
|
+
|
|
269
|
+
def tornar_recorrente(self, id_: int) -> bool:
|
|
270
|
+
"""Faz do lançamento o começo de uma série mensal (D36). False se já é parcela
|
|
271
|
+
ou já é recorrente."""
|
|
272
|
+
item = self.lancamento(id_)
|
|
273
|
+
if item is None or item.parcela or item.grupo_recorrencia:
|
|
274
|
+
return False
|
|
275
|
+
grupo = self.novo_grupo_parcela()
|
|
276
|
+
with self.transacao() as c:
|
|
277
|
+
agora = self.carimbar(c)
|
|
278
|
+
c.execute(
|
|
279
|
+
"INSERT INTO recorrencia (grupo, dia, descricao, valor, tipo, conta_id,"
|
|
280
|
+
" categoria_id, gerada_ate, alterado_em) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
281
|
+
(
|
|
282
|
+
grupo,
|
|
283
|
+
item.data.day,
|
|
284
|
+
item.descricao,
|
|
285
|
+
item.valor,
|
|
286
|
+
item.tipo.value,
|
|
287
|
+
item.conta_id,
|
|
288
|
+
item.categoria_id,
|
|
289
|
+
Mes.de(item.data).inicio.isoformat(),
|
|
290
|
+
agora,
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
c.execute(
|
|
294
|
+
"UPDATE lancamento SET grupo_recorrencia = ?, alterado_em = ? WHERE id = ?",
|
|
295
|
+
(grupo, agora, id_),
|
|
296
|
+
)
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def estender_recorrencias(self, ate: Mes) -> int:
|
|
300
|
+
"""Gera os meses que faltam das séries ativas, até `ate` (D36). Devolve quantos.
|
|
301
|
+
|
|
302
|
+
Só avança: mês excluído com "só este" nunca volta, e série encerrada não gera.
|
|
303
|
+
O mês gerado tem `uid` fixo (série + mês) e o carimbo do modelo da série, para dois
|
|
304
|
+
aparelhos gerarem o mesmo registro e qualquer edição ou exclusão vencer (D39).
|
|
305
|
+
"""
|
|
306
|
+
series = self._conexao.execute(
|
|
307
|
+
"SELECT grupo, dia, descricao, valor, tipo, conta_id, categoria_id, gerada_ate,"
|
|
308
|
+
" alterado_em"
|
|
309
|
+
" FROM recorrencia WHERE encerrada = 0 AND gerada_ate < ?",
|
|
310
|
+
(ate.inicio.isoformat(),),
|
|
311
|
+
).fetchall()
|
|
312
|
+
if not series:
|
|
313
|
+
return 0
|
|
314
|
+
criados = 0
|
|
315
|
+
with self.transacao() as c:
|
|
316
|
+
for grupo, dia, descricao, valor, tipo, conta_id, categoria_id, gerada, em in series:
|
|
317
|
+
meses = meses_a_gerar(Mes.de(dt.date.fromisoformat(gerada)), ate)
|
|
318
|
+
for mes in meses:
|
|
319
|
+
uid = uid_do_mes(grupo, mes.ano, mes.mes)
|
|
320
|
+
if c.execute(
|
|
321
|
+
"SELECT 1 FROM lancamento WHERE uid = ?1"
|
|
322
|
+
" UNION ALL SELECT 1 FROM lapide WHERE uid = ?1",
|
|
323
|
+
(uid,),
|
|
324
|
+
).fetchone():
|
|
325
|
+
continue # já veio de outro aparelho, ou foi excluído lá
|
|
326
|
+
_inserir(
|
|
327
|
+
c,
|
|
328
|
+
NovoLancamento(
|
|
329
|
+
data_no_mes(dia, mes),
|
|
330
|
+
descricao,
|
|
331
|
+
valor,
|
|
332
|
+
Tipo(tipo),
|
|
333
|
+
conta_id,
|
|
334
|
+
categoria_id,
|
|
335
|
+
grupo_recorrencia=grupo,
|
|
336
|
+
),
|
|
337
|
+
em,
|
|
338
|
+
uid=uid,
|
|
339
|
+
gerado=True,
|
|
340
|
+
)
|
|
341
|
+
criados += 1
|
|
342
|
+
c.execute(
|
|
343
|
+
"UPDATE recorrencia SET gerada_ate = ? WHERE grupo = ?",
|
|
344
|
+
(ate.inicio.isoformat(), grupo),
|
|
345
|
+
)
|
|
346
|
+
return criados
|
|
347
|
+
|
|
348
|
+
def lancamentos_do_mes(
|
|
349
|
+
self, mes: Mes, limite: int = 200, desde: int = 0, filtro: Filtro = SEM_FILTRO
|
|
350
|
+
) -> list[Lancamento]:
|
|
351
|
+
"""Uma página do mês, mais recentes primeiro. `desde` é quantos pular."""
|
|
352
|
+
onde, parametros = _onde_mes(mes, filtro)
|
|
353
|
+
linhas = self._conexao.execute(
|
|
354
|
+
"SELECT id, data, descricao, valor, tipo, conta_id, categoria_id,"
|
|
355
|
+
" grupo_parcela, parcela_numero, parcela_total, grupo_recorrencia"
|
|
356
|
+
f" FROM lancamento WHERE {onde}"
|
|
357
|
+
" ORDER BY data DESC, id DESC LIMIT ? OFFSET ?",
|
|
358
|
+
(*parametros, limite, desde),
|
|
359
|
+
)
|
|
360
|
+
return [_lancamento(linha) for linha in linhas]
|
|
361
|
+
|
|
362
|
+
def lancamento(self, id_: int) -> Lancamento | None:
|
|
363
|
+
linha = self._conexao.execute(
|
|
364
|
+
"SELECT id, data, descricao, valor, tipo, conta_id, categoria_id,"
|
|
365
|
+
" grupo_parcela, parcela_numero, parcela_total, grupo_recorrencia"
|
|
366
|
+
" FROM lancamento WHERE id = ?",
|
|
367
|
+
(id_,),
|
|
368
|
+
).fetchone()
|
|
369
|
+
return _lancamento(linha) if linha else None
|
|
370
|
+
|
|
371
|
+
def excluir(self, id_: int, alcance: Alcance = Alcance.TODAS) -> int:
|
|
372
|
+
"""Exclui o lançamento; se é parcela e alcance é TODAS, a compra inteira (D12).
|
|
373
|
+
|
|
374
|
+
Devolve quantos lançamentos saíram.
|
|
375
|
+
"""
|
|
376
|
+
onde, parametros = self._alvo(id_, alcance)
|
|
377
|
+
with self.transacao() as c:
|
|
378
|
+
agora = self.carimbar(c)
|
|
379
|
+
if alcance is Alcance.PROXIMAS: # encerra a série; meses passados ficam (D36)
|
|
380
|
+
c.execute(
|
|
381
|
+
"UPDATE recorrencia SET encerrada = 1, alterado_em = ? WHERE grupo = ?",
|
|
382
|
+
(agora, parametros[0]),
|
|
383
|
+
)
|
|
384
|
+
c.execute( # lápide: o excluído não volta ao mesclar com outro aparelho (D39)
|
|
385
|
+
"INSERT OR REPLACE INTO lapide (uid, excluido_em)"
|
|
386
|
+
f" SELECT uid, ? FROM lancamento WHERE {onde}",
|
|
387
|
+
(agora, *parametros),
|
|
388
|
+
)
|
|
389
|
+
return c.execute(f"DELETE FROM lancamento WHERE {onde}", parametros).rowcount
|
|
390
|
+
|
|
391
|
+
def renomear(self, id_: int, descricao: str, alcance: Alcance = Alcance.TODAS) -> int:
|
|
392
|
+
"""Troca a descrição; com parcela e alcance TODAS, de todas as parcelas (D12)."""
|
|
393
|
+
onde, parametros = self._alvo(id_, alcance)
|
|
394
|
+
with self.transacao() as c:
|
|
395
|
+
agora = self.carimbar(c)
|
|
396
|
+
if alcance is Alcance.PROXIMAS:
|
|
397
|
+
c.execute(
|
|
398
|
+
"UPDATE recorrencia SET descricao = ?, alterado_em = ? WHERE grupo = ?",
|
|
399
|
+
(descricao.strip(), agora, parametros[0]),
|
|
400
|
+
)
|
|
401
|
+
return c.execute(
|
|
402
|
+
f"UPDATE lancamento SET descricao = ?, alterado_em = ?, gerado = 0 WHERE {onde}",
|
|
403
|
+
(descricao.strip(), agora, *parametros),
|
|
404
|
+
).rowcount
|
|
405
|
+
|
|
406
|
+
def editar(self, id_: int, novo: NovoLancamento, alcance: Alcance = Alcance.TODAS) -> int:
|
|
407
|
+
"""Troca valor, tipo, descrição, conta e categoria (D28).
|
|
408
|
+
|
|
409
|
+
A data muda só quando o alvo é um lançamento só; em TODAS as parcelas e em PROXIMAS
|
|
410
|
+
as datas ficam como estão (PROXIMAS também muda o modelo da série, D36). Número e
|
|
411
|
+
grupo de parcelas nunca mudam. Devolve quantos mudaram.
|
|
412
|
+
"""
|
|
413
|
+
onde, parametros = self._alvo(id_, alcance)
|
|
414
|
+
campos = "descricao = ?, valor = ?, tipo = ?, conta_id = ?, categoria_id = ?"
|
|
415
|
+
valores: list = [novo.descricao.strip(), novo.valor, novo.tipo.value, novo.conta_id]
|
|
416
|
+
valores.append(novo.categoria_id)
|
|
417
|
+
if onde.startswith("id"):
|
|
418
|
+
campos += ", data = ?"
|
|
419
|
+
valores.append(novo.data.isoformat())
|
|
420
|
+
with self.transacao() as c:
|
|
421
|
+
agora = self.carimbar(c)
|
|
422
|
+
if alcance is Alcance.PROXIMAS:
|
|
423
|
+
c.execute(
|
|
424
|
+
"UPDATE recorrencia SET descricao = ?, valor = ?, tipo = ?, conta_id = ?,"
|
|
425
|
+
" categoria_id = ?, alterado_em = ? WHERE grupo = ?",
|
|
426
|
+
(*valores[:5], agora, parametros[0]),
|
|
427
|
+
)
|
|
428
|
+
return c.execute(
|
|
429
|
+
f"UPDATE lancamento SET {campos}, alterado_em = ?, gerado = 0 WHERE {onde}",
|
|
430
|
+
(*valores, agora, *parametros),
|
|
431
|
+
).rowcount
|
|
432
|
+
|
|
433
|
+
def _alvo(self, id_: int, alcance: Alcance) -> tuple[str, tuple]:
|
|
434
|
+
item = self.lancamento(id_)
|
|
435
|
+
if item is not None and item.parcela and alcance is Alcance.TODAS:
|
|
436
|
+
return "grupo_parcela = ?", (item.parcela.grupo,)
|
|
437
|
+
if item is not None and item.grupo_recorrencia and alcance is Alcance.PROXIMAS:
|
|
438
|
+
return "grupo_recorrencia = ? AND data >= ?", (
|
|
439
|
+
item.grupo_recorrencia,
|
|
440
|
+
item.data.isoformat(),
|
|
441
|
+
)
|
|
442
|
+
return "id = ?", (id_,)
|
|
443
|
+
|
|
444
|
+
def totais_do_mes(self, mes: Mes, filtro: Filtro = SEM_FILTRO) -> TotaisMes:
|
|
445
|
+
onde, parametros = _onde_mes(mes, filtro)
|
|
446
|
+
linha = self._conexao.execute(
|
|
447
|
+
f"SELECT {_SOMAS} FROM lancamento WHERE {onde}", parametros
|
|
448
|
+
).fetchone()
|
|
449
|
+
return TotaisMes(*linha)
|
|
450
|
+
|
|
451
|
+
def totais_por_conta(self, mes: Mes, filtro: Filtro = SEM_FILTRO) -> dict[int, TotaisMes]:
|
|
452
|
+
"""Receitas e despesas de cada conta que teve lançamento no mês (D9, D29)."""
|
|
453
|
+
onde, parametros = _onde_mes(mes, filtro)
|
|
454
|
+
linhas = self._conexao.execute(
|
|
455
|
+
f"SELECT conta_id, {_SOMAS} FROM lancamento WHERE {onde} GROUP BY conta_id",
|
|
456
|
+
parametros,
|
|
457
|
+
)
|
|
458
|
+
return {conta_id: TotaisMes(*resto) for conta_id, *resto in linhas}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
_SOMAS = (
|
|
462
|
+
"coalesce(sum(CASE WHEN tipo = 'receita' THEN valor END), 0),"
|
|
463
|
+
" coalesce(sum(CASE WHEN tipo = 'despesa' THEN valor END), 0),"
|
|
464
|
+
" count(*)"
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _onde_mes(mes: Mes, filtro: Filtro) -> tuple[str, tuple]:
|
|
469
|
+
"""Faixa de datas do mês (usa o índice) mais o filtro, se houver."""
|
|
470
|
+
onde = "data >= ? AND data < ?"
|
|
471
|
+
parametros: list = [mes.inicio.isoformat(), mes.fim_exclusivo.isoformat()]
|
|
472
|
+
if filtro.conta_id is not None:
|
|
473
|
+
onde += " AND conta_id = ?"
|
|
474
|
+
parametros.append(filtro.conta_id)
|
|
475
|
+
if filtro.categoria_id is not None:
|
|
476
|
+
onde += " AND categoria_id = ?"
|
|
477
|
+
parametros.append(filtro.categoria_id)
|
|
478
|
+
return onde, tuple(parametros)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _inserir(
|
|
482
|
+
c: sqlite3.Connection,
|
|
483
|
+
n: NovoLancamento,
|
|
484
|
+
alterado_em: str,
|
|
485
|
+
uid: str | None = None,
|
|
486
|
+
gerado: bool = False,
|
|
487
|
+
) -> int:
|
|
488
|
+
p = n.parcela
|
|
489
|
+
return c.execute(
|
|
490
|
+
"INSERT INTO lancamento (data, descricao, valor, tipo, conta_id, categoria_id,"
|
|
491
|
+
" grupo_parcela, parcela_numero, parcela_total, grupo_recorrencia,"
|
|
492
|
+
" uid, alterado_em, gerado)"
|
|
493
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
494
|
+
(
|
|
495
|
+
n.data.isoformat(),
|
|
496
|
+
n.descricao,
|
|
497
|
+
n.valor,
|
|
498
|
+
n.tipo.value,
|
|
499
|
+
n.conta_id,
|
|
500
|
+
n.categoria_id,
|
|
501
|
+
p.grupo if p else None,
|
|
502
|
+
p.numero if p else None,
|
|
503
|
+
p.total if p else None,
|
|
504
|
+
n.grupo_recorrencia,
|
|
505
|
+
uid or uuid.uuid4().hex,
|
|
506
|
+
alterado_em,
|
|
507
|
+
int(gerado),
|
|
508
|
+
),
|
|
509
|
+
).lastrowid
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _lancamento(linha: tuple) -> Lancamento:
|
|
513
|
+
(id_, data, descricao, valor, tipo, conta_id, categoria_id, grupo, numero, total, rec) = linha
|
|
514
|
+
parcela = Parcela(grupo, numero, total) if grupo is not None else None
|
|
515
|
+
return Lancamento(
|
|
516
|
+
id_,
|
|
517
|
+
dt.date.fromisoformat(data),
|
|
518
|
+
descricao,
|
|
519
|
+
valor,
|
|
520
|
+
Tipo(tipo),
|
|
521
|
+
conta_id,
|
|
522
|
+
categoria_id,
|
|
523
|
+
parcela,
|
|
524
|
+
rec,
|
|
525
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Onde ficam os dados do usuário."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tomllib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
NOME_ARQUIVO = "money.sqlite"
|
|
12
|
+
NOME_CONFIG = "local.toml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pasta_local() -> Path:
|
|
16
|
+
"""Pasta própria deste computador: %APPDATA%\\money-cli ou ~/.local/share/money-cli.
|
|
17
|
+
|
|
18
|
+
Nunca é sincronizada: guarda `local.toml` (onde estão os dados) e, quando os dados estão
|
|
19
|
+
numa pasta sincronizada, os backups (D38).
|
|
20
|
+
"""
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
23
|
+
return Path(base) / "money-cli"
|
|
24
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
25
|
+
return Path(base) / "money-cli"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pasta_configurada() -> Path | None:
|
|
29
|
+
"""`pasta_dados` do `local.toml`, ou None (ausente, ilegível ou inválido)."""
|
|
30
|
+
try:
|
|
31
|
+
with (pasta_local() / NOME_CONFIG).open("rb") as arquivo:
|
|
32
|
+
valor = tomllib.load(arquivo).get("pasta_dados")
|
|
33
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
34
|
+
return None
|
|
35
|
+
return Path(valor) if isinstance(valor, str) and valor.strip() else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def gravar_pasta_configurada(pasta: Path | None) -> None:
|
|
39
|
+
"""Grava (ou, com None, apaga) a pasta de dados deste computador. Levanta OSError."""
|
|
40
|
+
destino = pasta_local() / NOME_CONFIG
|
|
41
|
+
if pasta is None:
|
|
42
|
+
destino.unlink(missing_ok=True)
|
|
43
|
+
return
|
|
44
|
+
destino.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
temporario = destino.with_suffix(".toml.tmp")
|
|
46
|
+
# json.dumps gera uma string TOML válida (barras e acentos escapados).
|
|
47
|
+
temporario.write_text(f"pasta_dados = {json.dumps(str(pasta))}\n", encoding="utf-8")
|
|
48
|
+
temporario.replace(destino)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pasta_de_dados() -> Path:
|
|
52
|
+
"""Pasta de dados: MONEY_CLI_DADOS, ou `local.toml`, ou a pasta local do computador."""
|
|
53
|
+
explicita = os.environ.get("MONEY_CLI_DADOS")
|
|
54
|
+
if explicita:
|
|
55
|
+
return Path(explicita)
|
|
56
|
+
return pasta_configurada() or pasta_local()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def pasta_de_backups(pasta_dados: Path) -> Path | None:
|
|
60
|
+
"""Backups fora da pasta sincronizada quando a pasta vem do `local.toml` (D38).
|
|
61
|
+
|
|
62
|
+
None = padrão (`backups/` ao lado do banco).
|
|
63
|
+
"""
|
|
64
|
+
if os.environ.get("MONEY_CLI_DADOS"):
|
|
65
|
+
return None
|
|
66
|
+
if pasta_dados == pasta_configurada():
|
|
67
|
+
return pasta_local() / "backups"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def arquivo_do_banco(pasta: Path | None = None) -> Path:
|
|
72
|
+
return (pasta or pasta_de_dados()) / NOME_ARQUIVO
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- 0001: esquema inicial da v1.
|
|
2
|
+
-- Dinheiro em centavos inteiros e positivos; o sinal vem de `tipo`.
|
|
3
|
+
-- Datas civis ISO (AAAA-MM-DD), sem fuso. Totais nunca são gravados.
|
|
4
|
+
-- Todo lançamento tem conta (D21); categoria é opcional.
|
|
5
|
+
|
|
6
|
+
CREATE TABLE conta (
|
|
7
|
+
id INTEGER PRIMARY KEY,
|
|
8
|
+
nome TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(trim(nome)) > 0),
|
|
9
|
+
cor TEXT NOT NULL
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE TABLE categoria (
|
|
13
|
+
id INTEGER PRIMARY KEY,
|
|
14
|
+
nome TEXT NOT NULL UNIQUE COLLATE NOCASE CHECK (length(trim(nome)) > 0)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE lancamento (
|
|
18
|
+
id INTEGER PRIMARY KEY,
|
|
19
|
+
data TEXT NOT NULL CHECK (date(data) IS data),
|
|
20
|
+
descricao TEXT NOT NULL,
|
|
21
|
+
valor INTEGER NOT NULL CHECK (typeof(valor) = 'integer' AND valor > 0),
|
|
22
|
+
tipo TEXT NOT NULL CHECK (tipo IN ('receita', 'despesa')),
|
|
23
|
+
conta_id INTEGER NOT NULL REFERENCES conta (id) ON DELETE RESTRICT,
|
|
24
|
+
categoria_id INTEGER REFERENCES categoria (id) ON DELETE RESTRICT,
|
|
25
|
+
grupo_parcela TEXT,
|
|
26
|
+
parcela_numero INTEGER,
|
|
27
|
+
parcela_total INTEGER,
|
|
28
|
+
CHECK (
|
|
29
|
+
(grupo_parcela IS NULL AND parcela_numero IS NULL AND parcela_total IS NULL)
|
|
30
|
+
OR (grupo_parcela IS NOT NULL AND parcela_numero BETWEEN 1 AND parcela_total)
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
CREATE INDEX lancamento_data ON lancamento (data, id);
|
|
35
|
+
CREATE INDEX lancamento_grupo ON lancamento (grupo_parcela) WHERE grupo_parcela IS NOT NULL;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
-- 0002: categorias recorrentes (D36). Lançamento numa categoria recorrente se repete todo mês,
|
|
2
|
+
-- sem fim, até a pessoa encerrar. Cada série guarda o modelo e até que mês já foi gerada.
|
|
3
|
+
|
|
4
|
+
ALTER TABLE categoria ADD COLUMN recorrente INTEGER NOT NULL DEFAULT 0 CHECK (recorrente IN (0, 1));
|
|
5
|
+
|
|
6
|
+
CREATE TABLE recorrencia (
|
|
7
|
+
grupo TEXT PRIMARY KEY,
|
|
8
|
+
dia INTEGER NOT NULL CHECK (dia BETWEEN 1 AND 31),
|
|
9
|
+
descricao TEXT NOT NULL,
|
|
10
|
+
valor INTEGER NOT NULL CHECK (typeof(valor) = 'integer' AND valor > 0),
|
|
11
|
+
tipo TEXT NOT NULL CHECK (tipo IN ('receita', 'despesa')),
|
|
12
|
+
conta_id INTEGER NOT NULL REFERENCES conta (id) ON DELETE RESTRICT,
|
|
13
|
+
categoria_id INTEGER REFERENCES categoria (id) ON DELETE RESTRICT,
|
|
14
|
+
gerada_ate TEXT NOT NULL CHECK (date(gerada_ate) IS gerada_ate),
|
|
15
|
+
encerrada INTEGER NOT NULL DEFAULT 0 CHECK (encerrada IN (0, 1))
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
ALTER TABLE lancamento ADD COLUMN grupo_recorrencia TEXT REFERENCES recorrencia (grupo);
|
|
19
|
+
|
|
20
|
+
CREATE INDEX lancamento_recorrencia ON lancamento (grupo_recorrencia, data)
|
|
21
|
+
WHERE grupo_recorrencia IS NOT NULL;
|