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