invoiceguard 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.
- invoiceguard/__init__.py +7 -0
- invoiceguard/cli.py +303 -0
- invoiceguard/config.py +87 -0
- invoiceguard/contracts.py +67 -0
- invoiceguard/db.py +213 -0
- invoiceguard/dunning.py +108 -0
- invoiceguard/email_templates/day1.md +12 -0
- invoiceguard/email_templates/day15.md +17 -0
- invoiceguard/email_templates/day7.md +16 -0
- invoiceguard/mail.py +37 -0
- invoiceguard/stripe_links.py +55 -0
- invoiceguard/web/__init__.py +0 -0
- invoiceguard/web/db.py +306 -0
- invoiceguard/web/main.py +152 -0
- invoiceguard/web/static/style.css +152 -0
- invoiceguard/web/templates/base.html +23 -0
- invoiceguard/web/templates/detail.html +53 -0
- invoiceguard/web/templates/index.html +79 -0
- invoiceguard/webhooks.py +60 -0
- invoiceguard-0.1.0.dist-info/METADATA +234 -0
- invoiceguard-0.1.0.dist-info/RECORD +25 -0
- invoiceguard-0.1.0.dist-info/WHEEL +5 -0
- invoiceguard-0.1.0.dist-info/entry_points.txt +2 -0
- invoiceguard-0.1.0.dist-info/licenses/LICENSE +200 -0
- invoiceguard-0.1.0.dist-info/top_level.txt +1 -0
invoiceguard/__init__.py
ADDED
invoiceguard/cli.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""invoiceguard CLI — freelancer payment enforcement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import (CONFIG_TEMPLATE, config_path, db_path, load_config,
|
|
13
|
+
templates_dir)
|
|
14
|
+
from .contracts import render_contract
|
|
15
|
+
from .db import DB
|
|
16
|
+
from .dunning import check_due, money
|
|
17
|
+
from .stripe_links import create_payment_link, default_due_date
|
|
18
|
+
|
|
19
|
+
TEMPLATE_SOURCE = Path(__file__).resolve().parent / "email_templates"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _db() -> DB:
|
|
23
|
+
return DB(db_path())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.group()
|
|
27
|
+
@click.version_option(__version__)
|
|
28
|
+
def cli():
|
|
29
|
+
"""InvoiceGuard — deposit links + contract, then automated collections."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# -- init ----------------------------------------------------------------
|
|
33
|
+
@cli.command()
|
|
34
|
+
def init():
|
|
35
|
+
"""Create ~/.invoiceguard (config template, DB, email templates)."""
|
|
36
|
+
cfg_path = config_path()
|
|
37
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
if not cfg_path.exists():
|
|
39
|
+
cfg_path.write_text(CONFIG_TEMPLATE, encoding="utf-8")
|
|
40
|
+
click.echo(f"wrote config template: {cfg_path}")
|
|
41
|
+
else:
|
|
42
|
+
click.echo(f"config already exists: {cfg_path}")
|
|
43
|
+
|
|
44
|
+
db = DB(db_path()) # creates tables
|
|
45
|
+
db.close()
|
|
46
|
+
click.echo(f"database ready: {db_path()}")
|
|
47
|
+
|
|
48
|
+
tdir = templates_dir()
|
|
49
|
+
tdir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
for src in TEMPLATE_SOURCE.glob("*.md"):
|
|
51
|
+
dest = tdir / src.name
|
|
52
|
+
if not dest.exists():
|
|
53
|
+
shutil.copy(src, dest)
|
|
54
|
+
click.echo(f"installed template: {dest}")
|
|
55
|
+
click.echo("done. Edit the config, then add a Stripe test key.")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# -- client ---------------------------------------------------------------
|
|
59
|
+
@cli.group()
|
|
60
|
+
def client():
|
|
61
|
+
"""Manage clients."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@client.command("add")
|
|
65
|
+
@click.option("--name", required=True, help="Client name")
|
|
66
|
+
@click.option("--email", default=None, help="Client email (for dunning)")
|
|
67
|
+
def client_add(name, email):
|
|
68
|
+
"""Add a client."""
|
|
69
|
+
db = _db()
|
|
70
|
+
cid = db.add_client(name, email)
|
|
71
|
+
db.close()
|
|
72
|
+
click.echo(f"client #{cid}: {name} <{email or 'no email'}>")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@client.command("list")
|
|
76
|
+
def client_list():
|
|
77
|
+
"""List clients."""
|
|
78
|
+
db = _db()
|
|
79
|
+
rows = db.list_clients()
|
|
80
|
+
db.close()
|
|
81
|
+
for r in rows:
|
|
82
|
+
click.echo(f"#{r['id']:>3} {r['name']:<30} {r['email'] or ''}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- project --------------------------------------------------------------
|
|
86
|
+
@cli.group()
|
|
87
|
+
def project():
|
|
88
|
+
"""Manage projects (contract + terms)."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@project.command("create")
|
|
92
|
+
@click.option("--client", "client_name", required=True,
|
|
93
|
+
help="Client name (must already exist)")
|
|
94
|
+
@click.option("--title", required=True, help="Project title")
|
|
95
|
+
@click.option("--amount", type=float, required=True,
|
|
96
|
+
help="Total project amount in currency units, e.g. 2000")
|
|
97
|
+
@click.option("--currency", default="USD")
|
|
98
|
+
@click.option("--deposit-pct", type=float, default=50.0)
|
|
99
|
+
@click.option("--late-fee-pct", type=float, default=1.5)
|
|
100
|
+
@click.option("--late-fee-grace-days", type=int, default=15)
|
|
101
|
+
@click.option("--ack", is_flag=True, default=False,
|
|
102
|
+
help="Record the contract as acknowledged by the client")
|
|
103
|
+
def project_create(client_name, title, amount, currency, deposit_pct,
|
|
104
|
+
late_fee_pct, late_fee_grace_days, ack):
|
|
105
|
+
"""Create a project and generate its contract (with late-fee clause)."""
|
|
106
|
+
db = _db()
|
|
107
|
+
clients = [c for c in db.list_clients() if c["name"] == client_name]
|
|
108
|
+
if not clients:
|
|
109
|
+
db.close()
|
|
110
|
+
raise click.ClickException(
|
|
111
|
+
f'no client named "{client_name}" — add them first '
|
|
112
|
+
f"with `invoiceguard client add`"
|
|
113
|
+
)
|
|
114
|
+
client = clients[0]
|
|
115
|
+
amount_cents = round(amount * 100)
|
|
116
|
+
contract = render_contract(
|
|
117
|
+
client["name"], client["email"], title, amount_cents, currency,
|
|
118
|
+
deposit_pct, late_fee_pct, late_fee_grace_days,
|
|
119
|
+
)
|
|
120
|
+
pid = db.add_project(
|
|
121
|
+
client["id"], title, amount_cents, currency, deposit_pct,
|
|
122
|
+
late_fee_pct, late_fee_grace_days, contract,
|
|
123
|
+
)
|
|
124
|
+
if ack:
|
|
125
|
+
db.ack_contract(pid)
|
|
126
|
+
db.close()
|
|
127
|
+
click.echo(f"project #{pid}: {title}")
|
|
128
|
+
click.echo(f" client: {client['name']}")
|
|
129
|
+
click.echo(f" amount: {money(amount_cents, currency)} "
|
|
130
|
+
f"(deposit {deposit_pct}% = "
|
|
131
|
+
f"{money(round(amount_cents * deposit_pct / 100), currency)})")
|
|
132
|
+
click.echo(f" late fee: {late_fee_pct}%/mo after {late_fee_grace_days} days grace")
|
|
133
|
+
click.echo(f" contract acknowledged: {'yes' if ack else 'no — run `invoiceguard project ack ' + str(pid) + '` after the client accepts'}")
|
|
134
|
+
click.echo(" --- contract preview ---")
|
|
135
|
+
click.echo(contract)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@project.command("list")
|
|
139
|
+
def project_list():
|
|
140
|
+
"""List projects."""
|
|
141
|
+
db = _db()
|
|
142
|
+
rows = db.list_projects()
|
|
143
|
+
db.close()
|
|
144
|
+
for r in rows:
|
|
145
|
+
ack = "ack" if r["contract_ack"] else "no-ack"
|
|
146
|
+
click.echo(f"#{r['id']:>3} {r['title']:<35} {r['client_name']:<20} "
|
|
147
|
+
f"{money(r['amount_cents'], r['currency']):>12} [{ack}]")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@project.command("ack")
|
|
151
|
+
@click.argument("project_id", type=int)
|
|
152
|
+
def project_ack(project_id):
|
|
153
|
+
"""Record client acknowledgment of the contract (v1: paid deposit link)."""
|
|
154
|
+
db = _db()
|
|
155
|
+
if not db.get_project(project_id):
|
|
156
|
+
db.close()
|
|
157
|
+
raise click.ClickException(f"no project #{project_id}")
|
|
158
|
+
db.ack_contract(project_id)
|
|
159
|
+
db.close()
|
|
160
|
+
click.echo(f"project #{project_id}: contract acknowledged at "
|
|
161
|
+
f"{datetime.now(timezone.utc).isoformat()}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -- invoice --------------------------------------------------------------
|
|
165
|
+
@cli.group()
|
|
166
|
+
def invoice():
|
|
167
|
+
"""Manage invoices (Stripe payment links)."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@invoice.command("create")
|
|
171
|
+
@click.option("--project", "project_id", type=int, required=True)
|
|
172
|
+
@click.option("--kind", type=click.Choice(["deposit", "milestone", "final"]),
|
|
173
|
+
required=True)
|
|
174
|
+
@click.option("--amount", type=float, default=None,
|
|
175
|
+
help="Override amount in currency units (default: kind-based)")
|
|
176
|
+
@click.option("--due-days", type=int, default=7,
|
|
177
|
+
help="Days until due (default 7)")
|
|
178
|
+
def invoice_create(project_id, kind, amount, due_days):
|
|
179
|
+
"""Create a Stripe payment link for a deposit/milestone/final invoice."""
|
|
180
|
+
db = _db()
|
|
181
|
+
proj = db.get_project(project_id)
|
|
182
|
+
if not proj:
|
|
183
|
+
db.close()
|
|
184
|
+
raise click.ClickException(f"no project #{project_id}")
|
|
185
|
+
|
|
186
|
+
if amount is None:
|
|
187
|
+
if kind == "deposit":
|
|
188
|
+
amount_cents = round(proj["amount_cents"] * proj["deposit_pct"] / 100)
|
|
189
|
+
elif kind == "final":
|
|
190
|
+
amount_cents = proj["amount_cents"] - round(
|
|
191
|
+
proj["amount_cents"] * proj["deposit_pct"] / 100)
|
|
192
|
+
else: # milestone
|
|
193
|
+
amount_cents = proj["amount_cents"]
|
|
194
|
+
else:
|
|
195
|
+
amount_cents = round(amount * 100)
|
|
196
|
+
|
|
197
|
+
due = (datetime.now(timezone.utc).date()
|
|
198
|
+
).isoformat() if due_days <= 0 else default_due_date(due_days)
|
|
199
|
+
iid = db.add_invoice(project_id, kind, amount_cents, proj["currency"],
|
|
200
|
+
status="draft", due_date=due)
|
|
201
|
+
|
|
202
|
+
cfg = load_config()
|
|
203
|
+
try:
|
|
204
|
+
inv = db.invoice_full(iid)
|
|
205
|
+
url, link_id = create_payment_link(inv, cfg.get("stripe_secret_key", ""))
|
|
206
|
+
except ValueError as e:
|
|
207
|
+
db.close()
|
|
208
|
+
raise click.ClickException(str(e))
|
|
209
|
+
|
|
210
|
+
db.update_invoice(
|
|
211
|
+
iid,
|
|
212
|
+
status="sent",
|
|
213
|
+
stripe_url=url,
|
|
214
|
+
stripe_session_id=link_id,
|
|
215
|
+
sent_at=datetime.now(timezone.utc).isoformat(),
|
|
216
|
+
)
|
|
217
|
+
db.close()
|
|
218
|
+
click.echo(f"invoice #{iid} ({kind}): {money(amount_cents, proj['currency'])}")
|
|
219
|
+
click.echo(f" pay link: {url}")
|
|
220
|
+
click.echo(f" due: {due} status: sent")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@invoice.command("list")
|
|
224
|
+
def invoice_list():
|
|
225
|
+
"""List invoices."""
|
|
226
|
+
db = _db()
|
|
227
|
+
rows = db.list_invoices()
|
|
228
|
+
db.close()
|
|
229
|
+
for r in rows:
|
|
230
|
+
click.echo(f"#{r['id']:>3} {r['kind']:<9} {r['status']:<8} "
|
|
231
|
+
f"{money(r['amount_cents'], r['currency']):>12} "
|
|
232
|
+
f"{r['project_title']:<30} due {r['due_date'] or '-'}")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@invoice.command("mark-paid")
|
|
236
|
+
@click.argument("invoice_id", type=int)
|
|
237
|
+
def invoice_mark_paid(invoice_id):
|
|
238
|
+
"""Manually mark an invoice as paid."""
|
|
239
|
+
db = _db()
|
|
240
|
+
inv = db.get_invoice(invoice_id)
|
|
241
|
+
if not inv:
|
|
242
|
+
db.close()
|
|
243
|
+
raise click.ClickException(f"no invoice #{invoice_id}")
|
|
244
|
+
db.update_invoice(invoice_id, status="paid",
|
|
245
|
+
paid_at=datetime.now(timezone.utc).isoformat())
|
|
246
|
+
db.close()
|
|
247
|
+
click.echo(f"invoice #{invoice_id} marked paid")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@invoice.command("void")
|
|
251
|
+
@click.argument("invoice_id", type=int)
|
|
252
|
+
def invoice_void(invoice_id):
|
|
253
|
+
"""Void an invoice."""
|
|
254
|
+
db = _db()
|
|
255
|
+
inv = db.get_invoice(invoice_id)
|
|
256
|
+
if not inv:
|
|
257
|
+
db.close()
|
|
258
|
+
raise click.ClickException(f"no invoice #{invoice_id}")
|
|
259
|
+
db.update_invoice(invoice_id, status="void")
|
|
260
|
+
db.close()
|
|
261
|
+
click.echo(f"invoice #{invoice_id} voided")
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# -- dunning ---------------------------------------------------------------
|
|
265
|
+
@cli.command("check-due")
|
|
266
|
+
def check_due_cmd():
|
|
267
|
+
"""Scan for overdue invoices and send the next dunning stage."""
|
|
268
|
+
db = _db()
|
|
269
|
+
cfg = load_config()
|
|
270
|
+
smtp_cfg = cfg.get("smtp") or {}
|
|
271
|
+
if not smtp_cfg.get("host") or "example.com" in str(smtp_cfg.get("host")):
|
|
272
|
+
db.close()
|
|
273
|
+
raise click.ClickException(
|
|
274
|
+
"SMTP is not configured — set smtp.host etc. in "
|
|
275
|
+
f"{config_path()} first."
|
|
276
|
+
)
|
|
277
|
+
results = check_due(db, smtp_cfg)
|
|
278
|
+
db.close()
|
|
279
|
+
if not results:
|
|
280
|
+
click.echo("nothing due — no emails sent")
|
|
281
|
+
for r in results:
|
|
282
|
+
click.echo(f"sent {r['stage']} to {r['to']} "
|
|
283
|
+
f"(invoice #{r['invoice_id']})")
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# -- dashboard -------------------------------------------------------------
|
|
287
|
+
@cli.command()
|
|
288
|
+
@click.option("--port", type=int, default=8000, show_default=True,
|
|
289
|
+
help="Port to listen on")
|
|
290
|
+
@click.option("--host", default="127.0.0.1", show_default=True,
|
|
291
|
+
help="Host to bind (keep 127.0.0.1: localhost only)")
|
|
292
|
+
def dashboard(port, host):
|
|
293
|
+
"""Launch the InvoiceGuard web dashboard (local web UI)."""
|
|
294
|
+
import uvicorn
|
|
295
|
+
|
|
296
|
+
from .web.main import app
|
|
297
|
+
|
|
298
|
+
click.echo(f"InvoiceGuard dashboard: http://{host}:{port}/")
|
|
299
|
+
uvicorn.run(app, host=host, port=port)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def main():
|
|
303
|
+
cli()
|
invoiceguard/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Configuration loading.
|
|
2
|
+
|
|
3
|
+
Config lives at ~/.invoiceguard/config.yaml by default; the
|
|
4
|
+
INVOICEGUARD_CONFIG environment variable overrides the path.
|
|
5
|
+
Secrets may also be supplied via environment variables:
|
|
6
|
+
|
|
7
|
+
INVOICEGUARD_STRIPE_SECRET_KEY, INVOICEGUARD_STRIPE_WEBHOOK_SECRET,
|
|
8
|
+
INVOICEGUARD_SMTP_PASSWORD
|
|
9
|
+
|
|
10
|
+
Env values always win over the YAML file — never the reverse.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
DEFAULT_DIR = Path.home() / ".invoiceguard"
|
|
21
|
+
DEFAULT_DB = DEFAULT_DIR / "invoiceguard.db"
|
|
22
|
+
DEFAULT_TEMPLATES_DIR = DEFAULT_DIR / "templates"
|
|
23
|
+
|
|
24
|
+
CONFIG_TEMPLATE = """\
|
|
25
|
+
# InvoiceGuard configuration.
|
|
26
|
+
# NEVER commit this file with real secrets (it is git-ignored upstream).
|
|
27
|
+
|
|
28
|
+
# Stripe — get test keys at https://dashboard.stripe.com/test/apikeys
|
|
29
|
+
stripe_secret_key: "sk_test_..." # or env INVOICEGUARD_STRIPE_SECRET_KEY
|
|
30
|
+
stripe_webhook_secret: "whsec_..." # or env INVOICEGUARD_STRIPE_WEBHOOK_SECRET
|
|
31
|
+
|
|
32
|
+
# SMTP — how dunning emails are sent
|
|
33
|
+
smtp:
|
|
34
|
+
host: "smtp.example.com" # e.g. smtp.gmail.com
|
|
35
|
+
port: 587
|
|
36
|
+
username: "you@example.com"
|
|
37
|
+
password: "APP_PASSWORD" # or env INVOICEGUARD_SMTP_PASSWORD
|
|
38
|
+
from_addr: "you@example.com"
|
|
39
|
+
use_tls: true
|
|
40
|
+
|
|
41
|
+
# Web-facing base URL (used to point the Stripe webhook at this app)
|
|
42
|
+
app:
|
|
43
|
+
base_url: "http://localhost:8000"
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_path() -> Path:
|
|
48
|
+
override = os.environ.get("INVOICEGUARD_CONFIG")
|
|
49
|
+
if override:
|
|
50
|
+
return Path(override).expanduser()
|
|
51
|
+
return DEFAULT_DIR / "config.yaml"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def db_path() -> Path:
|
|
55
|
+
return Path(os.environ.get("INVOICEGUARD_DB", str(DEFAULT_DB))).expanduser()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def templates_dir() -> Path:
|
|
59
|
+
"""Templates live next to the config file, so overrides stay together."""
|
|
60
|
+
return config_path().parent / "templates"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_config() -> dict:
|
|
64
|
+
"""Load merged config: YAML file first, then env overrides."""
|
|
65
|
+
cfg: dict = {}
|
|
66
|
+
path = config_path()
|
|
67
|
+
if path.exists():
|
|
68
|
+
with open(path) as f:
|
|
69
|
+
cfg = yaml.safe_load(f) or {}
|
|
70
|
+
|
|
71
|
+
# Env overrides (never hardcode secrets in the file or the code).
|
|
72
|
+
if os.environ.get("INVOICEGUARD_STRIPE_SECRET_KEY"):
|
|
73
|
+
cfg["stripe_secret_key"] = os.environ["INVOICEGUARD_STRIPE_SECRET_KEY"]
|
|
74
|
+
if os.environ.get("INVOICEGUARD_STRIPE_WEBHOOK_SECRET"):
|
|
75
|
+
cfg["stripe_webhook_secret"] = os.environ["INVOICEGUARD_STRIPE_WEBHOOK_SECRET"]
|
|
76
|
+
smtp = cfg.setdefault("smtp", {})
|
|
77
|
+
if os.environ.get("INVOICEGUARD_SMTP_PASSWORD"):
|
|
78
|
+
smtp["password"] = os.environ["INVOICEGUARD_SMTP_PASSWORD"]
|
|
79
|
+
return cfg
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_placeholder(value: str | None) -> bool:
|
|
83
|
+
"""True when a value still looks like the template placeholder."""
|
|
84
|
+
if not value:
|
|
85
|
+
return True
|
|
86
|
+
v = value.strip()
|
|
87
|
+
return v.endswith("...") or v in {"sk_test_...", "whsec_...", "APP_PASSWORD", "you@example.com"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Contract markdown generator.
|
|
2
|
+
|
|
3
|
+
v1 contract signature = the client acknowledgment checkbox: the client
|
|
4
|
+
acknowledges the contract by accepting (paying) the deposit link, and the
|
|
5
|
+
freelancer records that with `invoiceguard project ack`.
|
|
6
|
+
Real e-signature (typed name / drawn signature capture) is roadmap.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import date
|
|
12
|
+
|
|
13
|
+
CONTRACT_TEMPLATE = """\
|
|
14
|
+
# Freelance Services Agreement
|
|
15
|
+
|
|
16
|
+
**Project:** {title}
|
|
17
|
+
**Client:** {client_name} ({client_email})
|
|
18
|
+
**Date:** {date}
|
|
19
|
+
|
|
20
|
+
## 1. Scope
|
|
21
|
+
Work will be performed as agreed between the freelancer and the client
|
|
22
|
+
for the project titled "{title}".
|
|
23
|
+
|
|
24
|
+
## 2. Fee and payment
|
|
25
|
+
Total project fee: **{amount} {currency}**.
|
|
26
|
+
|
|
27
|
+
A deposit of **{deposit_pct}%** ({deposit_amount} {currency}) is due before
|
|
28
|
+
work begins. The deposit is collected through an online payment link that
|
|
29
|
+
also serves as the client's acknowledgment of this agreement: paying the deposit means the client has read and accepts these terms.
|
|
30
|
+
|
|
31
|
+
## 3. Late fees
|
|
32
|
+
If any invoice remains unpaid {grace_days} days after its due date, a late
|
|
33
|
+
fee of **{late_fee_pct}% per month** on the overdue balance will apply from
|
|
34
|
+
day {grace_days} + 1, compounding monthly until the balance is paid in full.
|
|
35
|
+
The client acknowledges this clause by accepting the deposit link.
|
|
36
|
+
|
|
37
|
+
> Late-fee clause: {late_fee_pct}% per month on overdue balances after {grace_days} days, client acknowledges by accepting the deposit link.
|
|
38
|
+
|
|
39
|
+
## 4. Sign-off (v1)
|
|
40
|
+
Client acknowledgment is recorded when the deposit payment link is paid.
|
|
41
|
+
Recorded in InvoiceGuard as contract_ack = {ack}.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_contract(client_name: str, client_email: str | None, title: str,
|
|
46
|
+
amount_cents: int, currency: str, deposit_pct: float,
|
|
47
|
+
late_fee_pct: float, late_fee_grace_days: int) -> str:
|
|
48
|
+
deposit_cents = round(amount_cents * deposit_pct / 100.0)
|
|
49
|
+
return CONTRACT_TEMPLATE.format(
|
|
50
|
+
title=title,
|
|
51
|
+
client_name=client_name,
|
|
52
|
+
client_email=client_email or "n/a",
|
|
53
|
+
date=date.today().isoformat(),
|
|
54
|
+
amount=_money(amount_cents, currency),
|
|
55
|
+
currency=currency,
|
|
56
|
+
deposit_pct=deposit_pct,
|
|
57
|
+
deposit_amount=_money(deposit_cents, currency),
|
|
58
|
+
late_fee_pct=late_fee_pct,
|
|
59
|
+
grace_days=late_fee_grace_days,
|
|
60
|
+
ack="1 (client paid the deposit link)",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _money(cents: int, currency: str) -> str:
|
|
65
|
+
if currency.upper() == "USD":
|
|
66
|
+
return f"${cents / 100:,.2f}"
|
|
67
|
+
return f"{cents / 100:,.2f} {currency.upper()}"
|
invoiceguard/db.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""SQLite persistence.
|
|
2
|
+
|
|
3
|
+
THIS SCHEMA IS THE CONTRACT shared with the dashboard app — do not
|
|
4
|
+
change column names or types without coordinating with the dashboard
|
|
5
|
+
worker. Additive migrations only.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sqlite3
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
SCHEMA = """
|
|
15
|
+
CREATE TABLE IF NOT EXISTS clients(
|
|
16
|
+
id INTEGER PRIMARY KEY,
|
|
17
|
+
name TEXT NOT NULL,
|
|
18
|
+
email TEXT,
|
|
19
|
+
created_at TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS projects(
|
|
22
|
+
id INTEGER PRIMARY KEY,
|
|
23
|
+
client_id INTEGER NOT NULL REFERENCES clients(id),
|
|
24
|
+
title TEXT NOT NULL,
|
|
25
|
+
amount_cents INTEGER NOT NULL,
|
|
26
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
27
|
+
deposit_pct REAL NOT NULL DEFAULT 50.0,
|
|
28
|
+
late_fee_pct REAL NOT NULL DEFAULT 1.5,
|
|
29
|
+
late_fee_grace_days INTEGER NOT NULL DEFAULT 15,
|
|
30
|
+
contract_md TEXT NOT NULL,
|
|
31
|
+
contract_ack INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
contract_ack_at TEXT,
|
|
33
|
+
created_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS invoices(
|
|
36
|
+
id INTEGER PRIMARY KEY,
|
|
37
|
+
project_id INTEGER NOT NULL REFERENCES projects(id),
|
|
38
|
+
kind TEXT NOT NULL,
|
|
39
|
+
amount_cents INTEGER NOT NULL,
|
|
40
|
+
currency TEXT NOT NULL DEFAULT 'USD',
|
|
41
|
+
status TEXT NOT NULL DEFAULT 'draft',
|
|
42
|
+
stripe_url TEXT,
|
|
43
|
+
stripe_session_id TEXT,
|
|
44
|
+
due_date TEXT,
|
|
45
|
+
sent_at TEXT,
|
|
46
|
+
paid_at TEXT,
|
|
47
|
+
created_at TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS dunning_events(
|
|
50
|
+
id INTEGER PRIMARY KEY,
|
|
51
|
+
invoice_id INTEGER NOT NULL REFERENCES invoices(id),
|
|
52
|
+
stage TEXT NOT NULL,
|
|
53
|
+
sent_at TEXT NOT NULL
|
|
54
|
+
);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_invoices_status_due
|
|
56
|
+
ON invoices(status, due_date);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_dunning_invoice_stage
|
|
58
|
+
ON dunning_events(invoice_id, stage);
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def now_iso() -> str:
|
|
63
|
+
return datetime.now(timezone.utc).isoformat()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DB:
|
|
67
|
+
def __init__(self, path: str | Path):
|
|
68
|
+
self.path = Path(path)
|
|
69
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
self.conn = sqlite3.connect(str(self.path))
|
|
71
|
+
self.conn.row_factory = sqlite3.Row
|
|
72
|
+
self.conn.execute("PRAGMA foreign_keys = ON")
|
|
73
|
+
self.conn.executescript(SCHEMA)
|
|
74
|
+
|
|
75
|
+
# -- clients -----------------------------------------------------
|
|
76
|
+
def add_client(self, name: str, email: str | None = None) -> int:
|
|
77
|
+
cur = self.conn.execute(
|
|
78
|
+
"INSERT INTO clients(name, email, created_at) VALUES (?, ?, ?)",
|
|
79
|
+
(name, email, now_iso()),
|
|
80
|
+
)
|
|
81
|
+
self.conn.commit()
|
|
82
|
+
return cur.lastrowid
|
|
83
|
+
|
|
84
|
+
def get_client(self, client_id: int) -> sqlite3.Row | None:
|
|
85
|
+
return self.conn.execute(
|
|
86
|
+
"SELECT * FROM clients WHERE id = ?", (client_id,)
|
|
87
|
+
).fetchone()
|
|
88
|
+
|
|
89
|
+
def list_clients(self) -> list[sqlite3.Row]:
|
|
90
|
+
return self.conn.execute(
|
|
91
|
+
"SELECT * FROM clients ORDER BY id"
|
|
92
|
+
).fetchall()
|
|
93
|
+
|
|
94
|
+
# -- projects ----------------------------------------------------
|
|
95
|
+
def add_project(self, client_id: int, title: str, amount_cents: int,
|
|
96
|
+
currency: str = "USD", deposit_pct: float = 50.0,
|
|
97
|
+
late_fee_pct: float = 1.5, late_fee_grace_days: int = 15,
|
|
98
|
+
contract_md: str = "") -> int:
|
|
99
|
+
cur = self.conn.execute(
|
|
100
|
+
"""INSERT INTO projects(client_id, title, amount_cents, currency,
|
|
101
|
+
deposit_pct, late_fee_pct, late_fee_grace_days, contract_md,
|
|
102
|
+
created_at)
|
|
103
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
104
|
+
(client_id, title, amount_cents, currency, deposit_pct,
|
|
105
|
+
late_fee_pct, late_fee_grace_days, contract_md, now_iso()),
|
|
106
|
+
)
|
|
107
|
+
self.conn.commit()
|
|
108
|
+
return cur.lastrowid
|
|
109
|
+
|
|
110
|
+
def get_project(self, project_id: int) -> sqlite3.Row | None:
|
|
111
|
+
return self.conn.execute(
|
|
112
|
+
"SELECT * FROM projects WHERE id = ?", (project_id,)
|
|
113
|
+
).fetchone()
|
|
114
|
+
|
|
115
|
+
def list_projects(self) -> list[sqlite3.Row]:
|
|
116
|
+
return self.conn.execute(
|
|
117
|
+
"""SELECT p.*, c.name AS client_name
|
|
118
|
+
FROM projects p JOIN clients c ON c.id = p.client_id
|
|
119
|
+
ORDER BY p.id"""
|
|
120
|
+
).fetchall()
|
|
121
|
+
|
|
122
|
+
def ack_contract(self, project_id: int) -> None:
|
|
123
|
+
self.conn.execute(
|
|
124
|
+
"UPDATE projects SET contract_ack = 1, contract_ack_at = ? "
|
|
125
|
+
"WHERE id = ?",
|
|
126
|
+
(now_iso(), project_id),
|
|
127
|
+
)
|
|
128
|
+
self.conn.commit()
|
|
129
|
+
|
|
130
|
+
# -- invoices ----------------------------------------------------
|
|
131
|
+
def add_invoice(self, project_id: int, kind: str, amount_cents: int,
|
|
132
|
+
currency: str = "USD", status: str = "draft",
|
|
133
|
+
due_date: str | None = None) -> int:
|
|
134
|
+
cur = self.conn.execute(
|
|
135
|
+
"""INSERT INTO invoices(project_id, kind, amount_cents, currency,
|
|
136
|
+
status, due_date, created_at)
|
|
137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
|
138
|
+
(project_id, kind, amount_cents, currency, status, due_date,
|
|
139
|
+
now_iso()),
|
|
140
|
+
)
|
|
141
|
+
self.conn.commit()
|
|
142
|
+
return cur.lastrowid
|
|
143
|
+
|
|
144
|
+
def get_invoice(self, invoice_id: int) -> sqlite3.Row | None:
|
|
145
|
+
return self.conn.execute(
|
|
146
|
+
"SELECT * FROM invoices WHERE id = ?", (invoice_id,)
|
|
147
|
+
).fetchone()
|
|
148
|
+
|
|
149
|
+
def list_invoices(self) -> list[sqlite3.Row]:
|
|
150
|
+
return self.conn.execute(
|
|
151
|
+
"""SELECT i.*, p.title AS project_title, c.name AS client_name
|
|
152
|
+
FROM invoices i
|
|
153
|
+
JOIN projects p ON p.id = i.project_id
|
|
154
|
+
JOIN clients c ON c.id = p.client_id
|
|
155
|
+
ORDER BY i.id"""
|
|
156
|
+
).fetchall()
|
|
157
|
+
|
|
158
|
+
def update_invoice(self, invoice_id: int, **fields) -> None:
|
|
159
|
+
sets = ", ".join(f"{k} = ?" for k in fields)
|
|
160
|
+
self.conn.execute(
|
|
161
|
+
f"UPDATE invoices SET {sets} WHERE id = ?",
|
|
162
|
+
(*fields.values(), invoice_id),
|
|
163
|
+
)
|
|
164
|
+
self.conn.commit()
|
|
165
|
+
|
|
166
|
+
def invoice_full(self, invoice_id: int) -> dict | None:
|
|
167
|
+
"""Invoice joined with project + client, as a plain dict."""
|
|
168
|
+
row = self.conn.execute(
|
|
169
|
+
"""SELECT i.*, p.title AS project_title, p.late_fee_pct,
|
|
170
|
+
p.late_fee_grace_days, p.contract_ack,
|
|
171
|
+
c.name AS client_name, c.email AS client_email
|
|
172
|
+
FROM invoices i
|
|
173
|
+
JOIN projects p ON p.id = i.project_id
|
|
174
|
+
JOIN clients c ON c.id = p.client_id
|
|
175
|
+
WHERE i.id = ?""",
|
|
176
|
+
(invoice_id,),
|
|
177
|
+
).fetchone()
|
|
178
|
+
return dict(row) if row else None
|
|
179
|
+
|
|
180
|
+
# -- dunning -----------------------------------------------------
|
|
181
|
+
def record_dunning(self, invoice_id: int, stage: str) -> None:
|
|
182
|
+
self.conn.execute(
|
|
183
|
+
"INSERT INTO dunning_events(invoice_id, stage, sent_at) "
|
|
184
|
+
"VALUES (?, ?, ?)",
|
|
185
|
+
(invoice_id, stage, now_iso()),
|
|
186
|
+
)
|
|
187
|
+
self.conn.commit()
|
|
188
|
+
|
|
189
|
+
def sent_stages(self, invoice_id: int) -> set[str]:
|
|
190
|
+
rows = self.conn.execute(
|
|
191
|
+
"SELECT stage FROM dunning_events WHERE invoice_id = ?",
|
|
192
|
+
(invoice_id,),
|
|
193
|
+
).fetchall()
|
|
194
|
+
return {r["stage"] for r in rows}
|
|
195
|
+
|
|
196
|
+
def overdue_invoices(self) -> list[dict]:
|
|
197
|
+
"""Invoices still (sent|overdue) and past due_date."""
|
|
198
|
+
rows = self.conn.execute(
|
|
199
|
+
"""SELECT i.*, p.title AS project_title, p.late_fee_pct,
|
|
200
|
+
p.late_fee_grace_days, c.name AS client_name,
|
|
201
|
+
c.email AS client_email
|
|
202
|
+
FROM invoices i
|
|
203
|
+
JOIN projects p ON p.id = i.project_id
|
|
204
|
+
JOIN clients c ON c.id = p.client_id
|
|
205
|
+
WHERE i.status IN ('sent', 'overdue')
|
|
206
|
+
AND i.due_date IS NOT NULL
|
|
207
|
+
AND date(i.due_date) <= date('now')
|
|
208
|
+
ORDER BY i.due_date"""
|
|
209
|
+
).fetchall()
|
|
210
|
+
return [dict(r) for r in rows]
|
|
211
|
+
|
|
212
|
+
def close(self) -> None:
|
|
213
|
+
self.conn.close()
|