This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Splitogram — Telegram Mini App for splitting group expenses with on-chain USDT settlement on TON blockchain. Splitwise meets Telegram Wallet.
# Install
bun install
# Dev (backend + frontend in parallel)
bun run dev
# Backend only (Cloudflare Worker via wrangler)
bun run dev:backend # starts on localhost:8787
# Frontend only (Vite dev server, proxies /api to :8787)
bun run dev:frontend # starts on localhost:5173
# Database
bun run db:generate # generate migration from schema changes
bun run db:migrate:local # apply migrations to local D1
# Test
bun run test # run all tests (backend + frontend)
bun run test:backend # backend tests only
bun run test:frontend # frontend tests only
# Single test file:
cd backend && bunx vitest run tests/debt-solver.test.ts
# Lint, typecheck, format
bun run typecheck # typecheck backend + frontend
bun run lint # placeholder (echo 'lint ok') — no ESLint yet
bun run format # prettier --write .
bun run format:check # prettier --check . (CI-friendly)
bun run check # typecheck + lint + test (all at once)
# Tunnel & webhook (for bot testing)
bun run tunnel:start # ngrok tunnel on :5173 (Vite, proxies API+webhook to :8787)
bun run tunnel:stop # stop ngrok
bun run webhook:set # set bot webhook to current tunnel URL
bun run webhook:status # check webhook config
bun run webhook:clear # remove webhook (back to long polling)
# Deploy (CI does this, but manual if needed)
bun run deploy # deploy worker to CloudflareFull local setup: backend + frontend + bot webhook via ngrok tunnel.
- Bun, ngrok, jq installed
.envfile withTELEGRAM_BOT_TOKEN,VITE_TELEGRAM_BOT_USERNAME.dev.varsfile with secrets for wrangler (TELEGRAM_BOT_TOKEN, DEV_AUTH_BYPASS_ENABLED, TONAPI_KEY, USDT_MASTER_ADDRESS, PAGES_URL, ADMIN_TELEGRAM_ID, ADMIN_SECRET)
bun install
bun run db:generate # if schema changed
bun run db:migrate:local # apply migrations to local D1
# Terminal 1: backend
bun run dev:backend # wrangler dev on :8787
# Terminal 2: frontend
bun run dev:frontend # vite dev on :5173
# Terminal 3: tunnel + webhook
bun run tunnel:start # ngrok → :5173 (Vite proxies /api, /webhook to :8787)
bun run webhook:set # point bot webhook to tunnel- Tunnel points to Vite (5173), not wrangler (8787). Vite proxies
/api/*,/webhook,/r2, and/adminto the backend. This way one tunnel serves both the Mini App frontend and the bot webhook. .dev.varsPAGES_URL must match the current ngrok URL (changes every restart on free tier). Update it and restart wrangler when tunnel URL changes.DEV_AUTH_BYPASS_ENABLED=truein.dev.varsskips TG initData validation, auto-creates a mock "DEV Developer" user.- Frontend build (
bun run build:frontend) is NOT required for local dev — Vite serves hot-reloaded source. Only needed if testing wrangler pages serving. - Stopping:
bun run stopkills wrangler+vite.bun run tunnel:stopkills ngrok.
Cloudflare Pages Cloudflare Worker Cloudflare D1 (SQLite)
(frontend static) → (API + bot webhook) → Cloudflare R2 (image storage)
React + Vite + Tailwind Hono + grammY + Drizzle TONAPI (external REST)
TON Blockchain
SplitogramSettlement (Tact) → USDT settlement with commission split
- Backend runs as a single Cloudflare Worker handling both API routes and Telegram bot webhook
- Frontend is a static React app on Cloudflare Pages
- Database is Cloudflare D1 (SQLite) accessed via Drizzle ORM
- Auth is stateless HMAC verification of Telegram
initDataper request — no sessions, no KV. Auth middleware storesuserId(internal DB PK) inSessionDataso route handlers never need a redundant D1 user lookup. - Frontend UI is plain React + Tailwind + react-i18next (no component library — decided Phase 3). Theming via Telegram CSS variables mapped to
tg-*Tailwind tokens. - Image storage is Cloudflare R2, served via Worker at
/r2/*with Cloudflare Cache API edge caching + immutable browser caching. Client-side resize/compress via Canvas API (zero deps). One bucket withavatars/,groups/,receipts/prefixes. - TON verification via TONAPI REST API (plain
fetch, no SDK on backend) - Smart contract is a Tact contract (
contracts/splitogram-contract/) deployed on TON mainnet. Receives USDT, takes 1% commission (min 0.1, max 1.0 USDT), forwards remainder to recipient. Built with Blueprint SDK, tested with@ton/sandbox. Mainnet contract:EQBVVph-sYX2BI165SLXHdqluawmjXx5RWZZymeGvQ5hTDgq.
Root package.json defines workspaces: ["backend", "frontend", "packages/*"]. A single bun install at root installs all. Root scripts delegate to workspace scripts (e.g., bun run test:backend → cd backend && bun run test). Shared code lives in packages/shared/ (@splitogram/shared).
packages/shared/src/
├── currencies.ts # 150+ currency configs (canonical source)
├── commission.ts # calculateCommission (mirrors smart contract: 1% clamped [0.1, 1.0] USDT)
├── format.ts # formatAmount, formatSignedAmount (canonical source)
└── index.ts # Barrel re-export
backend/src/
├── index.ts # Hono app entry, routes, middleware, error handler
├── webhook.ts # grammY bot (module-level cached): /start, /stats, deep links, report moderation callbacks
├── env.ts # Env bindings (D1, R2, secrets) + SessionData type
├── api/ # Route handlers (auth, users, groups/*, expenses, comments, balances, settlements, activity, stats, r2, admin, reports)
│ ├── comments.ts # Expense comments CRUD (nested under expenses)
│ └── groups/ # Split into core.ts (CRUD/avatar), membership.ts (join/leave/kick/reminders), placeholders.ts, export.ts
├── middleware/ # auth (initData HMAC validation), db (Drizzle injection)
├── services/ # telegram-auth, notifications, debt-solver, activity, moderation, exchange-rates, tonapi
├── utils/ # currencies, format, commission, notify-ctx, r2 (key gen, safe delete, receipt upload), auth-guards (membership checks, param parsing)
├── db/
│ ├── index.ts # Drizzle factory for D1
│ └── schema.ts # All table definitions
├── dev/ # Dev-only utilities (mock user for auth bypass)
└── models/ # Zod request/response schemas
frontend/src/
├── App.tsx # TG SDK init + AppLayout router + deep link handling
├── i18n.ts # react-i18next config, language detection, CloudStorage persistence
├── locales/ # Translation JSON files (en, ru, es, hi, id, fa, pt, uk, de, it, vi)
├── services/api.ts # Fetch wrapper with initData auth header
├── pages/ # Home, Group, GroupSettings, AddExpense, ExpenseDetail, SettleUp, Activity, Account
├── icons/ # SVG icon components (IconUsers, IconActivity, IconUser, IconCopy, IconCrown, IconCheck, IconTon, IconStats, IconExternalLink, IconChevron, IconUserPlus, IconSettings, IconInfo)
├── contexts/ # UserContext (avatar/name/isAdmin state for BottomTabs + Account)
├── utils/ # currencies, format, commission, time, share, transactions, image, activity (getActivityText), ton (buildSettlementBody, formatUsdtAmount, formatTonAmount)
├── components/ # PageLayout, LoadingScreen, ErrorBanner, SuccessBanner, BottomSheet, AppLayout, BottomTabs, CurrencyPicker, Avatar, DonutChart, MonthSelector
└── hooks/ # useAuth, useCurrentUser, useSettlement, useTelegramBackButton, useTelegramMainButton
contracts/splitogram-contract/ # Separate npm project (not Bun workspace)
├── contracts/SplitogramSettlement.tact # Settlement contract (Tact)
├── tests/SplitogramSettlement.spec.ts # 16 sandbox tests
├── scripts/ # Deploy + test scripts (Blueprint)
├── wrappers/ # Auto-generated TS wrappers
└── build/ # Compiled contract artifacts
The backend uses Hono's typed context pattern to pass data through middleware. This is a key pattern to understand:
// middleware/auth.ts — sets session on context
export type AuthContext = { Bindings: Env; Variables: { session: SessionData } };
// SessionData: { telegramId, userId, username?, displayName }
// middleware/db.ts — sets Drizzle instance on context
export type DBContext = { Bindings: Env; Variables: { db: Database } };
// Route handlers use intersection types to require both:
type GroupEnv = AuthContext & DBContext;
const app = new Hono<GroupEnv>();
app.get('/', (c) => {
const db = c.get('db'); // from dbMiddleware
const session = c.get('session'); // from authMiddleware
const userId = session.userId; // internal DB PK — no extra D1 lookup needed
});Middleware registration order matters — dbMiddleware is global (all routes), authMiddleware is applied per-prefix before app.route():
app.use('/api/v1/groups/*', authMiddleware); // must come before .route()
app.route('/api/v1/groups', groupsApp);- Mini App loads → calls
POST /api/v1/authto upsert user (register/update profile in D1) - Every API request sends
Authorization: tma <initData>header - Auth middleware validates HMAC-SHA256 signature, looks up user in D1, sets session context
- Bot webhook at
POST /webhook— grammY handles /start commands, deep links, and report moderation callback queries
Bot sends links with start_param. Frontend reads window.Telegram.WebApp.initDataUnsafe.start_param in App.tsx and routes to the matching page after auth completes. Patterns:
group_{id}→ navigate to group pagejoin_{inviteCode}→ auto-resolve invite, join group, navigate to groupjp_{inviteCode}_{placeholderId}→ join group + auto-claim placeholder (personalized invite)settle_{id}→ navigate to settlement pageexpense_{id}→ navigate to home (no standalone page yet)
- users: telegram_id, username, display_name, wallet_address, bot_started, avatar_key, is_dummy
- groups: name, invite_code, is_pair, currency (default 'USD'), created_by, avatar_key, avatar_emoji, deleted_at (soft-delete)
- group_members: group_id, user_id, role (admin/member), muted, net_balance (cached, updated on mutations)
- expenses: group_id, paid_by, amount (micro-units integer), description, receipt_key, receipt_thumb_key
- expense_participants: expense_id, user_id, share_amount
- settlements: group_id, from_user, to_user, amount, status (open/payment_pending/settled_onchain/settled_external), tx_hash, usdt_amount, commission, comment, settled_by
- activity_log: group_id, actor_id, type (expense_created/edited/deleted, settlement_completed, member_joined/left/kicked, placeholder_claimed, member_deleted), target_user_id, expense_id, settlement_id, amount, metadata (JSON), created_at
- debt_reminders: group_id, from_user_id (creditor), to_user_id (debtor), last_sent_at (24h cooldown)
- expense_comments: expense_id, user_id, text (nullable), image_key (nullable), image_thumb_key (nullable), created_at. FK cascade on expense delete.
- image_reports: reporter_telegram_id, image_key, reason, details, status (pending/rejected/removed)
Amounts stored as integers in micro-units (1 unit = 1,000,000). Currency is per-group. No floating point.
- User taps "Settle up" on a balance →
POST /api/v1/groups/:id/settlementscreates settlement from debt graph - Frontend navigates to
/settle/:idshowing settlement details - Either party (debtor or creditor) taps "Mark as Settled" with optional comment
POST /api/v1/settlements/:id/settle→ status →settled_external, records who settled and comment- Both parties get bot notification (if not muted and bot started)
- Debtor taps "Pay with USDT" →
GET /api/v1/settlements/:id/tx?senderAddress=...runs preflight:- Looks up sender's USDT Jetton Wallet via TONAPI
- Checks USDT balance ≥ debt + commission
- Estimates gas via TONAPI emulate (builds external message BOC, emulates full trace, sums fees) — falls back to empirical if emulate fails
- Checks TON balance ≥ gas attach
- Detects wallet version (V4R2/W5) and returns it in response
- Returns
SettlementTxParams(amounts, addresses, gas values,walletVersion)
- Frontend shows confirm screen with line-item breakdown (recipient, commission, total in USDT, gas in TON)
- User confirms → frontend builds Jetton transfer BOC (
frontend/src/utils/ton.ts) → sends via TON Connect - Backend transitions settlement to
payment_pendingviaPOST /api/v1/settlements/:id/verify - Frontend polls
POST /api/v1/settlements/:id/confirmevery 3s for up to 2 minutes - Backend verifies the transfer on-chain via TONAPI events. Three outcomes:
- Confirmed: matching JettonTransfer found →
settled_onchain - Failed/bounced: failed JettonTransfer detected → immediate rollback to
open - Timeout: no matching event after 10 min → rollback to
open
- Confirmed: matching JettonTransfer found →
- Group page shows
payment_pendingsettlements with yellow card + spinner. Auto-checks on page load. Tapping navigates to SettleUp page which resumes polling.
Gas is estimated via TONAPI trace emulation. The preflight endpoint builds the full external message (wallet → jetton wallet → contract → recipients), emulates it with ignore_signature_check=true, and sums total_fees from the trace. Supports V4R2 and V5R1 wallets.
estimateSettlementGas()
1. Fetch wallet seqno (+ wallet_id for V5 via get_subwallet_id) in parallel
2. Build jetton transfer body (same as frontend's buildSettlementBody)
3. Wrap in wallet-version-specific external message:
- V4R2: zero sig + subwallet + valid_until + seqno + op + mode + ^msg
- V5R1: prefix + wallet_id + valid_until + seqno + Maybe(^C5 OutList) + 0 + zero sig
4. POST /v2/traces/emulate?ignore_signature_check=true
5. Sum total_fees from all 11 transactions in the trace (~0.035 TON)
→ returns nanoTON or null on failure
gasAttach = FORWARD_TON (0.3 TON) + emulated_fees + 15% buffer
(if emulate fails: FORWARD_TON + EMPIRICAL_JETTON_CHAIN (0.04) + [WALLET_DEPLOY (0.01)] + 20% buffer)
Uninit wallets (never sent a tx): detected via status === 'uninit' from TONAPI account info. Emulation impossible (no seqno/interfaces), so empirical fallback adds WALLET_DEPLOY_GAS (0.01 TON) surcharge. Frontend shows "wallet activation fee" note on confirm screen. The walletUninit flag is returned in the preflight response.
Older wallets (V3 and below): emulation not supported, empirical fallback used. Transaction still works (TON Connect handles wallet-specific wrapping). No need to block older wallets.
Troubleshooting out-of-gas:
- If emulate is working: it catches network gas changes automatically. If still failing, increase
contingencyPctinsettlements.ts. - If emulate returns null (uninit, unknown wallet, TONAPI down): empirical fallback is used. Check a failed tx on Tonviewer → compute gas burn (attached - excess) → update
EMPIRICAL_JETTON_CHAINif too low. - Emulate diagnostics: wallet not V4/V5 →
interfacesempty or other version; TONAPI unavailable → checkTONAPI_KEY; W5 message format outdated → checktonkeeper/w5repo.
| Cause | Handled by emulate? | Action if not |
|---|---|---|
Validators raise gas_price (Config 21/25) |
Yes, automatically | Increase EMPIRICAL_JETTON_CHAIN |
| Forward fee increase from network load | Yes, automatically | Increase EMPIRICAL_JETTON_CHAIN |
| Contract structure change (new version) | Yes, if deployed | Re-measure on testnet |
| New wallet version (V6+) | No | Add buildV6Body() in tonapi.ts |
| Adding third recipient to contract | Yes, automatically | FORWARD_TON += 150_000_000 |
Re-measuring fallback constants: do a test settlement on testnet, find the tx on Tonviewer, compute gas_burn = attached - sum(excess), update EMPIRICAL_JETTON_CHAIN = ceil(gas_burn / 10_000_000) * 10_000_000.
Debug scripts: cd backend && bun run tests/_debug_emulate_v4.ts (V4R2) and tests/_debug_emulate_v5.ts (V5R1). Import estimateSettlementGas from project codebase, verify emulation works with testnet wallets.
Excess TON is always refunded — response_destination points to sender's wallet. Overpaying gas is safe (user gets it back), underpaying causes tx failure.
Cloudflare Workers terminate after the response is sent. To run fire-and-forget work (e.g., sending Telegram notifications), use c.executionCtx.waitUntil(promise) — this keeps the worker alive until the promise resolves without blocking the response. All notification sends use this pattern.
- All API routes under
/api/v1prefix - Error responses:
{ error: "machine_code", detail: "human message" } - Zod validation on all endpoint inputs via
@hono/zod-validator— access validated data viac.req.valid('json') - Explicit timeout on every external I/O call (
AbortSignal.timeout()) - Bot notifications are fire-and-forget via
waitUntil()with 1 bounded retry — never block the API response - Bot 403 handling: catch
GrammyError403, setbotStarted = false, skip non-started users viacanNotify() - Per-group mute:
group_members.mutedflag, muted users skip expense notifications - Health endpoint
GET /api/healthexcluded from auth - Settlements created on demand when user taps "Settle up" (not pre-created)
- Settlement logging — structured
console.logwith[settlement:*]prefix on both backend (preflight,verify,confirm,confirmed) and frontend (preflight,sending,sent,poll,confirmed,rolled-back). Logs wallet version, amounts, fees, addresses, timing. - Pending settlement visibility —
payment_pendingsettlements included in group settlement list. Group page shows yellow card with spinner, auto-checks on load. SettleUp page auto-resumes polling on revisit. - Wallet version display — Account page fetches wallet version from TONAPI, shows V4R2/W5/V3 badge next to address. Preflight response includes
walletVersionfield. - Group soft-delete —
groups.deleted_atset on deletion. Expenses, participants, activity_log, debt_reminders, group_members, and non-onchain settlements are hard-deleted. On-chain settlements (settled_onchain) and the group row are retained for commission accounting and Tonviewer audit trail. GDPR-compliant: on-chain txs are public blockchain records (legal basis: legitimate interest). Invite/join endpoints filterdeleted_at IS NULL. User-facing group list filters viagroup_membersjoin (members removed on delete). - Shared currency utilities in
@splitogram/shared(packages/shared/src/currencies.ts+format.ts). Backend/frontendutils/re-export from shared package. - Dev auth bypass via
DEV_AUTH_BYPASS_ENABLEDenv var (skips TG initData validation, auto-creates mock user frombackend/src/dev/mock-user.ts) - Theming — Telegram
--tg-theme-*CSS vars mapped to Tailwindtg-*tokens (e.g.,bg-tg-bg,text-tg-hint,bg-tg-button). Nodark:prefixes — CSS vars handle both modes. Fallback values inindex.cssfor dev outside Telegram. Semantic colors (positive/negative/warning) use--app-*CSS vars with light/dark variants, mapped to Tailwindapp-*tokens (e.g.,text-app-positive,bg-app-negative-bg).data-themeattribute set fromwebApp.colorScheme. - i18n —
react-i18nextwith 11 JSON locale files (src/locales/{en,ru,es,hi,id,fa,pt,uk,de,it,vi}.json). All UI strings uset('key'). Plurals viat('key', { count })(CLDR rules: one/few/many for ru/uk, other-only for id/vi). Locale resolved server-side from TGlanguage_codeduring auth (prefix matching, e.g.pt-BR→pt, fallbacken), returned in auth response, applied on frontend unless user has a persisted CloudStorage preference. Selectable on Account page via BottomSheet picker with flags. - First-login language picker — Auth response includes
isNewUser: boolean(true only when DB row is first created). On first login,LanguagePickerModal(centered modal, 11 languages + flags) is shown with TG language pre-selected. Titles are hardcoded per-language (not i18n — shown before language is chosen). Selection persisted to CloudStorage. One-time server-side: subsequent logins from any device returnisNewUser: false. After first login, language is changeable in Account settings. - Accessibility — All icon-only buttons (dismiss ×, navigation arrows, emoji-only buttons like 📎) and hidden file inputs have
aria-labelattributes. Uses existing i18n keys where available, hardcoded English for screen-reader-only labels. Images havealtattributes. - Feedback —
POST /api/v1/users/feedbackaccepts multipart FormData (message + up to 5 attachments). Text sent as bot DM, attachments forwarded as photos/documents. Fire-and-forget viawaitUntil(). - Content moderation —
POST /api/v1/reportsstores report inimage_reportstable and sends reported image as photo to admin with inline keyboard (Reject/Remove). Botcallback_query:datahandler in webhook.ts looks up report by ID from DB (callback_data:rj|{id}/rm|{id}— avoids Telegram's 64-byte limit). Reject/Remove update report status, notify reporter, edit caption. Image removal viaremoveImage()inservices/moderation.ts. - Legal pages — Privacy Policy (
/privacy) and Terms of Service (/terms) served as Worker HTML routes. Source of truth isdocs/privacy-policy.mdanddocs/terms-of-service.md— imported as raw text via wranglerTextrule, converted to HTML withmarkedat module init. Public, no auth. Opened from Account page viaWebApp.openLink(). Vite proxy includes/privacyand/termsfor local dev. - Admin dashboard — Plain HTML at
/admin, served from the Worker (no React). Protected byhono/basic-authwithADMIN_SECRETenv var (needed because the page opens in an external browser where TGinitDatais unavailable). Shows metrics (users, groups, expenses, settlements, active groups, on-chain volume/fees), paginated groups table (with soft-deleted groups toggle), on-chain transactions table with Tonviewer links, group detail with members (placeholder badge)/expenses/settlements/images, and image delete. Testnet badge shown whenTON_NETWORK !== 'mainnet'. Bot/statscommand (admin TG ID only) provides quick metrics via DM. Frontend link usesconfig.apiBaseUrl(Worker URL) as base — notwindow.location.origin(Pages domain) — so the browser hits the Worker directly. Vite proxy includes/adminfor local dev. isAdminflag — Auth response includesisAdmin: boolean(comparestelegramIdtoADMIN_TELEGRAM_ID). Propagated throughuseAuth→UserContext→ Account page, which shows an "Admin Dashboard" link that opens/adminin external browser viaWebApp.openLink().- Placeholder members — "dummy" users for people not on the app.
users.is_dummy = truewith negativetelegramId(real TG IDs are always positive, keeps unique constraint). Admin-only creation viaPOST /groups/:id/placeholdersin GroupSettings. Placeholders participate fully in expenses and manual settlements (no on-chain). Shown with 👤 badge in member lists, payer dropdown, participant chips. Admin can rename (PUT) or delete (DELETE, zero balance required). Real users can claim a placeholder viaPOST /groups/:id/claim-placeholder— merges all FK references (expenses.paid*by, expense_participants, settlements, activity_log) from dummy to real user, deletes dummy. Claim UI: banner on Group page ("Are you one of these people?") with balance preview before confirming. One claim per user per group — enforced viaplaceholder_claimedactivity_log event check. Claim eligibility — only placeholders that existed when the user joined are claimable (comparesgroupMembers.joinedAttimestamps); backend enforcesplaceholder_created_after_joinerror. Placeholder debt cards show "Invite" button (personalized invite link withjp*{inviteCode}\_{placeholderId}deep link — auto-claims on join) instead of "Send Reminder". "Invite" button also shown on balance cards with no debt relationship. GroupSettings "Invite" shares personalized link (not generic). Bot message for personalized invites mentions placeholder name. - User deletion & dummy reclaim — When a user deletes their account: (1) dummy created with
(originalName)display name (brackets), NO avatar (R2 avatar deleted), NO wallet; (2)member_deletedactivity logged in each group; (3) dummy added to all groups with FK transfer. When deleted user re-opens the app, they start completely fresh (no auto-rejoin to old groups). Reclaim only happens per-group when user joins via invite link —reclaimDeletionDummy()inservices/deletion-reclaim.tsdoes per-group FK transfer (same pattern as claim-placeholder), deletes dummy when no memberships remain. - Versioning — Git commit hash injected at build time via Vite
define(__APP_VERSION__). Displayed as subtlev{hash}footer on Account page. - Security headers — HSTS on both Pages (
frontend/public/_headers) and Worker (Hono middleware inindex.ts). CSP on Pages only (Worker serves JSON, not HTML). CSP allows: scripts fromtelegram.org,tganalytics.xyz,umami.dksg.qzz.io; connections/images from both custom domains (splitogram.dksg.qzz.io,splitogram-worker.dksg.qzz.io) and CF native URLs (splitogram.pages.dev,*.workers.dev), plus TONAPI;frame-ancestorsrestricted toweb.telegram.org+t.me(Mini App iframe embedding only). - Usertour —
usertour.jsSDK for user onboarding flows (tooltips, checklists, tours). Initialized client-side inApp.tsxafter auth + profile fetch. Environment token viaVITE_USERTOUR_TOKEN. Users identified by internal DB ID withtelegram_id,username, syntheticemail(id{telegramId}@t.me), andsigned_up_at(new users only) as attributes. Tours/flows configured in Usertour dashboard. - Auth guards —
backend/src/utils/auth-guards.tsprovidesgetMembership(db, groupId, userId),getMembershipRole(db, groupId, userId),notMemberResponse(c),parseIntParam(c, name), andinvalidIdResponse(c). All route handlers use these instead of inline membership checks and parseInt/isNaN guards. - Receipt upload —
uploadReceiptPair(bucket, entityId, body)inutils/r2.tshandles receipt + optional thumbnail upload with correctcontentTypefrom the file. Used by both expense and settlement receipt endpoints. - SVG icons — All reusable icons live in
frontend/src/icons/as components (Lucide-style,currentColor, configurablesize/className). Never inline SVGs in pages — extract to the icon system. - useSettlement hook —
frontend/src/hooks/useSettlement.tsencapsulates settlement fetch + amount parsing. Shared bySettleManualandSettleCrypto. - getActivityText — Lives in
frontend/src/utils/activity.ts(not in a page component). Imported by bothActivity.tsxandGroup.tsx. - USDT/TON formatting —
formatUsdtAmount,formatUsdtCommission,formatTonAmountlive infrontend/src/utils/ton.ts. Shared by both settle pages. - Expense comments — Flat chat threads per expense.
expense_commentstable (migration 0017) with cascade delete on expense FK. Backend API at/api/v1/groups/:id/expenses/:expenseId/comments(GET list, POST create, DELETE). Comments can have text and/or image attachment (R2comments/prefix). Frontend:ExpenseDetailpage (/groups/:id/expense/:expenseId) with chat-style UI, input bar, image upload. Expense list returnscommentCountper expense. Group.tsx shows comment count badge on cards and "Comments (N)" link in expense BottomSheet. Bot notificationnotify.commentAdded()to payer + participants. i18n:comments.*keys in all 11 locales.
Prettier config (.prettierrc): single quotes, trailing commas, semicolons, 100 char width, 2-space indent. Run bun run format before committing.
Push to main triggers .github/workflows/deploy-pipeline.yml which orchestrates 4 steps in sequence:
- Build & Test — lint + typecheck + tests (backend & frontend in parallel)
- Deploy Worker — D1 migrations → secrets →
wrangler deploy→ health check - Deploy Pages — build frontend → deploy to Cloudflare Pages
- Setup Webhook — configure Telegram bot webhook to worker URL
docs/architecture.md— stack, architecture, key engineering principlesdocs/idea.md— business overview and competitive landscapedocs/smart-contract.md— TON settlement contract manualdocs/envs.md— TON environment variables and network switching guidedocs/TWA-checklist.md— Telegram Mini App listing requirementswork_docs/CODE_REVIEW.md— code review findings (3 critical + 7 major all fixed, 8 minor remaining)