Skip to content

Architecture

Overview

Zentr is a single Cloudflare Worker (Hono/TypeScript) acting as the backend for a Telegram bot and a future mobile app. Users interact via Telegram; their financial data lives in their own Google Sheet, inside their own Google Drive.

Hexagonal Architecture (Ports & Adapters)

┌─────────────────────────────────────────────────────┐
│  ports/          (primary ports — driving side)      │
│    http/         HTTP entry points (Hono handlers)   │
│    cron/         Scheduled job runners               │
├─────────────────────────────────────────────────────┤
│  application/    Use cases — orchestrate domain      │
│    transaction/  Add, list, settle, defer            │
│    digest/       Daily & weekly digests              │
│    snapshot/     Net-worth snapshots                 │
│    user/         Onboard, get                        │
├─────────────────────────────────────────────────────┤
│  infrastructure/ Adapters — implement ports          │
│    sheets/       Google Sheets API (data store)      │
│    persistence/  D1 (users) + KV (sessions)          │
│    ai/           Gemini analysis                     │
│    messaging/    Telegram send + parse               │
├─────────────────────────────────────────────────────┤
│  domain/         Pure business logic — no I/O        │
│    transaction/  Transaction entity + ports          │
│    user/         User entity + port                  │
│    digest/       Digest value objects                │
│    shared/       Money, DateRange                    │
└─────────────────────────────────────────────────────┘

Layer Rules

  • domain/ — zero external imports. No Hono, no KV, no fetch, no npm packages.
  • application/ — imports from domain/ only. Depends on repository interfaces (ports), not concrete adapters.
  • infrastructure/ — implements domain ports. All network/storage I/O lives here.
  • ports/ — wires use cases to the outside world. Thin layer; no business logic.

Request Flow (Webhook)

POST /webhook
  └─ WebhookHandler.handle()
       ├─ TelegramParser.extractChatId / extractText / extractCommand
       ├─ [pre-auth] OnboardUserUseCase / config reply
       └─ GetUserUseCase → User (with locale)
            ├─ SheetsAuth.getAccessToken (KV cache → refresh if expired)
            ├─ SheetsClient (Google Sheets API)
            ├─ SheetsTransactionRepository (implements TransactionRepository)
            └─ AddTransactionUseCase / ListOpenTransactionsUseCase / SettleTransactionUseCase
                 └─ TelegramSender.sendText (Telegram Bot API)

Cron Flow

Three cron expressions in wrangler.toml:

Cron Handler Purpose
0 8 * * * DailyDigestCron Yesterday's transactions + per-diem status
0 8 * * 1 WeeklySummaryCron Last week summary + Gemini AI analysis
0 7 * * * DeferredSettleCron Auto-settle deferred transactions whose date ≤ today

Each cron iterates all users from D1 and fans out with Promise.all.

OAuth Flow

/start command
  └─ SheetsAuth.buildOAuthUrl(chatId, redirectUri)
       └─ User visits Google OAuth consent screen
            └─ Google redirects to GET /auth/callback?code=…&state=chatId
                 └─ OAuthCallbackHandler
                      ├─ SheetsAuth.exchangeCode(code, redirectUri) → refreshToken
                      └─ KVSessionStore.set("pending_token:{chatId}", refreshToken, ttl=600s)
                           └─ User sends /config SHEET_ID
                                └─ OnboardUserUseCase: save {chatId, sheetId, refreshToken, locale} to D1

Cloudflare Bindings

Binding Type Purpose
KV KV Namespace Refresh token cache, pending OAuth sessions
DB D1 Database User records (chat_id, sheet_id, refresh_token, plan, locale)
TELEGRAM_TOKEN var Bot API token
GEMINI_KEY var Gemini API key
GEMINI_MODEL var Model name (default: gemini-2.5-flash)
GOOGLE_CLIENT_ID var OAuth client ID
GOOGLE_CLIENT_SECRET var OAuth client secret
TEMPLATE_SHEET_ID var ID of master template Sheet

Project Structure

backend/
├── src/
│   ├── domain/
│   │   ├── transaction/     Transaction entity, TransactionType, TransactionStatus, port
│   │   ├── user/            User entity, port
│   │   ├── digest/          DailyDigest, WeeklyDigest value objects
│   │   └── shared/          Money, DateRange
│   ├── application/
│   │   ├── transaction/     Add, ListOpen, Settle, SettleDeferred use cases
│   │   ├── digest/          SendDailyDigest, SendWeeklyDigest
│   │   ├── snapshot/        SaveSnapshot
│   │   └── user/            OnboardUser, GetUser
│   ├── infrastructure/
│   │   ├── sheets/          SheetsTransactionRepository, SheetsClient, SheetsAuth
│   │   ├── persistence/     D1UserRepository, KVSessionStore
│   │   ├── ai/              GeminiAnalysisService
│   │   └── messaging/       TelegramSender, TelegramParser
│   ├── ports/
│   │   ├── http/            Hono app, WebhookHandler, OAuthCallbackHandler
│   │   └── cron/            DailyDigestCron, WeeklySummaryCron, DeferredSettleCron
│   └── types.ts
├── migrations/
│   └── 001_users.sql
├── wrangler.toml
└── package.json

D1 Schema

CREATE TABLE users (
  chat_id TEXT PRIMARY KEY,
  sheet_id TEXT NOT NULL,
  refresh_token TEXT NOT NULL,
  plan TEXT DEFAULT 'free',
  created_at TEXT DEFAULT (datetime('now'))
);