feat(proj): init
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
.env
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.db
|
||||||
|
data/
|
||||||
|
context/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
src/**/bin/
|
||||||
|
src/**/obj/
|
||||||
|
api/
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
BOT_TOKEN=your_telegram_bot_token_here
|
||||||
|
TELEGRAM_BOT_USERNAME=YourBotUsernameWithoutAt
|
||||||
|
|
||||||
|
# Recommended with docker-compose network_mode: host
|
||||||
|
PROXY_URL=socks5://127.0.0.1:10808
|
||||||
|
|
||||||
|
# Required for bot → API (POST /api/auth/internal)
|
||||||
|
API_TOKEN=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# Bot talks to the C# API (same HTTP contract as before)
|
||||||
|
API_BASE_URL=http://127.0.0.1:51291
|
||||||
|
|
||||||
|
# PostgreSQL (docker-compose service `db`)
|
||||||
|
POSTGRES_DB=please_pay_me
|
||||||
|
POSTGRES_USER=ppm
|
||||||
|
POSTGRES_PASSWORD=ppm
|
||||||
|
|
||||||
|
# Optional explicit JWT secret (defaults to derived from BOT_TOKEN)
|
||||||
|
# JWT_SECRET=change-me-long-random
|
||||||
|
|
||||||
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# Yandex ID (authorization code; secret stays on the API)
|
||||||
|
YANDEX_CLIENT_ID=
|
||||||
|
YANDEX_CLIENT_SECRET=
|
||||||
|
YANDEX_REDIRECT_URI=https://please-pay-me.ru/
|
||||||
|
PUBLIC_WEB_ORIGIN=https://please-pay-me.ru
|
||||||
|
# Optional extra callbacks, comma-separated
|
||||||
|
# YANDEX_REDIRECT_URIS=http://localhost:51290/,http://localhost:5173/
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.env
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.db
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
web/public/*.apk
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>404 — Дожить до ЗП</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Syne:wght@700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg0: #071510;
|
||||||
|
--bg1: #0d2a1f;
|
||||||
|
--ink: #e8f2ec;
|
||||||
|
--muted: #9bb5a8;
|
||||||
|
--accent: #3dd68c;
|
||||||
|
--accent-dim: #1f8f5f;
|
||||||
|
--line: rgba(232, 242, 236, 0.14);
|
||||||
|
--font-display: "Syne", "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Manrope", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 560px at 12% -8%, rgba(61, 214, 140, 0.2), transparent 55%),
|
||||||
|
radial-gradient(800px 480px at 100% 0%, rgba(20, 90, 70, 0.4), transparent 52%),
|
||||||
|
linear-gradient(160deg, var(--bg0), var(--bg1) 48%, #0a1c16 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
animation: rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin: 0 0 1.6rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
font-size: clamp(1.2rem, 2.5vw, 1.45rem);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 18% 18% auto;
|
||||||
|
height: 42%;
|
||||||
|
background: radial-gradient(circle, rgba(61, 214, 140, 0.22), transparent 70%);
|
||||||
|
filter: blur(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pulse 4.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(6.5rem, 22vw, 11rem);
|
||||||
|
line-height: 0.85;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
background: linear-gradient(180deg, #f3fff8 10%, #3dd68c 55%, #145c3d 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
text-shadow: 0 18px 60px rgba(61, 214, 140, 0.12);
|
||||||
|
animation: float 5.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 1.1rem 0 0.55rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2.35rem);
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 28rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.7rem 1.15rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 180ms ease, filter 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-dim), #166b48);
|
||||||
|
color: #f4fff9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(7, 21, 16, 0.45);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 1.6rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: color-mix(in srgb, var(--muted) 80%, transparent);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit {
|
||||||
|
position: absolute;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.55;
|
||||||
|
top: 12%;
|
||||||
|
left: 18%;
|
||||||
|
animation: drift 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit.b {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
top: 28%;
|
||||||
|
left: auto;
|
||||||
|
right: 16%;
|
||||||
|
animation-delay: -2.2s;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit.c {
|
||||||
|
top: auto;
|
||||||
|
bottom: 18%;
|
||||||
|
left: 28%;
|
||||||
|
animation-delay: -4s;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(12px, -18px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scene,
|
||||||
|
.code,
|
||||||
|
.glow,
|
||||||
|
.orbit,
|
||||||
|
.btn {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="scene">
|
||||||
|
<span class="orbit" aria-hidden="true"></span>
|
||||||
|
<span class="orbit b" aria-hidden="true"></span>
|
||||||
|
<span class="orbit c" aria-hidden="true"></span>
|
||||||
|
<div class="glow" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<p class="code" aria-label="Ошибка 404">404</p>
|
||||||
|
<h1>Страница ушла куда-то</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Такого адреса нет.</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-primary" href="/">На главную</a>
|
||||||
|
<a class="btn btn-ghost" href="javascript:history.back()">Назад</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">ошибка · страница не найдена</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>502 — Дожить до ЗП</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600&family=Syne:wght@700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
--bg0: #071510;
|
||||||
|
--bg1: #0d2a1f;
|
||||||
|
--ink: #e8f2ec;
|
||||||
|
--muted: #9bb5a8;
|
||||||
|
--accent: #3dd68c;
|
||||||
|
--accent-dim: #1f8f5f;
|
||||||
|
--line: rgba(232, 242, 236, 0.14);
|
||||||
|
--font-display: "Syne", "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Manrope", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 560px at 12% -8%, rgba(61, 214, 140, 0.2), transparent 55%),
|
||||||
|
radial-gradient(800px 480px at 100% 0%, rgba(20, 90, 70, 0.4), transparent 52%),
|
||||||
|
linear-gradient(160deg, var(--bg0), var(--bg1) 48%, #0a1c16 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 2rem 1.25rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scene {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
animation: rise 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
margin: 0 0 1.6rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
font-size: clamp(1.2rem, 2.5vw, 1.45rem);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
inset: 18% 18% auto;
|
||||||
|
height: 42%;
|
||||||
|
background: radial-gradient(circle, rgba(61, 214, 140, 0.22), transparent 70%);
|
||||||
|
filter: blur(8px);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: pulse 4.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(6.5rem, 22vw, 11rem);
|
||||||
|
line-height: 0.85;
|
||||||
|
letter-spacing: -0.08em;
|
||||||
|
background: linear-gradient(180deg, #f3fff8 10%, #3dd68c 55%, #145c3d 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
text-shadow: 0 18px 60px rgba(61, 214, 140, 0.12);
|
||||||
|
animation: float 5.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 1.1rem 0 0.55rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2.35rem);
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 28rem;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.7rem 1.15rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 180ms ease, filter 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent-dim), #166b48);
|
||||||
|
color: #f4fff9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: rgba(7, 21, 16, 0.45);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
filter: brightness(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 1.6rem 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: color-mix(in srgb, var(--muted) 80%, transparent);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit {
|
||||||
|
position: absolute;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.55;
|
||||||
|
top: 12%;
|
||||||
|
left: 18%;
|
||||||
|
animation: drift 7s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit.b {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
top: 28%;
|
||||||
|
left: auto;
|
||||||
|
right: 16%;
|
||||||
|
animation-delay: -2.2s;
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orbit.c {
|
||||||
|
top: auto;
|
||||||
|
bottom: 18%;
|
||||||
|
left: 28%;
|
||||||
|
animation-delay: -4s;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(12px, -18px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.scene,
|
||||||
|
.code,
|
||||||
|
.glow,
|
||||||
|
.orbit,
|
||||||
|
.btn {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="scene">
|
||||||
|
<span class="orbit" aria-hidden="true"></span>
|
||||||
|
<span class="orbit b" aria-hidden="true"></span>
|
||||||
|
<span class="orbit c" aria-hidden="true"></span>
|
||||||
|
<div class="glow" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<p class="code" aria-label="Ошибка 502">502</p>
|
||||||
|
<h1>Сервер не отвечает</h1>
|
||||||
|
<p class="lead">
|
||||||
|
Шлюз не смог достучаться до кабинета. Обычно это на минуту — обновите страницу.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-primary" href="javascript:location.reload()">Обновить</a>
|
||||||
|
<a class="btn btn-ghost" href="/">На главную</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="hint">ошибка · плохой шлюз</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1000 bot \
|
||||||
|
&& useradd --system --uid 1000 --gid bot --home-dir /app --shell /usr/sbin/nologin bot
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY bot ./bot
|
||||||
|
|
||||||
|
RUN chown -R bot:bot /app
|
||||||
|
|
||||||
|
USER bot
|
||||||
|
|
||||||
|
CMD ["python", "-m", "bot.main"]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY PleasePayMe.sln ./
|
||||||
|
COPY src/PleasePayMe.Domain/PleasePayMe.Domain.csproj src/PleasePayMe.Domain/
|
||||||
|
COPY src/PleasePayMe.Application/PleasePayMe.Application.csproj src/PleasePayMe.Application/
|
||||||
|
COPY src/PleasePayMe.Infrastructure/PleasePayMe.Infrastructure.csproj src/PleasePayMe.Infrastructure/
|
||||||
|
COPY src/PleasePayMe.Api/PleasePayMe.Api.csproj src/PleasePayMe.Api/
|
||||||
|
RUN dotnet restore src/PleasePayMe.Api/PleasePayMe.Api.csproj
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN dotnet publish src/PleasePayMe.Api/PleasePayMe.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
|
||||||
|
WORKDIR /app
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8000
|
||||||
|
EXPOSE 8000
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
USER $APP_UID
|
||||||
|
ENTRYPOINT ["dotnet", "PleasePayMe.Api.dll"]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /web
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY web/ ./
|
||||||
|
ARG VITE_API_BASE_URL=
|
||||||
|
ARG VITE_TELEGRAM_BOT_USERNAME=
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||||
|
VITE_TELEGRAM_BOT_USERNAME=$VITE_TELEGRAM_BOT_USERNAME
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /web/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.0.31903.59
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasePayMe.Domain", "src\PleasePayMe.Domain\PleasePayMe.Domain.csproj", "{C857E6E0-79E7-425A-89AA-2249B8E97C84}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasePayMe.Application", "src\PleasePayMe.Application\PleasePayMe.Application.csproj", "{E5B4B880-6D8F-4224-9134-E009FE1DD84F}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasePayMe.Infrastructure", "src\PleasePayMe.Infrastructure\PleasePayMe.Infrastructure.csproj", "{0C1C947F-D99C-4287-B664-858604606AB4}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasePayMe.Api", "src\PleasePayMe.Api\PleasePayMe.Api.csproj", "{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasePayMe.Domain.Tests", "src\PleasePayMe.Domain.Tests\PleasePayMe.Domain.Tests.csproj", "{17EDC00D-3329-4839-8A49-86F8CD7D3A15}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Debug|x64 = Debug|x64
|
||||||
|
Debug|x86 = Debug|x86
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
Release|x64 = Release|x64
|
||||||
|
Release|x86 = Release|x86
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|x64.Build.0 = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15}.Release|x86.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(NestedProjects) = preSolution
|
||||||
|
{C857E6E0-79E7-425A-89AA-2249B8E97C84} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{E5B4B880-6D8F-4224-9134-E009FE1DD84F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{0C1C947F-D99C-4287-B664-858604606AB4} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{2FBEDAD1-8DD6-48E5-82F0-2A0052B2C577} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
{17EDC00D-3329-4839-8A49-86F8CD7D3A15} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Please Pay Me Bot
|
||||||
|
|
||||||
|
Telegram-бот + web-кабинет для жизни «от зарплаты до зарплаты».
|
||||||
|
|
||||||
|
**Стек сейчас:** Telegram bot (Python) · **API ASP.NET Core + PostgreSQL** (`src/PleasePayMe.*`) · React web.
|
||||||
|
Python-папка `api/` устарела; HTTP-контракт `/api/...` для бота и web сохранён.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Задать бюджет: сумма + дата до зарплаты
|
||||||
|
- Пересчёт дневного лимита по остатку и числу дней
|
||||||
|
- Быстрый ввод трат: `250`, `250 кофе`, `кофе 250`
|
||||||
|
- Статус / траты за сегодня / отмена последней траты
|
||||||
|
- Кнопки внизу чата для частых действий
|
||||||
|
|
||||||
|
## Логика лимита
|
||||||
|
|
||||||
|
```
|
||||||
|
остаток = бюджет − все траты периода
|
||||||
|
дней_осталось = (дата_конца − сегодня) + 1 # включая сегодня
|
||||||
|
остаток_на_утро = остаток + траты_сегодня
|
||||||
|
лимит_на_день = остаток_на_утро / дней_осталось
|
||||||
|
можно_сегодня = лимит_на_день − траты_сегодня
|
||||||
|
```
|
||||||
|
|
||||||
|
Лимит дня не сжимается от каждой траты внутри дня (модель «утреннего конверта»). Если сегодня вылез из лимита — завтра лимит пересчитается по новому остатку.
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
1. Создай бота у [@BotFather](https://t.me/BotFather), скопируй токен.
|
||||||
|
2. Установи зависимости:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows Git Bash / Linux:
|
||||||
|
source .venv/Scripts/activate # или .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Создай `.env` из примера:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# пропиши BOT_TOKEN=...
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m bot.main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
| Команда / кнопка | Действие |
|
||||||
|
|---|---|
|
||||||
|
| `/budget` | Новый бюджет (сумма → дата) |
|
||||||
|
| `/status` | Лимит и остаток |
|
||||||
|
| `/today` | Список трат за сегодня |
|
||||||
|
| `/history` | Траты за период бюджета (постранично) |
|
||||||
|
| `/day 12.09` | Список трат за дату |
|
||||||
|
| `/spend` | Диалог: трата за другую дату |
|
||||||
|
| `/undo` | Удалить последнюю трату |
|
||||||
|
| `/help` | Справка |
|
||||||
|
|
||||||
|
Трата за дату одним сообщением: `за 12.09 250 кофе`, `12.09 250`, `250 кофе 12.09`.
|
||||||
|
|
||||||
|
Данные хранятся локально в SQLite: `data/budget.db`.
|
||||||
|
|
||||||
|
## Web-кабинет (личный, через Telegram)
|
||||||
|
|
||||||
|
Вход через [Telegram Login Widget](https://core.telegram.org/widgets/login). После входа пользователь видит **только свой** бюджет и траты (`/api/me/...`).
|
||||||
|
|
||||||
|
### Настройка BotFather
|
||||||
|
|
||||||
|
1. `/setdomain` → укажи домен, с которого открывается кабинет (нужен публичный HTTPS; чистый `localhost` обычно не работает).
|
||||||
|
2. Username бота без `@` положи в `.env` как `TELEGRAM_BOT_USERNAME`.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
| Метод | Путь | Auth | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/auth/telegram` | — | обмен данных Login Widget на JWT |
|
||||||
|
| GET | `/api/me` | Bearer JWT | профиль |
|
||||||
|
| GET | `/api/me/budget` | Bearer JWT | свой бюджет |
|
||||||
|
| GET | `/api/me/expenses` | Bearer JWT | свои траты |
|
||||||
|
| GET | `/api/budgets`… | `API_TOKEN` | админский список (опционально) |
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=...
|
||||||
|
TELEGRAM_BOT_USERNAME=MyPayBot
|
||||||
|
PROXY_URL=socks5://127.0.0.1:10808
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- кабинет: http://HOST:51290
|
||||||
|
- API: http://HOST:51291
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t please-pay-me-bot .
|
||||||
|
docker run --rm -e BOT_TOKEN=your_token -v "$(pwd)/data:/app/data" please-pay-me-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
На Windows (PowerShell):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker build -t please-pay-me-bot .
|
||||||
|
docker run --rm -e BOT_TOKEN=your_token -v ${PWD}/data:/app/data please-pay-me-bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Том `data` сохраняет SQLite между перезапусками.
|
||||||
|
|
||||||
|
### Прокси (Xray на хосте + бот в Docker)
|
||||||
|
|
||||||
|
**Рекомендуемый способ:** `network_mode: host` — контейнер видит `127.0.0.1` хоста, Xray можно оставить на loopback.
|
||||||
|
|
||||||
|
В `.env` (токен только здесь, не в compose):
|
||||||
|
|
||||||
|
```env
|
||||||
|
BOT_TOKEN=...
|
||||||
|
PROXY_URL=socks5://127.0.0.1:10808
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка на хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -x socks5h://127.0.0.1:10808 https://api.telegram.org
|
||||||
|
```
|
||||||
|
|
||||||
|
Если без `host` сети (bridge): Xray должен слушать `0.0.0.0`, а в `PROXY_URL` — gateway хоста (`172.17.0.1` или `host.docker.internal`). Скрипт: `./scripts/open_xray_listen.sh`. У Portainer custom-сеть часто **не** имеет маршрута к `172.17.0.1`, из‑за этого был timeout.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Deprecated Python API
|
||||||
|
|
||||||
|
Этот каталог больше не используется в runtime.
|
||||||
|
|
||||||
|
Актуальный API: `src/PleasePayMe.Api` (ASP.NET Core + PostgreSQL).
|
||||||
|
Контракт HTTP (`/api/...`) сохранён для web и Telegram-бота.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# API package
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class ApiSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=BASE_DIR / ".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
database_path: Path = BASE_DIR / "data" / "budget.db"
|
||||||
|
bot_token: str
|
||||||
|
# Shared secret for bot (/api/auth/internal) and admin /api/budgets/*
|
||||||
|
api_token: str | None = None
|
||||||
|
jwt_secret: str | None = None
|
||||||
|
jwt_ttl_seconds: int = 60 * 60 * 24 * 14
|
||||||
|
telegram_auth_max_age_seconds: int = 60 * 60 * 24
|
||||||
|
cors_origins: str = "*"
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8000
|
||||||
|
|
||||||
|
@field_validator("bot_token", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def require_bot_token(cls, value: object) -> object:
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
raise ValueError("BOT_TOKEN is required for Telegram login")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("api_token", "jwt_secret", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_as_none(cls, value: object) -> object:
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def session_secret(self) -> str:
|
||||||
|
return self.jwt_secret or f"ppm-jwt::{self.bot_token}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [item.strip() for item in self.cors_origins.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
settings = ApiSettings()
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
|
from api.tokens import decode_access_token
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AuthUser:
|
||||||
|
user_id: int
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bearer(authorization: str | None) -> str | None:
|
||||||
|
if not authorization:
|
||||||
|
return None
|
||||||
|
if authorization.lower().startswith("bearer "):
|
||||||
|
return authorization[7:].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def require_api_token(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
x_api_token: str | None = Header(default=None, alias="X-API-Token"),
|
||||||
|
) -> None:
|
||||||
|
if not settings.api_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
|
detail="Admin API_TOKEN is not configured",
|
||||||
|
)
|
||||||
|
token = (x_api_token or "").strip() or _extract_bearer(authorization)
|
||||||
|
if not token or token != settings.api_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or missing API token",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_user(
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> AuthUser:
|
||||||
|
token = _extract_bearer(authorization)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authorization Bearer token required",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(token)
|
||||||
|
user_id = int(payload["uid"])
|
||||||
|
except (jwt.PyJWTError, KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired session",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return AuthUser(
|
||||||
|
user_id=user_id,
|
||||||
|
first_name=payload.get("fn"),
|
||||||
|
last_name=payload.get("ln"),
|
||||||
|
username=payload.get("un"),
|
||||||
|
)
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
|
from api.routes import router as api_router
|
||||||
|
from bot.db.database import Database
|
||||||
|
from bot.db.repository import BudgetRepository
|
||||||
|
from bot.services.budget import BudgetService
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
db = Database(settings.database_path, read_only=False)
|
||||||
|
await db.connect()
|
||||||
|
app.state.db = db
|
||||||
|
app.state.budget_service = BudgetService(BudgetRepository(db))
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Please Pay Me API",
|
||||||
|
version="1.0.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||||
|
allow_headers=["Authorization", "X-API-Token", "Content-Type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(api_router)
|
||||||
+412
@@ -0,0 +1,412 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
|
from api.deps import AuthUser, require_api_token, require_user
|
||||||
|
from api.schemas import (
|
||||||
|
AuthSessionOut,
|
||||||
|
AuthUserOut,
|
||||||
|
BudgetActiveIn,
|
||||||
|
BudgetCreateIn,
|
||||||
|
BudgetOut,
|
||||||
|
BudgetStatusOut,
|
||||||
|
BudgetUpdateIn,
|
||||||
|
BudgetUpsertIn,
|
||||||
|
BudgetsListOut,
|
||||||
|
ExpenseCreateIn,
|
||||||
|
ExpenseOut,
|
||||||
|
ExpensesPageOut,
|
||||||
|
InternalAuthIn,
|
||||||
|
TelegramLoginIn,
|
||||||
|
UndoExpenseOut,
|
||||||
|
)
|
||||||
|
from api.telegram_auth import TelegramAuthError, verify_telegram_login
|
||||||
|
from api.tokens import create_access_token
|
||||||
|
from bot.services.budget import BudgetService, BudgetStatus, PeriodExpensesPage
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
def _status_out(status_data: BudgetStatus) -> BudgetStatusOut:
|
||||||
|
b = status_data.budget
|
||||||
|
return BudgetStatusOut(
|
||||||
|
budget=BudgetOut(
|
||||||
|
id=b.id,
|
||||||
|
user_id=b.user_id,
|
||||||
|
name=b.name,
|
||||||
|
total_amount=b.total_amount,
|
||||||
|
start_date=b.start_date,
|
||||||
|
end_date=b.end_date,
|
||||||
|
currency=b.currency,
|
||||||
|
is_active=b.is_active,
|
||||||
|
),
|
||||||
|
today=status_data.today,
|
||||||
|
days_left=status_data.days_left,
|
||||||
|
total_spent=status_data.total_spent,
|
||||||
|
remaining=status_data.remaining,
|
||||||
|
daily_limit=status_data.daily_limit,
|
||||||
|
spent_today=status_data.spent_today,
|
||||||
|
remaining_today=status_data.remaining_today,
|
||||||
|
is_over_daily=status_data.is_over_daily,
|
||||||
|
is_over_budget=status_data.is_over_budget,
|
||||||
|
is_expired=status_data.is_expired,
|
||||||
|
selected=status_data.selected,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _page_out(page: PeriodExpensesPage) -> ExpensesPageOut:
|
||||||
|
return ExpensesPageOut(
|
||||||
|
page=page.page,
|
||||||
|
total_pages=page.total_pages,
|
||||||
|
total_count=page.total_count,
|
||||||
|
total_sum=page.total_sum,
|
||||||
|
page_size=page.page_size,
|
||||||
|
budget_id=page.budget.id,
|
||||||
|
items=[
|
||||||
|
ExpenseOut(
|
||||||
|
id=item.id,
|
||||||
|
budget_id=item.budget_id,
|
||||||
|
amount=item.amount,
|
||||||
|
note=item.note,
|
||||||
|
spent_at=item.spent_at,
|
||||||
|
)
|
||||||
|
for item in page.items
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(request: Request) -> BudgetService:
|
||||||
|
return request.app.state.budget_service
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/auth/telegram", response_model=AuthSessionOut)
|
||||||
|
async def auth_telegram(body: TelegramLoginIn) -> AuthSessionOut:
|
||||||
|
try:
|
||||||
|
profile = verify_telegram_login(
|
||||||
|
body.model_dump(),
|
||||||
|
settings.bot_token,
|
||||||
|
max_age_seconds=settings.telegram_auth_max_age_seconds,
|
||||||
|
)
|
||||||
|
except TelegramAuthError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
token = create_access_token(user_id=profile["id"], profile=profile)
|
||||||
|
return AuthSessionOut(
|
||||||
|
access_token=token,
|
||||||
|
user=AuthUserOut(
|
||||||
|
user_id=profile["id"],
|
||||||
|
first_name=profile.get("first_name"),
|
||||||
|
last_name=profile.get("last_name"),
|
||||||
|
username=profile.get("username"),
|
||||||
|
photo_url=profile.get("photo_url"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/auth/internal",
|
||||||
|
response_model=AuthSessionOut,
|
||||||
|
dependencies=[Depends(require_api_token)],
|
||||||
|
)
|
||||||
|
async def auth_internal(body: InternalAuthIn) -> AuthSessionOut:
|
||||||
|
profile = {
|
||||||
|
"id": body.user_id,
|
||||||
|
"first_name": body.first_name,
|
||||||
|
"last_name": body.last_name,
|
||||||
|
"username": body.username,
|
||||||
|
}
|
||||||
|
token = create_access_token(user_id=body.user_id, profile=profile)
|
||||||
|
return AuthSessionOut(
|
||||||
|
access_token=token,
|
||||||
|
user=AuthUserOut(
|
||||||
|
user_id=body.user_id,
|
||||||
|
first_name=body.first_name,
|
||||||
|
last_name=body.last_name,
|
||||||
|
username=body.username,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=AuthUserOut)
|
||||||
|
async def me(user: AuthUser = Depends(require_user)) -> AuthUserOut:
|
||||||
|
return AuthUserOut(
|
||||||
|
user_id=user.user_id,
|
||||||
|
first_name=user.first_name,
|
||||||
|
last_name=user.last_name,
|
||||||
|
username=user.username,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/budgets", response_model=BudgetsListOut)
|
||||||
|
async def my_budgets(
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetsListOut:
|
||||||
|
service = _service(request)
|
||||||
|
items = await service.list_user_statuses(user.user_id)
|
||||||
|
return BudgetsListOut(items=[_status_out(item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/budgets", response_model=BudgetStatusOut)
|
||||||
|
async def create_my_budget(
|
||||||
|
body: BudgetCreateIn,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.create_budget(
|
||||||
|
user_id=user.user_id,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
end_date=body.end_date,
|
||||||
|
name=body.name,
|
||||||
|
is_active=body.is_active,
|
||||||
|
select=body.select,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/budgets/{budget_id}", response_model=BudgetStatusOut)
|
||||||
|
async def update_my_budget(
|
||||||
|
budget_id: int,
|
||||||
|
body: BudgetUpdateIn,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.update_budget(
|
||||||
|
user.user_id,
|
||||||
|
budget_id,
|
||||||
|
name=body.name,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
end_date=body.end_date,
|
||||||
|
reset_expenses=body.reset_expenses,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
code = (
|
||||||
|
status.HTTP_404_NOT_FOUND
|
||||||
|
if "не найден" in str(exc).lower()
|
||||||
|
else status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=code, detail=str(exc)) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/budgets/{budget_id}/active", response_model=BudgetStatusOut)
|
||||||
|
async def set_my_budget_active(
|
||||||
|
budget_id: int,
|
||||||
|
body: BudgetActiveIn,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.set_budget_active(
|
||||||
|
user.user_id,
|
||||||
|
budget_id,
|
||||||
|
body.is_active,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/budgets/{budget_id}/select", response_model=BudgetStatusOut)
|
||||||
|
async def select_my_budget(
|
||||||
|
budget_id: int,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.select_budget(user.user_id, budget_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/budget", response_model=BudgetStatusOut)
|
||||||
|
async def my_budget(
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
budget_id: int | None = Query(default=None),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.get_status(user.user_id, budget_id=budget_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/expenses", response_model=ExpensesPageOut)
|
||||||
|
async def my_expenses(
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
page: int = Query(default=0, ge=0),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
spent_at: date | None = Query(default=None),
|
||||||
|
budget_id: int | None = Query(default=None),
|
||||||
|
) -> ExpensesPageOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
if spent_at is not None:
|
||||||
|
page_data = await service.get_expenses_on_date_page(
|
||||||
|
user.user_id,
|
||||||
|
spent_at,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
page_data = await service.get_period_expenses_page(
|
||||||
|
user.user_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _page_out(page_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/expenses", response_model=BudgetStatusOut)
|
||||||
|
async def create_my_expense(
|
||||||
|
body: ExpenseCreateIn,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.add_expense(
|
||||||
|
user_id=user.user_id,
|
||||||
|
amount=body.amount,
|
||||||
|
note=body.note,
|
||||||
|
spent_at=body.spent_at,
|
||||||
|
budget_id=body.budget_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/expenses/last", response_model=UndoExpenseOut)
|
||||||
|
async def undo_my_last_expense(
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
budget_id: int | None = Query(default=None),
|
||||||
|
) -> UndoExpenseOut:
|
||||||
|
service = _service(request)
|
||||||
|
status_data, amount = await service.undo_last_expense(
|
||||||
|
user.user_id,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
if amount is None or status_data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Нечего отменять",
|
||||||
|
)
|
||||||
|
return UndoExpenseOut(deleted_amount=amount, status=_status_out(status_data))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/budget", response_model=BudgetStatusOut)
|
||||||
|
async def upsert_my_budget(
|
||||||
|
body: BudgetUpsertIn,
|
||||||
|
request: Request,
|
||||||
|
user: AuthUser = Depends(require_user),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.set_budget(
|
||||||
|
user_id=user.user_id,
|
||||||
|
total_amount=body.total_amount,
|
||||||
|
end_date=body.end_date,
|
||||||
|
reset_expenses=body.reset_expenses,
|
||||||
|
name=body.name,
|
||||||
|
budget_id=body.budget_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(exc),
|
||||||
|
) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/budgets",
|
||||||
|
response_model=BudgetsListOut,
|
||||||
|
dependencies=[Depends(require_api_token)],
|
||||||
|
)
|
||||||
|
async def list_budgets(request: Request) -> BudgetsListOut:
|
||||||
|
service = _service(request)
|
||||||
|
items = await service.list_budget_summaries()
|
||||||
|
return BudgetsListOut(items=[_status_out(item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/budgets/{user_id}",
|
||||||
|
response_model=BudgetStatusOut,
|
||||||
|
dependencies=[Depends(require_api_token)],
|
||||||
|
)
|
||||||
|
async def get_budget(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
budget_id: int | None = Query(default=None),
|
||||||
|
) -> BudgetStatusOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
status_data = await service.get_status(user_id, budget_id=budget_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _status_out(status_data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/budgets/{user_id}/expenses",
|
||||||
|
response_model=ExpensesPageOut,
|
||||||
|
dependencies=[Depends(require_api_token)],
|
||||||
|
)
|
||||||
|
async def list_expenses(
|
||||||
|
user_id: int,
|
||||||
|
request: Request,
|
||||||
|
page: int = Query(default=0, ge=0),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
spent_at: date | None = Query(default=None),
|
||||||
|
budget_id: int | None = Query(default=None),
|
||||||
|
) -> ExpensesPageOut:
|
||||||
|
service = _service(request)
|
||||||
|
try:
|
||||||
|
if spent_at is not None:
|
||||||
|
page_data = await service.get_expenses_on_date_page(
|
||||||
|
user_id,
|
||||||
|
spent_at,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
page_data = await service.get_period_expenses_page(
|
||||||
|
user_id,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||||
|
return _page_out(page_data)
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
total_amount: float
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
currency: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetStatusOut(BaseModel):
|
||||||
|
budget: BudgetOut
|
||||||
|
today: date
|
||||||
|
days_left: int
|
||||||
|
total_spent: float
|
||||||
|
remaining: float
|
||||||
|
daily_limit: float
|
||||||
|
spent_today: float
|
||||||
|
remaining_today: float
|
||||||
|
is_over_daily: bool
|
||||||
|
is_over_budget: bool
|
||||||
|
is_expired: bool
|
||||||
|
selected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
budget_id: int
|
||||||
|
amount: float
|
||||||
|
note: str | None
|
||||||
|
spent_at: date
|
||||||
|
|
||||||
|
|
||||||
|
class ExpensesPageOut(BaseModel):
|
||||||
|
page: int
|
||||||
|
total_pages: int
|
||||||
|
total_count: int
|
||||||
|
total_sum: float
|
||||||
|
page_size: int
|
||||||
|
budget_id: int
|
||||||
|
items: list[ExpenseOut]
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetsListOut(BaseModel):
|
||||||
|
items: list[BudgetStatusOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramLoginIn(BaseModel):
|
||||||
|
id: int
|
||||||
|
first_name: str
|
||||||
|
last_name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
photo_url: str | None = None
|
||||||
|
auth_date: int
|
||||||
|
hash: str
|
||||||
|
|
||||||
|
|
||||||
|
class InternalAuthIn(BaseModel):
|
||||||
|
"""Service auth for trusted clients (Telegram bot) acting as a user."""
|
||||||
|
|
||||||
|
user_id: int = Field(gt=0)
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthUserOut(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
photo_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionOut(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
user: AuthUserOut
|
||||||
|
|
||||||
|
|
||||||
|
class ExpenseCreateIn(BaseModel):
|
||||||
|
amount: float = Field(gt=0)
|
||||||
|
note: str | None = None
|
||||||
|
spent_at: date | None = None
|
||||||
|
budget_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetCreateIn(BaseModel):
|
||||||
|
total_amount: float = Field(gt=0)
|
||||||
|
end_date: date
|
||||||
|
name: str = "Бюджет"
|
||||||
|
is_active: bool = True
|
||||||
|
select: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetUpdateIn(BaseModel):
|
||||||
|
total_amount: float | None = Field(default=None, gt=0)
|
||||||
|
end_date: date | None = None
|
||||||
|
name: str | None = None
|
||||||
|
reset_expenses: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetActiveIn(BaseModel):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetUpsertIn(BaseModel):
|
||||||
|
"""Legacy upsert against selected budget (or create if none)."""
|
||||||
|
|
||||||
|
total_amount: float = Field(gt=0)
|
||||||
|
end_date: date
|
||||||
|
reset_expenses: bool = True
|
||||||
|
name: str | None = None
|
||||||
|
budget_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UndoExpenseOut(BaseModel):
|
||||||
|
deleted_amount: float
|
||||||
|
status: BudgetStatusOut
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramAuthError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def verify_telegram_login(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
bot_token: str,
|
||||||
|
*,
|
||||||
|
max_age_seconds: int = 86400,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify Telegram Login Widget data per Telegram docs."""
|
||||||
|
received_hash = payload.get("hash")
|
||||||
|
if not received_hash or not isinstance(received_hash, str):
|
||||||
|
raise TelegramAuthError("Missing hash")
|
||||||
|
|
||||||
|
check_pairs: list[str] = []
|
||||||
|
for key in sorted(payload.keys()):
|
||||||
|
if key == "hash":
|
||||||
|
continue
|
||||||
|
value = payload[key]
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
check_pairs.append(f"{key}={value}")
|
||||||
|
data_check_string = "\n".join(check_pairs)
|
||||||
|
|
||||||
|
secret_key = hashlib.sha256(bot_token.encode("utf-8")).digest()
|
||||||
|
calculated = hmac.new(
|
||||||
|
secret_key,
|
||||||
|
data_check_string.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(calculated, received_hash):
|
||||||
|
raise TelegramAuthError("Invalid Telegram login signature")
|
||||||
|
|
||||||
|
auth_date_raw = payload.get("auth_date")
|
||||||
|
try:
|
||||||
|
auth_date = int(auth_date_raw)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise TelegramAuthError("Invalid auth_date") from exc
|
||||||
|
|
||||||
|
if max_age_seconds > 0 and time.time() - auth_date > max_age_seconds:
|
||||||
|
raise TelegramAuthError("Telegram login data expired")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = int(payload["id"])
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise TelegramAuthError("Missing Telegram user id") from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user_id,
|
||||||
|
"first_name": str(payload.get("first_name") or ""),
|
||||||
|
"last_name": str(payload.get("last_name") or "") or None,
|
||||||
|
"username": str(payload.get("username") or "") or None,
|
||||||
|
"photo_url": str(payload.get("photo_url") or "") or None,
|
||||||
|
"auth_date": auth_date,
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from api.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(*, user_id: int, profile: dict[str, Any]) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"uid": user_id,
|
||||||
|
"fn": profile.get("first_name"),
|
||||||
|
"ln": profile.get("last_name"),
|
||||||
|
"un": profile.get("username"),
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + timedelta(seconds=settings.jwt_ttl_seconds),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.session_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict[str, Any]:
|
||||||
|
return jwt.decode(token, settings.session_secret, algorithms=["HS256"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""HTTP clients for talking to the Please Pay Me API."""
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from bot.db.repository import Budget, Expense
|
||||||
|
from bot.services.budget import (
|
||||||
|
PERIOD_PAGE_SIZE,
|
||||||
|
BudgetStatus,
|
||||||
|
PeriodExpensesPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
YANDEX_NAMESPACE_BIT = 1 << 50
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetApiError(Exception):
|
||||||
|
"""HTTP / transport failure talking to the API."""
|
||||||
|
|
||||||
|
|
||||||
|
class YandexLoginRequired(Exception):
|
||||||
|
"""Bot identity is not linked to a Yandex account yet."""
|
||||||
|
|
||||||
|
def __init__(self, login_url: str, detail: str = "") -> None:
|
||||||
|
self.login_url = login_url
|
||||||
|
self.detail = detail or "Чтобы пользоваться ботом, войдите через Яндекс."
|
||||||
|
super().__init__(self.detail)
|
||||||
|
|
||||||
|
|
||||||
|
def _jwt_uid(token: str) -> int | None:
|
||||||
|
try:
|
||||||
|
payload = token.split(".")[1]
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||||
|
raw = data.get("uid") or data.get("sub")
|
||||||
|
return int(raw)
|
||||||
|
except (IndexError, ValueError, TypeError, json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetApiClient:
|
||||||
|
"""Budget operations via the same HTTP API as the web cabinet."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str,
|
||||||
|
api_token: str,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._base = base_url.rstrip("/")
|
||||||
|
self._api_token = api_token
|
||||||
|
self._session = session
|
||||||
|
self._owns_session = session is None
|
||||||
|
self._tokens: dict[int, tuple[str, datetime]] = {}
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if self._session is None:
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._owns_session and self._session is not None:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def _ensure_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None:
|
||||||
|
raise RuntimeError("BudgetApiClient is not started")
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _detail(payload: Any, fallback: str) -> str:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
detail = payload.get("detail", fallback)
|
||||||
|
if isinstance(detail, str):
|
||||||
|
return detail
|
||||||
|
if isinstance(detail, list) and detail:
|
||||||
|
first = detail[0]
|
||||||
|
if isinstance(first, dict) and "msg" in first:
|
||||||
|
return str(first["msg"])
|
||||||
|
return str(first)
|
||||||
|
return str(detail)
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
user_id: int | None = None,
|
||||||
|
json_body: dict[str, Any] | None = None,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
retry_auth: bool = True,
|
||||||
|
) -> Any:
|
||||||
|
session = self._ensure_session()
|
||||||
|
headers: dict[str, str] = {"Accept": "application/json"}
|
||||||
|
if user_id is not None:
|
||||||
|
headers["Authorization"] = f"Bearer {await self._access_token(user_id)}"
|
||||||
|
else:
|
||||||
|
headers["X-API-Token"] = self._api_token
|
||||||
|
|
||||||
|
clean_params = None
|
||||||
|
if params:
|
||||||
|
clean_params = {k: v for k, v in params.items() if v is not None}
|
||||||
|
|
||||||
|
url = f"{self._base}{path}"
|
||||||
|
async with session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
json=json_body,
|
||||||
|
params=clean_params,
|
||||||
|
headers=headers,
|
||||||
|
) as resp:
|
||||||
|
if resp.status == 401 and user_id is not None and retry_auth:
|
||||||
|
self._tokens.pop(user_id, None)
|
||||||
|
await resp.read()
|
||||||
|
return await self._request(
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
user_id=user_id,
|
||||||
|
json_body=json_body,
|
||||||
|
params=params,
|
||||||
|
retry_auth=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status == 204:
|
||||||
|
return None
|
||||||
|
|
||||||
|
body: Any
|
||||||
|
try:
|
||||||
|
body = await resp.json(content_type=None)
|
||||||
|
except aiohttp.ContentTypeError:
|
||||||
|
text = await resp.text()
|
||||||
|
body = {"detail": text or resp.reason}
|
||||||
|
|
||||||
|
if resp.status >= 400:
|
||||||
|
if (
|
||||||
|
resp.status == 403
|
||||||
|
and isinstance(body, dict)
|
||||||
|
and body.get("code") == "yandex_required"
|
||||||
|
):
|
||||||
|
if user_id is not None:
|
||||||
|
self._tokens.pop(user_id, None)
|
||||||
|
raise YandexLoginRequired(
|
||||||
|
str(body.get("login_url") or ""),
|
||||||
|
self._detail(body, "Чтобы пользоваться ботом, войдите через Яндекс."),
|
||||||
|
)
|
||||||
|
message = self._detail(body, f"API error {resp.status}")
|
||||||
|
if resp.status in {400, 404}:
|
||||||
|
raise ValueError(message)
|
||||||
|
raise BudgetApiError(message)
|
||||||
|
return body
|
||||||
|
|
||||||
|
async def ensure_yandex_login(self, telegram_user_id: int) -> None:
|
||||||
|
await self._access_token(telegram_user_id)
|
||||||
|
|
||||||
|
async def _access_token(self, user_id: int) -> str:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cached = self._tokens.get(user_id)
|
||||||
|
if cached and cached[1] > now + timedelta(seconds=60):
|
||||||
|
uid = _jwt_uid(cached[0])
|
||||||
|
if uid is not None and (uid & YANDEX_NAMESPACE_BIT):
|
||||||
|
return cached[0]
|
||||||
|
self._tokens.pop(user_id, None)
|
||||||
|
|
||||||
|
data = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/auth/internal",
|
||||||
|
json_body={"user_id": user_id},
|
||||||
|
)
|
||||||
|
token = str(data["access_token"])
|
||||||
|
uid = _jwt_uid(token)
|
||||||
|
if uid is None or not (uid & YANDEX_NAMESPACE_BIT):
|
||||||
|
raise BudgetApiError("API did not issue a Yandex-linked session")
|
||||||
|
self._tokens[user_id] = (token, now + timedelta(days=1))
|
||||||
|
return token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date(value: str | date) -> date:
|
||||||
|
if isinstance(value, date) and not isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
return date.fromisoformat(str(value)[:10])
|
||||||
|
|
||||||
|
def _status_from(self, data: dict[str, Any]) -> BudgetStatus:
|
||||||
|
b = data["budget"]
|
||||||
|
budget = Budget(
|
||||||
|
id=int(b["id"]),
|
||||||
|
user_id=int(b["user_id"]),
|
||||||
|
name=str(b.get("name") or "Бюджет"),
|
||||||
|
total_amount=float(b["total_amount"]),
|
||||||
|
start_date=self._parse_date(b["start_date"]),
|
||||||
|
end_date=self._parse_date(b["end_date"]),
|
||||||
|
currency=str(b.get("currency") or "RUB"),
|
||||||
|
is_active=bool(b.get("is_active", True)),
|
||||||
|
)
|
||||||
|
return BudgetStatus(
|
||||||
|
budget=budget,
|
||||||
|
today=self._parse_date(data["today"]),
|
||||||
|
days_left=int(data["days_left"]),
|
||||||
|
total_spent=float(data["total_spent"]),
|
||||||
|
remaining=float(data["remaining"]),
|
||||||
|
daily_limit=float(data["daily_limit"]),
|
||||||
|
spent_today=float(data["spent_today"]),
|
||||||
|
remaining_today=float(data["remaining_today"]),
|
||||||
|
is_over_daily=bool(data["is_over_daily"]),
|
||||||
|
is_over_budget=bool(data["is_over_budget"]),
|
||||||
|
is_expired=bool(data["is_expired"]),
|
||||||
|
selected=bool(data.get("selected", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _expense_from(self, data: dict[str, Any], user_id: int) -> Expense:
|
||||||
|
return Expense(
|
||||||
|
id=int(data["id"]),
|
||||||
|
user_id=user_id,
|
||||||
|
budget_id=int(data.get("budget_id") or 0),
|
||||||
|
amount=float(data["amount"]),
|
||||||
|
note=data.get("note"),
|
||||||
|
spent_at=self._parse_date(data["spent_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _page_from(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
data: dict[str, Any],
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> PeriodExpensesPage:
|
||||||
|
status = await self.get_status(
|
||||||
|
user_id,
|
||||||
|
budget_id=budget_id or int(data.get("budget_id") or 0) or None,
|
||||||
|
)
|
||||||
|
return PeriodExpensesPage(
|
||||||
|
budget=status.budget,
|
||||||
|
page=int(data["page"]),
|
||||||
|
total_pages=int(data["total_pages"]),
|
||||||
|
total_count=int(data["total_count"]),
|
||||||
|
total_sum=float(data["total_sum"]),
|
||||||
|
page_size=int(data["page_size"]),
|
||||||
|
items=[self._expense_from(item, user_id) for item in data.get("items", [])],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_user_statuses(self, user_id: int) -> list[BudgetStatus]:
|
||||||
|
data = await self._request("GET", "/api/me/budgets", user_id=user_id)
|
||||||
|
return [self._status_from(item) for item in data.get("items", [])]
|
||||||
|
|
||||||
|
async def get_status(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
today: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
del today
|
||||||
|
data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/api/me/budget",
|
||||||
|
user_id=user_id,
|
||||||
|
params={"budget_id": budget_id} if budget_id else None,
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def create_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
total_amount: float,
|
||||||
|
end_date: date,
|
||||||
|
*,
|
||||||
|
name: str = "Бюджет",
|
||||||
|
start_date: date | None = None,
|
||||||
|
is_active: bool = True,
|
||||||
|
select: bool = True,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"end_date": end_date.isoformat(),
|
||||||
|
"name": name,
|
||||||
|
"is_active": is_active,
|
||||||
|
"select": select,
|
||||||
|
}
|
||||||
|
if start_date is not None:
|
||||||
|
body["start_date"] = start_date.isoformat()
|
||||||
|
data = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/me/budgets",
|
||||||
|
user_id=user_id,
|
||||||
|
json_body=body,
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def set_budget_active(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
data = await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/api/me/budgets/{budget_id}/active",
|
||||||
|
user_id=user_id,
|
||||||
|
json_body={"is_active": is_active},
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def select_budget(self, user_id: int, budget_id: int) -> BudgetStatus:
|
||||||
|
data = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/api/me/budgets/{budget_id}/select",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def delete_budget(self, user_id: int, budget_id: int) -> None:
|
||||||
|
await self._request(
|
||||||
|
"DELETE",
|
||||||
|
f"/api/me/budgets/{budget_id}",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
total_amount: float,
|
||||||
|
end_date: date,
|
||||||
|
start_date: date | None = None,
|
||||||
|
reset_expenses: bool = True,
|
||||||
|
name: str | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
# Creating a new named budget is the common bot flow.
|
||||||
|
if budget_id is None and name is not None:
|
||||||
|
return await self.create_budget(
|
||||||
|
user_id,
|
||||||
|
total_amount,
|
||||||
|
end_date,
|
||||||
|
name=name,
|
||||||
|
start_date=start_date,
|
||||||
|
)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"end_date": end_date.isoformat(),
|
||||||
|
"reset_expenses": reset_expenses,
|
||||||
|
}
|
||||||
|
if name is not None:
|
||||||
|
body["name"] = name
|
||||||
|
if budget_id is not None:
|
||||||
|
body["budget_id"] = budget_id
|
||||||
|
if start_date is not None:
|
||||||
|
body["start_date"] = start_date.isoformat()
|
||||||
|
data = await self._request(
|
||||||
|
"PUT",
|
||||||
|
"/api/me/budget",
|
||||||
|
user_id=user_id,
|
||||||
|
json_body=body,
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def add_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
amount: float,
|
||||||
|
note: str | None = None,
|
||||||
|
spent_at: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
body: dict[str, Any] = {"amount": amount}
|
||||||
|
if note is not None:
|
||||||
|
body["note"] = note
|
||||||
|
if spent_at is not None:
|
||||||
|
body["spent_at"] = spent_at.isoformat()
|
||||||
|
if budget_id is not None:
|
||||||
|
body["budget_id"] = budget_id
|
||||||
|
data = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/me/expenses",
|
||||||
|
user_id=user_id,
|
||||||
|
json_body=body,
|
||||||
|
)
|
||||||
|
return self._status_from(data)
|
||||||
|
|
||||||
|
async def undo_last_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> tuple[BudgetStatus | None, float | None]:
|
||||||
|
try:
|
||||||
|
data = await self._request(
|
||||||
|
"DELETE",
|
||||||
|
"/api/me/expenses/last",
|
||||||
|
user_id=user_id,
|
||||||
|
params={"budget_id": budget_id} if budget_id else None,
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return None, None
|
||||||
|
return self._status_from(data["status"]), float(data["deleted_amount"])
|
||||||
|
|
||||||
|
async def get_period_expenses_page(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
page: int = 0,
|
||||||
|
page_size: int = PERIOD_PAGE_SIZE,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> PeriodExpensesPage:
|
||||||
|
data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/api/me/expenses",
|
||||||
|
user_id=user_id,
|
||||||
|
params={"page": page, "page_size": page_size, "budget_id": budget_id},
|
||||||
|
)
|
||||||
|
return await self._page_from(user_id, data, budget_id=budget_id)
|
||||||
|
|
||||||
|
async def expenses_on_date(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
day: date,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> list[Expense]:
|
||||||
|
data = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/api/me/expenses",
|
||||||
|
user_id=user_id,
|
||||||
|
params={
|
||||||
|
"spent_at": day.isoformat(),
|
||||||
|
"page": 0,
|
||||||
|
"page_size": 100,
|
||||||
|
"budget_id": budget_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return [self._expense_from(item, user_id) for item in data.get("items", [])]
|
||||||
|
|
||||||
|
async def today_expenses(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
today: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> list[Expense]:
|
||||||
|
return await self.expenses_on_date(
|
||||||
|
user_id,
|
||||||
|
today or date.today(),
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import field_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=BASE_DIR / ".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_token: str
|
||||||
|
# Same API the web cabinet uses (bot no longer opens SQLite).
|
||||||
|
api_base_url: str = "http://127.0.0.1:51291"
|
||||||
|
api_token: str
|
||||||
|
# Example for Docker → host Xray: socks5://127.0.0.1:10808
|
||||||
|
proxy_url: str | None = None
|
||||||
|
|
||||||
|
@field_validator("proxy_url", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_proxy_as_none(cls, value: object) -> object:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("api_token", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def require_api_token(cls, value: object) -> object:
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
raise ValueError(
|
||||||
|
"API_TOKEN is required: bot authenticates to the API with it"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("api_base_url")
|
||||||
|
@classmethod
|
||||||
|
def normalize_base_url(cls, value: str) -> str:
|
||||||
|
return value.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
selected_budget_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
total_amount REAL NOT NULL,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
budget_id INTEGER NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
spent_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_indexes(conn: aiosqlite.Connection) -> None:
|
||||||
|
await conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_budgets_user
|
||||||
|
ON budgets(user_id, is_active, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expenses_budget_spent_at
|
||||||
|
ON expenses(budget_id, spent_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_expenses_user_spent_at
|
||||||
|
ON expenses(user_id, spent_at);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_columns(conn: aiosqlite.Connection, table: str) -> set[str]:
|
||||||
|
cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return {str(row[1]) for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _has_unique_user_on_budgets(conn: aiosqlite.Connection) -> bool:
|
||||||
|
cursor = await conn.execute("PRAGMA index_list(budgets)")
|
||||||
|
indexes = await cursor.fetchall()
|
||||||
|
for idx in indexes:
|
||||||
|
# (seq, name, unique, origin, partial)
|
||||||
|
if not idx[2]:
|
||||||
|
continue
|
||||||
|
name = idx[1]
|
||||||
|
info = await conn.execute(f"PRAGMA index_info({name})")
|
||||||
|
cols = [row[2] for row in await info.fetchall()]
|
||||||
|
if cols == ["user_id"]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_schema(conn: aiosqlite.Connection) -> None:
|
||||||
|
"""Upgrade legacy one-budget-per-user schema in place."""
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in await (
|
||||||
|
await conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
if "budgets" not in tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
budget_cols = await _table_columns(conn, "budgets")
|
||||||
|
if "name" not in budget_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE budgets ADD COLUMN name TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
if "is_active" not in budget_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE budgets ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_cols = await _table_columns(conn, "users")
|
||||||
|
if "selected_budget_id" not in user_cols:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE users ADD COLUMN selected_budget_id INTEGER"
|
||||||
|
)
|
||||||
|
|
||||||
|
if await _has_unique_user_on_budgets(conn):
|
||||||
|
await conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE budgets_migrated (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
total_amount REAL NOT NULL,
|
||||||
|
start_date TEXT NOT NULL,
|
||||||
|
end_date TEXT NOT NULL,
|
||||||
|
currency TEXT NOT NULL DEFAULT 'RUB',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO budgets_migrated (
|
||||||
|
id, user_id, name, total_amount, start_date, end_date,
|
||||||
|
currency, is_active, created_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, user_id,
|
||||||
|
COALESCE(NULLIF(name, ''), 'Бюджет'),
|
||||||
|
total_amount, start_date, end_date, currency,
|
||||||
|
COALESCE(is_active, 1), created_at
|
||||||
|
FROM budgets;
|
||||||
|
DROP TABLE budgets;
|
||||||
|
ALTER TABLE budgets_migrated RENAME TO budgets;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
expense_cols = await _table_columns(conn, "expenses")
|
||||||
|
if "budget_id" not in expense_cols:
|
||||||
|
await conn.execute("ALTER TABLE expenses ADD COLUMN budget_id INTEGER")
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE expenses
|
||||||
|
SET budget_id = (
|
||||||
|
SELECT b.id FROM budgets b
|
||||||
|
WHERE b.user_id = expenses.user_id
|
||||||
|
ORDER BY b.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE budget_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Drop orphan expenses that have no budget (should be rare)
|
||||||
|
await conn.execute("DELETE FROM expenses WHERE budget_id IS NULL")
|
||||||
|
await conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE expenses_migrated (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
budget_id INTEGER NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
spent_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO expenses_migrated (
|
||||||
|
id, user_id, budget_id, amount, note, spent_at, created_at
|
||||||
|
)
|
||||||
|
SELECT id, user_id, budget_id, amount, note, spent_at, created_at
|
||||||
|
FROM expenses;
|
||||||
|
DROP TABLE expenses;
|
||||||
|
ALTER TABLE expenses_migrated RENAME TO expenses;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill selected budget for users who have budgets
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET selected_budget_id = (
|
||||||
|
SELECT b.id FROM budgets b
|
||||||
|
WHERE b.user_id = users.user_id
|
||||||
|
ORDER BY b.is_active DESC, b.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE selected_budget_id IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM budgets b WHERE b.user_id = users.user_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE budgets
|
||||||
|
SET name = 'Бюджет'
|
||||||
|
WHERE name IS NULL OR TRIM(name) = ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await _ensure_indexes(conn)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, path: Path, *, read_only: bool = False) -> None:
|
||||||
|
self.path = path
|
||||||
|
self.read_only = read_only
|
||||||
|
self._conn: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
if self.read_only:
|
||||||
|
if not self.path.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Database not found: {self.path}. "
|
||||||
|
"Сначала запусти API, чтобы создался data/budget.db"
|
||||||
|
)
|
||||||
|
uri = f"file:{self.path.resolve().as_posix()}?mode=ro"
|
||||||
|
self._conn = await aiosqlite.connect(uri, uri=True)
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = await aiosqlite.connect(self.path)
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
await self._conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
try:
|
||||||
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
except aiosqlite.OperationalError:
|
||||||
|
pass
|
||||||
|
await self._conn.executescript(SCHEMA)
|
||||||
|
await migrate_schema(self._conn)
|
||||||
|
# Fresh DBs: migrate may no-op early if tables were just created with
|
||||||
|
# full columns — still ensure indexes exist.
|
||||||
|
budget_cols = await _table_columns(self._conn, "budgets")
|
||||||
|
if "is_active" in budget_cols:
|
||||||
|
await _ensure_indexes(self._conn)
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn(self) -> aiosqlite.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
return self._conn
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from bot.db.database import Database
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Budget:
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
total_amount: float
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
currency: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Expense:
|
||||||
|
id: int
|
||||||
|
user_id: int
|
||||||
|
budget_id: int
|
||||||
|
amount: float
|
||||||
|
note: str | None
|
||||||
|
spent_at: date
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> date:
|
||||||
|
return date.fromisoformat(value[:10])
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_budget(row: Any) -> Budget:
|
||||||
|
return Budget(
|
||||||
|
id=int(row["id"]),
|
||||||
|
user_id=int(row["user_id"]),
|
||||||
|
name=str(row["name"] or "Бюджет"),
|
||||||
|
total_amount=float(row["total_amount"]),
|
||||||
|
start_date=_parse_date(row["start_date"]),
|
||||||
|
end_date=_parse_date(row["end_date"]),
|
||||||
|
currency=str(row["currency"] or "RUB"),
|
||||||
|
is_active=bool(row["is_active"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_expense(row: Any) -> Expense:
|
||||||
|
return Expense(
|
||||||
|
id=int(row["id"]),
|
||||||
|
user_id=int(row["user_id"]),
|
||||||
|
budget_id=int(row["budget_id"]),
|
||||||
|
amount=float(row["amount"]),
|
||||||
|
note=row["note"],
|
||||||
|
spent_at=_parse_date(row["spent_at"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetRepository:
|
||||||
|
def __init__(self, db: Database) -> None:
|
||||||
|
self._db = db
|
||||||
|
|
||||||
|
async def ensure_user(self, user_id: int) -> None:
|
||||||
|
await self._db.conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users(user_id) VALUES (?)",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await self._db.conn.commit()
|
||||||
|
|
||||||
|
async def get_selected_budget_id(self, user_id: int) -> int | None:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"SELECT selected_budget_id FROM users WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None or row["selected_budget_id"] is None:
|
||||||
|
return None
|
||||||
|
return int(row["selected_budget_id"])
|
||||||
|
|
||||||
|
async def set_selected_budget_id(self, user_id: int, budget_id: int | None) -> None:
|
||||||
|
await self.ensure_user(user_id)
|
||||||
|
await self._db.conn.execute(
|
||||||
|
"UPDATE users SET selected_budget_id = ? WHERE user_id = ?",
|
||||||
|
(budget_id, user_id),
|
||||||
|
)
|
||||||
|
await self._db.conn.commit()
|
||||||
|
|
||||||
|
async def create_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
total_amount: float,
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
currency: str = "RUB",
|
||||||
|
is_active: bool = True,
|
||||||
|
select: bool = True,
|
||||||
|
) -> Budget:
|
||||||
|
await self.ensure_user(user_id)
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO budgets(
|
||||||
|
user_id, name, total_amount, start_date, end_date, currency, is_active
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
name.strip() or "Бюджет",
|
||||||
|
total_amount,
|
||||||
|
start_date.isoformat(),
|
||||||
|
end_date.isoformat(),
|
||||||
|
currency,
|
||||||
|
1 if is_active else 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
await self._db.conn.commit()
|
||||||
|
if row is None:
|
||||||
|
raise RuntimeError("Failed to create budget")
|
||||||
|
budget = _row_to_budget(row)
|
||||||
|
if select:
|
||||||
|
await self.set_selected_budget_id(user_id, budget.id)
|
||||||
|
return budget
|
||||||
|
|
||||||
|
async def update_budget(
|
||||||
|
self,
|
||||||
|
budget_id: int,
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
total_amount: float | None = None,
|
||||||
|
start_date: date | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
currency: str | None = None,
|
||||||
|
) -> Budget:
|
||||||
|
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Бюджет не найден")
|
||||||
|
|
||||||
|
next_name = name.strip() if name is not None else budget.name
|
||||||
|
next_total = total_amount if total_amount is not None else budget.total_amount
|
||||||
|
next_start = start_date if start_date is not None else budget.start_date
|
||||||
|
next_end = end_date if end_date is not None else budget.end_date
|
||||||
|
next_currency = currency if currency is not None else budget.currency
|
||||||
|
|
||||||
|
await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE budgets
|
||||||
|
SET name = ?, total_amount = ?, start_date = ?, end_date = ?, currency = ?
|
||||||
|
WHERE id = ? AND user_id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
next_name or "Бюджет",
|
||||||
|
next_total,
|
||||||
|
next_start.isoformat(),
|
||||||
|
next_end.isoformat(),
|
||||||
|
next_currency,
|
||||||
|
budget_id,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self._db.conn.commit()
|
||||||
|
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||||
|
if updated is None:
|
||||||
|
raise RuntimeError("Failed to update budget")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def set_budget_active(
|
||||||
|
self,
|
||||||
|
budget_id: int,
|
||||||
|
user_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
) -> Budget:
|
||||||
|
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Бюджет не найден")
|
||||||
|
await self._db.conn.execute(
|
||||||
|
"UPDATE budgets SET is_active = ? WHERE id = ? AND user_id = ?",
|
||||||
|
(1 if is_active else 0, budget_id, user_id),
|
||||||
|
)
|
||||||
|
await self._db.conn.commit()
|
||||||
|
updated = await self.get_budget_for_user(budget_id, user_id)
|
||||||
|
if updated is None:
|
||||||
|
raise RuntimeError("Failed to update budget activity")
|
||||||
|
return updated
|
||||||
|
|
||||||
|
async def get_budget_by_id(self, budget_id: int) -> Budget | None:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"SELECT * FROM budgets WHERE id = ?",
|
||||||
|
(budget_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_budget(row) if row else None
|
||||||
|
|
||||||
|
async def get_budget_for_user(self, budget_id: int, user_id: int) -> Budget | None:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"SELECT * FROM budgets WHERE id = ? AND user_id = ?",
|
||||||
|
(budget_id, user_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return _row_to_budget(row) if row else None
|
||||||
|
|
||||||
|
async def list_budgets_for_user(self, user_id: int) -> list[Budget]:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM budgets
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY is_active DESC, id DESC
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_budget(row) for row in rows]
|
||||||
|
|
||||||
|
async def resolve_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
*,
|
||||||
|
require_active: bool = False,
|
||||||
|
) -> Budget | None:
|
||||||
|
if budget_id is not None:
|
||||||
|
budget = await self.get_budget_for_user(budget_id, user_id)
|
||||||
|
if budget is None:
|
||||||
|
return None
|
||||||
|
if require_active and not budget.is_active:
|
||||||
|
raise ValueError("Бюджет неактивен — включи его или выбери другой")
|
||||||
|
return budget
|
||||||
|
|
||||||
|
selected_id = await self.get_selected_budget_id(user_id)
|
||||||
|
if selected_id is not None:
|
||||||
|
selected = await self.get_budget_for_user(selected_id, user_id)
|
||||||
|
if selected is not None:
|
||||||
|
if not require_active or selected.is_active:
|
||||||
|
return selected
|
||||||
|
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM budgets
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY is_active DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
budget = _row_to_budget(row)
|
||||||
|
if require_active and not budget.is_active:
|
||||||
|
raise ValueError("Нет активного бюджета — создай или включи существующий")
|
||||||
|
return budget
|
||||||
|
|
||||||
|
async def list_all_budgets(self) -> list[Budget]:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM budgets
|
||||||
|
ORDER BY user_id ASC, is_active DESC, id DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_budget(row) for row in rows]
|
||||||
|
|
||||||
|
async def add_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int,
|
||||||
|
amount: float,
|
||||||
|
note: str | None = None,
|
||||||
|
spent_at: date | None = None,
|
||||||
|
) -> Expense:
|
||||||
|
await self.ensure_user(user_id)
|
||||||
|
spent = spent_at or date.today()
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expenses(user_id, budget_id, amount, note, spent_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
RETURNING *
|
||||||
|
""",
|
||||||
|
(user_id, budget_id, amount, note, spent.isoformat()),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
await self._db.conn.commit()
|
||||||
|
if row is None:
|
||||||
|
raise RuntimeError("Failed to insert expense")
|
||||||
|
return _row_to_expense(row)
|
||||||
|
|
||||||
|
async def delete_last_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> Expense | None:
|
||||||
|
if budget_id is None:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expenses
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expenses
|
||||||
|
WHERE user_id = ? AND budget_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(user_id, budget_id),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
await self._db.conn.execute("DELETE FROM expenses WHERE id = ?", (row["id"],))
|
||||||
|
await self._db.conn.commit()
|
||||||
|
return _row_to_expense(row)
|
||||||
|
|
||||||
|
async def spent_on_date(
|
||||||
|
self,
|
||||||
|
budget_id: int,
|
||||||
|
day: date,
|
||||||
|
) -> float:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM expenses
|
||||||
|
WHERE budget_id = ? AND spent_at = ?
|
||||||
|
""",
|
||||||
|
(budget_id, day.isoformat()),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return float(row["total"]) if row else 0.0
|
||||||
|
|
||||||
|
async def list_expenses_on_date(
|
||||||
|
self,
|
||||||
|
budget_id: int,
|
||||||
|
day: date,
|
||||||
|
) -> list[Expense]:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expenses
|
||||||
|
WHERE budget_id = ? AND spent_at = ?
|
||||||
|
ORDER BY id ASC
|
||||||
|
""",
|
||||||
|
(budget_id, day.isoformat()),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_expense(row) for row in rows]
|
||||||
|
|
||||||
|
async def count_expenses_for_budget(self, budget_id: int) -> int:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM expenses WHERE budget_id = ?",
|
||||||
|
(budget_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return int(row["cnt"]) if row else 0
|
||||||
|
|
||||||
|
async def sum_expenses_for_budget(self, budget_id: int) -> float:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(amount), 0) AS total
|
||||||
|
FROM expenses
|
||||||
|
WHERE budget_id = ?
|
||||||
|
""",
|
||||||
|
(budget_id,),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return float(row["total"]) if row else 0.0
|
||||||
|
|
||||||
|
async def list_expenses_for_budget(
|
||||||
|
self,
|
||||||
|
budget_id: int,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
) -> list[Expense]:
|
||||||
|
cursor = await self._db.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM expenses
|
||||||
|
WHERE budget_id = ?
|
||||||
|
ORDER BY spent_at DESC, id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(budget_id, limit, offset),
|
||||||
|
)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [_row_to_expense(row) for row in rows]
|
||||||
|
|
||||||
|
async def clear_expenses_for_budget_id(self, budget_id: int) -> None:
|
||||||
|
await self._db.conn.execute(
|
||||||
|
"DELETE FROM expenses WHERE budget_id = ?",
|
||||||
|
(budget_id,),
|
||||||
|
)
|
||||||
|
await self._db.conn.commit()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.filters import Command, CommandObject, CommandStart, StateFilter
|
||||||
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from aiogram.types import (
|
||||||
|
CallbackQuery,
|
||||||
|
InlineKeyboardButton,
|
||||||
|
InlineKeyboardMarkup,
|
||||||
|
KeyboardButton,
|
||||||
|
Message,
|
||||||
|
ReplyKeyboardMarkup,
|
||||||
|
ReplyKeyboardRemove,
|
||||||
|
)
|
||||||
|
|
||||||
|
from bot.clients.budget_api import BudgetApiError
|
||||||
|
from bot.handlers.yandex_gate import YandexLoginGateMiddleware
|
||||||
|
from bot.handlers.states import BudgetSetup, DatedExpense
|
||||||
|
from bot.services.budget import BudgetService, PeriodExpensesPage
|
||||||
|
from bot.services.parsing import (
|
||||||
|
format_date,
|
||||||
|
format_money,
|
||||||
|
format_status,
|
||||||
|
parse_amount,
|
||||||
|
parse_end_date,
|
||||||
|
parse_expense_message,
|
||||||
|
parse_spent_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
router.message.middleware(YandexLoginGateMiddleware())
|
||||||
|
router.callback_query.middleware(YandexLoginGateMiddleware())
|
||||||
|
|
||||||
|
HELP_TEXT = """\
|
||||||
|
Я помогаю дотянуть до зарплаты без сюрпризов.
|
||||||
|
|
||||||
|
Команды:
|
||||||
|
/budget — новый бюджет (имя · сумма · дата)
|
||||||
|
/budgets — список бюджетов, выбрать / вкл-выкл / удалить
|
||||||
|
/status — сколько можно тратить сегодня
|
||||||
|
/today — траты за сегодня
|
||||||
|
/history — траты за весь период (постранично)
|
||||||
|
/day 12.09 — траты за дату
|
||||||
|
/spend — трата за другую дату (диалог)
|
||||||
|
/undo — отменить последнюю трату
|
||||||
|
/cancel — отменить текущий диалог
|
||||||
|
/help — эта справка
|
||||||
|
|
||||||
|
Быстрый ввод трат:
|
||||||
|
• 250
|
||||||
|
• 250 кофе
|
||||||
|
• кофе 250
|
||||||
|
• за 12.09 250 кофе
|
||||||
|
• 250 кофе за 12.09
|
||||||
|
• 12.09 250 кофе
|
||||||
|
"""
|
||||||
|
|
||||||
|
MENU_STATUS = "📊 Статус"
|
||||||
|
MENU_TODAY = "🧾 Сегодня"
|
||||||
|
MENU_HISTORY = "📒 Период"
|
||||||
|
MENU_DATED = "📅 За дату"
|
||||||
|
MENU_UNDO = "↩️ Отмена траты"
|
||||||
|
MENU_BUDGET = "💰 Новый бюджет"
|
||||||
|
MENU_BUDGETS = "🗂 Бюджеты"
|
||||||
|
|
||||||
|
HISTORY_CB_PREFIX = "hist:"
|
||||||
|
BUDGET_SELECT_PREFIX = "bsel:"
|
||||||
|
BUDGET_TOGGLE_PREFIX = "btgl:"
|
||||||
|
BUDGET_DELETE_PREFIX = "bdel:"
|
||||||
|
|
||||||
|
|
||||||
|
def main_keyboard() -> ReplyKeyboardMarkup:
|
||||||
|
return ReplyKeyboardMarkup(
|
||||||
|
keyboard=[
|
||||||
|
[KeyboardButton(text=MENU_STATUS), KeyboardButton(text=MENU_TODAY)],
|
||||||
|
[KeyboardButton(text=MENU_HISTORY), KeyboardButton(text=MENU_DATED)],
|
||||||
|
[KeyboardButton(text=MENU_BUDGETS), KeyboardButton(text=MENU_BUDGET)],
|
||||||
|
[KeyboardButton(text=MENU_UNDO)],
|
||||||
|
],
|
||||||
|
resize_keyboard=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_period_page(page_data: PeriodExpensesPage) -> str:
|
||||||
|
budget = page_data.budget
|
||||||
|
header = (
|
||||||
|
f"📒 {budget.name}\n"
|
||||||
|
f"Траты {format_date(budget.start_date)}–{format_date(budget.end_date)}\n"
|
||||||
|
f"Всего записей: {page_data.total_count} · "
|
||||||
|
f"сумма: {format_money(page_data.total_sum)}\n"
|
||||||
|
f"Страница {page_data.page + 1}/{page_data.total_pages}"
|
||||||
|
)
|
||||||
|
if not page_data.items:
|
||||||
|
return header + "\n\nПока нет трат за период."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for item in page_data.items:
|
||||||
|
note = f" — {item.note}" if item.note else ""
|
||||||
|
lines.append(
|
||||||
|
f"• {format_date(item.spent_at)} · {format_money(item.amount)}{note}"
|
||||||
|
)
|
||||||
|
return header + "\n\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_budgets_list(items) -> str:
|
||||||
|
if not items:
|
||||||
|
return "Бюджетов пока нет. Создай: /budget"
|
||||||
|
lines = ["Твои бюджеты:"]
|
||||||
|
for status in items:
|
||||||
|
b = status.budget
|
||||||
|
marks = []
|
||||||
|
if status.selected:
|
||||||
|
marks.append("текущий")
|
||||||
|
marks.append("активен" if b.is_active else "выкл")
|
||||||
|
mark = ", ".join(marks)
|
||||||
|
lines.append(
|
||||||
|
f"• #{b.id} {b.name} — {format_money(b.total_amount)} "
|
||||||
|
f"до {format_date(b.end_date)} ({mark})"
|
||||||
|
)
|
||||||
|
lines.append("\nКнопки: выбрать · вкл/выкл · удалить.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def budgets_keyboard(items) -> InlineKeyboardMarkup | None:
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
rows: list[list[InlineKeyboardButton]] = []
|
||||||
|
for status in items:
|
||||||
|
b = status.budget
|
||||||
|
select_label = f"{'✓ ' if status.selected else ''}{b.name}"[:28]
|
||||||
|
toggle_label = "Выкл" if b.is_active else "Вкл"
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=select_label,
|
||||||
|
callback_data=f"{BUDGET_SELECT_PREFIX}{b.id}",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=toggle_label,
|
||||||
|
callback_data=f"{BUDGET_TOGGLE_PREFIX}{b.id}",
|
||||||
|
),
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="🗑",
|
||||||
|
callback_data=f"{BUDGET_DELETE_PREFIX}{b.id}",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||||
|
|
||||||
|
|
||||||
|
def history_keyboard(page_data: PeriodExpensesPage) -> InlineKeyboardMarkup | None:
|
||||||
|
if page_data.total_pages <= 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
buttons: list[InlineKeyboardButton] = []
|
||||||
|
if page_data.page > 0:
|
||||||
|
buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="‹ Назад",
|
||||||
|
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page - 1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{page_data.page + 1}/{page_data.total_pages}",
|
||||||
|
callback_data=f"{HISTORY_CB_PREFIX}nop",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if page_data.page + 1 < page_data.total_pages:
|
||||||
|
buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text="Вперёд ›",
|
||||||
|
callback_data=f"{HISTORY_CB_PREFIX}{page_data.page + 1}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[buttons])
|
||||||
|
|
||||||
|
|
||||||
|
async def _reply_expense_saved(
|
||||||
|
message: Message,
|
||||||
|
*,
|
||||||
|
amount: float,
|
||||||
|
note: str | None,
|
||||||
|
spent_at: date | None,
|
||||||
|
status,
|
||||||
|
) -> None:
|
||||||
|
note_part = f" ({note})" if note else ""
|
||||||
|
date_part = f" за {format_date(spent_at)}" if spent_at else ""
|
||||||
|
await message.answer(
|
||||||
|
f"Записал {format_money(amount)}{note_part}{date_part}.\n\n"
|
||||||
|
f"{format_status(status)}",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_expense(
|
||||||
|
message: Message,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
*,
|
||||||
|
amount: float,
|
||||||
|
note: str | None,
|
||||||
|
spent_at: date | None,
|
||||||
|
state: FSMContext | None = None,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
status = await budget_service.add_expense(
|
||||||
|
user_id=message.from_user.id,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
if state is not None:
|
||||||
|
await state.clear()
|
||||||
|
return False
|
||||||
|
except BudgetApiError as exc:
|
||||||
|
await message.answer(
|
||||||
|
f"API недоступен: {exc}",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
if state is not None:
|
||||||
|
await state.clear()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if state is not None:
|
||||||
|
await state.clear()
|
||||||
|
await _reply_expense_saved(
|
||||||
|
message,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# --- Global commands / menu (always win over FSM) ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart())
|
||||||
|
async def cmd_start(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
"Привет! Я бот «от зарплаты до зарплаты».\n\n"
|
||||||
|
"1) Задай бюджет: /budget\n"
|
||||||
|
"2) Пиши траты: 250 или кофе 250\n"
|
||||||
|
"3) За другую дату: за 12.09 250 кофе\n"
|
||||||
|
"4) Смотри лимит: /status",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("help"))
|
||||||
|
async def cmd_help(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(HELP_TEXT, reply_markup=main_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("cancel"))
|
||||||
|
@router.message(F.text.casefold() == "отмена")
|
||||||
|
async def cmd_cancel(message: Message, state: FSMContext) -> None:
|
||||||
|
current = await state.get_state()
|
||||||
|
if current is None:
|
||||||
|
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
await state.clear()
|
||||||
|
await message.answer("Ок, отменил.", reply_markup=main_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("status"))
|
||||||
|
@router.message(F.text == MENU_STATUS)
|
||||||
|
async def cmd_status(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
try:
|
||||||
|
status = await budget_service.get_status(message.from_user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
await message.answer(format_status(status), reply_markup=main_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("today"))
|
||||||
|
@router.message(F.text == MENU_TODAY)
|
||||||
|
async def cmd_today(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
try:
|
||||||
|
status = await budget_service.get_status(message.from_user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
expenses = await budget_service.today_expenses(message.from_user.id)
|
||||||
|
if not expenses:
|
||||||
|
body = "Сегодня трат пока нет."
|
||||||
|
else:
|
||||||
|
lines = []
|
||||||
|
for item in expenses:
|
||||||
|
note = f" — {item.note}" if item.note else ""
|
||||||
|
lines.append(f"• {format_money(item.amount)}{note}")
|
||||||
|
body = "Траты сегодня:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"{body}\n\n{format_status(status)}",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("history"))
|
||||||
|
@router.message(F.text == MENU_HISTORY)
|
||||||
|
async def cmd_history(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
try:
|
||||||
|
page_data = await budget_service.get_period_expenses_page(
|
||||||
|
message.from_user.id,
|
||||||
|
page=0,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
format_period_page(page_data),
|
||||||
|
reply_markup=history_keyboard(page_data) or main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith(HISTORY_CB_PREFIX))
|
||||||
|
async def cb_history_page(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
raw = (callback.data or "")[len(HISTORY_CB_PREFIX) :]
|
||||||
|
if raw == "nop":
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("Некорректная страница", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
page_data = await budget_service.get_period_expenses_page(
|
||||||
|
callback.from_user.id,
|
||||||
|
page=page,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await callback.answer(str(exc), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
text = format_period_page(page_data)
|
||||||
|
markup = history_keyboard(page_data)
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.edit_text(text, reply_markup=markup)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("day"))
|
||||||
|
async def cmd_day(
|
||||||
|
message: Message,
|
||||||
|
command: CommandObject,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
args = (command.args or "").strip()
|
||||||
|
if not args:
|
||||||
|
await message.answer(
|
||||||
|
"Укажи дату: /day 12.09\n"
|
||||||
|
"Или список трат за сегодня: /today",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
day = parse_spent_date(args)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await budget_service.get_status(message.from_user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc), reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
expenses = await budget_service.expenses_on_date(message.from_user.id, day)
|
||||||
|
if not expenses:
|
||||||
|
body = f"За {format_date(day)} трат нет."
|
||||||
|
else:
|
||||||
|
lines = []
|
||||||
|
for item in expenses:
|
||||||
|
note = f" — {item.note}" if item.note else ""
|
||||||
|
lines.append(f"• {format_money(item.amount)}{note}")
|
||||||
|
body = f"Траты за {format_date(day)}:\n" + "\n".join(lines)
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
f"{body}\n\n{format_status(status)}",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("undo"))
|
||||||
|
@router.message(F.text == MENU_UNDO)
|
||||||
|
async def cmd_undo(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
status, amount = await budget_service.undo_last_expense(message.from_user.id)
|
||||||
|
if amount is None:
|
||||||
|
await message.answer("Нечего отменять.", reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
|
||||||
|
text = f"Удалил последнюю трату: {format_money(amount)}."
|
||||||
|
if status is not None:
|
||||||
|
text += f"\n\n{format_status(status)}"
|
||||||
|
await message.answer(text, reply_markup=main_keyboard())
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("budget"))
|
||||||
|
@router.message(F.text == MENU_BUDGET)
|
||||||
|
async def cmd_budget(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.set_state(BudgetSetup.waiting_name)
|
||||||
|
await message.answer(
|
||||||
|
"Новый бюджет. Как назвать? (например: Зарплата, Отпуск)\n"
|
||||||
|
"Или «-» чтобы оставить «Бюджет».\n"
|
||||||
|
"Отмена: /cancel",
|
||||||
|
reply_markup=ReplyKeyboardRemove(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("budgets"))
|
||||||
|
@router.message(F.text == MENU_BUDGETS)
|
||||||
|
async def cmd_budgets(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
await state.clear()
|
||||||
|
try:
|
||||||
|
items = await budget_service.list_user_statuses(message.from_user.id)
|
||||||
|
except (ValueError, BudgetApiError) as exc:
|
||||||
|
await message.answer(f"Не удалось загрузить: {exc}", reply_markup=main_keyboard())
|
||||||
|
return
|
||||||
|
await message.answer(
|
||||||
|
format_budgets_list(items),
|
||||||
|
reply_markup=budgets_keyboard(items) or main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith(BUDGET_SELECT_PREFIX))
|
||||||
|
async def cb_budget_select(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
raw = (callback.data or "")[len(BUDGET_SELECT_PREFIX) :]
|
||||||
|
try:
|
||||||
|
budget_id = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("Некорректный id", show_alert=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
status = await budget_service.select_budget(callback.from_user.id, budget_id)
|
||||||
|
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
await callback.answer(str(exc), show_alert=True)
|
||||||
|
return
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
format_budgets_list(items) + f"\n\nТекущий: {status.budget.name}",
|
||||||
|
reply_markup=budgets_keyboard(items),
|
||||||
|
)
|
||||||
|
await callback.answer(f"Выбран: {status.budget.name}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith(BUDGET_TOGGLE_PREFIX))
|
||||||
|
async def cb_budget_toggle(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
raw = (callback.data or "")[len(BUDGET_TOGGLE_PREFIX) :]
|
||||||
|
try:
|
||||||
|
budget_id = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("Некорректный id", show_alert=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current = await budget_service.get_status(
|
||||||
|
callback.from_user.id,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
status = await budget_service.set_budget_active(
|
||||||
|
callback.from_user.id,
|
||||||
|
budget_id,
|
||||||
|
not current.budget.is_active,
|
||||||
|
)
|
||||||
|
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
await callback.answer(str(exc), show_alert=True)
|
||||||
|
return
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
format_budgets_list(items),
|
||||||
|
reply_markup=budgets_keyboard(items),
|
||||||
|
)
|
||||||
|
state_label = "включён" if status.budget.is_active else "выключен"
|
||||||
|
await callback.answer(f"{status.budget.name}: {state_label}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith(BUDGET_DELETE_PREFIX))
|
||||||
|
async def cb_budget_delete(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
raw = (callback.data or "")[len(BUDGET_DELETE_PREFIX) :]
|
||||||
|
try:
|
||||||
|
budget_id = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
await callback.answer("Некорректный id", show_alert=True)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
current = await budget_service.get_status(
|
||||||
|
callback.from_user.id,
|
||||||
|
budget_id=budget_id,
|
||||||
|
)
|
||||||
|
name = current.budget.name
|
||||||
|
await budget_service.delete_budget(callback.from_user.id, budget_id)
|
||||||
|
items = await budget_service.list_user_statuses(callback.from_user.id)
|
||||||
|
except (ValueError, BudgetApiError) as exc:
|
||||||
|
await callback.answer(str(exc), show_alert=True)
|
||||||
|
return
|
||||||
|
if callback.message:
|
||||||
|
await callback.message.edit_text(
|
||||||
|
format_budgets_list(items),
|
||||||
|
reply_markup=budgets_keyboard(items) or None,
|
||||||
|
)
|
||||||
|
await callback.answer(f"Удалён: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("spend"))
|
||||||
|
@router.message(F.text == MENU_DATED)
|
||||||
|
async def cmd_spend_dated(message: Message, state: FSMContext) -> None:
|
||||||
|
await state.set_state(DatedExpense.waiting_date)
|
||||||
|
await message.answer(
|
||||||
|
"Трата за дату — одним сообщением или по шагам.\n\n"
|
||||||
|
"Сразу: 12.09 250 кб\n"
|
||||||
|
"Или только дата: 12.09\n"
|
||||||
|
"Отмена: /cancel",
|
||||||
|
reply_markup=ReplyKeyboardRemove(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- FSM: budget ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BudgetSetup.waiting_name)
|
||||||
|
async def budget_name(message: Message, state: FSMContext) -> None:
|
||||||
|
raw = (message.text or "").strip()
|
||||||
|
name = "Бюджет" if raw in {"", "-", "—"} else raw[:64]
|
||||||
|
await state.update_data(name=name)
|
||||||
|
await state.set_state(BudgetSetup.waiting_amount)
|
||||||
|
await message.answer(
|
||||||
|
f"Название: {name}.\n"
|
||||||
|
"Сколько денег в этом бюджете? (например: 25000)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BudgetSetup.waiting_amount)
|
||||||
|
async def budget_amount(message: Message, state: FSMContext) -> None:
|
||||||
|
try:
|
||||||
|
amount = parse_amount(message.text or "")
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(f"{exc}\nПопробуй ещё раз, например: 25000")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(amount=amount)
|
||||||
|
await state.set_state(BudgetSetup.waiting_end_date)
|
||||||
|
await message.answer(
|
||||||
|
"До какой даты нужно протянуть?\n"
|
||||||
|
"Форматы: 25.09 · 25.09.2026 · 2026-09-25"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(BudgetSetup.waiting_end_date)
|
||||||
|
async def budget_end_date(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
end_date = parse_end_date(message.text or "")
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(f"{exc}\nПример: 25.09.2026")
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
amount = float(data["amount"])
|
||||||
|
name = str(data.get("name") or "Бюджет")
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = await budget_service.create_budget(
|
||||||
|
user_id=message.from_user.id,
|
||||||
|
total_amount=amount,
|
||||||
|
end_date=end_date,
|
||||||
|
name=name,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(str(exc))
|
||||||
|
return
|
||||||
|
except BudgetApiError as exc:
|
||||||
|
await message.answer(f"API недоступен: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await message.answer(
|
||||||
|
f"Бюджет «{name}» создан: {format_money(amount)} до {format_date(end_date)}.\n\n"
|
||||||
|
f"{format_status(status)}",
|
||||||
|
reply_markup=main_keyboard(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- FSM: dated expense ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(DatedExpense.waiting_date)
|
||||||
|
async def dated_expense_date(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
text = (message.text or "").strip()
|
||||||
|
if not text:
|
||||||
|
await message.answer("Введи дату, например: 12.09")
|
||||||
|
return
|
||||||
|
|
||||||
|
# One-shot: "12.09 250 кб" / "за 12.09 250 кб"
|
||||||
|
try:
|
||||||
|
amount, note, spent_at = parse_expense_message(text)
|
||||||
|
except ValueError:
|
||||||
|
amount, note, spent_at = None, None, None
|
||||||
|
|
||||||
|
if amount is not None and spent_at is not None:
|
||||||
|
await _save_expense(
|
||||||
|
message,
|
||||||
|
budget_service,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Date only, or "12.09 кб" (date + note without amount)
|
||||||
|
tokens = text.split()
|
||||||
|
try:
|
||||||
|
spent_at = parse_spent_date(tokens[0].rstrip(":"))
|
||||||
|
except ValueError:
|
||||||
|
await message.answer(
|
||||||
|
"Не понял дату.\n"
|
||||||
|
"Примеры: 12.09 или 12.09 250 кб\n"
|
||||||
|
"Отмена: /cancel"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if spent_at > date.today():
|
||||||
|
await message.answer("Нельзя добавить трату на будущую дату. Введи другую:")
|
||||||
|
return
|
||||||
|
|
||||||
|
rest = " ".join(tokens[1:]).strip()
|
||||||
|
if rest:
|
||||||
|
try:
|
||||||
|
amount, note, nested = parse_expense_message(rest)
|
||||||
|
except ValueError:
|
||||||
|
# "12.09 кб" → дата есть, суммы нет: запомним заметку и спросим сумму
|
||||||
|
await state.update_data(spent_at=spent_at.isoformat(), note_hint=rest)
|
||||||
|
await state.set_state(DatedExpense.waiting_expense)
|
||||||
|
await message.answer(
|
||||||
|
f"Дата: {format_date(spent_at)}, заметка: {rest}.\n"
|
||||||
|
"Теперь сумму, например: 250"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if nested is not None:
|
||||||
|
spent_at = nested
|
||||||
|
await _save_expense(
|
||||||
|
message,
|
||||||
|
budget_service,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(spent_at=spent_at.isoformat(), note_hint=None)
|
||||||
|
await state.set_state(DatedExpense.waiting_expense)
|
||||||
|
await message.answer(
|
||||||
|
f"Дата: {format_date(spent_at)}.\n"
|
||||||
|
"Теперь сумма (и комментарий): 250 или 250 кб"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(DatedExpense.waiting_expense)
|
||||||
|
async def dated_expense_amount(
|
||||||
|
message: Message,
|
||||||
|
state: FSMContext,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
data = await state.get_data()
|
||||||
|
spent_at = date.fromisoformat(data["spent_at"])
|
||||||
|
note_hint = data.get("note_hint")
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount, note, nested_date = parse_expense_message(message.text or "")
|
||||||
|
except ValueError as exc:
|
||||||
|
await message.answer(f"{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if nested_date is not None:
|
||||||
|
spent_at = nested_date
|
||||||
|
if note is None and note_hint:
|
||||||
|
note = note_hint
|
||||||
|
|
||||||
|
await _save_expense(
|
||||||
|
message,
|
||||||
|
budget_service,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Free-text expense (no active dialog) ---
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(StateFilter(None), F.text)
|
||||||
|
async def add_expense_from_text(
|
||||||
|
message: Message,
|
||||||
|
budget_service: BudgetService,
|
||||||
|
) -> None:
|
||||||
|
text = (message.text or "").strip()
|
||||||
|
if text.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amount, note, spent_at = parse_expense_message(text)
|
||||||
|
except ValueError:
|
||||||
|
await message.answer(
|
||||||
|
"Не понял. Примеры:\n"
|
||||||
|
"• 250 / 250 кофе / кофе 250\n"
|
||||||
|
"• за 12.09 250 кофе\n"
|
||||||
|
"• /spend — диалог за дату"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await _save_expense(
|
||||||
|
message,
|
||||||
|
budget_service,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=spent_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetSetup(StatesGroup):
|
||||||
|
waiting_name = State()
|
||||||
|
waiting_amount = State()
|
||||||
|
waiting_end_date = State()
|
||||||
|
|
||||||
|
|
||||||
|
class DatedExpense(StatesGroup):
|
||||||
|
waiting_date = State()
|
||||||
|
waiting_expense = State()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from aiogram import BaseMiddleware
|
||||||
|
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, TelegramObject
|
||||||
|
|
||||||
|
from bot.clients.budget_api import YandexLoginRequired
|
||||||
|
|
||||||
|
YANDEX_REQUIRED_TEXT = """\
|
||||||
|
Чтобы пользоваться ботом, войдите через Яндекс.
|
||||||
|
|
||||||
|
Так Telegram, сайт и приложение — один аккаунт, и бюджеты не потеряются.
|
||||||
|
|
||||||
|
Откройте ссылку, примите документы и нажмите «Войти через Яндекс». Потом вернитесь в бот и напишите /start.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def yandex_login_keyboard(login_url: str) -> InlineKeyboardMarkup | None:
|
||||||
|
url = (login_url or "").strip()
|
||||||
|
if not url.startswith(("https://", "http://")):
|
||||||
|
return None
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[[InlineKeyboardButton(text="Войти через Яндекс", url=url)]]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class YandexLoginGateMiddleware(BaseMiddleware):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: TelegramObject,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
user = getattr(event, "from_user", None)
|
||||||
|
service = data.get("budget_service")
|
||||||
|
ensure = getattr(service, "ensure_yandex_login", None)
|
||||||
|
if user is None or not callable(ensure):
|
||||||
|
return await handler(event, data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await ensure(user.id)
|
||||||
|
except YandexLoginRequired as exc:
|
||||||
|
markup = yandex_login_keyboard(exc.login_url)
|
||||||
|
if isinstance(event, Message):
|
||||||
|
await event.answer(YANDEX_REQUIRED_TEXT, reply_markup=markup)
|
||||||
|
elif isinstance(event, CallbackQuery):
|
||||||
|
await event.answer()
|
||||||
|
if event.message:
|
||||||
|
await event.message.answer(YANDEX_REQUIRED_TEXT, reply_markup=markup)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return await handler(event, data)
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.session.aiohttp import AiohttpSession
|
||||||
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
|
||||||
|
from bot.clients.budget_api import BudgetApiClient
|
||||||
|
from bot.config import settings
|
||||||
|
from bot.handlers.budget import router as budget_router
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_bot() -> Bot:
|
||||||
|
if settings.proxy_url:
|
||||||
|
logger.info("Using proxy for Telegram API: %s", settings.proxy_url)
|
||||||
|
session = AiohttpSession(proxy=settings.proxy_url)
|
||||||
|
else:
|
||||||
|
session = AiohttpSession()
|
||||||
|
return Bot(token=settings.bot_token, session=session)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
budget_service = BudgetApiClient(
|
||||||
|
base_url=settings.api_base_url,
|
||||||
|
api_token=settings.api_token,
|
||||||
|
)
|
||||||
|
await budget_service.start()
|
||||||
|
|
||||||
|
bot = create_bot()
|
||||||
|
dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
dp["budget_service"] = budget_service
|
||||||
|
dp.include_router(budget_router)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info("Bot started (API %s)", settings.api_base_url)
|
||||||
|
await dp.start_polling(bot)
|
||||||
|
finally:
|
||||||
|
await budget_service.close()
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Package marker
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
from bot.db.repository import Budget, BudgetRepository, Expense
|
||||||
|
|
||||||
|
PERIOD_PAGE_SIZE = 8
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BudgetStatus:
|
||||||
|
budget: Budget
|
||||||
|
today: date
|
||||||
|
days_left: int
|
||||||
|
total_spent: float
|
||||||
|
remaining: float
|
||||||
|
daily_limit: float
|
||||||
|
spent_today: float
|
||||||
|
remaining_today: float
|
||||||
|
is_over_daily: bool
|
||||||
|
is_over_budget: bool
|
||||||
|
is_expired: bool
|
||||||
|
selected: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PeriodExpensesPage:
|
||||||
|
budget: Budget
|
||||||
|
page: int
|
||||||
|
total_pages: int
|
||||||
|
total_count: int
|
||||||
|
total_sum: float
|
||||||
|
page_size: int
|
||||||
|
items: list[Expense]
|
||||||
|
|
||||||
|
|
||||||
|
class BudgetService:
|
||||||
|
def __init__(self, repo: BudgetRepository) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
|
||||||
|
async def list_user_statuses(self, user_id: int) -> list[BudgetStatus]:
|
||||||
|
budgets = await self._repo.list_budgets_for_user(user_id)
|
||||||
|
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||||
|
return [
|
||||||
|
await self.get_status_for_budget(
|
||||||
|
budget,
|
||||||
|
selected=(budget.id == selected_id),
|
||||||
|
)
|
||||||
|
for budget in budgets
|
||||||
|
]
|
||||||
|
|
||||||
|
async def create_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
total_amount: float,
|
||||||
|
end_date: date,
|
||||||
|
*,
|
||||||
|
name: str = "Бюджет",
|
||||||
|
start_date: date | None = None,
|
||||||
|
is_active: bool = True,
|
||||||
|
select: bool = True,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
start = start_date or date.today()
|
||||||
|
if end_date < start:
|
||||||
|
raise ValueError("Дата окончания не может быть раньше сегодняшнего дня")
|
||||||
|
if total_amount <= 0:
|
||||||
|
raise ValueError("Сумма бюджета должна быть больше нуля")
|
||||||
|
|
||||||
|
budget = await self._repo.create_budget(
|
||||||
|
user_id,
|
||||||
|
name=name,
|
||||||
|
total_amount=total_amount,
|
||||||
|
start_date=start,
|
||||||
|
end_date=end_date,
|
||||||
|
is_active=is_active,
|
||||||
|
select=select,
|
||||||
|
)
|
||||||
|
return await self.get_status_for_budget(budget, selected=select)
|
||||||
|
|
||||||
|
async def update_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
total_amount: float | None = None,
|
||||||
|
end_date: date | None = None,
|
||||||
|
start_date: date | None = None,
|
||||||
|
reset_expenses: bool = False,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
existing = await self._repo.get_budget_for_user(budget_id, user_id)
|
||||||
|
if existing is None:
|
||||||
|
raise ValueError("Бюджет не найден")
|
||||||
|
|
||||||
|
next_end = end_date if end_date is not None else existing.end_date
|
||||||
|
next_start = start_date if start_date is not None else existing.start_date
|
||||||
|
if next_end < next_start:
|
||||||
|
raise ValueError("Дата окончания не может быть раньше даты начала")
|
||||||
|
if total_amount is not None and total_amount <= 0:
|
||||||
|
raise ValueError("Сумма бюджета должна быть больше нуля")
|
||||||
|
|
||||||
|
if reset_expenses:
|
||||||
|
await self._repo.clear_expenses_for_budget_id(budget_id)
|
||||||
|
|
||||||
|
budget = await self._repo.update_budget(
|
||||||
|
budget_id,
|
||||||
|
user_id,
|
||||||
|
name=name,
|
||||||
|
total_amount=total_amount,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||||
|
return await self.get_status_for_budget(
|
||||||
|
budget,
|
||||||
|
selected=(budget.id == selected_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def set_budget_active(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int,
|
||||||
|
is_active: bool,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
budget = await self._repo.set_budget_active(budget_id, user_id, is_active)
|
||||||
|
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||||
|
if not is_active and selected_id == budget_id:
|
||||||
|
# Prefer another active budget as selected.
|
||||||
|
others = await self._repo.list_budgets_for_user(user_id)
|
||||||
|
next_selected = next((b.id for b in others if b.is_active), None)
|
||||||
|
await self._repo.set_selected_budget_id(user_id, next_selected)
|
||||||
|
selected_id = next_selected
|
||||||
|
return await self.get_status_for_budget(
|
||||||
|
budget,
|
||||||
|
selected=(budget.id == selected_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def select_budget(self, user_id: int, budget_id: int) -> BudgetStatus:
|
||||||
|
budget = await self._repo.get_budget_for_user(budget_id, user_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Бюджет не найден")
|
||||||
|
await self._repo.set_selected_budget_id(user_id, budget_id)
|
||||||
|
return await self.get_status_for_budget(budget, selected=True)
|
||||||
|
|
||||||
|
async def set_budget(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
total_amount: float,
|
||||||
|
end_date: date,
|
||||||
|
start_date: date | None = None,
|
||||||
|
reset_expenses: bool = True,
|
||||||
|
name: str | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
"""Backward-compatible: update selected/specified budget or create new."""
|
||||||
|
if budget_id is not None:
|
||||||
|
return await self.update_budget(
|
||||||
|
user_id,
|
||||||
|
budget_id,
|
||||||
|
name=name,
|
||||||
|
total_amount=total_amount,
|
||||||
|
end_date=end_date,
|
||||||
|
start_date=start_date,
|
||||||
|
reset_expenses=reset_expenses,
|
||||||
|
)
|
||||||
|
|
||||||
|
current = await self._repo.resolve_budget(user_id)
|
||||||
|
if current is None:
|
||||||
|
return await self.create_budget(
|
||||||
|
user_id,
|
||||||
|
total_amount,
|
||||||
|
end_date,
|
||||||
|
name=name or "Бюджет",
|
||||||
|
start_date=start_date,
|
||||||
|
)
|
||||||
|
return await self.update_budget(
|
||||||
|
user_id,
|
||||||
|
current.id,
|
||||||
|
name=name,
|
||||||
|
total_amount=total_amount,
|
||||||
|
end_date=end_date,
|
||||||
|
start_date=start_date,
|
||||||
|
reset_expenses=reset_expenses,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
amount: float,
|
||||||
|
note: str | None = None,
|
||||||
|
spent_at: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError("Сумма траты должна быть больше нуля")
|
||||||
|
budget = await self._repo.resolve_budget(
|
||||||
|
user_id,
|
||||||
|
budget_id,
|
||||||
|
require_active=True,
|
||||||
|
)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Сначала задай бюджет: /budget")
|
||||||
|
|
||||||
|
day = spent_at or date.today()
|
||||||
|
if day > date.today():
|
||||||
|
raise ValueError("Нельзя добавить трату на будущую дату")
|
||||||
|
if day < budget.start_date or day > budget.end_date:
|
||||||
|
raise ValueError(
|
||||||
|
"Дата вне периода бюджета "
|
||||||
|
f"({budget.start_date.strftime('%d.%m.%Y')}–"
|
||||||
|
f"{budget.end_date.strftime('%d.%m.%Y')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._repo.add_expense(
|
||||||
|
user_id,
|
||||||
|
budget.id,
|
||||||
|
amount=amount,
|
||||||
|
note=note,
|
||||||
|
spent_at=day,
|
||||||
|
)
|
||||||
|
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||||
|
return await self.get_status_for_budget(
|
||||||
|
budget,
|
||||||
|
selected=(budget.id == selected_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def expenses_on_date(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
day: date,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> list[Expense]:
|
||||||
|
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Сначала задай бюджет: /budget")
|
||||||
|
return await self._repo.list_expenses_on_date(budget.id, day)
|
||||||
|
|
||||||
|
async def today_expenses(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
today: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> list[Expense]:
|
||||||
|
day = today or date.today()
|
||||||
|
return await self.expenses_on_date(user_id, day, budget_id=budget_id)
|
||||||
|
|
||||||
|
async def get_expenses_on_date_page(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
day: date,
|
||||||
|
page: int = 0,
|
||||||
|
page_size: int = PERIOD_PAGE_SIZE,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> PeriodExpensesPage:
|
||||||
|
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Сначала задай бюджет: /budget")
|
||||||
|
|
||||||
|
if page < 0:
|
||||||
|
page = 0
|
||||||
|
if page_size < 1:
|
||||||
|
page_size = PERIOD_PAGE_SIZE
|
||||||
|
|
||||||
|
items_all = await self._repo.list_expenses_on_date(budget.id, day)
|
||||||
|
total_count = len(items_all)
|
||||||
|
total_sum = sum(item.amount for item in items_all)
|
||||||
|
total_pages = max(1, ceil(total_count / page_size)) if total_count else 1
|
||||||
|
if page >= total_pages:
|
||||||
|
page = total_pages - 1
|
||||||
|
|
||||||
|
start = page * page_size
|
||||||
|
items = items_all[start : start + page_size]
|
||||||
|
return PeriodExpensesPage(
|
||||||
|
budget=budget,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total_count=total_count,
|
||||||
|
total_sum=total_sum,
|
||||||
|
page_size=page_size,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_period_expenses_page(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
page: int = 0,
|
||||||
|
page_size: int = PERIOD_PAGE_SIZE,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> PeriodExpensesPage:
|
||||||
|
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Сначала задай бюджет: /budget")
|
||||||
|
|
||||||
|
if page < 0:
|
||||||
|
page = 0
|
||||||
|
if page_size < 1:
|
||||||
|
page_size = PERIOD_PAGE_SIZE
|
||||||
|
|
||||||
|
total_count = await self._repo.count_expenses_for_budget(budget.id)
|
||||||
|
total_sum = await self._repo.sum_expenses_for_budget(budget.id)
|
||||||
|
total_pages = max(1, ceil(total_count / page_size)) if total_count else 1
|
||||||
|
if page >= total_pages:
|
||||||
|
page = total_pages - 1
|
||||||
|
|
||||||
|
items = await self._repo.list_expenses_for_budget(
|
||||||
|
budget.id,
|
||||||
|
limit=page_size,
|
||||||
|
offset=page * page_size,
|
||||||
|
)
|
||||||
|
return PeriodExpensesPage(
|
||||||
|
budget=budget,
|
||||||
|
page=page,
|
||||||
|
total_pages=total_pages,
|
||||||
|
total_count=total_count,
|
||||||
|
total_sum=total_sum,
|
||||||
|
page_size=page_size,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_budget_summaries(self) -> list[BudgetStatus]:
|
||||||
|
budgets = await self._repo.list_all_budgets()
|
||||||
|
return [await self.get_status_for_budget(budget) for budget in budgets]
|
||||||
|
|
||||||
|
async def undo_last_expense(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> tuple[BudgetStatus | None, float | None]:
|
||||||
|
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||||
|
target_id = budget.id if budget else None
|
||||||
|
deleted = await self._repo.delete_last_expense(user_id, target_id)
|
||||||
|
if deleted is None:
|
||||||
|
return None, None
|
||||||
|
status = await self.get_status(
|
||||||
|
user_id,
|
||||||
|
budget_id=deleted.budget_id,
|
||||||
|
)
|
||||||
|
return status, deleted.amount
|
||||||
|
|
||||||
|
async def get_status(
|
||||||
|
self,
|
||||||
|
user_id: int,
|
||||||
|
today: date | None = None,
|
||||||
|
budget_id: int | None = None,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
budget = await self._repo.resolve_budget(user_id, budget_id)
|
||||||
|
if budget is None:
|
||||||
|
raise ValueError("Сначала задай бюджет: /budget")
|
||||||
|
selected_id = await self._repo.get_selected_budget_id(user_id)
|
||||||
|
return await self.get_status_for_budget(
|
||||||
|
budget,
|
||||||
|
today=today,
|
||||||
|
selected=(budget.id == selected_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_status_for_budget(
|
||||||
|
self,
|
||||||
|
budget: Budget,
|
||||||
|
today: date | None = None,
|
||||||
|
*,
|
||||||
|
selected: bool = False,
|
||||||
|
) -> BudgetStatus:
|
||||||
|
day = today or date.today()
|
||||||
|
total_spent = await self._repo.sum_expenses_for_budget(budget.id)
|
||||||
|
spent_today = await self._repo.spent_on_date(budget.id, day)
|
||||||
|
remaining = budget.total_amount - total_spent
|
||||||
|
|
||||||
|
if day > budget.end_date:
|
||||||
|
days_left = 0
|
||||||
|
daily_limit = 0.0
|
||||||
|
remaining_today = 0.0
|
||||||
|
is_expired = True
|
||||||
|
else:
|
||||||
|
days_left = (budget.end_date - day).days + 1
|
||||||
|
remaining_at_day_start = remaining + spent_today
|
||||||
|
daily_limit = (
|
||||||
|
remaining_at_day_start / days_left if days_left > 0 else 0.0
|
||||||
|
)
|
||||||
|
remaining_today = daily_limit - spent_today
|
||||||
|
is_expired = False
|
||||||
|
|
||||||
|
return BudgetStatus(
|
||||||
|
budget=budget,
|
||||||
|
today=day,
|
||||||
|
days_left=days_left,
|
||||||
|
total_spent=total_spent,
|
||||||
|
remaining=remaining,
|
||||||
|
daily_limit=max(daily_limit, 0.0),
|
||||||
|
spent_today=spent_today,
|
||||||
|
remaining_today=remaining_today,
|
||||||
|
is_over_daily=spent_today > daily_limit and days_left > 0,
|
||||||
|
is_over_budget=remaining < 0,
|
||||||
|
is_expired=is_expired,
|
||||||
|
selected=selected,
|
||||||
|
)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
AMOUNT_RE = re.compile(
|
||||||
|
r"^\s*(?P<amount>\d+(?:[.,]\d{1,2})?)\s*(?P<note>.*)?$",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
NOTE_FIRST_RE = re.compile(
|
||||||
|
r"^\s*(?P<note>.+?)\s+(?P<amount>\d+(?:[.,]\d{1,2})?)\s*$",
|
||||||
|
re.UNICODE,
|
||||||
|
)
|
||||||
|
DATE_RE = re.compile(
|
||||||
|
r"^(?P<d>\d{1,2})[./](?P<m>\d{1,2})(?:[./](?P<y>\d{2,4}))?$"
|
||||||
|
)
|
||||||
|
# Unambiguous date token: ISO, with year, slash, or zero-padded month (12.09)
|
||||||
|
DATE_TOKEN_RE = re.compile(
|
||||||
|
r"(?P<date>"
|
||||||
|
r"\d{4}-\d{2}-\d{2}"
|
||||||
|
r"|"
|
||||||
|
r"\d{1,2}[./]\d{1,2}[./]\d{2,4}"
|
||||||
|
r"|"
|
||||||
|
r"\d{1,2}/\d{1,2}"
|
||||||
|
r"|"
|
||||||
|
r"\d{1,2}\.(?:0[1-9]|1[0-2])"
|
||||||
|
r")"
|
||||||
|
)
|
||||||
|
ZA_DATE_RE = re.compile(
|
||||||
|
r"(?i)(?:^|\s)за\s+(?P<date>"
|
||||||
|
r"\d{4}-\d{2}-\d{2}"
|
||||||
|
r"|"
|
||||||
|
r"\d{1,2}[./]\d{1,2}(?:[./]\d{2,4})?"
|
||||||
|
r")"
|
||||||
|
r"(?=\s|$)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_amount(value: str) -> float:
|
||||||
|
normalized = value.strip().replace(",", ".").replace(" ", "")
|
||||||
|
amount = float(normalized)
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError("Сумма должна быть больше нуля")
|
||||||
|
return round(amount, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_spent_date(value: str, today: date | None = None) -> date:
|
||||||
|
"""Parse expense date. Bare DD.MM prefers current/past year (not future)."""
|
||||||
|
day = today or date.today()
|
||||||
|
raw = value.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
match = DATE_RE.match(raw)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
||||||
|
|
||||||
|
d = int(match.group("d"))
|
||||||
|
m = int(match.group("m"))
|
||||||
|
y_raw = match.group("y")
|
||||||
|
if y_raw is None:
|
||||||
|
try:
|
||||||
|
candidate = date(day.year, m, d)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Некорректная дата") from exc
|
||||||
|
if candidate > day:
|
||||||
|
try:
|
||||||
|
candidate = date(day.year - 1, m, d)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Некорректная дата") from exc
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
y = int(y_raw)
|
||||||
|
if y < 100:
|
||||||
|
y += 2000
|
||||||
|
try:
|
||||||
|
return date(y, m, d)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Некорректная дата") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_end_date(value: str, today: date | None = None) -> date:
|
||||||
|
"""Parse DD.MM, DD.MM.YYYY, YYYY-MM-DD (for budget end — may roll to next year)."""
|
||||||
|
day = today or date.today()
|
||||||
|
raw = value.strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
match = DATE_RE.match(raw)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Дата в формате ДД.ММ или ДД.ММ.ГГГГ")
|
||||||
|
|
||||||
|
d = int(match.group("d"))
|
||||||
|
m = int(match.group("m"))
|
||||||
|
y_raw = match.group("y")
|
||||||
|
if y_raw is None:
|
||||||
|
y = day.year
|
||||||
|
candidate = date(y, m, d)
|
||||||
|
if candidate < day:
|
||||||
|
candidate = date(y + 1, m, d)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
y = int(y_raw)
|
||||||
|
if y < 100:
|
||||||
|
y += 2000
|
||||||
|
return date(y, m, d)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_amount_and_note(raw: str) -> tuple[float, str | None]:
|
||||||
|
text = raw.strip()
|
||||||
|
if not text:
|
||||||
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||||
|
|
||||||
|
first_token = text.split()[0]
|
||||||
|
if re.fullmatch(r"\d+(?:[.,]\d{1,2})?", first_token):
|
||||||
|
match = AMOUNT_RE.match(text)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||||
|
amount = parse_amount(match.group("amount"))
|
||||||
|
note = (match.group("note") or "").strip() or None
|
||||||
|
return amount, note
|
||||||
|
|
||||||
|
match = NOTE_FIRST_RE.match(text)
|
||||||
|
if match:
|
||||||
|
amount = parse_amount(match.group("amount"))
|
||||||
|
note = match.group("note").strip() or None
|
||||||
|
return amount, note
|
||||||
|
|
||||||
|
raise ValueError("Не понял трату. Примеры: 250, 250 кофе, кофе 250")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expense_message(
|
||||||
|
text: str,
|
||||||
|
today: date | None = None,
|
||||||
|
) -> tuple[float, str | None, date | None]:
|
||||||
|
"""Parse expense text.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- 250 / 250 кофе / кофе 250
|
||||||
|
- за 12.09 250 кофе
|
||||||
|
- 250 кофе за 12.09
|
||||||
|
- 12.09 250 кофе
|
||||||
|
- 250 кофе 12.09
|
||||||
|
"""
|
||||||
|
raw = text.strip()
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("Пустое сообщение")
|
||||||
|
|
||||||
|
spent_at: date | None = None
|
||||||
|
body = raw
|
||||||
|
|
||||||
|
za_match = ZA_DATE_RE.search(raw)
|
||||||
|
if za_match:
|
||||||
|
spent_at = parse_spent_date(za_match.group("date"), today=today)
|
||||||
|
body = (raw[: za_match.start()] + " " + raw[za_match.end() :]).strip()
|
||||||
|
else:
|
||||||
|
tokens = raw.split()
|
||||||
|
if len(tokens) >= 2:
|
||||||
|
first = tokens[0].rstrip(":")
|
||||||
|
last = tokens[-1]
|
||||||
|
if DATE_TOKEN_RE.fullmatch(first):
|
||||||
|
spent_at = parse_spent_date(first, today=today)
|
||||||
|
body = " ".join(tokens[1:])
|
||||||
|
elif DATE_TOKEN_RE.fullmatch(last):
|
||||||
|
spent_at = parse_spent_date(last, today=today)
|
||||||
|
body = " ".join(tokens[:-1])
|
||||||
|
|
||||||
|
amount, note = _parse_amount_and_note(body)
|
||||||
|
return amount, note, spent_at
|
||||||
|
|
||||||
|
|
||||||
|
def format_money(amount: float, currency: str = "₽") -> str:
|
||||||
|
sign = "-" if amount < 0 else ""
|
||||||
|
return f"{sign}{abs(amount):,.2f} {currency}".replace(",", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def format_date(value: date) -> str:
|
||||||
|
return value.strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
|
||||||
|
def format_status(status) -> str:
|
||||||
|
from bot.services.budget import BudgetStatus
|
||||||
|
|
||||||
|
assert isinstance(status, BudgetStatus)
|
||||||
|
currency = "₽"
|
||||||
|
active = "активен" if status.budget.is_active else "неактивен"
|
||||||
|
selected = " · текущий" if status.selected else ""
|
||||||
|
lines = [
|
||||||
|
f"🏷 {status.budget.name} ({active}{selected})",
|
||||||
|
f"📅 До {format_date(status.budget.end_date)} · дней осталось: {status.days_left}",
|
||||||
|
f"💰 Бюджет: {format_money(status.budget.total_amount, currency)}",
|
||||||
|
f"🧾 Потрачено: {format_money(status.total_spent, currency)}",
|
||||||
|
f"🛡 Остаток: {format_money(status.remaining, currency)}",
|
||||||
|
"",
|
||||||
|
f"📊 Лимит на день: {format_money(status.daily_limit, currency)}",
|
||||||
|
f"🛒 Сегодня: {format_money(status.spent_today, currency)}",
|
||||||
|
f"✅ Ещё можно сегодня: {format_money(status.remaining_today, currency)}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if status.is_expired:
|
||||||
|
lines.append("\n⚠️ Период бюджета закончился. Задай новый: /budget")
|
||||||
|
elif status.is_over_budget:
|
||||||
|
lines.append("\n🚨 Бюджет уже превышен.")
|
||||||
|
elif status.is_over_daily:
|
||||||
|
lines.append("\n⚠️ Сегодняшний лимит превышен — завтра лимит пересчитается по остатку.")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 2026-09-09 — Telegram-бот «от зарплаты до зарплаты»
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Сделать бота: ввод суммы и даты «до зарплаты», расчёт дневного лимита, быстрый учёт дневных трат.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
С нуля собран Telegram-бот на **aiogram 3** + **SQLite (aiosqlite)**.
|
||||||
|
|
||||||
|
### Структура
|
||||||
|
- `bot/main.py` — точка входа, polling
|
||||||
|
- `bot/config.py` — `BOT_TOKEN` из `.env`
|
||||||
|
- `bot/db/` — схема, репозиторий
|
||||||
|
- `bot/services/budget.py` — лимит дня от остатка на утро (`(остаток+траты_сегодня)/дней`), модель «утреннего конверта»
|
||||||
|
- `bot/services/parsing.py` — парсинг сумм/дат/трат
|
||||||
|
- `bot/handlers/budget.py` — команды и FSM настройки бюджета
|
||||||
|
|
||||||
|
### UX
|
||||||
|
- `/budget` → сумма → дата
|
||||||
|
- Трата одним сообщением: `250`, `250 кофе`, `кофе 250`
|
||||||
|
- `/status`, `/today`, `/undo`
|
||||||
|
- Reply-клавиатура: Статус / Сегодня / Новый бюджет / Отмена траты
|
||||||
|
|
||||||
|
### Запуск
|
||||||
|
1. Токен от BotFather в `.env`
|
||||||
|
2. `pip install -r requirements.txt`
|
||||||
|
3. `python -m bot.main`
|
||||||
|
|
||||||
|
## Не сделано (можно следующим шагом)
|
||||||
|
- Категории трат и отчёты за период
|
||||||
|
- Напоминание вечером, если лимит не выбран
|
||||||
|
- Мультивалютность / несколько бюджетов
|
||||||
|
- Деплой (systemd / Docker)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 2026-09-13 — Dockerfile для бота
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Написать Dockerfile.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- `Dockerfile` — `python:3.12-slim`, non-root user `bot`, `CMD python -m bot.main`
|
||||||
|
- `.dockerignore` — исключает `.venv`, `.env`, `data/`, кэши
|
||||||
|
- В README добавлен пример `docker build` / `docker run` с volume для SQLite
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```bash
|
||||||
|
docker build -t please-pay-me-bot .
|
||||||
|
docker run --rm -e BOT_TOKEN=... -v ./data:/app/data please-pay-me-bot
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 2026-09-13 — SOCKS5-прокси для бота в Docker
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Xray на хосте (10808/10809) слушает 127.0.0.1; контейнер его не видит. Нужно открыть listen на 0.0.0.0 и настроить aiogram через SOCKS5.
|
||||||
|
|
||||||
|
## Сделано в репозитории
|
||||||
|
- `aiohttp-socks` в `requirements.txt`
|
||||||
|
- `PROXY_URL` в `bot/config.py` (опционально)
|
||||||
|
- `bot/main.py` — `AiohttpSession(proxy=...)` (официальный API aiogram 3, без `_connector_init`)
|
||||||
|
- `scripts/open_xray_listen.sh` — правки Xray на Ubuntu-хосте + restart
|
||||||
|
- `docker-compose.yml` — `PROXY_URL=socks5://172.17.0.1:10808` по умолчанию
|
||||||
|
- README: секция прокси + firewall warning
|
||||||
|
|
||||||
|
## На хосте (вручную)
|
||||||
|
```bash
|
||||||
|
./scripts/open_xray_listen.sh
|
||||||
|
# в .env: PROXY_URL=socks5://172.17.0.1:10808
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Конфиг `/usr/local/etc/xray/config.json` с этой Windows-машины недоступен — правки только через скрипт на Ubuntu.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 2026-09-13 — Fix: ProxyTimeout к 172.17.0.1
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
Контейнер: `Proxy connection timed out` к `socks5://172.17.0.1:10808`.
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
В bridge/Portainer-сети хост `172.17.0.1:10808` часто недоступен (Xray на 127.0.0.1 и/или другая docker-сеть).
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
- `docker-compose.yml`: `network_mode: host` + `PROXY_URL=socks5://127.0.0.1:10808`
|
||||||
|
- Убран захардкоженный `BOT_TOKEN` из compose (был в логах/файле — нужен revoke у BotFather)
|
||||||
|
- Xray можно оставить на `127.0.0.1` (безопаснее, чем 0.0.0.0)
|
||||||
|
|
||||||
|
## Действия на сервере
|
||||||
|
1. BotFather → revoke/перевыпустить токен
|
||||||
|
2. В `.env`: новый токен + `PROXY_URL=socks5://127.0.0.1:10808`
|
||||||
|
3. `docker compose up -d --build`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 2026-09-13 — Траты за произвольную дату
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Возможность добавлять трату за определённое число.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- Парсер: `за 12.09 250 кофе`, `250 кофе за 12.09`, `12.09 250`, `250 кофе 12.09`
|
||||||
|
- Дата без года для трат берёт прошлое/текущее (не будущее), в отличие от даты бюджета
|
||||||
|
- `BudgetService.add_expense(..., spent_at=)` с проверкой периода бюджета
|
||||||
|
- FSM `/spend` и кнопка «📅 За дату»
|
||||||
|
- `/day 12.09` — список трат за дату
|
||||||
|
|
||||||
|
Месяц в дате с точкой лучше писать двузначно (`12.09`), чтобы не путать с суммой `12.5`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-09-13 — Fix: FSM «За дату» глотал все сообщения
|
||||||
|
|
||||||
|
## Баг
|
||||||
|
После «📅 За дату» состояние `waiting_date` было зарегистрировано раньше `/status`, `/today`, `/day`. Любой ввод (включая кнопки) шёл в парсер даты. Сообщение `12.09 кб` целиком не парсилось как дата → вечный цикл ошибки.
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
- Команды и кнопки меню регистрируются **до** FSM и сбрасывают state
|
||||||
|
- На шаге даты: one-shot `12.09 250 кб`, либо дата из первого токена (`12.09 кб` → дата + заметка, потом спросить сумму)
|
||||||
|
- Подсказка про `/cancel`
|
||||||
|
|
||||||
|
После деплоя: если бот всё ещё «залип» — один раз `/cancel` или `/help`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-09-13 — Постраничный просмотр трат периода
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Просматривать траты за текущий период бюджета постранично.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- Репозиторий: `count/sum/list_expenses_in_period` с LIMIT/OFFSET
|
||||||
|
- `BudgetService.get_period_expenses_page` (по 8 записей, новые сверху)
|
||||||
|
- `/history` и кнопка «📒 Период»
|
||||||
|
- Inline «‹ Назад» / «N/M» / «Вперёд ›»
|
||||||
|
- Сумма статуса периода тоже считается строго в границах бюджета
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 2026-09-13 — API + веб-кабинет для бюджетов и трат
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Сделать API и фронт для просмотра бюджетов и трат.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
### API (`api/`)
|
||||||
|
- FastAPI, тот же SQLite (`data/budget.db`, WAL)
|
||||||
|
- `GET /api/health` без токена
|
||||||
|
- `GET /api/budgets`, `/api/budgets/{user_id}`, `/api/budgets/{user_id}/expenses`
|
||||||
|
- Auth: `X-API-Token` / Bearer `API_TOKEN`
|
||||||
|
|
||||||
|
### Web (`web/`)
|
||||||
|
- React + Vite + React Router
|
||||||
|
- Список бюджетов, деталка со статусом и постраничными тратами
|
||||||
|
- Токен в localStorage
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- `Dockerfile.api` (multi-stage: npm build + uvicorn)
|
||||||
|
- `docker-compose.yml`: сервисы `bot` + `web` (:8000)
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```bash
|
||||||
|
# .env: API_TOKEN=...
|
||||||
|
docker compose up -d --build
|
||||||
|
# кабинет: http://HOST:8000
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 2026-09-13 — Уточнение docker-compose: BOT_TOKEN vs API_TOKEN
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
Почему у bot убран токен и зачем фронту токен бота?
|
||||||
|
|
||||||
|
## Ответ
|
||||||
|
1. **BOT_TOKEN не убирался у бота** — убрали только хардкод из compose (токен светился в логах). Бот читал его из `.env` через `env_file`. Сейчас явно: `BOT_TOKEN: ${BOT_TOKEN}`.
|
||||||
|
2. **Фронту BOT_TOKEN не нужен.** Нужен отдельный `API_TOKEN` для защиты read-only API кабинета. У сервиса `web` убран `env_file`, чтобы Telegram-токен туда вообще не попадал.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 2026-09-13 — Разделение портов фронт/API
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Фронт на внешнем порту 51290, API на 51291; фронт ходит на localhost:51291.
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- `api` сервис: `51291:8000`, только FastAPI (без статики)
|
||||||
|
- `web` сервис: nginx `51290:80`, `VITE_API_BASE_URL=http://localhost:51291`
|
||||||
|
- CORS по умолчанию: `http://localhost:51290`, `http://127.0.0.1:51290`
|
||||||
|
- `web/src/api.ts` собирает абсолютные URL к API
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 2026-09-13 — Красивая 404
|
||||||
|
|
||||||
|
Стилизована под кабинет (Syne/Manrope, зелёный градиент, motion).
|
||||||
|
Файлы: корневой `404.html` + `web/public/404.html`, nginx `error_page 404`.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 2026-09-13 — Nginx proxy /api → API
|
||||||
|
|
||||||
|
Фронт ходит на same-origin `/api`. Nginx проксирует на `http://api:8000` (сервис compose = хост `localhost:51291`).
|
||||||
|
`VITE_API_BASE_URL` по умолчанию пустой. Vite dev proxy → `http://localhost:51291`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-09-13 — Fix: API readonly database
|
||||||
|
|
||||||
|
## Ошибка
|
||||||
|
`sqlite3.OperationalError: attempt to write a readonly database` на `PRAGMA journal_mode=WAL`.
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
API (uid 1001) и бот (uid 1000) делили `./data`; WAL/схема требуют запись.
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
- `Database(read_only=True)` для API: `file:...?mode=ro`, без WAL и SCHEMA
|
||||||
|
- `Dockerfile.api` — uid 1000 как у бота
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-09-13 — Fix: JSON.parse на фронте
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
`JSON.parse: unexpected character at line 1 column 1` — в ответ на `/api/budgets` приходил HTML `index.html` (SPA fallback).
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
В `web/nginx.conf` не было `location /api/` → `try_files` отдавал фронт.
|
||||||
|
|
||||||
|
## Исправление
|
||||||
|
Вернул `proxy_pass http://api:8000` для `/api/`, улучшил разбор ошибок во фронте.
|
||||||
|
Пересобрать: `docker compose up -d --build web`
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# 2026-09-13 — Личный кабинет через Telegram Login
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- POST `/api/auth/telegram` — проверка Login Widget + JWT
|
||||||
|
- GET `/api/me`, `/api/me/budget`, `/api/me/expenses` — только свой user_id
|
||||||
|
- Фронт: кнопка Telegram Login, кабинет своего бюджета
|
||||||
|
- Env: `BOT_TOKEN`, `TELEGRAM_BOT_USERNAME` (+ опционально `API_TOKEN` для админских `/api/budgets`)
|
||||||
|
|
||||||
|
## BotFather
|
||||||
|
`/setdomain` на домен кабинета (нужен публичный HTTPS).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 2026-09-13 — Write API + формы трат/бюджета в кабинете
|
||||||
|
|
||||||
|
## Сделано
|
||||||
|
- API: `read_only=False`
|
||||||
|
- `POST /api/me/expenses`, `DELETE /api/me/expenses/last`, `PUT /api/me/budget`
|
||||||
|
- Фронт: формы «Добавить трату» и «Задать/обновить бюджет»
|
||||||
|
- CORS: PUT/DELETE
|
||||||
|
|
||||||
|
## На сервере
|
||||||
|
```bash
|
||||||
|
sudo chown -R 1000:1000 ./data
|
||||||
|
docker compose up -d --build api web
|
||||||
|
```
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 2026-09-13 — Редизайн web-кабинета
|
||||||
|
|
||||||
|
## UX
|
||||||
|
- Login: brand-first, один CTA (Telegram)
|
||||||
|
- Кабинет: hero = остаток; быстрая трата; история; настройки бюджета в `<details>`
|
||||||
|
- Flash с aria-live, skeleton, focus-visible, reduced-motion
|
||||||
|
- Крупные touch-target (48px), sticky topbar
|
||||||
|
|
||||||
|
Сохранить палитру леса/зелени проекта. Пересборка: `docker compose up -d --build web`
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 2026-09-13 — Строгий системный UI кабинета
|
||||||
|
|
||||||
|
Светлая тема, IBM Plex Sans/Mono, шкала отступов 4–48px, hairline-границы,
|
||||||
|
без glow/градиентов. Суммы в mono. Секции: Обзор → Операция → Журнал → Параметры.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 2026-09-13 — Системный продуктовый дизайн (web)
|
||||||
|
|
||||||
|
Заложена расширяемая дизайн-система кабинета вместо монолита `App.tsx`.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `web/src/design/` — токены (`tokens.css`), base, стили shell/UI
|
||||||
|
- `web/src/components/ui/` — Button, Field, Flash, ProgressBar, MetricGrid, PageHeader, Section, EmptyState, Banner
|
||||||
|
- `web/src/components/layout/` — AppShell, AuthLayout, `PRIMARY_NAV` (точка расширения меню)
|
||||||
|
- `web/src/auth/` — AuthProvider, RequireAuth
|
||||||
|
- `web/src/cabinet/` — CabinetProvider (бюджет/траты), CabinetLayout
|
||||||
|
- `web/src/pages/` — Login, Overview, Operations, Journal, Period (+ ComingSoon заготовка)
|
||||||
|
|
||||||
|
## Маршруты
|
||||||
|
|
||||||
|
`/login` · `/` · `/operations` · `/journal` · `/period`
|
||||||
|
В nav зарезервированы «Отчёты» и «Настройки» (`soon: true`).
|
||||||
|
|
||||||
|
## Как добавлять фичу
|
||||||
|
|
||||||
|
1. Пункт в `PRIMARY_NAV`
|
||||||
|
2. Route под `CabinetLayout` в `App.tsx`
|
||||||
|
3. Страница в `pages/`, UI из `components/ui`
|
||||||
|
4. Доменное состояние — в `cabinet/` или новый context
|
||||||
|
|
||||||
|
Сборка `npm run build` проходит успешно.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-09-13 — Бот ходит в БД только через API
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
Единый путь записи/чтения: web и Telegram-бот → FastAPI → SQLite.
|
||||||
|
Бот больше не монтирует `./data` и не открывает `budget.db`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `POST /api/auth/internal` + `X-API-Token` / `API_TOKEN` → JWT как у web
|
||||||
|
- Бот вызывает те же `/api/me/budget`, `/api/me/expenses`, undo, upsert
|
||||||
|
- `GET /api/me/expenses?spent_at=YYYY-MM-DD` — траты за день (для /today, /day)
|
||||||
|
|
||||||
|
## Bot
|
||||||
|
- `bot/clients/budget_api.py` — `BudgetApiClient` (aiohttp)
|
||||||
|
- `bot/config.py`: `API_BASE_URL`, `API_TOKEN` (обязателен)
|
||||||
|
- Compose: bot без volume data; `API_BASE_URL=http://127.0.0.1:51291` при host network
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
Пересобрать `api` + `bot`, убедиться что `API_TOKEN` одинаковый в обоих сервисах.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 2026-09-13 — Несколько бюджетов + is_active
|
||||||
|
|
||||||
|
## Модель
|
||||||
|
- У пользователя много строк в `budgets` (снят UNIQUE user_id)
|
||||||
|
- Поля: `name`, `is_active`, `users.selected_budget_id`
|
||||||
|
- `expenses.budget_id` — траты привязаны к конкретному бюджету
|
||||||
|
- Миграция на старте API (`migrate_schema`) для старых БД
|
||||||
|
|
||||||
|
## Семантика
|
||||||
|
- **Активный** — можно писать траты
|
||||||
|
- **Текущий (selected)** — default для /status, операций, журнала
|
||||||
|
- Неактивный отклоняет новые expenses
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `GET/POST /api/me/budgets`
|
||||||
|
- `PUT /api/me/budgets/{id}`
|
||||||
|
- `PATCH /api/me/budgets/{id}/active`
|
||||||
|
- `POST /api/me/budgets/{id}/select`
|
||||||
|
- `budget_id` на expenses / budget GET
|
||||||
|
|
||||||
|
## Клиенты
|
||||||
|
- Web: страница `/budgets`, переключатель вкл/выкл и «текущий»
|
||||||
|
- Bot: `/budgets`, `/budget` создаёт новый; inline select/toggle
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-09-13 — API на C# + PostgreSQL
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
- Новый стек: `src/PleasePayMe.*` (Domain / Application / Infrastructure / Api)
|
||||||
|
- EF Core + Npgsql, миграция `InitialCreate`, авто-`Migrate()` на старте
|
||||||
|
- HTTP-контракт прежний (`/api/auth/*`, `/api/me/*`, `/api/budgets/*`, snake_case JSON, `{detail}`)
|
||||||
|
- Docker: сервис `db` (postgres:16), `Dockerfile.api` → ASP.NET
|
||||||
|
- Python `api/` помечен deprecated; бот и web без смены контракта
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
Переменные: `BOT_TOKEN`, `API_TOKEN`, `POSTGRES_*`.
|
||||||
|
|
||||||
|
## Данные
|
||||||
|
SQLite `data/budget.db` в compose больше не используется.
|
||||||
|
Перенос старых данных — отдельной задачей (не автоматический).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-13 — Удаление бюджетов
|
||||||
|
|
||||||
|
- `DELETE /api/me/budgets/{id}` → 204; траты каскадом; selected переназначается
|
||||||
|
- Web: кнопка «Удалить» с confirm на странице Бюджеты
|
||||||
|
- Bot: кнопка 🗑 в `/budgets`
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 2026-09-13 — Раздел «Работа»
|
||||||
|
|
||||||
|
## Модель
|
||||||
|
- Таблица `jobs`: name, salary_amount, pay_days (integer[] 1–31), is_active
|
||||||
|
- Если дня нет в месяце (напр. 31) — берётся последний день месяца
|
||||||
|
- API считает `next_pay_dates` (ближайшие выплаты)
|
||||||
|
|
||||||
|
## API
|
||||||
|
- `GET/POST /api/me/jobs`
|
||||||
|
- `PUT/DELETE /api/me/jobs/{id}`
|
||||||
|
|
||||||
|
## Web
|
||||||
|
- Навигация «Работа» → `/work`
|
||||||
|
- Список + форма: название, ЗП, дни (текст `5, 20` + быстрые чипы)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-13 — Работа: доля выплат + сдвиг с выходных
|
||||||
|
|
||||||
|
- Максимум 2 дня в `pay_days`
|
||||||
|
- `first_pay_percent` — доля более раннего дня; второй = 100 − first
|
||||||
|
- `weekend_policy`: `before_weekend` | `after_weekend` (сб/вс → пт или пн)
|
||||||
|
- `next_pays[]`: дата (уже с учётом сдвига), scheduled_day, percent, amount
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-13 — Выбор даты начала бюджета
|
||||||
|
|
||||||
|
- API уже принимал `start_date` (create/update/upsert); upsert теперь пробрасывает дату.
|
||||||
|
- Web: поле «С даты» в Бюджеты и Период; по умолчанию `todayIso()` при создании.
|
||||||
|
- Bot client: `create_budget` / `set_budget` передают `start_date`, если задан (иначе API = сегодня).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-13 — Календарь бюджетов и выплат
|
||||||
|
|
||||||
|
- Маршрут `/calendar`, пункт «Календарь» в nav
|
||||||
|
- Полоски цвета = периоды бюджетов (стабильный цвет по id)
|
||||||
|
- Кружки = дни ЗП (с учётом weekend_policy, как в PaySchedule)
|
||||||
|
- Клик по дню — детали; легенда ниже сетки
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-13 — Календарь 2.0: траты на днях
|
||||||
|
|
||||||
|
- API `GET /api/me/expenses/range?from=&to=` — траты по всем бюджетам пользователя (≤93 дня)
|
||||||
|
- Ячейка: точка + компактная сумма трат за день
|
||||||
|
- Клик по дню → блок «Операции» (сумма, заметка, бюджет) + контекст (бюджеты/ЗП)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-19 — Траты без активного бюджета + журнал «все»
|
||||||
|
|
||||||
|
- `AddExpense`: больше не требует `is_active`; для неактивного бюджета даты периода не ограничивают
|
||||||
|
- `GET /api/me/expenses?all=true` — пагинация по всем бюджетам пользователя (`budget_id: null`)
|
||||||
|
- Журнал: переключатель **Все / Текущий** (по умолчанию все); в режиме «все» видно имя бюджета
|
||||||
|
- Операции: выбор бюджета в форме, в т.ч. выключенного
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 2026-09-19 — Flutter mobile + portable Widgetbook
|
||||||
|
|
||||||
|
## Mobile app (`mobile/`)
|
||||||
|
- Flutter scaffold (android/ios/web/windows)
|
||||||
|
- Theme tokens aligned with web `tokens.css` + light/dark `ThemeData`
|
||||||
|
- UI kit: atoms / molecules / navigation / feedback / sample screens
|
||||||
|
- IBM Plex Sans via `google_fonts`
|
||||||
|
|
||||||
|
## Widgetbook (`mobile/widgetbook/`)
|
||||||
|
- Separate package (`please_pay_me_widgetbook`) path-depends on app
|
||||||
|
- Catalog: Atoms, Molecules, Navigation, Feedback, Screens + knobs
|
||||||
|
- Addons: MaterialTheme (light/dark), Localization (ru/en), Viewport (iPhone/Android)
|
||||||
|
- Smoke tests: `flutter test` — all use-cases registered
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
cd mobile/widgetbook && flutter pub get && flutter run -d chrome
|
||||||
|
```
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-19 — PS1 скрипты запуска mobile/widgetbook
|
||||||
|
|
||||||
|
- `mobile/run.ps1` — app или `-Target widgetbook`
|
||||||
|
- `mobile/widgetbook/run.ps1` — каталог UI (`-Device chrome|windows|edge`)
|
||||||
|
- Ищут Flutter в PATH и `%USERPROFILE%\flutter\bin`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-19 — Widgetbook: добавлен Windows desktop support
|
||||||
|
|
||||||
|
- `flutter create . --platforms=windows,web,android,ios` в `mobile/widgetbook`
|
||||||
|
- Исправлена заглушка `test/widget_test.dart` после create
|
||||||
|
- Повтор: `.\run.ps1 -Device windows`
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# 2026-09-19 — Мобильный UI-кит переведён на iOS (Cupertino)
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Widgetbook запустился, но Material-компоненты визуально не устроили. Задача — сделать
|
||||||
|
компоненты «из iOS kit».
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
### Токены и тема (`mobile/lib/theme/`)
|
||||||
|
- `tokens.dart` переписан под Apple HIG: семантические цвета (`label`, `secondaryLabel`,
|
||||||
|
`separator`, `opaqueSeparator`, `groupedBackground`, `groupedSurface`, `barBackground`)
|
||||||
|
и системная палитра (`systemRed/Orange/Green/Gray/Gray3/Gray5/Gray6`) объявлены как
|
||||||
|
`CupertinoDynamicColor.withBrightness` — light/dark резолвится самим фреймворком через
|
||||||
|
`AppColors.of(context, color)`.
|
||||||
|
- Бренд-тинт остался зелёным (`accent` = #12885A / #3CD68C в тёмной) и подменяет systemBlue.
|
||||||
|
- Типографика — шкала SF Pro: largeTitle 34 … caption2 11 с нативными letterSpacing.
|
||||||
|
- Размеры контролов: 50 / 44 / 34 pt, hairline 0.5, радиусы 6/10/12/16/capsule.
|
||||||
|
- `app_theme.dart` теперь возвращает `CupertinoThemeData` (`buildLightTheme` /
|
||||||
|
`buildDarkTheme`), шрифт — Inter через google_fonts как кросс-платформенная замена SF Pro
|
||||||
|
(на Windows/web SF недоступен).
|
||||||
|
|
||||||
|
### Компоненты (`mobile/lib/ui/`)
|
||||||
|
Material-версии удалены, вместо них Cupertino:
|
||||||
|
- atoms: `AppButton` (filled/tinted/gray/plain/destructive × large/medium/small),
|
||||||
|
`AppTextField` (CupertinoTextField + error/prefix/clear), `AppText` (именованные
|
||||||
|
конструкторы по шкале iOS) + `AppSectionHeader`, `AppIcon` + `AppIconBadge`
|
||||||
|
(Settings-style скруглённый квадрат).
|
||||||
|
- molecules: `AppListSection` (inset-grouped, header/footer, hairline с indent),
|
||||||
|
`AppListTile` (leading/value/chevron, press-highlight, destructive), `AppSwitchRow`
|
||||||
|
(CupertinoSwitch), `AppCard`, `AppChip` (капсула), `AppAvatar`.
|
||||||
|
- navigation: `AppNavBar` (реализует `ObstructingPreferredSizeWidget`, есть subtitle),
|
||||||
|
`AppLargeNavBar` (`CupertinoSliverNavigationBar`), `AppTabBar` (наследник
|
||||||
|
`CupertinoTabBar`, чтобы отдавать в `CupertinoTabScaffold`), `AppSegmentedControl`
|
||||||
|
(`CupertinoSlidingSegmentedControl`), `AppAlert` + `showAppAlert` + `showAppActionSheet`.
|
||||||
|
- feedback: `AppSpinner` (`CupertinoActivityIndicator`), `AppProgressBar` (4pt капсула),
|
||||||
|
`AppSkeleton` / `AppSkeletonRow`, `AppToast` + `showAppToast` (blur-капсула через
|
||||||
|
OverlayEntry — в iOS нет SnackBar).
|
||||||
|
- screens: Feed (large title + сегменты + группы + empty/loading), Detail (hero-сумма,
|
||||||
|
прогресс, группа параметров, кнопки), Profile (Settings-подобный экран со свитчами).
|
||||||
|
- `main.dart` переведён на `CupertinoApp` + `CupertinoTabScaffold`.
|
||||||
|
|
||||||
|
### Widgetbook (`mobile/widgetbook/`)
|
||||||
|
- `Widgetbook.cupertino` + `CupertinoThemeAddon` вместо material-варианта.
|
||||||
|
- Новый `lib/support/preview.dart` — `IosPreview` кладёт use-case на grouped-фон,
|
||||||
|
чтобы карточки и разделители читались как в приложении.
|
||||||
|
- Каталог: Atoms (Button/TextField/Typography/Icons), Molecules (Card/ListTile/SwitchRow/
|
||||||
|
Chip/Avatar), Navigation (NavBar/TabBar/SegmentedControl/Alert), Feedback (Progress/
|
||||||
|
Skeleton/Toast), Screens (Feed/Detail/Profile) — 42 use-case.
|
||||||
|
- Knobs: добавлены `knobEnum` и `knobTint` (дропдаун по системным тинтам), убран
|
||||||
|
deprecated `knobs.list`.
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
`widgetbook/test/catalog_smoke_test.dart` больше не проверяет только структуру: каждый
|
||||||
|
use-case пампится в light и dark внутри `WidgetbookScope` (knobs читают `WidgetbookState`
|
||||||
|
из контекста, иначе падает «No Widgetbook found in the context»). В тестах отключён
|
||||||
|
`GoogleFonts.config.allowRuntimeFetching` и вызван `TestWidgetsFlutterBinding.ensureInitialized()`.
|
||||||
|
|
||||||
|
## Результат
|
||||||
|
- `flutter analyze` (mobile + widgetbook) — 0 issues.
|
||||||
|
- `flutter test` — 84 теста в widgetbook + 1 в приложении, всё зелёное.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```powershell
|
||||||
|
cd mobile\widgetbook
|
||||||
|
.\run.ps1 -Device windows
|
||||||
|
```
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 2026-09-20 — Мобильное приложение Please Pay Me собрано на iOS-ките
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
«Собери теперь приложение please-pay-me, используя эти компоненты» — то есть поверх
|
||||||
|
Cupertino-кита из прошлой сессии сделать рабочее приложение против существующего API.
|
||||||
|
|
||||||
|
## Что сделано
|
||||||
|
|
||||||
|
### Слой данных (`lib/data/`)
|
||||||
|
- `models/` — `Budget`, `BudgetStatus` (+ вычисляемые `spentProgress` / `dailyProgress`),
|
||||||
|
`Expense`, `ExpensesPage`, `Job`, `UpcomingPay`, `WeekendPolicy`, `AuthUser`, `AuthSession`.
|
||||||
|
Зеркалят `web/src/types.ts`, парсятся через `models/json.dart` — толерантные
|
||||||
|
коэрсеры (`int`/`double`/`string`), потому что API отдаёт числа из C#-рекордов.
|
||||||
|
- `api/api_client.dart` — транспорт на `http`: Bearer-токен через `tokenProvider`,
|
||||||
|
маппинг `detail`/`title` в `ApiException`, отдельная обработка 401 с колбэком
|
||||||
|
`onUnauthorized` (разлогинивает сессию).
|
||||||
|
- `repositories/` — интерфейсы `BudgetRepository` / `ExpenseRepository` /
|
||||||
|
`JobRepository` / `UserRepository` + REST-реализации на все ручки `MeController`.
|
||||||
|
- `demo/demo_backend.dart` — in-memory бэкенд, повторяющий математику конверта
|
||||||
|
(`dailyLimit = remaining / daysLeft`). Используется в демо-режиме, превью и тестах;
|
||||||
|
`DemoBackend.empty()` — для пустых состояний.
|
||||||
|
|
||||||
|
### Ядро (`lib/core/`)
|
||||||
|
- `config/app_config.dart` — `PPM_API_BASE_URL`, `PPM_WEB_URL`, `PPM_DEMO` через
|
||||||
|
`--dart-define`; пустой адрес API автоматически включает демо-режим.
|
||||||
|
- `storage/session_storage.dart` — интерфейс + `SharedPreferences` и in-memory реализации.
|
||||||
|
- `state/async_value.dart` — sealed `AsyncLoading/AsyncData/AsyncError` с `map(...)`,
|
||||||
|
чтобы экраны матчились по состоянию, а не жонглировали тремя nullable-полями.
|
||||||
|
- `format/formatters.dart` — деньги (`1 234,50 ₽`), русские плюрали, «Сегодня/Вчера».
|
||||||
|
|
||||||
|
### Состояние (`lib/features/*/`)
|
||||||
|
- `SessionController` — владеет авторизацией и выдаёт репозитории (REST или demo),
|
||||||
|
так что остальное приложение не знает про токены. `sessionKey` пересобирает
|
||||||
|
фичевые контроллеры при входе/выходе.
|
||||||
|
- `BudgetsController` — источник правды по конвертам (список, выбранный, мутации
|
||||||
|
с авто-перезагрузкой, возврат текста ошибки вместо исключения).
|
||||||
|
- `JournalController` — пагинация + группировка по дням + скоуп «текущий/все».
|
||||||
|
- `JobsController` — работы и ближайшая выплата.
|
||||||
|
|
||||||
|
### Экраны (`lib/features/`, `lib/app/`)
|
||||||
|
Пять вкладок: Обзор, Журнал, Бюджеты, Работа, Профиль (+ экран входа и сплэш).
|
||||||
|
Формы — модальные шиты: новая/редактирование траты, бюджета, работы; все они
|
||||||
|
принимают `onSubmit`, возвращающий текст ошибки, и не знают про репозитории.
|
||||||
|
`_SessionGate` разводит `restoring / signedOut / signedIn`.
|
||||||
|
|
||||||
|
### Кит
|
||||||
|
Удалены демо-экраны `lib/ui/screens/*`; добавлены `AppEmptyState`, `AppErrorView`,
|
||||||
|
`AppLoadingView`, `showAppDatePicker`, `showAppFormSheet`.
|
||||||
|
|
||||||
|
### Widgetbook
|
||||||
|
Папка Screens теперь показывает **настоящие экраны** приложения на `DemoScope`
|
||||||
|
(demo-бэкенд + провайдеры), включая пустые состояния, формы и полный таб-бар.
|
||||||
|
|
||||||
|
### Авторизация — известное ограничение
|
||||||
|
Telegram Login Widget работает только в вебе. Мобильный клиент принимает JWT из
|
||||||
|
`localStorage.ppm_session_jwt` веб-кабинета и валидирует его через `GET /api/me`.
|
||||||
|
Нативный вход (WebView с Telegram-виджетом либо one-time code через бота)
|
||||||
|
не делался — это отдельное продуктовое решение.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
- `mobile`: 32 теста — парсинг моделей, форматтеры, все контроллеры на demo-бэкенде,
|
||||||
|
виджет-сценарии (вход, табы, пустые состояния, запись траты end-to-end).
|
||||||
|
- `mobile/widgetbook`: 100 — каждый use-case рендерится в light и dark.
|
||||||
|
- `flutter analyze` по обоим пакетам — 0 issues.
|
||||||
|
- `flutter build windows --debug` проходит (проверка плагинов shared_preferences/url_launcher).
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
```powershell
|
||||||
|
cd mobile
|
||||||
|
.\run.ps1 # демо-данные
|
||||||
|
.\run.ps1 -ApiBaseUrl https://ppm.example.com
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# 2026-09-20 — Вход через WebView + Telegram Login Widget
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Реализовать нативный вход: открыть кабинет в WebView и забрать JWT через
|
||||||
|
JS-канал. Бэкенд не менять.
|
||||||
|
|
||||||
|
## Почему не «просто открыть страницу»
|
||||||
|
|
||||||
|
Telegram Login Widget в обычном браузере работает через iframe + `data-onauth`.
|
||||||
|
В Android/iOS WebView этот путь ломается:
|
||||||
|
|
||||||
|
- popup (`window.open` на `oauth.telegram.org`) не возвращает `opener`;
|
||||||
|
- iframe режется third-party cookies и User-Agent с маркером `; wv`;
|
||||||
|
- callback `onTelegramAuth` просто не вызывается.
|
||||||
|
|
||||||
|
Поэтому в WebView виджет переключается на **redirect-режим** (`data-auth-url`),
|
||||||
|
а Flutter забирает уже готовый JWT, который кабинет и так кладёт в
|
||||||
|
`localStorage.ppm_session_jwt`. API (`POST /api/auth/telegram`) не трогали.
|
||||||
|
|
||||||
|
## Поток
|
||||||
|
|
||||||
|
1. Экран входа на iOS/Android показывает «Войти через Telegram».
|
||||||
|
2. Открывается fullscreen WebView на `{cabinet}/login`.
|
||||||
|
3. Кабинет видит канал `window.PpmAuth` → ставит виджету `data-auth-url=/login`.
|
||||||
|
4. Пользователь логинится в Telegram; редирект приходит на `/login?id&hash&…`.
|
||||||
|
5. `telegramPayloadFromQuery` собирает payload, кабинет зовёт тот же
|
||||||
|
`loginWithTelegram` / `POST /api/auth/telegram`.
|
||||||
|
6. `setSession` пишет JWT в `localStorage` и зовёт `PpmAuth.postMessage`.
|
||||||
|
7. Flutter парсит сообщение, закрывает WebView, проверяет токен через
|
||||||
|
`GET /api/me`, кладёт сессию в `SharedPreferences`.
|
||||||
|
|
||||||
|
Запасные пути, если кабинет ещё не задеплоен с мостом:
|
||||||
|
|
||||||
|
- JS в WebView хукает `localStorage.setItem('ppm_session_jwt')` и поллит ключ;
|
||||||
|
- на Windows/web WebView нет — форма «вставить токен»;
|
||||||
|
- «Демо-режим» по-прежнему без сети.
|
||||||
|
|
||||||
|
## Что изменилось
|
||||||
|
|
||||||
|
### Web (не API)
|
||||||
|
- `web/src/auth/telegramRedirect.ts` — разбор query и `postTokenToNativeApp`.
|
||||||
|
- `TelegramLoginButton` — `data-auth-url` только если есть `window.PpmAuth`.
|
||||||
|
- `LoginPage` — автологин, если в URL уже лежит telegram-payload.
|
||||||
|
- `setSession` — после записи токена шлёт его в канал.
|
||||||
|
|
||||||
|
### Mobile
|
||||||
|
- `TelegramLoginScreen` (`webview_flutter`): канал `PpmAuth`, Chrome UA без
|
||||||
|
`; wv`, third-party cookies на Android, `tg://` уходит во внешнее приложение,
|
||||||
|
кнопка «назад» после oauth.telegram.org.
|
||||||
|
- `resolveCabinetLoginUri` всегда открывает `/login`.
|
||||||
|
- `INTERNET` в main `AndroidManifest` (раньше был только в debug).
|
||||||
|
- iOS: `LSApplicationQueriesSchemes` для `tg` / `telegram`.
|
||||||
|
- `PPM_WEB_URL` по умолчанию = `PPM_API_BASE_URL` (один origin за nginx).
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
- `resolveApiBaseUrl` / `resolveCabinetLoginUri` / парсер канала.
|
||||||
|
- виджет: инжектированный launcher отдаёт JWT → `GET /api/me` → signed in.
|
||||||
|
- 32 теста приложения, 102 в Widgetbook, `flutter analyze` чистый.
|
||||||
|
|
||||||
|
## Запуск против живого кабинета
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd mobile
|
||||||
|
.\run.ps1 -Device windows # на Windows будет форма токена
|
||||||
|
# на телефоне:
|
||||||
|
.\run.ps1 -ApiBaseUrl https://<домен-кабинета>
|
||||||
|
```
|
||||||
|
|
||||||
|
Нужен публичный HTTPS и BotFather `/setdomain` на этот домен — как для веба.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-09-20 — API URL мобильного приложения из `.env`
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Задавать `ApiBaseUrl` через переменные `.env`, а не флагом `run.ps1`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Конфиг читается из `mobile/.env` и уходит во Flutter как
|
||||||
|
`--dart-define-from-file` — без `flutter_dotenv` в ассетах (иначе gitignored
|
||||||
|
`.env` ломал бы CI: asset обязан существовать в момент сборки).
|
||||||
|
|
||||||
|
Порядок в `AppConfig.fromEnvironment`:
|
||||||
|
1. `--dart-define` (в т.ч. из файла)
|
||||||
|
2. явно переданная map (тесты)
|
||||||
|
3. пустой URL → демо-режим
|
||||||
|
|
||||||
|
`run.ps1`: если `.env` нет — копирует `.env.example`. `-ApiBaseUrl` / `-WebUrl`
|
||||||
|
остались как разовый override поверх файла. Скрипт сохранён в UTF-8 с BOM и
|
||||||
|
без вложенных кавычек — иначе PowerShell 5.1 на Windows-1251 сыпется
|
||||||
|
`TerminatorExpectedAtEndOfString`.
|
||||||
|
|
||||||
|
Ключи: `PPM_API_BASE_URL`, `PPM_WEB_URL`, `PPM_DEMO`.
|
||||||
|
Локальный `.env` в gitignore (есть и в корневом).
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 2026-09-20 — Вход через Яндекс ID (web + mobile)
|
||||||
|
|
||||||
|
## Запрос
|
||||||
|
Кнопка «Войти через Яндекс» в кабинете и в приложении. Client ID / secret
|
||||||
|
уже заведены в Yandex OAuth.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Authorization code. Secret только на API (`YANDEX_CLIENT_SECRET` в `.env`,
|
||||||
|
не в web/mobile и не в git). Клиенты получают `client_id` с
|
||||||
|
`GET /api/auth/providers` и шлют `code` + `redirect_uri` в
|
||||||
|
`POST /api/auth/yandex`.
|
||||||
|
|
||||||
|
API меняет код на токен Яндекса (`oauth.yandex.ru/token`), читает профиль
|
||||||
|
(`login.yandex.ru/info`) и выдаёт тот же JWT, что и Telegram.
|
||||||
|
|
||||||
|
`users.user_id` для Яндекса: ` (1 << 50) | yandexId ` — не пересекается с
|
||||||
|
Telegram id и остаётся внутри `Number.MAX_SAFE_INTEGER`, чтобы JSON в
|
||||||
|
браузере и Flutter не терял точность.
|
||||||
|
|
||||||
|
`redirect_uri` сверяется с allowlist (продакшен `https://please-pay-me.ru/`,
|
||||||
|
localhost:51290 и :5173, плюс `YANDEX_REDIRECT_URI` / `YANDEX_REDIRECT_URIS`).
|
||||||
|
|
||||||
|
## Потоки
|
||||||
|
|
||||||
|
- Web: кнопка → `oauth.yandex.ru/authorize` → возврат на `/?code=`
|
||||||
|
(как в кабинете Яндекса) → `RequireAuth` переносит query на `/login` →
|
||||||
|
`loginWithYandex` → `setSession`.
|
||||||
|
- Mobile: своя кнопка → WebView на Яндекс → перехват `{кабинет}/?code=`
|
||||||
|
→ `POST /api/auth/yandex` из Flutter.
|
||||||
|
|
||||||
|
## Что нужно в консоли Яндекса
|
||||||
|
Redirect URI: `https://please-pay-me.ru/` — как зарегистрировано в Яндексе.
|
||||||
|
|
||||||
|
## Секрет
|
||||||
|
Значения записаны в корневой `.env` (gitignore). В репозиторий не коммитятся.
|
||||||
|
Секрет светился в чате — если репозиторий общий, лучше перевыпустить в
|
||||||
|
кабинете Яндекса.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 2026-09-20 — Страница 502
|
||||||
|
|
||||||
|
По аналогии с `404.html`: тот же тёмный градиент, Syne/Manrope, motion.
|
||||||
|
Текст про недоступный шлюз, кнопки «Обновить» и «На главную».
|
||||||
|
|
||||||
|
Файлы: корневой `502.html` + `web/public/502.html`.
|
||||||
|
nginx: `error_page 502 503 504 /502.html` — 503/504 на ту же страницу,
|
||||||
|
потому что при мёртвом API приходит не только 502.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 2026-09-20 — Yandex redirect_uri = корень сайта
|
||||||
|
|
||||||
|
В кабинете Яндекса Callback URL: `https://please-pay-me.ru/` (не `/login`).
|
||||||
|
Клиенты слали `/login` → Яндекс отвечал «redirect_uri не совпадает».
|
||||||
|
|
||||||
|
Теперь authorize и обмен кода используют `https://please-pay-me.ru/`.
|
||||||
|
Возврат `/?code=` RequireAuth переносит на `/login?code=`, чтобы SPA
|
||||||
|
не выкинула query. Mobile перехватывает тот же корень.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-09-20 — Web UI-кит как в mobile iOS kit
|
||||||
|
|
||||||
|
Кабинет переведён на те же токены и примитивы, что Flutter `mobile/lib/ui`.
|
||||||
|
|
||||||
|
## Токены
|
||||||
|
`web/src/design/tokens.css` — Apple HIG: accent #12885A / #3CD68C, label /
|
||||||
|
separator / grouped surfaces, SF-шкала, 4pt сетка, радиусы 6/10/12/16.
|
||||||
|
Светлая и тёмная тема через `prefers-color-scheme`. Шрифт Inter.
|
||||||
|
Старые `--color-*` оставлены как алиасы.
|
||||||
|
|
||||||
|
## Кит
|
||||||
|
`web/src/ui/` — AppButton (filled/tinted/gray/plain/destructive), AppText,
|
||||||
|
AppTextField, AppListSection/Tile, AppSegmented, AppProgress, AppEmpty/Error,
|
||||||
|
Chip, Avatar, Skeleton. Стили в `kit.css`.
|
||||||
|
|
||||||
|
Старые `components/ui` (Button, Field, PageHeader, MetricGrid, …) — тонкие
|
||||||
|
обёртки над китом, страницы не ломаются.
|
||||||
|
|
||||||
|
## Оболочка
|
||||||
|
AppShell: translucent bar, inset-grouped нав на десктопе, капсулы на узком
|
||||||
|
экране. Обзор и журнал собраны на list/segmented. Остальные экраны получают
|
||||||
|
iOS-вид через те же кнопки, поля и `data-list`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-20 — Адрес кабинета только из `.env`
|
||||||
|
|
||||||
|
С экрана входа убрано поле «Адрес кабинета». Origin берётся из
|
||||||
|
`PPM_WEB_URL` / `PPM_API_BASE_URL` (`https://please-pay-me.ru/`).
|
||||||
|
Поле токена осталось только в запасном режиме.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-09-20 — Почему «Яндекс недоступен»
|
||||||
|
|
||||||
|
Тост шёл из `_yandex == null`: `GET /api/auth/providers` не вызывался,
|
||||||
|
потому что `AppConfig.fromEnvironment()` читает только `--dart-define`.
|
||||||
|
Запуск из IDE без `run.ps1` оставлял URL пустым, ошибку глотали.
|
||||||
|
|
||||||
|
На проде `/api/auth/providers` уже отдаёт `enabled: true`.
|
||||||
|
|
||||||
|
Фикс: `main()` читает `mobile/.env` с диска (dart-define по-прежнему
|
||||||
|
главнее). По тапу провайдеры перезапрашиваются, в тосте — реальная
|
||||||
|
причина. На Windows WebView нет — отдельное сообщение.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 2026-09-20 — Дефолт кабинета
|
||||||
|
|
||||||
|
Если `PPM_API_BASE_URL` / `PPM_WEB_URL` не заданы, `AppConfig` берёт
|
||||||
|
`https://please-pay-me.ru`. Демо только при `PPM_DEMO=true`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-20 — Переключатель темы в профиле
|
||||||
|
|
||||||
|
На экране профиля: Системная / Светлая / Тёмная (`AppSegmentedControl`).
|
||||||
|
`ThemeController` хранит выбор в SharedPreferences (`ppm_theme_preference`),
|
||||||
|
отдельно от JWT — тема переживает logout. `PleasePayMeApp` резолвит
|
||||||
|
brightness и прокидывает его в `CupertinoTheme` + `MediaQuery.platformBrightness`.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-20 — Android nav bar в тёмной теме
|
||||||
|
|
||||||
|
Системная нижняя панель: прозрачный чёрный (`#00000000`), без contrast
|
||||||
|
scrim (`enforceNavigationBarContrast=false`). Иконки светлые. Edge-to-edge
|
||||||
|
включается в `main`, стиль окна — в `values-night`.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-20 — Сплэш Please Pay Me
|
||||||
|
|
||||||
|
Фирменный экран запуска: зелёный знак ₽, название, слоган, спиннер.
|
||||||
|
Нативный Android (вкл. 12+ splash API) и iOS launch — тот же фон
|
||||||
|
`#F2F2F7` / `#000000`, чтобы не было вспышки. `main` рисует сплэш сразу
|
||||||
|
и держит его минимум 850 мс, пока восстанавливается сессия.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 2026-09-20 — Правовые документы
|
||||||
|
|
||||||
|
Публичные страницы `/legal/offer|privacy|consent|cookies`.
|
||||||
|
Футер на логине и в кабинете. Cookie-баннер при первом заходе
|
||||||
|
(технические / все, отказ от Метрики). Чекбоксы оферты и согласия
|
||||||
|
на входе в web и mobile; в профиле — «Правовая информация» со
|
||||||
|
ссылками на веб-версии. Реквизиты ИП Архангельский В.А.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-20 — Название и иконка
|
||||||
|
|
||||||
|
Отображаемое имя: **Дожить до ЗП**. Иконка: зелёный квадрат #12885A и белый ₽.
|
||||||
|
Проставлена в Android (adaptive), iOS, Windows, favicon кабинета.
|
||||||
|
`AppBrand.name` / `APP_NAME` — единый источник в UI.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 2026-09-20 — Иконка для RuStore
|
||||||
|
|
||||||
|
`mobile/assets/branding/rustore_icon_512.png`: 512×512, 1:1, ~145 КБ.
|
||||||
|
Уменьшена из `app_icon.png` (1024).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# 2026-09-20 — Скачать APK с сайта
|
||||||
|
|
||||||
|
Ссылка `/dozhit-do-zp.apk` на логине и в футере.
|
||||||
|
Файл: `web/public/dozhit-do-zp.apk` (gitignore, 53 МБ).
|
||||||
|
Перед деплоем веб скопировать свежий
|
||||||
|
`mobile/build/app/outputs/flutter-apk/app-release.apk`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 2026-09-20 Telegram → Yandex identity link
|
||||||
|
|
||||||
|
Вход везде только через Яндекс. Бот больше не выдаёт JWT по сырому Telegram `user_id`: пока аккаунт не связан с Яндексом, `POST /api/auth/internal` отвечает 403 `yandex_required` и ссылкой `https://please-pay-me.ru/?tg_link={token}`.
|
||||||
|
|
||||||
|
После входа через Яндекс веб вызывает `POST /api/auth/telegram-link/complete`. API пишет `telegram_yandex_links` и переносит бюджеты / траты / работы с Telegram-id на Yandex-namespaced id (`1<<50 | yandexId`). Дальше бот, кабинет и приложение работают с одним `user_id`.
|
||||||
|
|
||||||
|
`POST /api/auth/telegram` отключён (403). Старые Telegram-сессии в кабинете сбрасываются.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,72 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-please_pay_me}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-ppm}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ppm}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ppm} -d ${POSTGRES_DB:-please_pay_me}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
bot:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
|
PROXY_URL: ${PROXY_URL:-socks5://127.0.0.1:10808}
|
||||||
|
API_BASE_URL: ${API_BASE_URL:-http://127.0.0.1:51291}
|
||||||
|
API_TOKEN: ${API_TOKEN}
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "51291:8000"
|
||||||
|
environment:
|
||||||
|
BOT_TOKEN: ${BOT_TOKEN}
|
||||||
|
API_TOKEN: ${API_TOKEN}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
CORS_ORIGINS: ${CORS_ORIGINS:-*}
|
||||||
|
YANDEX_CLIENT_ID: ${YANDEX_CLIENT_ID:-}
|
||||||
|
YANDEX_CLIENT_SECRET: ${YANDEX_CLIENT_SECRET:-}
|
||||||
|
YANDEX_REDIRECT_URI: ${YANDEX_REDIRECT_URI:-https://please-pay-me.ru/}
|
||||||
|
YANDEX_REDIRECT_URIS: ${YANDEX_REDIRECT_URIS:-}
|
||||||
|
PUBLIC_WEB_ORIGIN: ${PUBLIC_WEB_ORIGIN:-https://please-pay-me.ru}
|
||||||
|
ConnectionStrings__Default: >-
|
||||||
|
Host=db;Port=5432;Database=${POSTGRES_DB:-please_pay_me};Username=${POSTGRES_USER:-ppm};Password=${POSTGRES_PASSWORD:-ppm}
|
||||||
|
ASPNETCORE_URLS: http://+:8000
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.web
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-}
|
||||||
|
VITE_TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "51290:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Optional. Empty keys fall back to https://please-pay-me.ru
|
||||||
|
# run.ps1 passes this file via --dart-define-from-file.
|
||||||
|
|
||||||
|
PPM_API_BASE_URL=https://please-pay-me.ru/
|
||||||
|
PPM_WEB_URL=https://please-pay-me.ru/
|
||||||
|
PPM_DEMO=false
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Miscellaneous
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.pyc
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
.atom/
|
||||||
|
.build/
|
||||||
|
.buildlog/
|
||||||
|
.history
|
||||||
|
.svn/
|
||||||
|
.swiftpm/
|
||||||
|
migrate_working_dir/
|
||||||
|
|
||||||
|
# IntelliJ related
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
# is commented out by default.
|
||||||
|
#.vscode/
|
||||||
|
|
||||||
|
# Flutter/Dart/Pub related
|
||||||
|
**/doc/api/
|
||||||
|
**/ios/Flutter/.last_build_id
|
||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
|
||||||
|
# Symbolication related
|
||||||
|
app.*.symbols
|
||||||
|
|
||||||
|
# Obfuscation related
|
||||||
|
app.*.map.json
|
||||||
|
|
||||||
|
# Android Studio will place build artifacts here
|
||||||
|
/android/app/debug
|
||||||
|
/android/app/profile
|
||||||
|
/android/app/release
|
||||||
|
|
||||||
|
# Widget Preview related
|
||||||
|
.widget_preview/
|
||||||
|
|
||||||
|
# Local runtime config (see .env.example)
|
||||||
|
.env
|
||||||
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "6a19cca56475dbfba1478ee68d7bd0c2ef891da1"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
- platform: android
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
- platform: ios
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
- platform: web
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
- platform: windows
|
||||||
|
create_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
base_revision: 6a19cca56475dbfba1478ee68d7bd0c2ef891da1
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Please Pay Me — Flutter mobile
|
||||||
|
|
||||||
|
Мобильный кабинет для бюджета «от зарплаты до зарплаты». UI построен на
|
||||||
|
iOS-ките (Cupertino, Apple HIG), данные — REST API из `src/PleasePayMe.Api`.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd mobile
|
||||||
|
copy .env.example .env # один раз
|
||||||
|
# отредактируй PPM_API_BASE_URL в .env
|
||||||
|
.\run.ps1
|
||||||
|
.\run.ps1 -Device chrome
|
||||||
|
.\run.ps1 -Target widgetbook
|
||||||
|
```
|
||||||
|
|
||||||
|
`run.ps1` читает `mobile/.env` и передаёт его во Flutter как
|
||||||
|
`--dart-define-from-file`. Если файла нет — копирует `.env.example`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="$HOME/flutter/bin:$PATH"
|
||||||
|
cd mobile
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d windows --dart-define-from-file=.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### Конфигурация (`mobile/.env`)
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
| --- | --- |
|
||||||
|
| `PPM_API_BASE_URL` | адрес API; если пусто — `https://please-pay-me.ru` |
|
||||||
|
| `PPM_WEB_URL` | веб-кабинет; если пусто — тот же origin, что API |
|
||||||
|
| `PPM_DEMO` | `true` — принудительный in-memory backend |
|
||||||
|
|
||||||
|
### Авторизация
|
||||||
|
|
||||||
|
На iOS и Android:
|
||||||
|
|
||||||
|
- **Telegram** — кабинет в WebView, JWT забирается из
|
||||||
|
`localStorage.ppm_session_jwt` через канал `PpmAuth`.
|
||||||
|
- **Яндекс** — WebView на `oauth.yandex.ru`. Код возвращается на
|
||||||
|
`{кабинет}/` (как Callback URL в кабинете Яндекса), приложение шлёт
|
||||||
|
его в `POST /api/auth/yandex`. Client secret живёт только на API.
|
||||||
|
|
||||||
|
Приложение проверяет JWT через `GET /api/me` и кладёт сессию в
|
||||||
|
`SharedPreferences`.
|
||||||
|
|
||||||
|
На Windows / в браузере WebView нет: остаётся ручной ввод токена. Альтернатива
|
||||||
|
на любой платформе — «Демо-режим» (`DemoBackend` без сети).
|
||||||
|
|
||||||
|
В кабинете Яндекса должен быть Redirect URI `https://<домен>/`
|
||||||
|
(для продакшена — `https://please-pay-me.ru/`). BotFather → `/setdomain`
|
||||||
|
для Telegram — тот же домен.
|
||||||
|
|
||||||
|
## Экраны
|
||||||
|
|
||||||
|
| Вкладка | Что делает |
|
||||||
|
| --- | --- |
|
||||||
|
| Обзор | остаток бюджета, дневной лимит, трата в один тап, ближайшая выплата |
|
||||||
|
| Журнал | операции по дням, фильтр «текущий / все бюджеты», подгрузка страниц |
|
||||||
|
| Бюджеты | выбор активного конверта, создание, редактирование, архив, удаление |
|
||||||
|
| Работа | оклад, дни выплат, правило выходных, график ближайших зарплат |
|
||||||
|
| Профиль | пользователь, режим подключения, выход |
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
mobile/
|
||||||
|
├── lib/
|
||||||
|
│ ├── app/ # CupertinoApp, session gate, таб-бар
|
||||||
|
│ ├── core/ # config, storage, AsyncValue, форматтеры
|
||||||
|
│ ├── data/
|
||||||
|
│ │ ├── api/ # ApiClient (http + маппинг ошибок)
|
||||||
|
│ │ ├── models/ # Budget/Expense/Job/AuthUser + JSON-хелперы
|
||||||
|
│ │ ├── repositories/ # интерфейсы + REST-реализации
|
||||||
|
│ │ └── demo/ # in-memory backend (превью, тесты, демо-режим)
|
||||||
|
│ ├── features/ # overview / journal / budgets / work / profile / auth
|
||||||
|
│ ├── theme/ # токены Apple HIG + CupertinoThemeData
|
||||||
|
│ └── ui/ # дизайн-система (atoms → molecules → navigation)
|
||||||
|
└── widgetbook/ # переносимый каталог компонентов и экранов
|
||||||
|
```
|
||||||
|
|
||||||
|
Слои связаны через интерфейсы репозиториев: экраны знают только
|
||||||
|
`BudgetRepository` / `ExpenseRepository` / `JobRepository`, а `SessionController`
|
||||||
|
подставляет REST- или demo-реализацию. Поэтому и Widgetbook, и виджет-тесты
|
||||||
|
гоняют настоящие экраны без сети.
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mobile && flutter test # модели, форматтеры, контроллеры, сквозной сценарий
|
||||||
|
cd mobile/widgetbook && flutter test # рендер каждого use-case в light и dark
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
analyzer:
|
||||||
|
exclude:
|
||||||
|
- build/**
|
||||||
|
- android/**
|
||||||
|
- ios/**
|
||||||
|
- web/**
|
||||||
|
- windows/**
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.pleasepayme.please_pay_me"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "com.pleasepayme.please_pay_me"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
|
||||||
|
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
|
||||||
|
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
|
||||||
|
// flag during build.
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
<application
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="tg"/>
|
||||||
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<data android:scheme="telegram"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.pleasepayme.please_pay_me
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user