# Kuruş — Telegram Mini App Teknik Şartnamesi

> Bu doküman, uygulamayı sıfırdan kurup canlıya alacak geliştirici/asistan için yazılmıştır.
> Sırayla uygulanabilir. Her bölümün sonunda "bitti" kriteri vardır.

---

## 0. Ürün özeti

**Kuruş**, Telegram içinde çalışan bir kişisel harcama takip uygulamasıdır.

**Bot:** `@kurus_hesapla_bot`
**Görünen ad:** Kuruş
**Tek cümlelik iş tanımı:** Kullanıcı harcamasını 5 saniyede kaydeder, ay sonunda parasının nereye gittiğini görür, isterse Excel olarak indirir.

### Kapsam (v1)

| Var | Yok (v2'ye bırakıldı) |
|---|---|
| Harcama ekle / düzenle / sil | Gelir takibi |
| Kategori atama | Bütçe limiti ve uyarı |
| Aylık özet + kategori kırılımı | Tekrarlayan (abonelik) harcamalar |
| Geçmişe dönük listeleme, ay bazlı gezinme | Çoklu para birimi dönüşümü |
| Excel (.xlsx) dışa aktarma | Banka/SMS entegrasyonu |
| Türkçe + İngilizce arayüz | Ortak/aile hesabı |

### Temel ürün kararı

**Para birimi TRY, tek para birimi.** Tutarlar veritabanında **tam sayı olarak kuruş** cinsinden tutulur (`amount_minor`). Hiçbir yerde `float`/`double` kullanılmaz. 12,50 TL → `1250`. Bu hem ürün adıyla örtüşüyor hem de yuvarlama hatalarını tamamen ortadan kaldırıyor.

---

## 1. Teknoloji yığını

Sabit seçimler — asistan bunları değiştirmesin, gerekçeleri aşağıda:

| Katman | Seçim | Gerekçe |
|---|---|---|
| Frontend | **React 19 + Vite + TypeScript** | Mini app statik build olarak servis edilir, SSR gereksiz |
| Stil | **Tailwind CSS v4** | Telegram tema değişkenlerini CSS custom property olarak bağlamak kolay |
| State/veri | **TanStack Query** | Cache + optimistic update; offline hissi için kritik |
| Backend | **Node.js 22 + Fastify + TypeScript** | Hızlı, düşük bellek, tek VPS'te rahat çalışır |
| Veritabanı | **PostgreSQL 16** | Tarih aralığı sorguları ve `SUM` gruplamaları için doğru araç |
| ORM | **Drizzle ORM** | SQL'e yakın, migration'ları dosya olarak versiyonlanır |
| Bot | **grammY** | Telegram Bot API için en temiz TS kütüphanesi |
| Excel | **ExcelJS** | Stil + formül + dondurulmuş başlık desteği |
| Süreç yönetimi | **systemd** | PM2'ye gerek yok, tek servis var |
| Reverse proxy | **Nginx** | TLS sonlandırma + statik dosya servisi |
| Sertifika | **Let's Encrypt / certbot** | Telegram HTTPS zorunlu kılıyor |

**Repo yapısı** — tek repo, üç paket:

```
kurus/
├── apps/
│   ├── web/          # React mini app
│   └── api/          # Fastify + bot (tek process)
├── packages/
│   └── shared/       # Zod şemaları, tipler, para formatlama
├── infra/
│   ├── nginx/kurus.conf
│   └── systemd/kurus-api.service
└── package.json      # pnpm workspaces
```

Bot ve API **aynı process içinde** çalışır. Ayrı servis gereksiz karmaşıklık; webhook zaten Fastify route'u olarak bağlanacak.

---

## 2. Domain ve DNS

### 2.1 Domain seçimi

Öncelik sırası: `kurus.app` → `kurusapp.com` → `kurus.io`
Kayıt: Cloudflare Registrar (maliyet fiyatına satar, ek ücret yok) veya Namecheap.

### 2.2 DNS kayıtları

Nameserver'ları Cloudflare'e taşı, sonra:

```
A     @      <SUNUCU_IP>     Proxy: DNS only (gri bulut)
A     api    <SUNUCU_IP>     Proxy: DNS only
CNAME www    kurus.app       Proxy: DNS only
```

**Kurulum sırasında proxy'yi kapalı tut (gri bulut).** certbot HTTP-01 doğrulaması yaparken turuncu bulut sorun çıkarır. Sertifika alındıktan sonra proxy'yi açabilirsin — açarsan Cloudflare SSL modunu **Full (strict)** yap, "Flexible" asla kullanma (sonsuz yönlendirme döngüsü yapar).

### 2.3 Adres planı

| Adres | İçerik |
|---|---|
| `https://kurus.app` | Mini app (statik React build) |
| `https://kurus.app/api/*` | Backend API |
| `https://kurus.app/tg/webhook/<SECRET_PATH>` | Telegram webhook |
| `https://kurus.app/privacy` | Gizlilik metni (statik HTML) |

Tek domain kullanmak CORS derdini tamamen ortadan kaldırıyor. Ayrı `api.` subdomain'i açma.

**Bitti kriteri:** `dig kurus.app +short` sunucu IP'sini döndürüyor.

---

## 3. Sunucu kurulumu

### 3.1 Makine

Hetzner CX22 veya DigitalOcean 2GB — Ubuntu 24.04 LTS. v1 için fazlasıyla yeterli.

### 3.2 İlk sertleştirme

```bash
# root olarak
adduser kurus
usermod -aG sudo kurus
rsync --archive --chown=kurus:kurus ~/.ssh /home/kurus

# /etc/ssh/sshd_config
#   PermitRootLogin no
#   PasswordAuthentication no
systemctl restart ssh

ufw allow OpenSSH
ufw allow 80,443/tcp
ufw enable

apt update && apt upgrade -y
apt install -y unattended-upgrades fail2ban
```

### 3.3 Çalışma zamanı

```bash
# Node 22
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
sudo npm i -g pnpm

# PostgreSQL 16
sudo apt install -y postgresql postgresql-contrib
sudo -u postgres psql <<'SQL'
CREATE USER kurus WITH PASSWORD 'GÜÇLÜ_ŞİFRE_BURAYA';
CREATE DATABASE kurus OWNER kurus;
SQL
```

Postgres'i dışarı **açma**. `listen_addresses = 'localhost'` varsayılan olarak doğru, dokunma.

### 3.4 Nginx + TLS

```bash
sudo apt install -y nginx certbot python3-certbot-nginx
sudo certbot --nginx -d kurus.app -d www.kurus.app
```

`/etc/nginx/sites-available/kurus.conf`:

```nginx
server {
    listen 443 ssl http2;
    server_name kurus.app www.kurus.app;

    ssl_certificate     /etc/letsencrypt/live/kurus.app/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/kurus.app/privkey.pem;

    # Telegram Mini App iframe içinde açılır — bu başlık şart
    add_header Content-Security-Policy "frame-ancestors https://web.telegram.org https://*.telegram.org" always;
    add_header X-Content-Type-Options nosniff always;
    add_header Referrer-Policy strict-origin-when-cross-origin always;

    # Statik frontend
    root /var/www/kurus;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # Hash'li asset'ler uzun cache
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # index.html asla cache'lenmez
    location = /index.html {
        add_header Cache-Control "no-store";
    }

    location /api/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /tg/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Telegram-Bot-Api-Secret-Token $http_x_telegram_bot_api_secret_token;
    }

    client_max_body_size 2m;
}

server {
    listen 80;
    server_name kurus.app www.kurus.app;
    return 301 https://$host$request_uri;
}
```

> **Kritik:** `X-Frame-Options: SAMEORIGIN` **koyma**. Mini app iframe içinde açıldığı için uygulamayı beyaz ekrana düşürür. Yukarıdaki `frame-ancestors` CSP'si doğru yöntem.

### 3.5 systemd servisi

`/etc/systemd/system/kurus-api.service`:

```ini
[Unit]
Description=Kurus API + Bot
After=network.target postgresql.service

[Service]
Type=simple
User=kurus
WorkingDirectory=/home/kurus/kurus/apps/api
EnvironmentFile=/home/kurus/kurus/.env
ExecStart=/usr/bin/node dist/server.js
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now kurus-api
journalctl -u kurus-api -f    # log takibi
```

### 3.6 Ortam değişkenleri

`/home/kurus/kurus/.env` — dosya izni `chmod 600`, repoya **asla** girmez:

```bash
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://kurus:ŞİFRE@localhost:5432/kurus
BOT_TOKEN=<BotFather token>
WEBHOOK_SECRET_PATH=<openssl rand -hex 24 çıktısı>
WEBHOOK_SECRET_TOKEN=<openssl rand -hex 24 çıktısı>
WEBAPP_URL=https://kurus.app
TZ=Europe/Istanbul
```

**Bitti kriteri:** `curl https://kurus.app/api/health` → `{"ok":true}`

---

## 4. Veritabanı şeması

```sql
CREATE TABLE users (
  id             BIGSERIAL PRIMARY KEY,
  tg_user_id     BIGINT UNIQUE NOT NULL,
  first_name     TEXT,
  username       TEXT,
  language_code  TEXT DEFAULT 'tr',
  currency       TEXT NOT NULL DEFAULT 'TRY',
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_seen_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE categories (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT REFERENCES users(id) ON DELETE CASCADE,  -- NULL = sistem varsayılanı
  slug        TEXT NOT NULL,
  name_tr     TEXT NOT NULL,
  name_en     TEXT NOT NULL,
  emoji       TEXT NOT NULL,
  color       TEXT NOT NULL,      -- hex
  sort_order  INT  NOT NULL DEFAULT 0,
  archived_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX categories_user_slug_idx
  ON categories (COALESCE(user_id, 0), slug);

CREATE TABLE expenses (
  id           BIGSERIAL PRIMARY KEY,
  user_id      BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  category_id  BIGINT REFERENCES categories(id) ON DELETE SET NULL,
  amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),  -- KURUŞ cinsinden
  currency     TEXT   NOT NULL DEFAULT 'TRY',
  note         TEXT   CHECK (char_length(note) <= 280),
  spent_at     DATE   NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at   TIMESTAMPTZ            -- soft delete
);

CREATE INDEX expenses_user_date_idx
  ON expenses (user_id, spent_at DESC, id DESC)
  WHERE deleted_at IS NULL;

CREATE INDEX expenses_user_cat_idx
  ON expenses (user_id, category_id)
  WHERE deleted_at IS NULL;

CREATE TABLE export_jobs (
  id          BIGSERIAL PRIMARY KEY,
  user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  range_from  DATE NOT NULL,
  range_to    DATE NOT NULL,
  status      TEXT NOT NULL DEFAULT 'pending',  -- pending|done|failed
  error       TEXT,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

### Varsayılan kategoriler (seed)

| slug | TR | EN | Emoji | Renk |
|---|---|---|---|---|
| `market` | Market | Groceries | 🛒 | `#4F8A5B` |
| `yeme-icme` | Yeme & İçme | Food & Drink | 🍽️ | `#C4622D` |
| `ulasim` | Ulaşım | Transport | 🚌 | `#3B6FA0` |
| `fatura` | Fatura & Abonelik | Bills | 🧾 | `#6B5B95` |
| `saglik` | Sağlık | Health | 💊 | `#B23A48` |
| `giyim` | Giyim | Clothing | 👕 | `#8A6D3B` |
| `eglence` | Eğlence | Fun | 🎬 | `#C89A2E` |
| `ev` | Ev & Kira | Home | 🏠 | `#5A7D7C` |
| `diger` | Diğer | Other | 📦 | `#7A7A7A` |

Yeni kullanıcı kaydolduğunda bu kategoriler kopyalanmaz — `user_id IS NULL` olarak paylaşımlı okunur. Kullanıcı kendi kategorisini eklerse `user_id` dolu satır oluşur.

---

## 5. Kimlik doğrulama — bu bölüm atlanamaz

Mini app'ten gelen her istekte Telegram `initData` gönderilir. Sunucu bunu bot token'la doğrular. **Doğrulama yapılmazsa herkes başkasının `user_id`'siyle veri okuyup yazabilir.** Harcama verisi tutulduğu için bu kritik.

### 5.1 Doğrulama algoritması

```ts
// apps/api/src/auth/verifyInitData.ts
import crypto from 'node:crypto';

const MAX_AGE_SECONDS = 24 * 60 * 60; // 24 saat

export type TgUser = {
  id: number;
  first_name?: string;
  username?: string;
  language_code?: string;
};

export function verifyInitData(initData: string, botToken: string): TgUser {
  const params = new URLSearchParams(initData);

  const hash = params.get('hash');
  if (!hash) throw new Error('AUTH_NO_HASH');
  params.delete('hash');
  params.delete('signature'); // varsa hesaba katılmaz

  // 1) Alfabetik sırayla key=value satırları
  const dataCheckString = [...params.entries()]
    .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
    .map(([k, v]) => `${k}=${v}`)
    .join('\n');

  // 2) Gizli anahtar: HMAC(key="WebAppData", data=botToken)
  const secretKey = crypto
    .createHmac('sha256', 'WebAppData')
    .update(botToken)
    .digest();

  // 3) Beklenen hash
  const computed = crypto
    .createHmac('sha256', secretKey)
    .update(dataCheckString)
    .digest('hex');

  // 4) Zamanlama saldırısına kapalı karşılaştırma
  const a = Buffer.from(computed, 'hex');
  const b = Buffer.from(hash, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('AUTH_BAD_HASH');
  }

  // 5) Tazelik kontrolü — replay saldırısını engeller
  const authDate = Number(params.get('auth_date') ?? 0);
  if (!authDate || Math.floor(Date.now() / 1000) - authDate > MAX_AGE_SECONDS) {
    throw new Error('AUTH_EXPIRED');
  }

  const userRaw = params.get('user');
  if (!userRaw) throw new Error('AUTH_NO_USER');
  return JSON.parse(userRaw) as TgUser;
}
```

**Sık yapılan üç hata — kaçının:**

1. `URLSearchParams` değerleri otomatik decode eder, bu doğrudur. Ama elle `decodeURIComponent` uygulayıp tekrar decode etmeyin, hash tutmaz.
2. `hash` alanını data-check-string'e dahil etmeyin.
3. `auth_date` kontrolünü atlamayın; yoksa bir kere ele geçirilen `initData` süresiz kullanılır.

### 5.2 Fastify middleware

```ts
// apps/api/src/plugins/tgAuth.ts
import fp from 'fastify-plugin';
import { verifyInitData } from '../auth/verifyInitData.js';
import { upsertUser } from '../db/users.js';

export default fp(async (app) => {
  app.decorateRequest('user', null);

  app.addHook('preHandler', async (req, reply) => {
    if (!req.url.startsWith('/api/') || req.url === '/api/health') return;

    const initData = req.headers['x-telegram-init-data'];
    if (typeof initData !== 'string') {
      return reply.code(401).send({ error: 'AUTH_REQUIRED' });
    }

    try {
      const tgUser = verifyInitData(initData, process.env.BOT_TOKEN!);
      req.user = await upsertUser(tgUser); // DB kaydı, id döner
    } catch (e) {
      req.logger?.warn({ e }, 'initData doğrulanamadı');
      return reply.code(401).send({ error: 'AUTH_INVALID' });
    }
  });
});
```

Her sorguda `WHERE user_id = req.user.id` şartı **zorunlu**. İstemciden gelen hiçbir `user_id` parametresine güvenilmez — zaten API'de böyle bir parametre olmayacak.

### 5.3 Hız sınırı

`@fastify/rate-limit` ile IP + `tg_user_id` bazlı: dakikada 60 istek, yazma uçlarında dakikada 20.

---

## 6. Backend API

Tüm istekler `X-Telegram-Init-Data` başlığı taşır. Tüm tutarlar **kuruş cinsinden tam sayı**.

| Metot | Uç | Açıklama |
|---|---|---|
| `GET` | `/api/health` | Auth'suz, sağlık kontrolü |
| `GET` | `/api/me` | Kullanıcı + ayarlar + kategoriler |
| `GET` | `/api/expenses?from=&to=&categoryId=&cursor=&limit=` | Cursor tabanlı sayfalama, tarihe göre azalan |
| `POST` | `/api/expenses` | `{ amountMinor, categoryId, note?, spentAt }` |
| `PATCH` | `/api/expenses/:id` | Kısmi güncelleme |
| `DELETE` | `/api/expenses/:id` | Soft delete |
| `GET` | `/api/summary?month=2026-08` | Toplam + kategori kırılımı + günlük seri |
| `GET` | `/api/summary/months` | Veri olan ayların listesi (ay gezgini için) |
| `POST` | `/api/categories` | Özel kategori |
| `POST` | `/api/export` | `{ from, to }` → Excel üretir, **bota gönderir** |

### Örnek yanıtlar

`GET /api/summary?month=2026-08`

```json
{
  "month": "2026-08",
  "totalMinor": 1847550,
  "count": 63,
  "prevMonthTotalMinor": 1622000,
  "byCategory": [
    { "categoryId": 1, "slug": "market", "totalMinor": 620000, "count": 14, "share": 0.336 },
    { "categoryId": 2, "slug": "yeme-icme", "totalMinor": 431000, "count": 27, "share": 0.233 }
  ],
  "byDay": [
    { "date": "2026-08-01", "totalMinor": 24500 },
    { "date": "2026-08-02", "totalMinor": 0 }
  ]
}
```

`POST /api/expenses` istek gövdesi (Zod ile doğrulanır):

```json
{
  "amountMinor": 12450,
  "categoryId": 1,
  "note": "Haftalık market",
  "spentAt": "2026-08-08"
}
```

### Doğrulama kuralları

- `amountMinor`: tam sayı, `1` ile `99_999_999_99` arası
- `spentAt`: `YYYY-MM-DD`, bugünden **ileri tarih kabul edilmez**, 5 yıldan eski kabul edilmez
- `note`: en fazla 280 karakter, HTML kaçışlanır
- `categoryId`: kullanıcının erişebildiği kategori olmalı (`user_id = me OR user_id IS NULL`)

### Zaman dilimi

Sunucu `TZ=Europe/Istanbul` ile çalışır. `spent_at` bir `DATE` alanıdır, saat tutulmaz — "hangi güne yazıldı" sorusunun cevabı kullanıcının takvimine göre olmalı. Frontend tarihi `YYYY-MM-DD` string olarak gönderir, `Date` nesnesi serileştirmez.

---

## 7. Bot tarafı

### 7.1 Webhook kurulumu

```ts
await bot.api.setWebhook(
  `${process.env.WEBAPP_URL}/tg/webhook/${process.env.WEBHOOK_SECRET_PATH}`,
  {
    secret_token: process.env.WEBHOOK_SECRET_TOKEN,
    allowed_updates: ['message', 'callback_query'],
    drop_pending_updates: true,
  }
);
```

Handler'da **iki katman** kontrol: URL'deki gizli yol + `X-Telegram-Bot-Api-Secret-Token` başlığı. Başlık eşleşmiyorsa 401 dön.

### 7.2 Komutlar

```ts
await bot.api.setMyCommands(
  [
    { command: 'start', description: 'Uygulamayı aç' },
    { command: 'ekle',  description: 'Hızlı harcama ekle' },
    { command: 'ozet',  description: 'Bu ayın özeti' },
    { command: 'excel', description: 'Excel olarak indir' },
  ],
  { language_code: 'tr' }
);

await bot.api.setMyCommands([
  { command: 'start', description: 'Open the app' },
  { command: 'add',   description: 'Quick add expense' },
  { command: 'summary', description: 'This month' },
  { command: 'excel', description: 'Download Excel' },
]);
```

### 7.3 Menü butonu

```ts
await bot.api.setChatMenuButton({
  menu_button: {
    type: 'web_app',
    text: 'Kuruş',
    web_app: { url: process.env.WEBAPP_URL! },
  },
});
```

### 7.4 Sohbetten hızlı ekleme

Kullanıcı bota düz metin yazarsa parse et — uygulamayı açmadan kayıt, en çok kullanılacak özellik olacak:

```
120 market
45,50 kahve
1250 ev kirası
```

Kural: baştaki sayı tutar (`,` veya `.` ondalık ayırıcı olabilir), kalan metin not. Nottaki kelimeler kategori slug/adıyla eşleşiyorsa kategori otomatik atanır, yoksa `diger`.

Yanıt: onay mesajı + `Düzenle` / `Sil` inline butonları. `Düzenle` mini app'i o kaydın üzerinde açar (`web_app` butonu `?expense=<id>` parametresiyle).

### 7.5 /start akışı

İlk kez gelen kullanıcıya: kısa karşılama + "Uygulamayı aç" `web_app` butonu + tek satırlık kullanım ipucu ("İstersen buraya doğrudan `120 market` yazarak da kaydedebilirsin").

---

## 8. Excel dışa aktarma

**Yaklaşım:** dosya sunucuda üretilir, tarayıcıya indirilmez, `sendDocument` ile kullanıcının bot sohbetine gönderilir. Böylece dosya Telegram'da kalıcı olur, iOS'ta indirme sorunu yaşanmaz.

```ts
import ExcelJS from 'exceljs';
import { InputFile } from 'grammy';

export async function buildAndSendExport(userId: number, tgUserId: number, from: string, to: string) {
  const rows = await getExpensesForRange(userId, from, to);

  const wb = new ExcelJS.Workbook();
  wb.creator = 'Kuruş';

  // --- Sayfa 1: Harcamalar ---
  const ws = wb.addWorksheet('Harcamalar', {
    views: [{ state: 'frozen', ySplit: 1 }],
  });

  ws.columns = [
    { header: 'Tarih',    key: 'date',   width: 12 },
    { header: 'Kategori', key: 'cat',    width: 20 },
    { header: 'Tutar',    key: 'amount', width: 14 },
    { header: 'Not',      key: 'note',   width: 40 },
  ];

  ws.getRow(1).font = { bold: true };
  ws.getRow(1).fill = {
    type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF0E6D2' },
  };

  for (const r of rows) {
    ws.addRow({
      date: new Date(r.spentAt),
      cat: r.categoryName,
      amount: r.amountMinor / 100,   // yalnızca Excel çıktısında bölünür
      note: r.note ?? '',
    });
  }

  ws.getColumn('date').numFmt = 'dd.mm.yyyy';
  ws.getColumn('amount').numFmt = '#,##0.00 ₺';
  ws.autoFilter = { from: 'A1', to: `D${rows.length + 1}` };

  // Toplam satırı — formül olarak, sabit değer değil
  const totalRow = ws.addRow({ cat: 'TOPLAM', amount: null });
  totalRow.getCell('amount').value = { formula: `SUM(C2:C${rows.length + 1})` };
  totalRow.font = { bold: true };

  // --- Sayfa 2: Kategori özeti ---
  const ws2 = wb.addWorksheet('Özet');
  ws2.columns = [
    { header: 'Kategori', key: 'cat',   width: 20 },
    { header: 'Toplam',   key: 'total', width: 14 },
    { header: 'Adet',     key: 'count', width: 8  },
    { header: 'Pay',      key: 'share', width: 10 },
  ];
  ws2.getRow(1).font = { bold: true };
  ws2.getColumn('total').numFmt = '#,##0.00 ₺';
  ws2.getColumn('share').numFmt = '0.0%';
  // ... satırlar

  const buffer = await wb.xlsx.writeBuffer();
  const filename = `kurus-${from}-${to}.xlsx`;

  await bot.api.sendDocument(
    tgUserId,
    new InputFile(Buffer.from(buffer), filename),
    { caption: `${from} – ${to} arası ${rows.length} harcama.` }
  );
}
```

**Dikkat edilecekler:**

- Türkçe karakterler için ek ayar gerekmiyor, ExcelJS UTF-8 yazıyor. Ama **CSV** üretilecek olursa BOM (`\uFEFF`) eklenmeli, yoksa Excel `ş/ğ/İ` karakterlerini bozuyor.
- 5.000 satırdan büyük dışa aktarımlarda üretimi HTTP isteğinden ayır: `export_jobs` kaydı oluştur, `202` dön, arka planda üret ve gönder. Kullanıcıya "Hazırlanıyor, birazdan gönderiyorum" mesajı geç.
- Telegram dosya sınırı 50 MB — bu ölçekte sorun olmaz.

---

## 9. Frontend

### 9.1 Telegram SDK entegrasyonu

`index.html` içinde, uygulama bundle'ından **önce**:

```html
<script src="https://telegram.org/js/telegram-web-app.js?58"></script>
```

Bu script CDN'den gelmeli, self-host edilmemeli — Telegram sürüm güncellemelerini buradan dağıtıyor.

Açılışta:

```ts
const tg = window.Telegram.WebApp;
tg.ready();
tg.expand();
tg.disableVerticalSwipes();   // liste kaydırırken app kapanmasın
tg.setHeaderColor('secondary_bg_color');
```

`disableVerticalSwipes()` atlanırsa kullanıcı harcama listesini yukarı kaydırmaya çalışırken uygulama kapanır. Küçük ama en çok şikâyet alan detay.

### 9.2 Tema bağlama

Telegram `themeParams` gönderir. Bunları CSS değişkenine bağla, sabit renk yazma:

```ts
function applyTheme() {
  const p = window.Telegram.WebApp.themeParams;
  const root = document.documentElement;
  const map: Record<string, string | undefined> = {
    '--tg-bg':           p.bg_color,
    '--tg-text':         p.text_color,
    '--tg-hint':         p.hint_color,
    '--tg-link':         p.link_color,
    '--tg-button':       p.button_color,
    '--tg-button-text':  p.button_text_color,
    '--tg-secondary-bg': p.secondary_bg_color,
    '--tg-section-bg':   p.section_bg_color,
    '--tg-separator':    p.section_separator_color,
  };
  for (const [k, v] of Object.entries(map)) if (v) root.style.setProperty(k, v);
  root.dataset.scheme = window.Telegram.WebApp.colorScheme; // 'light' | 'dark'
}

applyTheme();
window.Telegram.WebApp.onEvent('themeChanged', applyTheme);
```

Ayrıca güvenli alan:

```css
.app {
  padding-bottom: calc(16px + var(--tg-safe-area-inset-bottom, 0px));
  min-height: 100dvh;
}
```

### 9.3 Görsel yön

Yüzeyler ve metin renkleri **tamamen Telegram temasından** gelir — uygulama kullanıcının seçtiği temayla bütünleşik görünmeli. Marka kimliği tek bir yerde yaşar: **vurgu rengi ve rakam tipografisi.**

**Marka paleti** (temadan bağımsız sabitler):

```css
--kurus-brass:      #C8963E;  /* birincil vurgu — madeni para pirinci */
--kurus-brass-deep: #9A6F26;  /* basılı hâl, koyu temada kenarlık */
--kurus-ink:        #1C1A16;  /* pirinç üstü metin */
--kurus-positive:   #4F8A5B;  /* geçen aya göre azalış */
--kurus-negative:   #B23A48;  /* geçen aya göre artış */
```

Pirinç tonu bilinçli bir seçim: Türk madeni parasının rengi, ve fintech uygulamalarının alışılmış mor/mavi kümesinin dışında. Vurgu rengi **sadece** üç yerde kullanılır — aktif ay sekmesi, tutar girişi imleci, kategori şeridindeki seçili dilim. Başka hiçbir yerde görünmez.

**Tipografi:**

| Rol | Yüz | Kullanım |
|---|---|---|
| Rakamlar / başlık | **Bricolage Grotesque** | Sadece tutarlar ve ekran başlıkları |
| Arayüz / gövde | **Inter** | Diğer her şey, `font-variant-numeric: tabular-nums` |

Her iki yüz de **Türkçe karakter setini tam destekliyor** — `ş ğ ı İ ç ö ü` glifleri mevcut. Bu bir zorunluluk: birçok display font'ta `ı` ve `İ` eksik ve isim "Kuruş" olduğu için ilk ekranda hata görünür. Font seçimi değiştirilecekse Türkçe kapsama mutlaka doğrulanmalı.

Fontlar `woff2` olarak **self-host edilir** (`/assets/fonts/`), Google Fonts CDN'den çekilmez — mini app'te ilk render hızı kritik ve harici istek FOUT yaratıyor. `latin-ext` subset'i alınmalı, sadece `latin` alınırsa Türkçe karakterler düşer.

**İmza öğesi — kuruş ayrımı:**

Tutarlar iki kademede gösterilir: lira kısmı büyük ve display font ile, kuruş kısmı belirgin şekilde küçük ve soluk. Ürünün adı tam olarak bunu anlatıyor, ve tarama sırasında gözün büyük rakama kilitlenmesini sağlıyor.

```
      1.847,50 ₺  →   1.847  ,50 ₺
                     ─────  ──────
                      32px    16px
                     brass   hint
```

```tsx
export function Amount({ minor, size = 'md' }: { minor: number; size?: 'sm'|'md'|'lg' }) {
  const lira = Math.floor(minor / 100);
  const kurus = String(minor % 100).padStart(2, '0');
  return (
    <span className={`amount amount--${size}`}>
      <span className="amount__lira">{lira.toLocaleString('tr-TR')}</span>
      <span className="amount__kurus">,{kurus} ₺</span>
    </span>
  );
}
```

Bu bileşen uygulamadaki **tek** tutar gösterim yolu. Başka hiçbir yerde elle formatlama yapılmaz.

**İkinci imza — aylık şerit:**

Kategori dağılımı için pasta grafik **kullanılmaz**. Bunun yerine tek satırlık yatay yığılmış şerit: mobilde daha az yer kaplıyor, oranları karşılaştırmak daha kolay ve dokunulduğunda o kategoriye filtreliyor.

```
┌────────────┬──────────┬─────┬───┬──┐
│  Market    │ Yeme&İç. │Ulaş.│Fat│..│
└────────────┴──────────┴─────┴───┴──┘
```

Yüzde 3'ün altındaki kategoriler "Diğer" dilimine toplanır, yoksa şerit okunmaz hâle gelir.

**Kısıt:** Bunlar dışında dekorasyon yok. Gradyan yok, gölge yok, cam efekti yok, giriş animasyonu yok. Telegram'ın kendi arayüzü sade; mini app onun içinde yabancı durmamalı. Tek hareket: kayıt eklendiğinde listeye giren satırın 180ms'lik yükseklik açılışı ve haptic dokunuş.

`prefers-reduced-motion` her animasyonda kontrol edilir.

### 9.4 Ekranlar

**A. Ana ekran (`/`)**

```
┌─────────────────────────────────┐
│  ‹  Ağustos 2026  ›             │   ay gezgini, yatay kaydırılabilir
│                                 │
│         1.847,50 ₺              │   büyük tutar, kuruş ayrımı ile
│   geçen aya göre  ↑ %13,9       │   negative rengi
│                                 │
│  ▓▓▓▓▓▓▒▒▒▒▒░░░▒▒░░             │   kategori şeridi
│                                 │
│  BUGÜN                          │
│  🛒 Market          124,50 ₺  › │
│  🍽️ Öğle yemeği      85,00 ₺  › │
│                                 │
│  DÜN                            │
│  🚌 Metro            15,00 ₺  › │
│  ...                            │
└─────────────────────────────────┘
         MainButton: "Harcama ekle"
```

- Liste günlere göre gruplanır, gün başlıkları yapışkan (`position: sticky`)
- Sonsuz kaydırma, cursor tabanlı, sayfa başına 40 kayıt
- Satıra dokunma → düzenleme, sola kaydırma → sil (onay ile)
- Ay gezgini sadece **veri olan ayları** gösterir (`/api/summary/months`)

**B. Harcama ekleme (bottom sheet)**

Yeni sayfa değil, alttan açılan panel — bağlam kaybolmasın.

```
┌─────────────────────────────────┐
│           ,      ₺              │   dev sayısal giriş, otomatik odak
│                                 │
│  🛒  🍽️  🚌  🧾  💊  👕  🎬 ...  │   kategori seçici, yatay kaydırma
│                                 │
│  Not (isteğe bağlı)             │
│  ┌───────────────────────────┐  │
│  └───────────────────────────┘  │
│                                 │
│  Bugün ▾                        │   tarih, varsayılan bugün
└─────────────────────────────────┘
         MainButton: "Kaydet"
```

- Sayısal giriş: `inputMode="decimal"`, kendi tuş takımını **çizme**, sistem klavyesi kullan
- Tutar girildikçe canlı biçimlenir (`1240` → `12,40`)
- Kategori seçimi haptic `selectionChanged` tetikler
- `MainButton` tutar `> 0` olana kadar `disable()` durumunda
- Kaydetme sırasında `MainButton.showProgress()`

**C. Kayıt detayı / düzenleme**

Ekleme panelinin aynısı, dolu alanlarla. `BackButton` görünür. Altta metin bağlantısı olarak "Sil".

**D. Özet (`/ozet`)**

- Ay seçici
- Kategori listesi: tutar, adet, pay yüzdesi, geçen aya göre fark
- Günlük çubuk serisi (ayın günleri, boş günler dahil)
- En yüksek 3 harcama
- Altta: "Excel olarak indir" → tarih aralığı seçimi → `POST /api/export`

**E. Boş durumlar**

Boş ekran yönlendirmedir, dekor değil:

| Durum | Metin |
|---|---|
| Hiç kayıt yok | "Henüz kayıt yok. İlk harcamanı ekle, ay sonunda paranın nereye gittiğini gör." + MainButton |
| Bu ayda kayıt yok | "Ağustos'ta kayıt yok." + "Bu ay harcama ekle" |
| Ağ hatası | "Bağlanılamadı. Tekrar dene." + tekrar butonu |

Hata metinleri özür dilemez, ne olduğunu ve ne yapılacağını söyler.

### 9.5 Telegram bileşenleri

Kendi buton/geri tuşunu **çizme**, Telegram'ınkileri kullan:

```ts
// Kaydet
tg.MainButton.setText('Kaydet');
tg.MainButton.show();
tg.MainButton.onClick(handleSave);
tg.MainButton.disable();     // tutar boşken

// Geri
tg.BackButton.show();
tg.BackButton.onClick(() => navigate(-1));

// Silme onayı — kendi modalını yazma
tg.showConfirm('Bu harcama silinsin mi?', (ok) => ok && remove());

// Haptic
tg.HapticFeedback.selectionChanged();              // kategori seçimi
tg.HapticFeedback.impactOccurred('light');         // tutar tuşu
tg.HapticFeedback.notificationOccurred('success'); // kayıt başarılı
```

Bileşen kaldırılırken `offClick` ile dinleyiciyi temizle — yoksa eski handler'lar birikir ve ikinci kayıt iki kez oluşur. Bu, mini app'lerde en sık görülen hata.

### 9.6 Veri katmanı

```ts
const api = async (path: string, init?: RequestInit) => {
  const res = await fetch(`/api${path}`, {
    ...init,
    headers: {
      'Content-Type': 'application/json',
      'X-Telegram-Init-Data': window.Telegram.WebApp.initData,
      ...init?.headers,
    },
  });
  if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => ({})));
  return res.json();
};
```

- Harcama ekleme **optimistic** yapılır — kullanıcı ağ beklemez, hata olursa geri alınır ve uyarı gösterilir
- `staleTime` 30 sn, `refetchOnWindowFocus` açık (kullanıcı bottan dönünce güncellensin)

---

## 10. Deploy

### 10.1 Build

```bash
pnpm install --frozen-lockfile
pnpm --filter @kurus/web build          # → apps/web/dist
pnpm --filter @kurus/api build          # → apps/api/dist
pnpm --filter @kurus/api db:migrate     # drizzle-kit migrate
```

### 10.2 GitHub Actions

`.github/workflows/deploy.yml` — `main`'e push'ta:

1. `pnpm install`, `lint`, `typecheck`, `test`
2. Her iki paketi build et
3. SSH ile sunucuya `rsync`:
   - `apps/web/dist/` → `/var/www/kurus/`
   - `apps/api/dist/` + `node_modules` → `/home/kurus/kurus/apps/api/`
4. `pnpm db:migrate`
5. `sudo systemctl restart kurus-api`
6. `curl -f https://kurus.app/api/health` — başarısızsa iş fail

Sırlar GitHub Secrets'ta: `SSH_KEY`, `SSH_HOST`, `SSH_USER`.

### 10.3 BotFather son ayarları

```
/setmenubutton   → https://kurus.app
/setdescription  → Harcamalarını kaydet, paranın nereye gittiğini kuruşu
                   kuruşuna gör. Raporunu Excel olarak indir.
/setabouttext    → Kuruşu kuruşuna harcama takibi.
/setuserpic      → logo (512×512 PNG)
```

Mini App kaydı: BotFather → `Mini Apps` → `Create new app` → kısa ad, açıklama, 640×360 görsel, URL `https://kurus.app`.

### 10.4 Yedekleme

```bash
# /etc/cron.daily/kurus-backup
#!/bin/bash
set -e
DATE=$(date +%F)
sudo -u postgres pg_dump kurus | gzip > /var/backups/kurus-$DATE.sql.gz
find /var/backups -name 'kurus-*.sql.gz' -mtime +14 -delete
```

Haftada bir yedeği off-site kopyala (S3/R2). Yedeği **geri yükleyerek test et** — test edilmemiş yedek yedek değildir.

---

## 11. Güvenlik ve gizlilik kontrol listesi

- [ ] `initData` HMAC doğrulaması her `/api/*` isteğinde çalışıyor
- [ ] `auth_date` tazelik kontrolü aktif (24 saat)
- [ ] Hiçbir uçta istemciden `user_id` alınmıyor
- [ ] Her sorguda `WHERE user_id = req.user.id` var
- [ ] Webhook hem gizli yol hem `secret_token` başlığı ile korunuyor
- [ ] `BOT_TOKEN` yalnızca `.env`'de, repoda ve log'da geçmiyor
- [ ] Log'lara tutar/not içeriği **yazılmıyor** (kişisel finansal veri)
- [ ] TLS zorunlu, HTTP → HTTPS yönlendirmesi var
- [ ] `X-Frame-Options` yok, `frame-ancestors` CSP var
- [ ] Rate limit aktif
- [ ] Tüm girdiler Zod ile doğrulanıyor, SQL parametreli
- [ ] Postgres dışarı kapalı, `ufw` yalnızca 22/80/443
- [ ] `/privacy` sayfası yayında: hangi veri tutuluyor, ne kadar süre, nasıl silinir
- [ ] Hesap silme yolu var (`/sil` komutu → onay → tüm veriler kalıcı silinir)

---

## 12. Test kontrol listesi

**Fonksiyonel**

- [ ] iOS Telegram, Android Telegram, Telegram Desktop, Telegram Web — dördünde de açılıyor
- [ ] Açık ve koyu temada tüm ekranlar okunabilir
- [ ] Cihaz temasını uygulama açıkken değiştir → anında uyum sağlıyor
- [ ] Türkçe karakterler: kategori adı "Yeme & İçme", not alanına "şığüçöİ" yaz, listede ve Excel'de doğru görünüyor
- [ ] Tutar `0,01` ve `99.999.999,99` sınırlarında doğru çalışıyor
- [ ] Aynı kaydet butonuna hızlıca iki kez bas → tek kayıt oluşuyor
- [ ] Uçak modunda kayıt ekle → hata mesajı çıkıyor, optimistic satır geri alınıyor
- [ ] Listeyi yukarı kaydır → uygulama kapanmıyor
- [ ] Excel dosyası açılıyor, tarih formatı `dd.mm.yyyy`, toplam formülü çalışıyor
- [ ] Bota `120 market` yaz → kayıt oluşuyor, kategori doğru atanıyor
- [ ] Ay gezgini geçmiş aylara gidiyor, veri olmayan ay boş durum gösteriyor

**Güvenlik**

- [ ] `X-Telegram-Init-Data` başlığı olmadan istek → 401
- [ ] Bozuk hash ile istek → 401
- [ ] 25 saat önceki `auth_date` ile istek → 401
- [ ] Başka kullanıcının `expense.id`'siyle `PATCH`/`DELETE` → 404

**Performans**

- [ ] 3G'de ilk açılış < 2 sn (bundle < 200 KB gzip)
- [ ] 2.000 kayıtlı hesapta liste kaydırma takılmıyor
- [ ] `/api/summary` 5.000 kayıtta < 200 ms

---

## 13. Yol haritası (v1 sonrası)

1. **Bütçe limiti** — kategori bazlı aylık limit, %80'de bot uyarısı
2. **Tekrarlayan harcamalar** — abonelikler otomatik işlensin
3. **Fotoğraftan fiş okuma** — kullanıcı fişi bota gönderir, tutar ve tarih çıkarılır
4. **Çoklu para birimi** — Dubai/Türkiye arasında yaşayanlar için AED/USD, günlük kur snapshot'ı kayıt anında saklanır
5. **Widget** — Telegram ana ekran kısayolu ile tek dokunuşla ekleme

---

## Ek: İlk gün yapılacaklar sırası

1. Domain al, DNS'i yönlendir
2. VPS aç, sertleştir, Node + Postgres kur
3. Repo iskeletini kur (`pnpm` workspace, TS config, lint)
4. Şemayı yaz, migration çalıştır, kategorileri seed'le
5. `verifyInitData` fonksiyonunu yaz ve **birim testini geç** — bu adım tamamlanmadan API yazılmaz
6. `/api/health` + `/api/me` uçlarını ayağa kaldır
7. Nginx + certbot, `https://kurus.app/api/health` yeşil
8. Frontend iskeleti, tema bağlama, boş ana ekran → Telegram'da aç, görüntüyü doğrula
9. Ekleme paneli + `POST /api/expenses` → uçtan uca ilk kayıt
10. Liste, özet, Excel, bot komutları — bu sırayla
