feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:06:43 +03:00
commit c956b94983
1076 changed files with 50876 additions and 0 deletions
+18
View File
@@ -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/
+29
View File
@@ -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
View File
@@ -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
+261
View File
@@ -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>
+262
View File
@@ -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
View File
@@ -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"]
+20
View File
@@ -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"]
+17
View File
@@ -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
+99
View File
@@ -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
+141
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
# Deprecated Python API
Этот каталог больше не используется в runtime.
Актуальный API: `src/PleasePayMe.Api` (ASP.NET Core + PostgreSQL).
Контракт HTTP (`/api/...`) сохранён для web и Telegram-бота.
+1
View File
@@ -0,0 +1 @@
# API package
+52
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+65
View File
@@ -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,
}
+26
View File
@@ -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"])
+1
View File
@@ -0,0 +1 @@
# Package marker
+1
View File
@@ -0,0 +1 @@
"""HTTP clients for talking to the Please Pay Me API."""
+444
View File
@@ -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,
)
+47
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Package marker
+243
View File
@@ -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
+400
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# Package marker
+781
View File
@@ -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,
)
+12
View File
@@ -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()
+53
View File
@@ -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
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
# Package marker
+397
View File
@@ -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,
)
+213
View File
@@ -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)
+15
View File
@@ -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[] 131), 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.
+72
View File
@@ -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:
+6
View File
@@ -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
+52
View File
@@ -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
+39
View File
@@ -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'
+93
View File
@@ -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
```
+36
View File
@@ -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
+14
View File
@@ -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
+49
View File
@@ -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