# Social Habit Pledge Tracker — Technical Specification v1.1
## 0. One-Liner
A Telegram Mini App + bot that helps friends make playful "money-on-the-line" habit pacts. Users check in daily via WebApp; missed days add to a running ledger. Settlement happens off-platform.
---
## 1. Goals & Non-Goals
### Goals (MVP)
- **1:1 contracts**: One person pledges to pay the other on misses.
- **WebApp-first UX**: Primary interaction through Telegram Mini App.
- **Telegram bot as notifier**: Reminders, alerts, and quick-action buttons that deeplink to WebApp.
- **Ledger-only**: Track "owed" amounts; no real money movement.
- **Dispute flow**: Partner can challenge check-ins within a time window.
- **API-first architecture**: Clean separation enabling future standalone PWA.
### Non-Goals (MVP)
- Payment processing, escrow, KYC.
- Proof systems (photos, Strava integration).
- Group challenges (data model supports it; UI deferred).
- Mutual match pacts (users can create two one-way pacts manually).
---
## 2. Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Clients │
├──────────────────┬──────────────────┬───────────────────────────┤
│ Telegram Mini │ Telegram Bot │ Future: Standalone PWA │
│ App (React) │ (aiogram v3) │ │
│ ── Primary UI │ ── Notifier │ │
└────────┬─────────┴────────┬─────────┴───────────────────────────┘
│ │
│ HTTPS + JWT │ Webhook
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ Bot Webhook │ │ Background Jobs (arq) │ │
│ │ /api/v1/* │ │ /webhook/tg │ │ reminders, deadlines │ │
│ └──────────────┘ └──────────────┘ └────────────────────────┘ │
└────────────────────────────┬────────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Neon Postgres │ │ Redis │
│ (primary data) │ │ (job queue) │
└─────────────────┘ └─────────────────┘
```
### Design Principles
1. **API owns all state** — Telegram and WebApp are pure UI layers.
2. **Ledger is append-only** — Never mutate; add adjustment entries.
3. **Idempotent operations** — All check-ins and state transitions handle retries.
4. **Timezone-aware** — All date logic uses contract timezone.
---
## 3. Tech Stack
>> we don't need redis / caching. for queue purposes use postgres for now.
| Layer | Technology | Rationale |
|-------|------------|-----------|
| **Backend** | Python 3.12 + FastAPI | Async-native, excellent OpenAPI generation |
| **ORM** | SQLAlchemy 2.0 + Alembic | Type-safe, mature migration tooling |
| **Bot** | aiogram v3 | Modern async Telegram library |
| **Job Queue** | arq (Redis-backed) | Lightweight, async-native |
| **Database** | Neon Postgres | Serverless, branching for dev environments |
| **Cache/Queue** | Redis (Upstash or Railway) | For arq job queue |
| **WebApp** | Vite + React 18 + TypeScript | Fast builds, type safety |
| **Styling** | TailwindCSS + shadcn/ui | Utility-first, accessible components |
| **Mini App SDK** | @telegram-apps/sdk-react | Official Telegram integration |
| **Deployment** | Railway (monorepo) | Simple multi-service deployment |
| **Auth** | Telegram initData + optional JWT | Stateless validation or session tokens |
---
## 4. Core Concepts & Terminology
### Users & Roles
| Term | Definition |
|------|------------|
| **Initiator** | Creates the pact; commits to the habit |
| **Partner** | Accepts the pact; receives payment on initiator's misses; can dispute |
### Pact (Contract)
An agreement between two users with:
- Habit name and description
- Stake amount and currency
- Weekly schedule (which days)
- Daily deadline time
- Start and optional end date
- Dispute window duration
### Pact Day
A single scheduled day within a pact. States:
| Status | Description |
|--------|-------------|
| `PENDING` | Awaiting check-in; deadline not yet passed |
| `DONE` | Initiator checked in; dispute window may be active |
| `MISSED` | Deadline passed without check-in; forfeit applied |
| `DISPUTED` | Partner challenged the check-in |
| `SKIPPED` | Pre-deadline: day exempted (e.g., sick day) — no forfeit |
| `WAIVED` | Post-miss: partner forgave the miss — creates WAIVER ledger entry to reverse forfeit |
### Ledger Entry
Append-only record of balance changes:
| Type | Description |
|------|-------------|
| `FORFEIT` | Stake added to balance (initiator owes partner) |
| `SETTLEMENT` | Balance reduced (off-platform payment confirmed) |
| `ADJUSTMENT` | Manual correction (admin use) |
| `WAIVER` | Forfeit reversed (day was waived) |
### Balance
Derived value: `SUM(ledger_entries.amount)` where positive = initiator owes partner.
---
## 5. Data Model
### Entity Relationship Diagram
```
┌──────────┐ ┌───────────────────┐ ┌─────────────┐
│ users │──────<│ pact_participants │>──────│ pacts │
└──────────┘ └───────────────────┘ └─────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌─────────────────┐
│ pact_days │ │ ledger_entries│ │ settlements │
└─────────────┘ └───────────────┘ └─────────────────┘
```
### Table Definitions
#### `users`
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_user_id BIGINT UNIQUE NOT NULL,
username VARCHAR(255), -- @username, nullable
display_name VARCHAR(255) NOT NULL,
timezone VARCHAR(64) DEFAULT 'UTC', -- IANA timezone
notify_reminders BOOLEAN DEFAULT TRUE,
notify_disputes BOOLEAN DEFAULT TRUE,
reminder_minutes_before INT DEFAULT 60,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Auto-update updated_at on all tables that have it
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
#### `pacts`
```sql
CREATE TABLE pacts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL, -- "Gym Pact 🏋️"
description TEXT,
currency VARCHAR(3) DEFAULT 'GBP', -- ISO 4217
stake_amount INT NOT NULL, -- Minor units (pence)
schedule_days BOOLEAN[7] NOT NULL, -- [Mon, Tue, Wed, Thu, Fri, Sat, Sun] (ISO weekday order)
deadline_time TIME NOT NULL, -- Local time in pact timezone
timezone VARCHAR(64) NOT NULL, -- IANA; set from initiator
start_date DATE NOT NULL,
end_date DATE, -- NULL = indefinite
dispute_window_hours INT DEFAULT 12,
status VARCHAR(20) DEFAULT 'PENDING_ACCEPT', -- PENDING_ACCEPT, ACTIVE, ENDED
invite_code VARCHAR(32) UNIQUE, -- For invite deeplinks
created_by_user_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_pacts_invite_code ON pacts(invite_code);
CREATE INDEX idx_pacts_status ON pacts(status);
CREATE TRIGGER pacts_updated_at
BEFORE UPDATE ON pacts
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
```
#### `pact_participants`
```sql
CREATE TABLE pact_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
role VARCHAR(20) NOT NULL, -- INITIATOR, PARTNER
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, ACCEPTED, DECLINED
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(pact_id, user_id),
UNIQUE(pact_id, role) -- One initiator, one partner per pact
);
```
#### `pact_days`
```sql
CREATE TABLE pact_days (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE,
date DATE NOT NULL, -- In pact timezone
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, DONE, MISSED, DISPUTED, SKIPPED, WAIVED
deadline_at TIMESTAMPTZ NOT NULL, -- Precomputed: date + deadline_time in pact timezone (handles DST)
-- Check-in data
checked_in_at TIMESTAMPTZ,
checked_in_by_user_id UUID REFERENCES users(id),
checkin_note TEXT,
checkin_idempotency_key VARCHAR(64), -- Prevent duplicate check-ins
-- Dispute data
dispute_open_until TIMESTAMPTZ, -- Partner can open dispute until this time (checked_in_at + window)
disputed_at TIMESTAMPTZ,
disputed_by_user_id UUID REFERENCES users(id),
dispute_reason TEXT,
dispute_resolve_until TIMESTAMPTZ, -- Set when disputed: disputed_at + resolution_hours (default 12h)
-- Resolution data
resolution VARCHAR(20), -- DONE_STANDS (auto-expire), CONCEDED, WITHDRAWN, REJECTED
resolution_at TIMESTAMPTZ,
resolution_by_user_id UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(pact_id, date)
);
CREATE INDEX idx_pact_days_status ON pact_days(pact_id, status);
CREATE INDEX idx_pact_days_date ON pact_days(date);
CREATE INDEX idx_pact_days_deadline ON pact_days(deadline_at) WHERE status = 'PENDING'; -- For deadline batch job
CREATE INDEX idx_pact_days_dispute ON pact_days(dispute_resolve_until) WHERE status = 'DISPUTED'; -- For dispute resolution job
```
#### `ledger_entries`
```sql
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE,
pact_day_id UUID REFERENCES pact_days(id), -- NULL for settlements
type VARCHAR(20) NOT NULL, -- FORFEIT, SETTLEMENT, ADJUSTMENT, WAIVER
amount INT NOT NULL, -- Signed; positive = initiator owes more
from_user_id UUID REFERENCES users(id), -- Who owes
to_user_id UUID REFERENCES users(id), -- Who is owed
note TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
-- Append-only: no updated_at column
);
-- Enforce append-only: reject UPDATE and DELETE
CREATE OR REPLACE FUNCTION ledger_immutable() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'ledger_entries is append-only: UPDATE and DELETE are forbidden';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER ledger_no_modify
BEFORE UPDATE OR DELETE ON ledger_entries
FOR EACH ROW EXECUTE FUNCTION ledger_immutable();
CREATE INDEX idx_ledger_pact ON ledger_entries(pact_id);
```
```
#### `settlements`
```sql
CREATE TABLE settlements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pact_id UUID REFERENCES pacts(id) ON DELETE CASCADE,
amount INT NOT NULL, -- Amount being settled
proposed_by_user_id UUID REFERENCES users(id),
from_user_confirmed_at TIMESTAMPTZ,
to_user_confirmed_at TIMESTAMPTZ,
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, CONFIRMED, CANCELLED
ledger_entry_id UUID REFERENCES ledger_entries(id), -- Created on confirmation
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### Derived Views
```sql
-- Current balance per pact
CREATE VIEW pact_balances AS
SELECT
pact_id,
SUM(amount) AS balance, -- Positive = initiator owes partner
COUNT(*) FILTER (WHERE type = 'FORFEIT') AS total_forfeits,
COUNT(*) FILTER (WHERE type = 'SETTLEMENT') AS total_settlements
FROM ledger_entries
GROUP BY pact_id;
```
---
## 6. Authentication
### Telegram Mini App Auth Flow
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Mini App │ │ Backend │ │ Telegram │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. App loads │ │
│────────────────────────────────────────
│ │ │
│ 2. Get initData │ │
│<──────────────────────────────────────│
│ │ │
│ 3. POST /auth │ │
│ {initData} │ │
│──────────────────>│ │
│ │ │
│ │ 4. Validate HMAC │
│ │ using bot token│
│ │ │
│ 5. Return user │ │
│ + JWT token │ │
│<──────────────────│ │
│ │ │
│ 6. Subsequent │ │
│ requests with │ │
│ Authorization │ │
│ header │ │
│──────────────────>│ │
```
### initData Validation (Backend)
```python
import hashlib
import hmac
import time
from urllib.parse import parse_qs
def validate_init_data(init_data: str, bot_token: str, max_age_seconds: int = 86400) -> dict | None:
"""Validate Telegram Mini App initData and return parsed data.
Args:
init_data: The initData string from Telegram
bot_token: Your bot's token
max_age_seconds: Maximum age of auth_date (default 24 hours for replay protection)
"""
parsed = parse_qs(init_data)
# Extract hash
received_hash = parsed.pop('hash', [None])[0]
if not received_hash:
return None
# Validate auth_date is recent (replay protection)
auth_date = parsed.get('auth_date', [None])[0]
if auth_date:
if int(time.time()) - int(auth_date) > max_age_seconds:
return None # initData too old
# Build check string (sorted key=value pairs)
check_pairs = sorted(
f"{k}={v[0]}" for k, v in parsed.items()
)
check_string = "\n".join(check_pairs)
# Compute HMAC
secret_key = hmac.new(
b"WebAppData",
bot_token.encode(),
hashlib.sha256
).digest()
computed_hash = hmac.new(
secret_key,
check_string.encode(),
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(computed_hash, received_hash):
return None
return {k: v[0] for k, v in parsed.items()}
```
### JWT Token Strategy
- Issue JWT on successful initData validation
- Token contains: `user_id`, `telegram_user_id`, `exp`
- Expiry: 7 days (Mini App sessions are persistent)
- Refresh: Re-validate initData to issue new token
---
## 7. API Specification
### Base URL
```
Production: https://api.habitpact.app/api/v1
Development: http://localhost:8000/api/v1
```
### Authentication Header
```
Authorization: Bearer <jwt_token>
```
### Endpoints
#### Auth
##### `POST /auth/telegram`
Validate Telegram initData and return user + token.
**Request:**
```json
{
"init_data": "query_string_from_telegram"
}
```
**Response:**
```json
{
"user": {
"id": "uuid",
"telegram_user_id": 123456789,
"display_name": "Alex",
"username": "alexcode",
"timezone": "Europe/London"
},
"token": "jwt_token",
"is_new_user": false
}
```
---
#### Users
##### `GET /users/me`
Get current user profile.
##### `PATCH /users/me`
Update user settings.
**Request:**
```json
{
"timezone": "America/Los_Angeles",
"notify_reminders": true,
"reminder_minutes_before": 30
}
```
---
#### Pacts
##### `GET /pacts`
List user's pacts.
**Query params:**
- `status`: Filter by status (PENDING_ACCEPT, ACTIVE, ENDED)
- `role`: Filter by role (INITIATOR, PARTNER)
**Response:**
```json
{
"pacts": [
{
"id": "uuid",
"title": "Gym Pact 🏋️",
"status": "ACTIVE",
"my_role": "INITIATOR",
"partner": {
"id": "uuid",
"display_name": "Sam"
},
"stake_amount": 500,
"currency": "GBP",
"balance": 1500,
"today": {
"date": "2025-01-20",
"status": "PENDING",
"deadline": "2025-01-20T21:00:00Z"
}
}
]
}
```
##### `POST /pacts`
Create a new pact.
> **Note:** Telegram bots cannot resolve @username → user_id. Partner binding happens when they open the invite link and we get their `telegram_user_id` via initData. The `partner_username` field is optional display metadata only.
**Request:**
```json
{
"title": "Gym Pact 🏋️",
"description": "Hit the gym at least once",
"stake_amount": 500,
"currency": "GBP",
"schedule_days": [false, true, true, true, true, true, false],
"deadline_time": "21:00",
"start_date": "2025-01-20",
"end_date": null,
"dispute_window_hours": 12,
"partner_username": "samsmith"
}
```
**Response:**
```json
{
"pact": { ... },
"invite_link": "https://t.me/HabitPactBot?startapp=invite_abc123"
}
```
##### `GET /pacts/{pact_id}`
Get pact details including today's status and balance.
**Response:**
```json
{
"pact": {
"id": "uuid",
"title": "Gym Pact 🏋️",
"status": "ACTIVE",
"stake_amount": 500,
"currency": "GBP",
"schedule_days": [false, true, true, true, true, true, false],
"deadline_time": "21:00",
"timezone": "Europe/London",
"dispute_window_hours": 12,
"start_date": "2025-01-20",
"end_date": null,
"created_at": "2025-01-19T10:00:00Z"
},
"participants": [
{"user": {...}, "role": "INITIATOR", "status": "ACCEPTED"},
{"user": {...}, "role": "PARTNER", "status": "ACCEPTED"}
],
"balance": 1500,
"stats": {
"total_days": 30,
"done_days": 25,
"missed_days": 3,
"disputed_days": 2,
"current_streak": 5
},
"today": {
"id": "uuid",
"date": "2025-01-20",
"status": "PENDING",
"deadline": "2025-01-20T21:00:00Z",
"can_check_in": true,
"can_dispute": false
}
}
```
##### `POST /pacts/{pact_id}/accept`
Accept a pact invitation (partner only).
##### `POST /pacts/{pact_id}/decline`
Decline a pact invitation (partner only).
##### `POST /pacts/{pact_id}/end`
End an active pact (either participant).
**Request:**
```json
{
"reason": "Completed our 30-day challenge!"
}
```
---
#### Pact Days
##### `GET /pacts/{pact_id}/days`
List pact days with pagination.
**Query params:**
- `limit`: Max results (default 30)
- `offset`: Pagination offset
- `status`: Filter by status
##### `GET /pacts/{pact_id}/days/{date}`
Get specific day details.
##### `POST /pacts/{pact_id}/days/{date}/checkin`
Check in for a day (initiator only).
**Request:**
```json
{
"note": "Did 30 mins cardio",
"idempotency_key": "uuid-client-generated"
}
```
**Response:**
```json
{
"day": {
"id": "uuid",
"date": "2025-01-20",
"status": "DONE",
"checked_in_at": "2025-01-20T18:30:00Z",
"dispute_open_until": "2025-01-21T06:30:00Z"
}
}
```
##### `POST /pacts/{pact_id}/days/{date}/miss`
Manually mark day as missed (initiator only; before deadline).
##### `POST /pacts/{pact_id}/days/{date}/dispute`
Dispute a check-in (partner only).
**Request:**
```json
{
"reason": "Didn't see you at the gym today 🤔"
}
```
**Conditions:**
- Day status must be `DONE`
- Current time must be before `dispute_open_until`
##### `POST /pacts/{pact_id}/days/{date}/resolve`
Resolve a dispute.
**Request:**
```json
{
"resolution": "CONCEDE" // or "WITHDRAW" or "REJECT"
}
```
**Rules:**
- `CONCEDE`: Initiator admits miss → status becomes `MISSED`, forfeit applied
- `WITHDRAW`: Partner withdraws dispute → status becomes `DONE`
- `REJECT`: Initiator stands by check-in → status becomes `DONE`, dispute closed (partner can re-dispute if still in window)
##### `POST /pacts/{pact_id}/days/{date}/waive`
Waive a missed day (partner only; forgive the miss and reverse the forfeit).
**Conditions:**
- Day status must be `MISSED`
- Creates a `WAIVER` ledger entry to reverse the forfeit
##### `POST /pacts/{pact_id}/days/{date}/skip`
Skip a day before deadline (either participant; exempt from check-in requirement).
**Conditions:**
- Day status must be `PENDING`
- Current time must be before deadline
- No forfeit is applied
---
#### Ledger
##### `GET /pacts/{pact_id}/ledger`
Get ledger entries.
**Query params:**
- `limit`: Max results (default 20)
- `offset`: Pagination offset
**Response:**
```json
{
"entries": [
{
"id": "uuid",
"type": "FORFEIT",
"amount": 500,
"date": "2025-01-18",
"note": "Missed check-in",
"created_at": "2025-01-18T21:02:00Z"
}
],
"balance": 1500,
"total_entries": 5
}
```
---
#### Settlements
##### `POST /pacts/{pact_id}/settlements`
Propose a settlement.
**Request:**
```json
{
"amount": 1500 // Optional; defaults to current balance
}
```
##### `GET /pacts/{pact_id}/settlements`
List settlement history.
##### `POST /settlements/{settlement_id}/confirm`
Confirm a settlement (called by each party).
##### `POST /settlements/{settlement_id}/cancel`
Cancel a pending settlement.
---
#### Invites
##### `GET /invites/{code}`
Get pact info from invite code (for preview before accepting).
**Response:**
```json
{
"pact": {
"title": "Gym Pact 🏋️",
"stake_amount": 500,
"currency": "GBP",
"schedule_days": [false, true, true, true, true, true, false],
"deadline_time": "21:00"
},
"initiator": {
"display_name": "Alex",
"username": "alexcode"
},
"status": "PENDING_ACCEPT" // or "ALREADY_ACCEPTED", "EXPIRED"
}
```
##### `POST /invites/{code}/accept`
Accept invite (alternative to `/pacts/{id}/accept`).
---
### Error Responses
```json
{
"error": {
"code": "DISPUTE_WINDOW_CLOSED",
"message": "The dispute window for this day has expired",
"details": {
"window_end": "2025-01-20T06:30:00Z"
}
}
}
```
**Standard error codes:**
- `UNAUTHORIZED` — Invalid or expired token
- `FORBIDDEN` — User doesn't have permission
- `NOT_FOUND` — Resource doesn't exist
- `CONFLICT` — Invalid state transition
- `VALIDATION_ERROR` — Invalid request body
- `IDEMPOTENCY_CONFLICT` — Duplicate idempotency key with different payload
---
## 8. Telegram Bot Specification
### Bot Role
The bot serves three purposes:
1. **Entry point**: `/start` command and deeplinks open Mini App
2. **Notifications**: Reminders, dispute alerts, settlement confirmations
3. **Quick actions**: Inline buttons for simple confirm/deny flows
### Commands
| Command | Description | Action |
|---------|-------------|--------|
| `/start` | Welcome + onboarding | Open Mini App home |
| `/start invite_{code}` | Accept invite deeplink | Open Mini App to invite screen |
| `/help` | How it works | Send help message with Mini App button |
### Notification Messages
#### Daily Reminder
**Trigger:** `reminder_minutes_before` deadline, if day is `PENDING`
```
🕒 Habit check! Time for **Gym**.
Hit Done before **9:00 PM** or the Tab Goblin adds **£5** to your bill. 👺
[Open Pact →] [Done ✅]
```
**Buttons:**
- `Open Pact →` — Opens Mini App to pact detail
- `Done ✅` — Quick check-in (hits API directly)
#### Check-in Confirmation
**Trigger:** After successful check-in
```
✅ Logged **DONE** for Gym today!
Sam can dispute within 12 hours (but only if they're feeling spicy 🌶️).
```
#### Missed Deadline
**Trigger:** Deadline passes with `PENDING` status
```
😬 Deadline passed for **Gym**!
**+£5** added to the tab.
Running total: Alex owes Sam **£15**.
[View Details →]
```
#### Dispute Opened (to Initiator)
**Trigger:** Partner disputes a check-in
```
🤨 **Sam** disputed your check-in for **Gym** (Jan 20).
Reason: "Didn't see you at the gym today 🤔"
Was it legit?
[View Dispute →] [Concede 😅] [Stand By It 💪]
```
#### Dispute Opened (to Partner)
**Trigger:** After dispute is created
```
⚖️ You disputed Alex's check-in for **Gym** (Jan 20).
Waiting for their response. The dispute window closes in 11 hours.
```
#### Dispute Resolved
**Trigger:** Resolution submitted
```
✅ Dispute resolved for **Gym** (Jan 20).
Result: Alex conceded — day marked as **MISSED**.
**+£5** added to the tab.
```
#### Dispute Window Expired
**Trigger:** Dispute window closes with no resolution
```
⏰ Dispute window closed for **Gym** (Jan 20).
No resolution submitted — check-in stands as **DONE**.
```
#### Settlement Proposed (to other party)
**Trigger:** Settlement created
```
💸 **Alex** wants to settle up!
Amount: **£15**
(Settle however you like — bank transfer, cash, or interpretive dance)
[Confirm Payment Received ✅] [Not Yet ⏳]
```
#### Settlement Confirmed
**Trigger:** Both parties confirm
```
🎉 Settlement complete!
**£15** marked as settled between Alex and Sam.
New balance: **£0**
```
#### Pact Ended
**Trigger:** Pact ended by either party
```
🏁 **Gym Pact** has ended!
Final stats:
• Duration: 30 days
• Completed: 25 days
• Missed: 3 days
• Final balance: Alex owes Sam £15
Thanks for playing! [Start New Pact →]
```
### Inline Keyboard Callbacks
| Callback Data | Action |
|---------------|--------|
| `checkin:{pact_id}:{date}` | Quick check-in |
| `concede:{pact_id}:{date}` | Concede dispute |
| `reject:{pact_id}:{date}` | Reject dispute (stand by check-in) |
| `confirm_settlement:{settlement_id}` | Confirm settlement |
| `open_app:{path}` | Open Mini App to specific path |
### Deeplink Format
```
https://t.me/HabitPactBot?startapp={path}
Examples:
- https://t.me/HabitPactBot?startapp=home
- https://t.me/HabitPactBot?startapp=invite_abc123
- https://t.me/HabitPactBot?startapp=pact_uuid123
```
---
## 9. WebApp Specification
### Tech Stack
- **Framework:** React 18 + TypeScript
- **Build:** Vite
- **Styling:** TailwindCSS + shadcn/ui
- **State:** TanStack Query (React Query)
- **Routing:** React Router v6
- **Telegram SDK:** @telegram-apps/sdk-react
### Route Structure
| Route | Screen | Description |
|-------|--------|-------------|
| `/` | Home | List of active pacts + create CTA |
| `/pacts/new` | Create Pact | Multi-step pact creation form |
| `/pacts/:id` | Pact Detail | Today's status, check-in, quick stats |
| `/pacts/:id/history` | Pact History | Calendar view + day list |
| `/pacts/:id/ledger` | Ledger | Full ledger with balance |
| `/pacts/:id/settings` | Pact Settings | Edit pact (limited), end pact |
| `/pacts/:id/days/:date` | Day Detail | Day status, dispute flow |
| `/invite/:code` | Accept Invite | Preview + accept/decline |
| `/settings` | User Settings | Timezone, notifications |
### Screen Wireframes
#### Home Screen
```
┌─────────────────────────────────────────┐
│ ≡ Habit Pact ⚙️ │
├─────────────────────────────────────────┤
│ │
│ Good evening, Alex! 👋 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🏋️ Gym Pact PENDING │ │
│ │ with Sam │ │
│ │ │ │
│ │ Due today by 9:00 PM │ │
│ │ Tab: £15 owed │ │
│ │ │ │
│ │ [────────────── Check In ✅] │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 📚 Reading Pact DONE │ │
│ │ with Jamie ✓ │ │
│ │ │ │
│ │ Completed today at 6:30 PM │ │
│ │ Tab: £0 │ │
│ └─────────────────────────────────┘ │
│ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ + Create New Pact │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
#### Pact Detail Screen
```
┌─────────────────────────────────────────┐
│ ← Gym Pact 🏋️ ⋮ │
├─────────────────────────────────────────┤
│ │
│ Today: Monday, Jan 20 │
│ ┌─────────────────────────────────┐ │
│ │ │ │
│ │ ⏰ PENDING │ │
│ │ │ │
│ │ Check in by 9:00 PM │ │
│ │ (2 hours 30 mins left) │ │
│ │ │ │
│ │ [────────── Done ✅ ──────────] │ │
│ │ [Skip Today 😬] │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ ────────────────────────────────── │
│ │
│ 📊 Stats │
│ ┌────────┬────────┬────────┐ │
│ │ 25 │ 3 │ 5 │ │
│ │ Done │ Missed │ Streak │ │
│ └────────┴────────┴────────┘ │
│ │
│ 💰 Balance │
│ Alex owes Sam: £15 │
│ [View Ledger →] [Settle Up 💸] │
│ │
│ 📅 This Week │
│ M T W T F S S │
│ ● ○ ○ ○ ○ ○ · │
│ ✓ ? ? ? ? ? │
│ │
└─────────────────────────────────────────┘
```
#### Create Pact Flow
```
Step 1: Basics
┌─────────────────────────────────────────┐
│ ← New Pact 1/4 │
├─────────────────────────────────────────┤
│ │
│ What habit are you committing to? │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Go to the gym │ │
│ └─────────────────────────────────┘ │
│ │
│ Pick an emoji: │
│ [🏋️] [📚] [🧘] [🏃] [💻] [🎸] [+] │
│ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Next → │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
Step 2: Schedule
┌─────────────────────────────────────────┐
│ ← New Pact 2/4 │
├─────────────────────────────────────────┤
│ │
│ Which days? │
│ │
│ ┌───┬───┬───┬───┬───┬───┬───┐ │
│ │ M │ T │ W │ T │ F │ S │ S │ │
│ │[●]│[●]│[●]│[●]│[●]│[ ]│[ ]│ │
│ └───┴───┴───┴───┴───┴───┴───┘ │
│ │
│ Daily deadline: │
│ ┌─────────────────────────────────┐ │
│ │ 9:00 PM ⌄ │ │
│ └─────────────────────────────────┘ │
│ │
│ Start date: │
│ ┌─────────────────────────────────┐ │
│ │ Today (Jan 20) ⌄ │ │
│ └─────────────────────────────────┘ │
│ │
│ End date: (optional) │
│ ┌─────────────────────────────────┐ │
│ │ No end date ⌄ │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Next → │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
Step 3: Stakes
┌─────────────────────────────────────────┐
│ ← New Pact 3/4 │
├─────────────────────────────────────────┤
│ │
│ What's at stake? │
│ │
│ Amount per missed day: │
│ ┌──────┬──────────────────────────┐ │
│ │ GBP ⌄│ 5.00 │ │
│ └──────┴──────────────────────────┘ │
│ │
│ Quick pick: │
│ [£1] [£5] [£10] [£20] │
│ │
│ ────────────────────────────────── │
│ │
│ Dispute window: │
│ How long can your partner challenge │
│ a check-in? │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 12 hours ⌄ │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Next → │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
Step 4: Partner
┌─────────────────────────────────────────┐
│ ← New Pact 4/4 │
├─────────────────────────────────────────┤
│ │
│ Who's holding you accountable? │
│ │
│ Partner's Telegram username: │
│ ┌─────────────────────────────────┐ │
│ │ @ │ │
│ └─────────────────────────────────┘ │
│ │
│ ─── or ─── │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 📤 Send Invite Link │ │
│ └─────────────────────────────────┘ │
│ │
│ ────────────────────────────────── │
│ │
│ Summary: │
│ 🏋️ Gym • Mon-Fri by 9PM • £5/miss │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Create Pact 🚀 │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
#### Dispute Screen
```
┌─────────────────────────────────────────┐
│ ← Dispute │
├─────────────────────────────────────────┤
│ │
│ ⚖️ Disputed Check-in │
│ │
│ Gym Pact • Monday, Jan 20 │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Alex checked in at 6:30 PM │ │
│ │ Note: "Did 30 mins cardio" │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Sam disputed at 8:45 PM │ │
│ │ "Didn't see you at the gym │ │
│ │ today 🤔" │ │
│ └─────────────────────────────────┘ │
│ │
│ Dispute window closes in: │
│ ┌─────────────────────────────────┐ │
│ │ 9h 45m remaining │ │
│ └─────────────────────────────────┘ │
│ │
│ If no action, check-in stands. │
│ │
│ ─── Alex's Options ─── │
│ │
│ ┌─────────────────────────────────┐ │
│ │ Concede — I missed it 😅 │ │
│ └─────────────────────────────────┘ │
│ ┌─────────────────────────────────┐ │
│ │ Stand By It — I did it 💪 │ │
│ └─────────────────────────────────┘ │
│ │
└─────────────────────────────────────────┘
```
### Component Library
Use shadcn/ui components:
- `Button`, `Card`, `Badge` — Core UI
- `Dialog`, `Sheet` — Modals and drawers
- `Select`, `Input`, `Checkbox` — Form elements
- `Tabs`, `Calendar` — Navigation and date picking
- `Toast` — Notifications
### State Management
```typescript
// React Query for server state
const { data: pacts } = useQuery({
queryKey: ['pacts'],
queryFn: () => api.getPacts()
});
const checkIn = useMutation({
mutationFn: (data: CheckInRequest) => api.checkIn(pactId, date, data),
onSuccess: () => {
queryClient.invalidateQueries(['pacts', pactId]);
hapticFeedback.notificationOccurred('success');
}
});
```
### Telegram Integration
```typescript
import { useLaunchParams, useHapticFeedback } from '@telegram-apps/sdk-react';
function App() {
const launchParams = useLaunchParams();
const haptic = useHapticFeedback();
// Extract startapp param for deeplinks
const startParam = launchParams.startParam; // e.g., "invite_abc123"
// Navigate based on deeplink
useEffect(() => {
if (startParam?.startsWith('invite_')) {
navigate(`/invite/${startParam.slice(7)}`);
} else if (startParam?.startsWith('pact_')) {
navigate(`/pacts/${startParam.slice(5)}`);
}
}, [startParam]);
// Use haptics on actions
const handleCheckIn = () => {
haptic.impactOccurred('medium');
checkIn.mutate();
};
}
```
---
## 10. Background Jobs
### Job Queue: arq
```python
# worker.py
from arq import cron
class WorkerSettings:
functions = [
send_reminder,
process_deadline,
resolve_expired_disputes,
send_weekly_summary,
]
cron_jobs = [
cron(process_deadlines_batch, minute={0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58}), # Every 2 mins
cron(resolve_expired_disputes, minute={1, 31}), # Every 30 mins (offset)
cron(send_weekly_summary, weekday=6, hour=18), # Sunday 6 PM
]
redis_settings = RedisSettings.from_dsn(REDIS_URL)
```
### Job: Send Reminder
**Trigger:** Scheduled per pact, `reminder_minutes_before` deadline
**Logic:**
```python
async def send_reminder(ctx, pact_id: str, date: str):
pact = await get_pact(pact_id)
day = await get_or_create_pact_day(pact_id, date)
if day.status != 'PENDING':
return # Already checked in or missed
if pact.status != 'ACTIVE':
return # Pact ended
initiator = await get_initiator(pact_id)
if not initiator.notify_reminders:
return
await bot.send_message(
chat_id=initiator.telegram_user_id,
text=format_reminder(pact, day),
reply_markup=reminder_keyboard(pact_id, date)
)
```
### Job: Process Deadline
**Trigger:** Runs every 2 minutes (batch)
**Logic:**
```python
async def process_deadlines_batch(ctx):
"""Process all pact_days where deadline has passed and status is PENDING."""
now = datetime.now(UTC)
# Query uses the precomputed deadline_at with partial index
overdue_days = await db.fetch("""
SELECT pd.*, p.stake_amount, p.id as pact_id
FROM pact_days pd
JOIN pacts p ON pd.pact_id = p.id
WHERE pd.status = 'PENDING'
AND pd.deadline_at < $1
AND p.status = 'ACTIVE'
FOR UPDATE OF pd SKIP LOCKED
""", now)
for day in overdue_days:
await process_single_deadline(day)
async def process_single_deadline(day):
async with db.transaction():
# Mark missed
await db.execute("""
UPDATE pact_days
SET status = 'MISSED', updated_at = NOW()
WHERE id = $1
""", day.id)
# Get participant IDs
initiator_id, partner_id = await get_pact_participants(day.pact_id)
# Create ledger entry
await db.execute("""
INSERT INTO ledger_entries (pact_id, pact_day_id, type, amount, from_user_id, to_user_id, note)
VALUES ($1, $2, 'FORFEIT', $3, $4, $5, 'Missed check-in')
""", day.pact_id, day.id, day.stake_amount, initiator_id, partner_id)
# Notify both parties
await notify_missed(day)
```
### Job: Resolve Expired Disputes
**Trigger:** Every 30 minutes
**Logic:**
```python
async def resolve_expired_disputes(ctx):
"""Auto-resolve disputes where the resolution window has expired."""
now = datetime.now(UTC)
expired = await db.fetch("""
SELECT * FROM pact_days
WHERE status = 'DISPUTED'
AND dispute_resolve_until < $1
FOR UPDATE SKIP LOCKED
""", now)
for day in expired:
await db.execute("""
UPDATE pact_days
SET status = 'DONE',
resolution = 'DONE_STANDS',
resolution_at = NOW()
WHERE id = $1
""", day.id)
await notify_dispute_expired(day)
```
### Job: Weekly Summary
**Trigger:** Sunday 6 PM local time
**Logic:**
```python
async def send_weekly_summary(ctx):
active_pacts = await get_active_pacts()
for pact in active_pacts:
stats = await get_weekly_stats(pact.id)
balance = await get_balance(pact.id)
for participant in pact.participants:
await bot.send_message(
chat_id=participant.telegram_user_id,
text=format_weekly_summary(pact, stats, balance),
reply_markup=summary_keyboard(pact.id)
)
```
---
## 11. State Machines
### Pact Status
```
┌──────────────┐
│ DRAFT │ (Future: for saved incomplete pacts)
└──────┬───────┘
│ create
▼
┌──────────────┐
┌───────────│PENDING_ACCEPT│───────────┐
│ decline └──────┬───────┘ expire │
│ │ accept │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ DECLINED │ │ ACTIVE │ │ EXPIRED │
└──────────────┘ └──────┬───────┘ └──────────────┘
│ end
▼
┌──────────────┐
│ ENDED │
└──────────────┘
```
### Pact Day Status
```
┌─────────────┐
│ PENDING │
└──────┬──────┘
│
┌──────────────────────┼──────────────────────┐
│ check_in │ deadline_pass │ skip (either)
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ DONE │ │ MISSED │ │ SKIPPED │
└──────┬──────┘ └──────┬──────┘ └─────────────┘
│ │
│ dispute (partner) │ waive (partner)
▼ ▼
┌─────────────┐ ┌─────────────┐
│ DISPUTED │ │ WAIVED │
└──────┬──────┘ └─────────────┘
│
├─── concede (initiator) ────► MISSED + forfeit
│
├─── reject (initiator) ─────► DONE
│
├─── withdraw (partner) ─────► DONE
│
└─── window expires ─────────► DONE (auto: DONE_STANDS)
```
### Settlement Status
```
┌─────────────┐
│ PENDING │
└──────┬──────┘
│
┌─────────┼─────────┐
│ cancel │ confirm │
▼ │ (both) ▼
┌─────────┐ │ ┌───────────┐
│CANCELLED│ └──>│ CONFIRMED │
└─────────┘ └───────────┘
```
---
## 12. Error Handling
### API Error Codes
| Code | HTTP | Description |
|------|------|-------------|
| `AUTH_INVALID_INIT_DATA` | 401 | initData validation failed |
| `AUTH_EXPIRED_TOKEN` | 401 | JWT expired |
| `FORBIDDEN_NOT_PARTICIPANT` | 403 | User not in pact |
| `FORBIDDEN_WRONG_ROLE` | 403 | Action requires different role |
| `PACT_NOT_FOUND` | 404 | Pact doesn't exist |
| `DAY_NOT_FOUND` | 404 | Day doesn't exist |
| `INVALID_STATE_TRANSITION` | 409 | e.g., disputing a PENDING day |
| `DISPUTE_WINDOW_CLOSED` | 409 | Too late to open dispute |
| `RESOLUTION_WINDOW_CLOSED` | 409 | Too late to resolve dispute |
| `ALREADY_CHECKED_IN` | 409 | Day already DONE |
| `IDEMPOTENCY_MISMATCH` | 409 | Same key, different payload |
| `PACT_NOT_ACTIVE` | 409 | Pact is ended/pending |
| `VALIDATION_ERROR` | 422 | Invalid request body |
| `RATE_LIMITED` | 429 | Too many requests |
### WebApp Error Handling
```typescript
// Global error boundary
function ErrorFallback({ error, resetErrorBoundary }) {
const haptic = useHapticFeedback();
useEffect(() => {
haptic.notificationOccurred('error');
}, []);
return (
<div className="error-screen">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<Button onClick={resetErrorBoundary}>Try again</Button>
</div>
);
}
// API error handling
const api = {
async request(url, options) {
const res = await fetch(url, options);
if (!res.ok) {
const error = await res.json();
if (error.code === 'AUTH_EXPIRED_TOKEN') {
// Re-authenticate with Telegram
await reauthenticate();
return this.request(url, options);
}
throw new ApiError(error.code, error.message);
}
return res.json();
}
};
```
### Bot Error Handling
```python
@router.error()
async def error_handler(event: ErrorEvent):
logger.error(f"Bot error: {event.exception}", exc_info=event.exception)
if isinstance(event.exception, TelegramAPIError):
# Don't retry Telegram errors
return
# Notify user of generic error
if event.update.message:
await event.update.message.answer(
"😅 Something went wrong. Please try again."
)
```
---
## 13. Rate Limiting
### API Rate Limits
| Endpoint Pattern | Limit | Window |
|-----------------|-------|--------|
| `POST /auth/*` | 10 | 1 min |
| `POST /pacts/*/days/*/checkin` | 5 | 1 min |
| `POST /pacts/*/days/*/dispute` | 3 | 1 min |
| `GET /*` | 100 | 1 min |
| `POST /*` (other) | 30 | 1 min |
### Implementation
```python
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_user_id_from_token)
@app.post("/pacts/{pact_id}/days/{date}/checkin")
@limiter.limit("5/minute")
async def checkin(pact_id: str, date: str, request: Request):
...
```
---
## 14. Observability
### Logging
```python
# Structured logging with structlog
import structlog
logger = structlog.get_logger()
@app.post("/pacts/{pact_id}/days/{date}/checkin")
async def checkin(pact_id: str, date: str, user: User = Depends(get_current_user)):
logger.info(
"checkin_attempt",
pact_id=pact_id,
date=date,
user_id=str(user.id)
)
# ... do checkin ...
logger.info(
"checkin_success",
pact_id=pact_id,
date=date,
user_id=str(user.id),
new_status="DONE"
)
```
### Metrics
Track:
- `pact_created_total` — Counter of pacts created
- `checkin_total{status}` — Counter by result (done, missed, disputed)
- `dispute_resolution_total{resolution}` — Counter by resolution type
- `api_request_duration_seconds` — Histogram of request latency
- `active_pacts_gauge` — Current active pacts
### Alerts
- High error rate (>5% 5xx in 5 min)
- Job queue backup (>100 pending jobs)
- Deadline job failures
- Database connection pool exhaustion
---
## 15. Security Considerations
### Input Validation
- All inputs validated with Pydantic
- Telegram usernames validated against pattern: `^[a-zA-Z][a-zA-Z0-9_]{4,31}$`
- Stake amounts capped (e.g., max £100 per miss)
- Schedule must have at least one day selected
### Authorization
- Every endpoint checks user is participant in pact
- Role-specific actions validated (e.g., only partner can dispute)
- Idempotency keys scoped to user
### Data Protection
- No sensitive data stored (no payment info)
- Telegram user IDs are pseudonymous
- Consider GDPR: provide data export/deletion endpoints
---
## 16. Deployment
### Railway Setup
```yaml
# railway.toml
[build]
builder = "dockerfile"
[deploy]
healthcheckPath = "/health"
healthcheckTimeout = 30
[[services]]
name = "api"
dockerfile = "backend/Dockerfile"
[[services]]
name = "webapp"
dockerfile = "webapp/Dockerfile"
[[services]]
name = "worker"
dockerfile = "backend/Dockerfile"
command = "arq app.jobs.worker.WorkerSettings"
```
### Environment Variables
```bash
# Backend
DATABASE_URL=postgresql://...
REDIS_URL=redis://...
TELEGRAM_BOT_TOKEN=...
JWT_SECRET=...
WEBAPP_URL=https://habitpact.app
# WebApp
VITE_API_URL=https://api.habitpact.app
VITE_BOT_USERNAME=HabitPactBot
```
### Database Migrations
```bash
# Initial setup
alembic revision --autogenerate -m "initial"
alembic upgrade head
# CI/CD: run migrations before deploy
alembic upgrade head
```
---
## 17. Testing Strategy
### Backend
```python
# pytest + httpx for API tests
@pytest.fixture
async def client():
async with AsyncClient(app=app, base_url="http://test") as client:
yield client
async def test_checkin_success(client, auth_headers, pact_with_pending_day):
response = await client.post(
f"/api/v1/pacts/{pact_with_pending_day.id}/days/2025-01-20/checkin",
headers=auth_headers,
json={"idempotency_key": "test-123"}
)
assert response.status_code == 200
assert response.json()["day"]["status"] == "DONE"
```
### WebApp
```typescript
// Vitest + React Testing Library
describe('CheckInButton', () => {
it('shows loading state during checkin', async () => {
render(<CheckInButton pactId="123" date="2025-01-20" />);
await userEvent.click(screen.getByRole('button', { name: /done/i }));
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText(/checking in/i)).toBeInTheDocument();
});
});
```
### E2E
```typescript
// Playwright
test('complete checkin flow', async ({ page }) => {
await page.goto('/pacts/test-pact-id');
await page.click('button:has-text("Done")');
await expect(page.locator('.status-badge')).toHaveText('DONE');
await expect(page.locator('.toast')).toContainText('Checked in');
});
```
---
## 18. MVP Acceptance Criteria
### Must Have
- [ ] User can authenticate via Telegram Mini App
- [ ] User can create a pact with another user
- [ ] Partner can accept/decline pact via deeplink
- [ ] Daily reminders sent via Telegram bot
- [ ] User can check in via WebApp or quick button
- [ ] Missed deadlines auto-apply forfeit to ledger
- [ ] Partner can dispute check-in within window
- [ ] Dispute can be resolved (concede/withdraw)
- [ ] Users can view running balance and ledger
- [ ] Users can propose and confirm settlements
- [ ] Users can end a pact
### Should Have
- [ ] Weekly summary notifications
- [ ] Streak tracking
- [ ] Timezone settings
- [ ] Notification preferences
### Nice to Have
- [ ] Calendar view of history
- [ ] Pact statistics (completion rate, etc.)
- [ ] Custom reminder times
---
## 19. Repository Structure
```
habit-pact/
├── backend/
│ ├── app/
│ │ ├── __init__.py
│ │ ├── main.py # FastAPI app + lifespan
│ │ ├── config.py # Settings from env
│ │ ├── database.py # SQLAlchemy setup
│ │ │
│ │ ├── api/
│ │ │ ├── __init__.py
│ │ │ ├── deps.py # Dependencies (auth, db session)
│ │ │ └── routes/
│ │ │ ├── __init__.py
│ │ │ ├── auth.py
│ │ │ ├── users.py
│ │ │ ├── pacts.py
│ │ │ ├── days.py
│ │ │ ├── ledger.py
│ │ │ ├── settlements.py
│ │ │ └── invites.py
│ │ │
│ │ ├── bot/
│ │ │ ├── __init__.py
│ │ │ ├── main.py # aiogram bot setup
│ │ │ ├── handlers/
│ │ │ │ ├── __init__.py
│ │ │ │ ├── commands.py # /start, /help
│ │ │ │ └── callbacks.py # Inline button handlers
│ │ │ ├── keyboards.py # Inline keyboard builders
│ │ │ └── notifications.py # Send notifications
│ │ │
│ │ ├── core/
│ │ │ ├── __init__.py
│ │ │ ├── models.py # SQLAlchemy models
│ │ │ ├── schemas.py # Pydantic schemas
│ │ │ └── services/
│ │ │ ├── __init__.py
│ │ │ ├── auth.py # initData validation, JWT
│ │ │ ├── pacts.py # Pact CRUD + logic
│ │ │ ├── days.py # Check-in, dispute logic
│ │ │ ├── ledger.py # Balance calculations
│ │ │ └── settlements.py
│ │ │
│ │ └── jobs/
│ │ ├── __init__.py
│ │ ├── worker.py # arq worker settings
│ │ ├── reminders.py
│ │ ├── deadlines.py
│ │ └── summaries.py
│ │
│ ├── alembic/
│ │ ├── env.py
│ │ └── versions/
│ │
│ ├── tests/
│ │ ├── conftest.py
│ │ ├── test_auth.py
│ │ ├── test_pacts.py
│ │ └── test_days.py
│ │
│ ├── alembic.ini
│ ├── pyproject.toml
│ ├── Dockerfile
│ └── .env.example
│
├── webapp/
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── vite-env.d.ts
│ │ │
│ │ ├── routes/
│ │ │ ├── index.tsx # Home
│ │ │ ├── pacts/
│ │ │ │ ├── index.tsx # Pact list (redirect to home)
│ │ │ │ ├── new.tsx # Create pact
│ │ │ │ ├── [id]/
│ │ │ │ │ ├── index.tsx # Pact detail
│ │ │ │ │ ├── history.tsx
│ │ │ │ │ ├── ledger.tsx
│ │ │ │ │ └── settings.tsx
│ │ │ │ └── [id]/days/[date].tsx # Day detail / dispute
│ │ │ ├── invite/
│ │ │ │ └── [code].tsx
│ │ │ └── settings.tsx
│ │ │
│ │ ├── components/
│ │ │ ├── ui/ # shadcn components
│ │ │ ├── pact-card.tsx
│ │ │ ├── checkin-button.tsx
│ │ │ ├── dispute-dialog.tsx
│ │ │ ├── ledger-table.tsx
│ │ │ ├── week-calendar.tsx
│ │ │ └── settlement-sheet.tsx
│ │ │
│ │ ├── lib/
│ │ │ ├── api.ts # API client
│ │ │ ├── telegram.ts # TG SDK helpers
│ │ │ ├── utils.ts
│ │ │ └── types.ts # Shared types
│ │ │
│ │ └── hooks/
│ │ ├── use-pacts.ts
│ │ ├── use-auth.ts
│ │ └── use-telegram.ts
│ │
│ ├── public/
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts
│ ├── tailwind.config.js
│ ├── Dockerfile
│ └── .env.example
│
├── docker-compose.yml # Local dev
├── railway.toml # Deployment config
├── .gitignore
└── README.md
```
---
## 20. Implementation Phases
### Phase 1: Foundation (Days 1-2)
- [ ] Project scaffolding (backend + webapp)
- [ ] Database models + migrations
- [ ] Auth flow (initData validation + JWT)
- [ ] Basic CRUD for pacts and participants
### Phase 2: Core Flow (Days 3-4)
- [ ] Check-in endpoint + state machine
- [ ] Deadline processing job
- [ ] Ledger entries on miss
- [ ] Telegram bot notifications (reminders, missed)
### Phase 3: WebApp UI (Days 5-6)
- [ ] Home screen with pact list
- [ ] Pact detail with check-in
- [ ] Create pact flow
- [ ] Accept invite flow
### Phase 4: Disputes & Settlements (Days 7-8)
- [ ] Dispute flow (open, resolve)
- [ ] Dispute window expiry job
- [ ] Settlement proposal + confirmation
- [ ] Dispute/settlement notifications
### Phase 5: Polish (Days 9-10)
- [ ] Weekly summaries
- [ ] Error handling + edge cases
- [ ] Testing
- [ ] Deployment + monitoring
---
## Appendix A: Copy Templates
### Bot Messages
See Section 8 for all notification templates.
### WebApp Microcopy
| Context | Copy |
|---------|------|
| Empty pacts list | "No pacts yet. Ready to commit to a habit?" |
| Pending check-in | "Check in by {time} or the Tab Goblin strikes! 👺" |
| Checked in | "Nice one! ✅" |
| Missed | "Deadline passed. +{amount} to the tab." |
| Dispute opened | "Your partner isn't convinced. Time to defend yourself!" |
| Settlement confirmed | "All squared up! 🎉" |
| Pact ended | "Journey complete. You showed up {n} times!" |
---
## Appendix B: Future Extensions
Designed into the data model but not shipped in MVP:
1. **Group pacts** — `pact_participants` supports N users; add pot rules
2. **Charity mode** — `to_user_id = NULL` + `charity_id`; generate donation links
3. **Proof attachments** — Add `media_file_id` to `pact_days`
4. **Recurring templates** — Save pact config for quick re-creation
5. **Integrations** — Strava, Apple Health webhooks for auto-check-in
6. **Payments** — Stripe integration; `payment_status` on settlements
---
*End of specification.*
---
## Changelog
### v1.1 (Review Feedback)
**Breaking changes:**
- `dispute_window_end` split into `dispute_open_until` and `dispute_resolve_until` for fairer timing
- Added `deadline_at` (precomputed TIMESTAMPTZ) to `pact_days` — must be set when creating days
**New features:**
- `SKIPPED` status for pre-deadline exemptions (distinct from `WAIVED` which is post-miss)
- `REJECT` resolution option for initiator to stand by check-in
- `POST /pacts/{id}/days/{date}/skip` endpoint
**Fixes:**
- Partner username lookup clarified as metadata-only (Telegram API limitation)
- Deadline processing now runs every 2 minutes (was hourly)
- initData validation now checks `auth_date` for replay protection
- Ledger immutability enforced via database trigger
- `updated_at` columns now have auto-update triggers
- `schedule_days` ordering explicitly documented as ISO weekday order [Mon..Sun]
- Fixed `standbychrome` callback typo → `reject`
**Schema changes:**
- Added `deadline_at TIMESTAMPTZ` to `pact_days`
- Renamed `dispute_window_end` → `dispute_open_until`
- Added `dispute_resolve_until` to `pact_days`
- Added partial indexes for deadline and dispute resolution queries
- Added `update_updated_at()` trigger function
- Added `ledger_immutable()` trigger to enforce append-only