---
# System prepended metadata

title: Social Habit Pledge Tracker — Technical Specification v1.1

---

# 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