# Specs & Plans

System specifications from ~/system/specs/drop-\*.md

# System Specifications

# drop-accessibility-spec

# Drop Accessibility Audit Specification (WCAG 2.1 AA)

**Version:** 1.0
**Date:** 2026-02-17
**Project:** Drop — Fintech Payment App
**Compliance Standard:** WCAG 2.1 Level AA + Norwegian Universal Design Law
**Author:** Architect Agent
**Status:** Draft

---

## Executive Summary

This specification defines the accessibility audit requirements for Drop, a fintech payment application serving all residents of Norway and Scandinavia. The audit ensures compliance with:

1. **WCAG 2.1 Level AA** — International accessibility standard
2. **Norwegian Law:** Likestillings- og diskrimineringsloven § 18 (Equality and Anti-Discrimination Act)
3. **IKT-forskriften** — Regulation for Universal Design of ICT Solutions
4. **Digitaliseringsdirektoratet (Digdir) Guidelines** — Norwegian government digital accessibility requirements

**Legal Context:** Norway requires universal design of digital services for both public and private sectors. Non-compliance can result in fines from Digitaliseringsdirektoratet. WCAG 2.1 AA is the legal minimum standard in Norway.

**Tech Stack:** Drop uses Next.js 16 + React 19 + Radix UI (which has built-in accessibility features) + Tailwind v4. The application is mobile-first, with most users on phones.

---

## 1. Norwegian Legal Requirements

### 1.1 Applicable Laws

| Law/Regulation | Section | Requirement |
|----------------|---------|-------------|
| Likestillings- og diskrimineringsloven | § 18 | Prohibition of discrimination based on disability; duty to provide universal design |
| Forskrift om universell utforming av IKT | All sections | Universal design of ICT solutions for public and private sectors (education, banking, commerce) |
| Finanstilsynet Guidelines | N/A | Financial services must be accessible to all customers |
| GDPR (DSGVO) | Article 25 | Privacy by design — accessible consent forms and data access requests |

### 1.2 Regulatory Bodies

- **Digitaliseringsdirektoratet (Digdir):** Supervises universal design of ICT
- **Likestillings- og diskrimineringsombudet (LDO):** Investigates discrimination complaints
- **Finanstilsynet:** Financial services regulator (monitors customer protection)

### 1.3 Compliance Deadlines

- **2025 Goal:** Norway universally designed by 2025 (national action plan)
- **Drop Target:** Full WCAG 2.1 AA compliance before public launch (Q2 2026)
- **Annual Monitoring:** Digdir annually monitors compliance — results reported to ESA/EU

### 1.4 Enforcement

- **Fines:** Digitaliseringsdirektoratet can issue fines for non-compliance
- **Discrimination Claims:** Users can file complaints with LDO
- **Reputational Risk:** Public disclosure of accessibility failures impacts trust in fintech sector

---

## 2. WCAG 2.1 AA Success Criteria Checklist

WCAG 2.1 AA includes **all 50 success criteria from WCAG 2.0 Level A/AA** plus **17 new criteria from WCAG 2.1**. Below is the complete checklist mapped to Drop features.

### 2.1 Principle 1: Perceivable

Information and user interface components must be presentable to users in ways they can perceive.

#### Guideline 1.1: Text Alternatives

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 1.1.1 | Non-text Content | A | All images, icons, QR codes, logos have alt text or ARIA labels |

**Drop Implementation:**
- Drop logo: `<img alt="Drop logo - green rounded square with dollar icon" />`
- Country flags in recipient list: `<span role="img" aria-label="Serbia flag">🇷🇸</span>`
- QR scanner UI: `<button aria-label="Start QR code scanner">` + screen reader announcement when camera opens
- Transaction status icons: CheckCircle, Shield icons have `aria-hidden="true"` + descriptive text nearby
- Decorative SVG patterns: `aria-hidden="true"`

#### Guideline 1.2: Time-based Media

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 1.2.1 | Audio-only and Video-only | A | Not applicable — Drop has no audio/video content |
| 1.2.2 | Captions | A | Not applicable |
| 1.2.3 | Audio Description or Media Alternative | A | Not applicable |
| 1.2.4 | Captions (Live) | AA | Not applicable |
| 1.2.5 | Audio Description (Prerecorded) | AA | Not applicable |

**Future Consideration:** If Drop adds tutorial videos or customer support videos, add Norwegian captions + audio descriptions.

#### Guideline 1.3: Adaptable

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 1.3.1 | Info and Relationships | A | Semantic HTML: `<h1>`, `<nav>`, `<form>`, `<label>`, `<button>`. Tables use `<table>`, `<th>`, `<tbody>` |
| 1.3.2 | Meaningful Sequence | A | Tab order follows visual order. Reading order is logical (header → main content → navigation) |
| 1.3.3 | Sensory Characteristics | A | Instructions do NOT rely on shape/color/sound alone. "Click green button" → "Click 'Send Money' button" |
| 1.3.4 | Orientation | AA | No orientation lock. App works in portrait and landscape |
| 1.3.5 | Identify Input Purpose | AA | autocomplete attributes on all form fields (email, name, phone, address) |

**Drop Implementation:**
- Login form: `<input type="email" autocomplete="email">`, `<input type="password" autocomplete="current-password">`
- Registration form: `<input autocomplete="given-name">`, `<input autocomplete="family-name">`, `<input autocomplete="tel">`
- Send money form: Amount input has `<label for="amount">Du sender</label>`
- All headings hierarchical: h1 (page title) → h2 (sections) → h3 (subsections)

#### Guideline 1.4: Distinguishable

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 1.4.1 | Use of Color | A | Color is NOT the only way to convey information. Success state = green checkmark icon + "Sendt!" text |
| 1.4.2 | Audio Control | A | Not applicable — no auto-playing audio |
| 1.4.3 | Contrast (Minimum) | AA | 4.5:1 for normal text, 3:1 for large text. Audit all color pairs |
| 1.4.4 | Resize Text | AA | Text resizable up to 200% without loss of content or functionality |
| 1.4.5 | Images of Text | AA | No images of text except logos. Use web fonts (Inter, Fraunces) |
| 1.4.10 | Reflow | AA | Content reflows for 320px viewport without horizontal scrolling |
| 1.4.11 | Non-text Contrast | AA | 3:1 for UI components (buttons, inputs, focus indicators) and graphical objects |
| 1.4.12 | Text Spacing | AA | No loss of content when user increases line-height, letter-spacing, word-spacing, paragraph spacing |
| 1.4.13 | Content on Hover or Focus | AA | Tooltips dismissible, hoverable, persistent. Not applicable if no tooltips |

**Drop Color Audit (Priority 1):**
| Element | Foreground | Background | Contrast Ratio | WCAG AA |
|---------|------------|------------|----------------|---------|
| Primary button text | #FFFFFF | #0B6E35 | TBD | ✅ Pass (likely 8:1+) |
| Body text | #1E293B | #F8FAFC | TBD | Test |
| Secondary text | #64748B | #F8FAFC | TBD | Test (risky — gray text often fails) |
| Error text | #EF4444 | #FFFFFF | TBD | Test |
| Link text | #0B6E35 | #F8FAFC | TBD | Test |
| Disabled button | #94A3B8 | #F8FAFC | TBD | Exempt (disabled state), but should still aim for 3:1 |
| Focus indicator | #0B6E35 | #FFFFFF | TBD | Test (3:1 minimum for non-text) |

**Tools:** Use [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/), axe DevTools, or Lighthouse.

**Remediation:** If any text fails 4.5:1, darken text color or lighten background. For #64748B (gray), consider #475569 or #334155 (darker grays).

---

### 2.2 Principle 2: Operable

User interface components and navigation must be operable.

#### Guideline 2.1: Keyboard Accessible

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 2.1.1 | Keyboard | A | All functionality available via keyboard (Tab, Enter, Space, Arrow keys) |
| 2.1.2 | No Keyboard Trap | A | User can navigate away from any focused component using keyboard alone |
| 2.1.4 | Character Key Shortcuts | A | If single-key shortcuts exist, provide way to turn off or remap. Check if any exist. |

**Drop Implementation:**
- **Tab order:** Header → Main content → Bottom navigation → Footer
- **Custom components:** QR scanner button, recipient cards, amount input, confirm button — all keyboard-accessible
- **Modals/dialogs:** Focus trapped inside modal while open, Esc closes modal and returns focus
- **Skip links:** Add "Skip to main content" link (hidden until focused) at top of page
- **Mobile touch targets:** Also keyboard-focusable (44x44px minimum)

**Test Plan:**
1. Unplug mouse. Navigate entire app using keyboard only.
2. Test all user flows: Login → Dashboard → Send Money → Confirm → Success
3. Check tab order is logical (not jumping around page)
4. Verify focus visible on all interactive elements

#### Guideline 2.2: Enough Time

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 2.2.1 | Timing Adjustable | A | Session timeout warnings with option to extend |
| 2.2.2 | Pause, Stop, Hide | A | No auto-updating content except notifications (user can disable in Settings) |

**Drop Implementation:**
- JWT token expires in 1 hour. Show warning 5 minutes before expiry: "Your session will expire in 5 minutes. [Extend session]"
- Dashboard transaction list: NOT auto-refreshing (user clicks Refresh button)
- Notifications: User can toggle push notifications in `/profile/notifications`
- No carousels, no auto-playing animations

#### Guideline 2.3: Seizures and Physical Reactions

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 2.3.1 | Three Flashes or Below Threshold | A | No flashing content. No animations flash more than 3 times per second |

**Drop Implementation:**
- Loading spinners: Slow rotation (1 rotation per 1.5 seconds), no flashing
- Success animation: Fade-in checkmark, no rapid flashing
- No video, no GIF animations

#### Guideline 2.4: Navigable

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 2.4.1 | Bypass Blocks | A | Skip links to bypass repetitive content |
| 2.4.2 | Page Titled | A | Every page has unique, descriptive `<title>` tag |
| 2.4.3 | Focus Order | A | Tab order is logical and predictable |
| 2.4.4 | Link Purpose (In Context) | A | Link text describes destination. Avoid "click here" or "read more" |
| 2.4.5 | Multiple Ways | AA | More than one way to find a page (menu, search, sitemap) |
| 2.4.6 | Headings and Labels | AA | Headings and labels are clear and descriptive |
| 2.4.7 | Focus Visible | AA | Keyboard focus indicator visible (outline or border) |

**Drop Implementation:**
- **Skip link:** `<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>` (Tailwind screen-reader-only class)
- **Page titles:**
  - `/login` → "Logg inn — Drop"
  - `/dashboard` → "Oversikt — Drop"
  - `/send` → "Send penger — Drop"
  - `/transactions` → "Transaksjoner — Drop"
- **Focus indicator:** Default browser outline (blue) or custom 3px solid #0B6E35 outline
- **Navigation:** Bottom nav (Dashboard, Send, Scan, Accounts, Profile) + header back button
- **Breadcrumbs:** Not applicable (flat navigation structure)
- **Link text:** "Opprett konto" (not "Click here to register"), "Se alle transaksjoner" (not "See more")

#### Guideline 2.5: Input Modalities

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 2.5.1 | Pointer Gestures | A | No multipoint or path-based gestures required. Single-tap/click works everywhere |
| 2.5.2 | Pointer Cancellation | A | Click/tap activated on up-event (not down-event). Allows user to drag away to cancel |
| 2.5.3 | Label in Name | A | Visible label text matches accessible name. Button text = aria-label |
| 2.5.4 | Motion Actuation | A | No shake-to-undo or tilt-to-zoom. All features work without device motion |

**Drop Implementation:**
- No swipe gestures required (all actions have buttons)
- QR scanner: Manual "Start Scan" button (no auto-activate on tilt)
- Touch targets: 44x44px minimum (WCAG 2.2 Target Size guideline)

---

### 2.3 Principle 3: Understandable

Information and the operation of user interface must be understandable.

#### Guideline 3.1: Readable

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 3.1.1 | Language of Page | A | `<html lang="nb">` (Norwegian Bokmål) |
| 3.1.2 | Language of Parts | AA | If mixing languages, mark with `lang` attribute. Example: `<span lang="en">Drop</span>` |

**Drop Implementation:**
- Primary language: Norwegian Bokmål (`lang="nb"`)
- Brand name "Drop" is English but used as proper noun (no lang tag needed)
- Currency codes (NOK, RSD, BAM) are ISO codes (no lang tag needed)
- Future: If adding English version, separate `/en/` route with `<html lang="en">`

#### Guideline 3.2: Predictable

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 3.2.1 | On Focus | A | Focusing on element does NOT trigger automatic context change |
| 3.2.2 | On Input | A | Changing setting does NOT auto-submit form unless user is warned |
| 3.2.3 | Consistent Navigation | AA | Navigation in same order on every page |
| 3.2.4 | Consistent Identification | AA | Icons/buttons with same function labeled consistently |

**Drop Implementation:**
- No auto-submitting forms. User must click "Bekreft sending" button.
- Bottom nav order consistent: Dashboard → Send → Scan → Accounts → Profile
- Back button (ChevronLeft icon) always top-left corner
- "Send penger" button always labeled "Send penger" (not sometimes "Transfer" or "Pay")

#### Guideline 3.3: Input Assistance

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 3.3.1 | Error Identification | A | Errors clearly identified in text (not color alone) |
| 3.3.2 | Labels or Instructions | A | Every form field has label or instruction |
| 3.3.3 | Error Suggestion | AA | Suggest fix for errors. "Ugyldig e-postadresse" → show example: "Eksempel: din@epost.no" |
| 3.3.4 | Error Prevention (Legal, Financial, Data) | AA | Confirmation step before financial transactions |

**Drop Implementation:**
- **Login errors:** "Feil e-post eller passord" (clear, not vague "Error 401")
- **Registration errors:** "E-post og passord er påkrevd" + field highlighted red border
- **Send money errors:** "Utilstrekkelig saldo. Du har 1,500 NOK tilgjengelig." (specific, actionable)
- **Confirmation step:** Step 3 of 4 shows full transaction details before "Bekreft sending" button
- **Error styling:** Red text (#EF4444) + red border on invalid field + error icon + `aria-invalid="true"` attribute
- **Error announcement:** Use `role="alert"` or `aria-live="polite"` for screen reader announcement

**Example Error Component:**
```tsx
{error && (
  <div role="alert" className="rounded-md bg-[#EF4444]/10 p-2 text-sm text-[#EF4444]">
    {error}
  </div>
)}
```

---

### 2.4 Principle 4: Robust

Content must be robust enough to be interpreted reliably by a wide variety of user agents, including assistive technologies.

#### Guideline 4.1: Compatible

| ID | Criterion | Level | Drop Application |
|----|-----------|-------|------------------|
| 4.1.1 | Parsing | A | Valid HTML (no duplicate IDs, proper nesting, correct ARIA) |
| 4.1.2 | Name, Role, Value | A | All UI components have accessible name, role, state (via HTML or ARIA) |
| 4.1.3 | Status Messages | AA | Status messages announced to screen readers (via `role="status"` or `aria-live`) |

**Drop Implementation:**
- **HTML validation:** Run W3C validator on all pages. Fix parsing errors.
- **ARIA roles:** Radix UI components have built-in ARIA. Custom components need manual ARIA.
- **Status messages:**
  - "Pengene er på vei!" (success) → `<div role="status" aria-live="polite">`
  - "Sender..." (loading) → `<button aria-busy="true">Sender...</button>`
  - "Feil e-post eller passord" (error) → `<div role="alert">`
- **Custom components:**
  - Recipient card button: `<button aria-label="Send penger til {recipient.name}">` (not just icon)
  - QR scanner: `<button aria-label="Start QR-skanning">` + `<div role="status">` when camera opens

---

## 3. Audit Methodology

### 3.1 Three-Tier Testing Approach

| Tier | Method | Coverage | Tools |
|------|--------|----------|-------|
| **Automated** | axe-core via Playwright | ~30% of WCAG issues | @axe-core/playwright, axe DevTools Chrome extension |
| **Manual** | Human testing with keyboard + browser tools | ~50% of WCAG issues | Keyboard navigation, browser DevTools, Lighthouse |
| **Assistive Technology** | Screen readers on real devices | ~20% of WCAG issues | VoiceOver (iOS/macOS), TalkBack (Android), NVDA (Windows) |

**Why 3 tiers?** Automated tools catch low-hanging fruit (missing alt text, color contrast). Manual testing catches logic issues (tab order, focus management). Assistive tech testing catches real-world experience issues (confusing labels, verbose announcements).

### 3.2 Automated Testing (Tier 1)

**Tool:** axe-core via Playwright
**Integration:** Add accessibility tests to existing Playwright test suite
**Frequency:** Every PR (CI pipeline), every deployment

**Implementation:**

1. Install dependencies:
   ```bash
   npm install --save-dev @axe-core/playwright axe-html-reporter
   ```

2. Create Playwright accessibility test file:
   ```typescript
   // tests/accessibility.spec.ts
   import { test, expect } from '@playwright/test';
   import AxeBuilder from '@axe-core/playwright';
   import { createHtmlReport } from 'axe-html-reporter';

   const routes = [
     { path: '/', name: 'Landing' },
     { path: '/login', name: 'Login' },
     { path: '/register', name: 'Register' },
     { path: '/dashboard', name: 'Dashboard' },
     { path: '/send', name: 'Send Money' },
     { path: '/scan', name: 'QR Scan' },
     { path: '/accounts', name: 'Bank Accounts' },
     { path: '/transactions', name: 'Transaction History' },
     { path: '/notifications', name: 'Notifications' },
     { path: '/profile', name: 'Profile' },
   ];

   for (const route of routes) {
     test(`${route.name} page should not have accessibility violations`, async ({ page }) => {
       await page.goto(route.path);

       const results = await new AxeBuilder({ page })
         .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
         .analyze();

       // Generate HTML report
       createHtmlReport({
         results,
         options: {
           outputDir: 'test-results/accessibility',
           reportFileName: `${route.name.toLowerCase().replace(' ', '-')}.html`,
         },
       });

       expect(results.violations).toEqual([]);
     });
   }
   ```

3. Add to CI pipeline:
   ```yaml
   # .github/workflows/ci.yml
   - name: Run accessibility tests
     run: npm run test:a11y
   - name: Upload accessibility reports
     if: failure()
     uses: actions/upload-artifact@v4
     with:
       name: accessibility-reports
       path: test-results/accessibility/
   ```

4. Configure axe rules (optional — custom ruleset):
   ```typescript
   await new AxeBuilder({ page })
     .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
     .disableRules(['color-contrast']) // Disable if testing on non-production colors
     .analyze();
   ```

**Severity Levels:**
- **Critical:** Blocks screen reader users (missing alt text, no keyboard access)
- **Serious:** Major barrier (insufficient contrast, missing labels)
- **Moderate:** Usability issue (redundant links, small touch targets)
- **Minor:** Best practice (missing lang attribute on secondary content)

**CI Behavior:**
- Fail build on **Critical** violations
- Warn on **Serious** violations (block merge if count increases)
- Report **Moderate/Minor** violations (don't block)

### 3.3 Manual Testing (Tier 2)

**Testers:** QA, developers, accessibility specialist
**Frequency:** Every major feature, every release candidate

**Checklist:**

#### Keyboard Navigation
- [ ] All interactive elements reachable via Tab key
- [ ] Tab order follows visual order (left-to-right, top-to-bottom)
- [ ] Focus indicator visible on all elements (3px outline minimum)
- [ ] No keyboard traps (can navigate away from modals, dropdowns)
- [ ] Enter/Space activates buttons and links
- [ ] Escape closes modals and returns focus to trigger
- [ ] Arrow keys navigate within dropdowns, radio groups (if applicable)
- [ ] Skip link visible when focused (bypasses header to main content)

#### Form Validation
- [ ] All form fields have visible labels (not just placeholder text)
- [ ] Required fields marked with `required` attribute or `aria-required="true"`
- [ ] Error messages appear near the field (not just at top of form)
- [ ] Error messages announce to screen readers (`role="alert"`)
- [ ] Error messages descriptive ("Ugyldig e-postadresse" not "Error")
- [ ] Invalid fields have `aria-invalid="true"` attribute
- [ ] Success messages announced (`role="status"`)

#### Color Contrast
- [ ] All text meets 4.5:1 contrast (normal text) or 3:1 (large text ≥18pt)
- [ ] UI components meet 3:1 contrast (buttons, inputs, focus indicators)
- [ ] Links distinguishable from surrounding text (underline or 3:1 contrast)
- [ ] Error/warning/success states not conveyed by color alone (icon + text)

#### Text Resizing
- [ ] Text resizable to 200% via browser zoom (Cmd/Ctrl + +)
- [ ] No horizontal scrolling at 200% zoom
- [ ] No content cut off or overlapping at 200% zoom
- [ ] Buttons and inputs remain usable at 200% zoom

#### Responsive Design
- [ ] App works at 320px viewport width (iPhone SE)
- [ ] No horizontal scrolling on mobile
- [ ] Touch targets at least 44x44px (thumb-friendly)
- [ ] No text smaller than 16px on mobile (prevents zoom on iOS)

#### Headings and Landmarks
- [ ] One `<h1>` per page (page title)
- [ ] Heading hierarchy logical (h1 → h2 → h3, no skipping levels)
- [ ] `<main>` landmark for main content
- [ ] `<nav>` landmark for navigation
- [ ] `<header>` and `<footer>` landmarks (if applicable)
- [ ] ARIA landmarks used correctly (not overused)

**Tools:**
- **Browser DevTools:** Chrome/Edge DevTools → Lighthouse → Accessibility audit
- **Chrome Extensions:** axe DevTools, WAVE, Accessibility Insights
- **Firefox Extensions:** Accessibility Inspector
- **Keyboard only:** Unplug mouse for 30 minutes, test all flows

### 3.4 Assistive Technology Testing (Tier 3)

**Devices:**
- **iOS:** VoiceOver on iPhone (iOS 16+)
- **Android:** TalkBack on Pixel or Samsung (Android 12+)
- **macOS:** VoiceOver on MacBook (macOS 13+)
- **Windows:** NVDA (free, open-source) on Windows 10/11

**Test Flows:**

| Flow | Steps | Screen Reader Expectations |
|------|-------|----------------------------|
| Registration | 1. Open app → 2. Tap "Opprett konto" → 3. Fill form → 4. Submit | Each field announces label + type + required state. Error messages read aloud. Success confirmation read. |
| Login | 1. Open app → 2. Fill email/password → 3. Submit | Fields announce labels. "Logg inn" button announces "button" role. Error read aloud. Dashboard loads. |
| Send Money | 1. Dashboard → 2. "Send penger" → 3. Select recipient → 4. Enter amount → 5. Confirm | Recipient list announces "list" + number of items. Amount input announces "Du sender" label. Confirmation screen reads all details. Success message announces. |
| QR Payment | 1. Dashboard → 2. "Skann" → 3. Scan QR → 4. Confirm | "Start QR-skanning" button announces. Camera opens + status message "Kamera åpnet". Scanned amount read aloud. Confirmation flow same as Send Money. |
| Transaction History | 1. Dashboard → 2. "Transaksjoner" → 3. Browse list | Transaction list announces "list" + number of items. Each item announces date, amount, recipient. Filter buttons announce state. |

**Common Issues to Check:**
- **Too much information:** Screen reader announces entire page on load (use `aria-live="polite"` sparingly)
- **Too little information:** Button announces "button" but no label (needs `aria-label`)
- **Wrong order:** Visual order ≠ DOM order (reorder HTML, don't use CSS to reorder)
- **Unlabeled regions:** "Region" announced without name (add `aria-label` to `<section>`)
- **Redundant announcements:** "Link, link, link" (avoid nested links)
- **Abbreviations:** "NOK" announced as "knock" (use `<abbr title="Norwegian Kroner">NOK</abbr>`)

**Testing Protocol:**
1. Turn on screen reader
2. Close eyes or look away from screen (simulate blind user)
3. Navigate using screen reader shortcuts only (swipe on mobile, arrow keys on desktop)
4. Can you complete the task without seeing the screen?
5. Are announcements clear and not too verbose?
6. Are interactive elements announced with correct role (button, link, checkbox)?

**Documentation:**
Record screen reader output via screen recording. Share with team to demonstrate issues.

---

## 4. Component Audit Plan

Every Drop component and page must be checked for accessibility. Below is the component-by-component audit plan.

### 4.1 UI Components (Radix UI Primitives)

Drop uses Radix UI, which has **built-in WCAG compliance**. However, **custom styling and usage can break accessibility**, so all components must be audited.

| Component | File | WCAG Concerns | Audit Tasks |
|-----------|------|---------------|-------------|
| Button | `components/ui/button.tsx` | Focus visible, contrast, disabled state | ✅ Check focus outline 3:1 contrast, ✅ Check disabled opacity not too low |
| Input | `components/ui/input.tsx` | Label association, error state, autocomplete | ✅ Check all inputs have `<label>`, ✅ Check `aria-invalid` on errors, ✅ Check `autocomplete` attributes |
| Select | `components/ui/select.tsx` | Keyboard nav, ARIA roles | ✅ Test arrow keys, Enter, Esc, ✅ Check `aria-expanded`, `aria-activedescendant` |
| Dialog | `components/ui/dialog.tsx` | Focus trap, Esc close, focus return | ✅ Tab stays inside modal, ✅ Esc closes and returns focus, ✅ Check `aria-labelledby` |
| Alert | `components/ui/alert.tsx` | Screen reader announcement | ✅ Check `role="alert"` or `aria-live="assertive"` for critical alerts |
| Card | `components/ui/card.tsx` | Semantic structure | ✅ Check not used as button (use `<button>` wrapper if clickable) |
| Tabs | `components/ui/tabs.tsx` | ARIA roles, keyboard nav | ✅ Check `role="tablist"`, `role="tab"`, `role="tabpanel"`, ✅ Arrow keys switch tabs |
| Sheet | `components/ui/sheet.tsx` | Focus trap, Esc close | Same as Dialog |
| Badge | `components/ui/badge.tsx` | Not interactive | ✅ Verify NOT focusable (decorative only) |
| Skeleton | `components/ui/skeleton.tsx` | Loading state announcement | ✅ Check `aria-busy="true"` or `role="status"` with "Laster..." text |

**Radix UI Accessibility Features (Built-in):**
- Focus management (focus trap in modals, focus return on close)
- Keyboard navigation (Arrow keys, Enter, Space, Esc)
- ARIA attributes (`role`, `aria-expanded`, `aria-selected`, `aria-labelledby`, `aria-describedby`)
- Screen reader announcements (live regions for dynamic content)

**Reference:** [Radix UI Accessibility Docs](https://www.radix-ui.com/primitives/docs/overview/accessibility)

### 4.2 Custom Components

| Component | File | WCAG Concerns | Audit Tasks |
|-----------|------|---------------|-------------|
| DropLogoFull | `components/drop-logo.tsx` | Alt text | ✅ Check `<svg role="img" aria-label="Drop logo">` or `<img alt="Drop logo">` |
| BottomNav | `components/bottom-nav.tsx` | ARIA roles, active state | ✅ Check `<nav role="navigation">`, ✅ Active tab has `aria-current="page"`, ✅ Icons have labels |
| QRScanner | `components/qr-scanner.tsx` | Camera access, error handling | ✅ Button labeled "Start QR-skanning", ✅ Camera error announced, ✅ Scanned value announced |
| AuthProvider | `components/auth-provider.tsx` | Loading state | ✅ Check loading spinner has `aria-label="Laster brukerdata"` |
| ErrorBoundary | `components/error-boundary.tsx` | Error announcement | ✅ Error message has `role="alert"`, ✅ Retry button keyboard-accessible |
| CookieConsent | `components/cookie-consent.tsx` | Focus management | ✅ Focus moves to consent dialog on page load, ✅ Buttons keyboard-accessible |
| PrePaymentDisclosure | `components/pre-payment-disclosure.tsx` | Readability | ✅ Text 4.5:1 contrast, ✅ Links clearly labeled |
| DropIcons | `components/drop-icons.tsx` | Decorative vs informative | ✅ Decorative icons have `aria-hidden="true"`, ✅ Informative icons have `aria-label` |

### 4.3 Pages (Routes)

| Page | Route | WCAG Concerns | Audit Tasks |
|------|-------|---------------|-------------|
| Landing | `/` | Headings, link labels | ✅ h1 "Drop", ✅ "Opprett konto" and "Logg inn" buttons clear, ✅ Skip link |
| Login | `/login` | Form labels, error handling | ✅ Email/password labeled, ✅ Errors announced, ✅ BankID button keyboard-accessible |
| Register | `/register` | Form labels, validation | ✅ All fields labeled, ✅ Age validation error clear, ✅ Phone format error shows example |
| Onboarding | `/onboarding` | Multi-step form | ✅ Progress indicator has `aria-label="Steg 1 av 3"`, ✅ "Neste" and "Tilbake" buttons clear |
| Dashboard | `/dashboard` | Headings, transaction list | ✅ h1 "Oversikt", ✅ Transaction list has semantic `<ul>`, ✅ Balance announced clearly |
| Send Money | `/send` | Multi-step flow, confirmation | ✅ Each step has h1, ✅ Recipient list keyboard-navigable, ✅ Amount input labeled, ✅ Confirmation summary read aloud |
| QR Scan | `/scan` | Camera access, error handling | ✅ Permission error announced, ✅ Manual entry alternative available, ✅ Camera button labeled |
| Bank Accounts | `/accounts` | Account list, sync status | ✅ Account list semantic, ✅ "Synkroniser" button labeled, ✅ Last synced time announced |
| Transaction History | `/transactions` | Filters, date range | ✅ Filter buttons keyboard-accessible, ✅ Date picker keyboard-navigable, ✅ Transaction count announced |
| Notifications | `/notifications` | List, mark as read | ✅ Notification list semantic, ✅ Unread badge announced, ✅ "Merk som lest" button labeled |
| Profile | `/profile` | Settings, logout | ✅ Settings sections have h2, ✅ Toggle switches have labels, ✅ Logout button confirmation |
| Privacy | `/privacy` | Long text, readability | ✅ Headings hierarchical, ✅ Paragraphs not too wide (60-80 chars), ✅ Line height 1.5+ |
| Terms | `/terms` | Long text, readability | Same as Privacy |
| Fees | `/fees` | Pricing table | ✅ Table has `<th>`, `<caption>`, ✅ Currency amounts clearly labeled |

---

## 5. Critical User Flows — Accessibility Requirements

Financial transactions require extra-clear confirmation states and error handling for accessibility.

### 5.1 Registration Flow

**Steps:** Landing → Register → Onboarding → BankID Verification → Dashboard

**Accessibility Requirements:**

| Step | Requirement | WCAG Criterion |
|------|-------------|----------------|
| Registration form | All fields labeled with `<label for="...">` | 3.3.2 |
| Email validation | Error message specific: "Ugyldig e-postadresse. Eksempel: din@epost.no" | 3.3.1, 3.3.3 |
| Password requirements | Requirements shown near password field (not just in tooltip) | 3.3.2 |
| Age validation | Error if < 18: "Du må være minst 18 år for å bruke Drop" | 3.3.1 |
| Phone validation | Error if not +47: "Kun norske telefonnumre (+47) er tillatt" | 3.3.1 |
| BankID redirect | Button labeled "Bekreft med BankID" + description "Du vil bli sendt til BankID" | 2.4.4 |
| Loading state | "Verifiserer med BankID..." with `aria-busy="true"` | 4.1.2 |
| Success | "Konto opprettet!" with `role="status"` + auto-redirect to dashboard in 3 seconds | 4.1.3 |

**Test with screen reader:** Does user understand each step? Are errors clear? Is success confirmed?

### 5.2 Login Flow

**Steps:** Landing → Login → Dashboard

**Accessibility Requirements:**

| Step | Requirement | WCAG Criterion |
|------|-------------|----------------|
| Email field | `<label for="email">E-postadresse</label>` + `autocomplete="email"` | 1.3.5, 3.3.2 |
| Password field | `<label for="password">Passord</label>` + `autocomplete="current-password"` + show/hide toggle | 1.3.5, 3.3.2 |
| Error | "Feil e-post eller passord" with `role="alert"` + focus moves to email field | 3.3.1 |
| Loading | "Logger inn..." with `aria-busy="true"` on button | 4.1.2 |
| Success | Auto-redirect to dashboard (no announcement needed) | N/A |
| BankID option | "Logg inn med BankID" button keyboard-accessible + icon has `aria-hidden="true"` | 2.1.1 |

**Test with screen reader:** Can user complete login without seeing screen? Are errors clear?

### 5.3 Send Money Flow

**Steps:** Dashboard → Send → Select Recipient → Enter Amount → Confirm → Success

**Accessibility Requirements:**

| Step | Requirement | WCAG Criterion |
|------|-------------|----------------|
| Progress indicator | "Steg 1 av 4" with `aria-label` + visual progress bar (not color alone) | 1.4.1 |
| Recipient list | `<ul role="list">` with `aria-label="Mottakere"` + each item keyboard-focusable | 4.1.2 |
| Search field | `<label for="search">Søk mottakere</label>` + clear button labeled | 3.3.2 |
| No results | "Ingen mottakere funnet" announced via `role="status"` | 4.1.3 |
| Amount input | `<label for="amount">Du sender</label>` + currency shown as text (not just symbol) | 1.3.1, 3.3.2 |
| Exchange rate | "1 NOK = 10.5 RSD" read aloud (not just visual) | 1.3.1 |
| Fee calculation | "Gebyr: 5.00 NOK (0.5%)" announced when amount changes | 4.1.3 |
| Total | "Totalt: 1,005.00 NOK" with `aria-live="polite"` | 4.1.3 |
| Confirmation screen | All details read aloud: amount, recipient, fee, total, delivery time | 1.3.1 |
| Confirm button | "Bekreft sending" button with focus on load + Enter activates | 2.4.7 |
| Loading | "Sender..." with `aria-busy="true"` on button + spinner `aria-hidden="true"` | 4.1.2 |
| Error | "Utilstrekkelig saldo. Du har 1,500 NOK tilgjengelig." with `role="alert"` | 3.3.1, 3.3.3 |
| Success | "Pengene er på vei!" with `role="status"` + checkmark icon `aria-hidden="true"` + success sound (optional, user can disable) | 4.1.3 |

**Test with screen reader:** Can blind user send money confidently? Are amounts clear? Is confirmation explicit?

**Test with keyboard only:** Can user complete flow without mouse? Is tab order logical?

### 5.4 QR Payment Flow

**Steps:** Dashboard → Scan → Camera Access → Scan QR → Confirm → Success

**Accessibility Requirements:**

| Step | Requirement | WCAG Criterion |
|------|-------------|----------------|
| Start scan button | "Start QR-skanning" with icon `aria-hidden="true"` | 1.1.1, 2.4.4 |
| Camera permission | If denied: "Kamera-tilgang nektet. Aktiver kamera i innstillinger." with `role="alert"` | 3.3.1 |
| Camera active | "Kamera åpnet. Hold QR-koden foran kameraet." with `role="status"` | 4.1.3 |
| Manual entry option | "Eller skriv inn beløp manuelt" link visible + keyboard-accessible | 2.4.1, 2.4.4 |
| Scanned value | "Skannet: 150 NOK til Rema 1000" announced via `role="status"` | 4.1.3 |
| Confirmation | Same as Send Money flow | 3.3.4 |

**Accessibility Challenge:** QR scanning is inherently visual. How do blind users pay merchants?

**Solution:**
1. **Manual entry option:** Merchant shows amount on screen, blind user asks sighted person or merchant to read amount, user enters manually.
2. **NFC tap-to-pay (future):** Accessible alternative to QR codes (works with screen curtain on iPhone).
3. **Audio QR codes (future):** QR code embeds tone sequence, app decodes via microphone.

**Test with screen reader:** Can blind user understand QR scanner is active? Is manual entry alternative discoverable?

### 5.5 Transaction History Flow

**Steps:** Dashboard → Transaksjoner → Filter → View Details

**Accessibility Requirements:**

| Step | Requirement | WCAG Criterion |
|------|-------------|----------------|
| Transaction list | `<ul role="list" aria-label="Transaksjoner">` + count announced: "20 transaksjoner" | 4.1.2 |
| Each item | "15. februar, Sent til Marko, 500 NOK" (date, action, recipient, amount) | 1.3.1 |
| Filter buttons | "Filter: Alle" (default) with `aria-pressed="true"` on active filter | 4.1.2 |
| Date range picker | Keyboard-navigable calendar with arrow keys | 2.1.1 |
| No results | "Ingen transaksjoner funnet" with `role="status"` | 4.1.3 |
| Load more | "Last flere transaksjoner" button at bottom (not infinite scroll) | 2.2.2 |

**Test with screen reader:** Can user browse transaction history? Are filters clear? Is date range picker usable?

---

## 6. Testing Tools

### 6.1 Automated Tools

| Tool | Purpose | Cost | Integration |
|------|---------|------|-------------|
| **@axe-core/playwright** | WCAG automated testing in CI | Free (open-source) | Playwright test suite |
| **axe DevTools** (Chrome) | Manual audit during development | Free tier available | Browser extension |
| **Lighthouse** | Overall accessibility score + performance | Free (built into Chrome) | Chrome DevTools |
| **WAVE** | Visual feedback on accessibility issues | Free | Browser extension |
| **Pa11y** | Command-line accessibility testing | Free (open-source) | CI pipeline (alternative to axe) |

### 6.2 Manual Testing Tools

| Tool | Purpose | Platform | Cost |
|------|---------|----------|------|
| **VoiceOver** | Screen reader | iOS, macOS | Free (built-in) |
| **TalkBack** | Screen reader | Android | Free (built-in) |
| **NVDA** | Screen reader | Windows | Free (open-source) |
| **JAWS** | Screen reader | Windows | $1,000+ (enterprise, most popular) |
| **Accessibility Insights** | Guided manual tests | Chrome, Edge | Free (Microsoft) |
| **Color Contrast Analyzer** | Check contrast ratios | Desktop app | Free (TPGi) |
| **HeadingsMap** | Visualize heading structure | Browser extension | Free |

### 6.3 Testing Checklist Template

Use this checklist for each release:

```markdown
## Accessibility Audit — [Feature Name] — [Date]

### Automated Testing
- [ ] axe-core tests pass (0 critical violations)
- [ ] Lighthouse accessibility score ≥ 90
- [ ] WAVE reports no errors

### Manual Testing
- [ ] Keyboard navigation works (no traps, logical order)
- [ ] Focus indicators visible (3:1 contrast)
- [ ] Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)
- [ ] Form labels present and associated
- [ ] Error messages clear and actionable
- [ ] Success messages announced to screen readers
- [ ] Page titles unique and descriptive
- [ ] Headings hierarchical (h1 → h2 → h3)
- [ ] Text resizable to 200% without scrolling

### Screen Reader Testing
- [ ] VoiceOver (iOS) — All flows completable
- [ ] TalkBack (Android) — All flows completable
- [ ] NVDA (Windows) — All flows completable
- [ ] Announcements clear and not too verbose

### Critical Violations
[List any critical violations found]

### Remediation Plan
[List fixes needed before launch]

### Sign-off
- [ ] QA Lead
- [ ] Accessibility Specialist (if available)
- [ ] Product Owner
```

---

## 7. Remediation Priority Framework

Not all accessibility issues are equal. Prioritize fixes based on **impact** (how many users affected) and **severity** (how badly affected).

### 7.1 Priority Matrix

| Priority | Severity | Impact | Example | Timeline |
|----------|----------|--------|---------|----------|
| **P0 — Critical** | Blocker | High | Missing alt text on critical images, form without labels, keyboard trap in payment flow | Fix before launch |
| **P1 — High** | Major barrier | Medium-High | Insufficient color contrast on body text, missing error messages, broken screen reader announcements | Fix in current sprint |
| **P2 — Medium** | Usability issue | Medium | Redundant link text, missing skip link, small touch targets (< 44px) | Fix in next sprint |
| **P3 — Low** | Best practice | Low | Missing lang attribute on secondary content, verbose ARIA labels | Fix when time allows |

### 7.2 Violation Examples by Priority

**P0 — Critical (Fix immediately)**
- Login form has no labels → Screen reader users cannot log in
- Payment confirmation button not keyboard-accessible → Keyboard users cannot send money
- Error messages shown only as red border (no text) → Blind users don't know what's wrong
- Modal dialog traps focus (cannot Esc close) → Keyboard users stuck

**P1 — High (Fix this sprint)**
- Body text (#64748B on #F8FAFC) has 3.2:1 contrast (fails WCAG AA 4.5:1)
- "Click here" link text (destination unclear)
- Transaction list missing semantic structure (`<div>` instead of `<ul>`)
- Loading spinner has no announcement (user doesn't know page is loading)

**P2 — Medium (Fix next sprint)**
- Touch target is 40x40px (should be 44x44px)
- Heading hierarchy skips level (h1 → h3)
- Page title not unique ("Drop" on every page)
- Image has alt text but it's too verbose ("Green rounded square logo with white dollar icon circular arrows and gold dot top-right")

**P3 — Low (Fix when time allows)**
- ARIA label redundant with visible text (`<button aria-label="Send penger">Send penger</button>`)
- Missing `lang="en"` on "Drop" brand name
- Abbreviation not expanded (`NOK` should be `<abbr title="Norske kroner">NOK</abbr>`)

### 7.3 Triage Process

1. **Run automated tests** → Generate violation report
2. **Classify violations** → Assign P0/P1/P2/P3 priority
3. **Create tasks** → P0/P1 go into current sprint backlog
4. **Assign owners** → Developer + QA pair for each P0/P1 issue
5. **Verify fixes** → Re-run automated tests + manual testing
6. **Document** → Update accessibility report

---

## 8. CI Integration

Accessibility tests must run on every PR and deployment to prevent regressions.

### 8.1 GitHub Actions Workflow

Add accessibility testing step to existing CI pipeline:

```yaml
# .github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main, staging]

jobs:
  accessibility:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build app
        run: npm run build

      - name: Run accessibility tests
        run: npm run test:a11y

      - name: Upload accessibility reports
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: accessibility-reports
          path: test-results/accessibility/
          retention-days: 7

      - name: Comment PR with violations
        if: failure() && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const report = fs.readFileSync('test-results/accessibility/summary.txt', 'utf8');
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## ❌ Accessibility Violations Found\n\n${report}\n\nSee full report in artifacts.`
            });
```

### 8.2 Fail Conditions

**Block merge if:**
- ≥ 1 **Critical** violation (blocking issue)
- ≥ 5 **Serious** violations (major accessibility barriers)

**Warn but allow merge if:**
- < 5 **Serious** violations (review required)
- Any number of **Moderate/Minor** violations (fix later)

**Implementation:**
```typescript
// tests/accessibility.spec.ts
const results = await new AxeBuilder({ page }).analyze();

const critical = results.violations.filter(v => v.impact === 'critical');
const serious = results.violations.filter(v => v.impact === 'serious');

if (critical.length > 0) {
  throw new Error(`CRITICAL: ${critical.length} critical accessibility violations found. Merge blocked.`);
}

if (serious.length >= 5) {
  throw new Error(`SERIOUS: ${serious.length} serious accessibility violations found. Merge blocked.`);
}

// Warn for moderate/minor
if (results.violations.length > 0) {
  console.warn(`⚠️ ${results.violations.length} accessibility violations found (non-blocking)`);
}
```

### 8.3 Pre-commit Hook (Optional)

Prevent developers from committing inaccessible code:

```bash
# .husky/pre-commit
#!/bin/sh
npm run lint
npm run test:a11y:quick  # Fast subset of tests (login, dashboard only)
```

---

## 9. Accessibility Reporting Template

After each audit, generate a report for stakeholders.

### 9.1 Report Structure

```markdown
# Drop Accessibility Audit Report

**Date:** [YYYY-MM-DD]
**Audited by:** [Name]
**Scope:** [All pages / Specific feature]
**Standard:** WCAG 2.1 Level AA

---

## Executive Summary

- **Overall Score:** [Lighthouse accessibility score] / 100
- **Critical Violations:** [Number]
- **Serious Violations:** [Number]
- **Moderate Violations:** [Number]
- **Minor Violations:** [Number]
- **Compliance Status:** ✅ Compliant / ⚠️ Non-compliant (remediation in progress) / ❌ Non-compliant (action required)

---

## Detailed Findings

### Critical Violations (P0)

| ID | Page | Issue | WCAG Criterion | Affected Users | Fix |
|----|------|-------|----------------|----------------|-----|
| 1 | /login | Email input missing label | 3.3.2 | Screen reader users | Add `<label for="email">E-postadresse</label>` |
| 2 | /send | Confirm button not keyboard-accessible | 2.1.1 | Keyboard users | Change `<div onClick>` to `<button>` |

### Serious Violations (P1)

[Same table format]

### Moderate Violations (P2)

[Same table format]

### Minor Violations (P3)

[Same table format]

---

## Compliance by WCAG Principle

| Principle | Criteria Tested | Pass | Fail | Pass Rate |
|-----------|-----------------|------|------|-----------|
| Perceivable | 25 | 22 | 3 | 88% |
| Operable | 20 | 18 | 2 | 90% |
| Understandable | 12 | 12 | 0 | 100% |
| Robust | 3 | 3 | 0 | 100% |
| **Total** | **60** | **55** | **5** | **92%** |

---

## Remediation Plan

| Priority | Issue Count | Assigned To | Deadline |
|----------|-------------|-------------|----------|
| P0 (Critical) | 2 | Dev Team | Before launch |
| P1 (High) | 3 | Dev Team | Sprint 24 |
| P2 (Medium) | 8 | Dev Team | Sprint 25-26 |
| P3 (Low) | 12 | Backlog | TBD |

---

## Testing Methodology

- **Automated:** axe-core via Playwright (30% coverage)
- **Manual:** Keyboard navigation, color contrast, form validation (50% coverage)
- **Assistive Technology:** VoiceOver (iOS), TalkBack (Android), NVDA (Windows) (20% coverage)

---

## Sign-off

- [ ] QA Lead: [Name]
- [ ] Accessibility Specialist: [Name]
- [ ] Product Owner: [Name]
- [ ] CEO: Alem (final approval before launch)
```

---

## 10. Compliance Statement (Tilgjengelighetserklæring)

Norwegian law requires a public accessibility statement (tilgjengelighetserklæring) on the website.

### 10.1 Required Content

- **Compliance level:** WCAG 2.1 Level AA
- **Date of last audit:** [Date]
- **Known issues:** List any non-compliant features (if any)
- **Contact for accessibility issues:** Email or form
- **Complaint process:** How to file a complaint with Digitaliseringsdirektoratet

### 10.2 Template (Norwegian)

Create page at `/tilgjengelighet`:

```markdown
# Tilgjengelighetserklæring for Drop

**Sist oppdatert:** [YYYY-MM-DD]

Drop forplikter seg til å gjøre våre tjenester tilgjengelige for alle, inkludert personer med funksjonsnedsettelser. Denne erklæringen beskriver i hvilken grad Drop oppfyller kravene til universell utforming av IKT-løsninger.

## Overholdelse av WCAG 2.1

Drop sikter mot å oppfylle **WCAG 2.1 nivå AA** i henhold til forskrift om universell utforming av IKT-løsninger.

## Kjente begrensninger

Per [dato] har vi identifisert følgende områder som ikke fullt ut oppfyller WCAG 2.1 AA:

- [Liste eventuelle kjente problemer, eller skriv "Ingen kjente begrensninger"]

Vi arbeider kontinuerlig med å forbedre tilgjengeligheten til våre tjenester.

## Tilbakemelding

Har du opplevd problemer med tilgjengeligheten til Drop? Vi vil gjerne høre fra deg.

**Kontakt:** support@getdrop.no

Beskriv problemet så detaljert som mulig, inkludert:
- Hvilken side eller funksjon du prøvde å bruke
- Hvilken enhet og nettleser du brukte
- Eventuell hjelpeteknologi du brukte (f.eks. skjermleser)

Vi vil svare på henvendelser om tilgjengelighet innen 5 virkedager.

## Klageadgang

Dersom du ikke er fornøyd med vårt svar, kan du klage til Digitaliseringsdirektoratet:

**Digitaliseringsdirektoratet**
Postboks 1382 Vika, 0114 Oslo
E-post: post@digdir.no
Nettside: https://www.digdir.no/

## Revisjon av erklæringen

Denne erklæringen ble opprettet [dato] og sist oppdatert [dato]. Vi gjennomgår og oppdaterer denne erklæringen minst én gang per år.
```

### 10.3 English Version

Also provide English version at `/en/accessibility` for international users.

---

## 11. Training & Developer Guidelines

Accessibility is everyone's responsibility. All developers must understand basic WCAG principles.

### 11.1 Developer Onboarding Checklist

New developers must complete:

- [ ] Read this accessibility spec (30 min)
- [ ] Watch "Introduction to Screen Readers" video (20 min)
- [ ] Install axe DevTools Chrome extension
- [ ] Run accessibility audit on 1 page (hands-on exercise)
- [ ] Fix 1 accessibility bug (pair with senior dev)

### 11.2 Coding Guidelines (Quick Reference)

**Semantic HTML First**
```tsx
// ❌ Bad
<div onClick={handleClick}>Click me</div>

// ✅ Good
<button onClick={handleClick}>Click me</button>
```

**Always Label Inputs**
```tsx
// ❌ Bad
<input type="email" placeholder="Email" />

// ✅ Good
<label htmlFor="email">E-postadresse</label>
<input id="email" type="email" autocomplete="email" />
```

**Focus Indicators**
```css
/* ❌ Bad */
button:focus {
  outline: none; /* NEVER do this without custom focus style */
}

/* ✅ Good */
button:focus-visible {
  outline: 3px solid #0B6E35;
  outline-offset: 2px;
}
```

**Error Announcements**
```tsx
// ❌ Bad
{error && <p className="text-red-500">{error}</p>}

// ✅ Good
{error && <p role="alert" className="text-red-500">{error}</p>}
```

**Loading States**
```tsx
// ❌ Bad
<button disabled={loading}>
  {loading ? <Spinner /> : "Send"}
</button>

// ✅ Good
<button disabled={loading} aria-busy={loading}>
  {loading ? "Sender..." : "Send"}
</button>
```

**Images**
```tsx
// ❌ Bad (decorative icon treated as informative)
<img src="icon.svg" />

// ✅ Good (decorative)
<img src="icon.svg" alt="" aria-hidden="true" />

// ✅ Good (informative)
<img src="success.svg" alt="Suksess" />
```

### 11.3 Code Review Checklist

Every PR must pass this accessibility checklist:

- [ ] All interactive elements keyboard-accessible (Tab, Enter, Space)
- [ ] All form inputs have labels (visible or `aria-label`)
- [ ] Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)
- [ ] Error messages have `role="alert"`
- [ ] Success messages have `role="status"`
- [ ] Images have alt text (or `alt=""` if decorative)
- [ ] Buttons/links have descriptive text (not "Click here")
- [ ] Focus indicators visible on all interactive elements
- [ ] No `outline: none` without custom focus style
- [ ] Page has unique `<title>` tag
- [ ] Headings hierarchical (h1 → h2 → h3)
- [ ] ARIA used correctly (not overused or misused)

---

## 12. Implementation Plan (Phased Approach)

Full accessibility compliance is a multi-sprint effort. Below is a phased rollout plan.

### Phase 1: Foundation (Sprint 1-2) — Before MVP Launch

**Goal:** Fix all P0 (critical) violations. Minimum viable accessibility.

| Task | Owner | Acceptance Criteria |
|------|-------|---------------------|
| Add labels to all form inputs | Dev Team | All inputs have `<label>` or `aria-label` |
| Fix keyboard navigation | Dev Team | All pages navigable via Tab key |
| Add focus indicators | Dev Team | All interactive elements have visible focus (3px outline) |
| Fix critical color contrast | Design + Dev | Body text meets 4.5:1, buttons meet 3:1 |
| Add error announcements | Dev Team | All errors have `role="alert"` |
| Add page titles | Dev Team | All pages have unique `<title>` |
| Set up axe-core CI tests | DevOps | CI fails on critical violations |
| Create accessibility statement | Content Team | `/tilgjengelighet` page published |

**Deliverable:** Lighthouse accessibility score ≥ 80, 0 critical violations

### Phase 2: Compliance (Sprint 3-4) — Pre-Public Launch

**Goal:** Full WCAG 2.1 AA compliance. Pass Digdir audit.

| Task | Owner | Acceptance Criteria |
|------|-------|---------------------|
| Fix all P1 (high) violations | Dev Team | Serious violations < 5 |
| Screen reader testing (iOS, Android, Windows) | QA + Accessibility Specialist | All critical flows completable |
| Add skip links | Dev Team | Skip to main content on all pages |
| Improve heading hierarchy | Dev Team | All pages have h1, logical h2/h3 structure |
| Add ARIA landmarks | Dev Team | `<main>`, `<nav>`, `<header>`, `<footer>` on all pages |
| Fix text resizing issues | Dev Team | Content usable at 200% zoom |
| Add autocomplete attributes | Dev Team | All form fields have `autocomplete` |
| Manual keyboard testing (all flows) | QA | No keyboard traps, tab order logical |
| Full accessibility audit report | Accessibility Specialist | Report delivered to CEO |

**Deliverable:** Lighthouse accessibility score ≥ 90, WCAG 2.1 AA compliant

### Phase 3: Optimization (Sprint 5-6) — Post-Launch

**Goal:** Exceed WCAG 2.1 AA. Best-in-class accessibility for fintech.

| Task | Owner | Acceptance Criteria |
|------|-------|---------------------|
| Fix all P2 (medium) violations | Dev Team | Moderate violations < 10 |
| Add alternative text improvements | Content Team | All alt text concise and descriptive |
| Improve ARIA label clarity | Dev Team | Labels tested with real screen reader users |
| Add QR code alternatives | Dev Team | Manual entry option visible and easy to find |
| Improve transaction announcements | Dev Team | Screen reader announces amounts clearly |
| Add accessibility preference controls | Dev Team | User can reduce motion, increase contrast |
| Conduct user testing with disabled users | Research Team | 5+ users with disabilities test app |
| WCAG 2.2 upgrade (optional) | Dev Team | Evaluate new success criteria (Target Size 2.5.8, Focus Not Obscured 2.4.11) |

**Deliverable:** Lighthouse accessibility score ≥ 95, user testimonials from disabled users

---

## 13. Budget & Resources

### 13.1 Estimated Effort

| Phase | Effort (Dev Days) | Timeline |
|-------|-------------------|----------|
| Phase 1: Foundation | 10-15 days | Sprint 1-2 (2 weeks) |
| Phase 2: Compliance | 15-20 days | Sprint 3-4 (2 weeks) |
| Phase 3: Optimization | 10-15 days | Sprint 5-6 (2 weeks) |
| **Total** | **35-50 days** | **6 weeks** |

**Assumptions:**
- 2 full-time developers (50% time on accessibility)
- 1 QA engineer (25% time on accessibility testing)
- 1 accessibility specialist (contractor, 20 hours consultation)

### 13.2 Tool Costs

| Tool | Cost | Justification |
|------|------|---------------|
| axe-core (open-source) | Free | CI integration, automated testing |
| Lighthouse (built into Chrome) | Free | Manual testing, audit reports |
| NVDA (open-source) | Free | Windows screen reader testing |
| VoiceOver (built into iOS/macOS) | Free | Apple screen reader testing |
| TalkBack (built into Android) | Free | Android screen reader testing |
| Accessibility Insights (Microsoft) | Free | Guided manual testing |
| **Optional: Accessibility Specialist** | ~$2,000 USD (20 hours @ $100/hr) | External audit, training, consultation |
| **Total** | **$0 - $2,000** | Optional contractor cost |

### 13.3 ROI (Return on Investment)

**Compliance Benefits:**
- **Legal:** Avoid fines from Digitaliseringsdirektoratet
- **Market:** 15% of population has disabilities — expand addressable market
- **Reputation:** First fully accessible fintech in Norway → competitive advantage
- **SEO:** Better semantic HTML → better Google rankings
- **Usability:** Accessible design benefits all users (clear labels, logical flow, keyboard shortcuts)

**Example:** If Drop has 10,000 users, 15% (1,500) have some disability. If accessibility unlocks this segment, revenue increase = 15%.

---

## Appendix A: WCAG 2.1 AA Complete Criteria List

Full list of all 50 WCAG 2.1 Level A/AA success criteria:

### Level A (25 criteria)

**Perceivable:**
- 1.1.1 Non-text Content
- 1.2.1 Audio-only and Video-only (Prerecorded)
- 1.2.2 Captions (Prerecorded)
- 1.2.3 Audio Description or Media Alternative (Prerecorded)
- 1.3.1 Info and Relationships
- 1.3.2 Meaningful Sequence
- 1.3.3 Sensory Characteristics
- 1.4.1 Use of Color
- 1.4.2 Audio Control

**Operable:**
- 2.1.1 Keyboard
- 2.1.2 No Keyboard Trap
- 2.1.4 Character Key Shortcuts
- 2.2.1 Timing Adjustable
- 2.2.2 Pause, Stop, Hide
- 2.3.1 Three Flashes or Below Threshold
- 2.4.1 Bypass Blocks
- 2.4.2 Page Titled
- 2.4.3 Focus Order
- 2.4.4 Link Purpose (In Context)
- 2.5.1 Pointer Gestures
- 2.5.2 Pointer Cancellation
- 2.5.3 Label in Name
- 2.5.4 Motion Actuation

**Understandable:**
- 3.1.1 Language of Page
- 3.2.1 On Focus
- 3.2.2 On Input
- 3.3.1 Error Identification
- 3.3.2 Labels or Instructions

**Robust:**
- 4.1.1 Parsing
- 4.1.2 Name, Role, Value

### Level AA (25 additional criteria)

**Perceivable:**
- 1.2.4 Captions (Live)
- 1.2.5 Audio Description (Prerecorded)
- 1.3.4 Orientation
- 1.3.5 Identify Input Purpose
- 1.4.3 Contrast (Minimum)
- 1.4.4 Resize Text
- 1.4.5 Images of Text
- 1.4.10 Reflow
- 1.4.11 Non-text Contrast
- 1.4.12 Text Spacing
- 1.4.13 Content on Hover or Focus

**Operable:**
- 2.4.5 Multiple Ways
- 2.4.6 Headings and Labels
- 2.4.7 Focus Visible

**Understandable:**
- 3.1.2 Language of Parts
- 3.2.3 Consistent Navigation
- 3.2.4 Consistent Identification
- 3.3.3 Error Suggestion
- 3.3.4 Error Prevention (Legal, Financial, Data)

**Robust:**
- 4.1.3 Status Messages

---

## Appendix B: Norwegian Accessibility Law Summary

**Primary Law:** Likestillings- og diskrimineringsloven (Equality and Anti-Discrimination Act)

**Key Section:** § 18 — Duty to provide universal design

**Scope:** Public and private sector digital services (including fintech, e-commerce, education)

**Standard:** WCAG 2.1 Level AA (minimum)

**Enforcement:** Digitaliseringsdirektoratet (Norwegian Digitalisation Agency)

**Penalties:** Fines for non-compliance, discrimination complaints to LDO (Equality and Anti-Discrimination Ombud)

**Deadline:** Norway universally designed by 2025 (national goal)

**Drop's Obligation:** As a fintech service provider operating in Norway, Drop MUST comply with universal design requirements before public launch. Failure to comply risks fines, legal liability, and reputational damage.

---

## Document Control

**Version History:**

| Version | Date | Author | Changes |
|---------|------|--------|---------|
| 1.0 | 2026-02-17 | Architect Agent | Initial draft — full WCAG 2.1 AA spec + Norwegian law + audit methodology |

**Approvals:**

- [x] Architect Agent — 2026-02-17
- [ ] John (AI Director) — Pending
- [ ] Alem (CEO) — Pending

**Next Steps:**

1. Review by John (validate Norwegian law references, tool recommendations)
2. Approval by Alem (finalize budget and timeline)
3. Share with Dev Team (begin Phase 1 implementation)
4. Log to HiveMind (post intel entry)

---

**END OF SPECIFICATION**

# drop-analytics-bi-spec

# Drop Analytics & Business Intelligence — Architecture Specification

**Version:** 1.0
**Date:** 2026-02-17
**Author:** architect agent (Sonnet 4.5)
**Status:** Draft
**MC Task:** #1195

---

## Executive Summary

This document specifies the analytics and business intelligence system for Drop, a Norwegian fintech payment app operating under PSD2 pass-through model. The system must track user engagement, transaction metrics, conversion funnels, and operational health while maintaining strict GDPR compliance (Datatilsynet requirements).

**Recommendation:** PostHog self-hosted (EU instance) for event tracking + Custom SQLite/PostgreSQL queries for financial KPIs + Admin dashboard built on existing Next.js stack.

---

## 1. Platform Decision

### 1.1 Requirements Matrix

| Requirement | Weight | PostHog (Self-hosted) | Mixpanel | Custom (SQL + Dashboard) |
|-------------|--------|----------------------|----------|--------------------------|
| GDPR compliance (Datatilsynet) | CRITICAL | ✅ Data stays in Norway | ⚠️ US-based, SCCs required | ✅ Full control |
| PSD2 audit trail | CRITICAL | ⚠️ Not built for fintech | ⚠️ Not built for fintech | ✅ Native to DB |
| Cost (10K MAU) | HIGH | €0 (self-hosted) | ~$1000/month | €0 (existing infra) |
| Real-time dashboards | MEDIUM | ✅ Built-in | ✅ Built-in | ⚠️ Build from scratch |
| Session replay | LOW | ✅ Built-in | ❌ Paid extra | ❌ N/A |
| Funnel analysis | HIGH | ✅ Built-in | ✅ Built-in | ⚠️ Custom SQL |
| Setup complexity | MEDIUM | Medium (Docker) | Low (SaaS) | Low (existing stack) |
| Financial metrics | CRITICAL | ❌ Not fintech-specific | ❌ Not fintech-specific | ✅ Direct DB access |

### 1.2 Decision: Hybrid Approach

**Recommended Stack:**
1. **PostHog (self-hosted)** — User behavior, funnels, session replay, A/B testing
2. **Custom SQL queries** — Financial KPIs (transaction volume, revenue, error rates)
3. **Admin Dashboard (Next.js)** — Internal BI dashboard at `/admin/analytics`

**Rationale:**
- **GDPR:** Self-hosted PostHog keeps data in Norway (Datatilsynet compliant)
- **PSD2 Audit:** Financial metrics pulled directly from `transactions`, `audit_log` tables (source of truth)
- **Cost:** PostHog self-hosted is free (infrastructure cost only), custom queries have zero marginal cost
- **Separation of concerns:** Product analytics (PostHog) vs financial compliance (SQL)

**Cost Analysis:**
| Solution | Setup | Monthly Cost (10K MAU) |
|----------|-------|------------------------|
| PostHog Cloud | 1 day | ~€299/month (EU hosting) |
| PostHog Self-hosted | 2 days | ~€50/month (Fly.io 2GB RAM) |
| Mixpanel | 1 day | ~$1000/month |
| Custom only | 5 days | €0 (existing infra) |
| **Hybrid (recommended)** | **3 days** | **~€50/month** |

---

## 2. KPI Definitions (Precise Logic)

### 2.1 User Engagement Metrics

#### DAU (Daily Active Users)
**Definition:** Unique users who performed any action in the app within the last 24 hours.

**SQL Query:**
```sql
SELECT COUNT(DISTINCT user_id) as dau
FROM audit_log
WHERE timestamp >= datetime('now', '-1 day');
```

**PostHog Event:** Track `app_opened` event on every session start.

---

#### WAU (Weekly Active Users)
**Definition:** Unique users active in the last 7 days.

**SQL Query:**
```sql
SELECT COUNT(DISTINCT user_id) as wau
FROM audit_log
WHERE timestamp >= datetime('now', '-7 days');
```

---

#### MAU (Monthly Active Users)
**Definition:** Unique users active in the last 30 days.

**SQL Query:**
```sql
SELECT COUNT(DISTINCT user_id) as mau
FROM audit_log
WHERE timestamp >= datetime('now', '-30 days');
```

---

#### Stickiness Ratio
**Definition:** DAU/MAU — measures how frequently users return.

**Target:** >20% (industry benchmark for fintech apps)

**Calculation:**
```sql
WITH daily AS (
  SELECT COUNT(DISTINCT user_id) as dau
  FROM audit_log
  WHERE timestamp >= datetime('now', '-1 day')
),
monthly AS (
  SELECT COUNT(DISTINCT user_id) as mau
  FROM audit_log
  WHERE timestamp >= datetime('now', '-30 days')
)
SELECT ROUND((daily.dau * 100.0 / monthly.mau), 2) as stickiness_pct
FROM daily, monthly;
```

---

### 2.2 Transaction Metrics

#### Transaction Volume (Count)
**Definition:** Total number of completed transactions per day.

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  COUNT(*) as tx_count
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;
```

---

#### Transaction Value (Amount)
**Definition:** Total monetary value of completed transactions per day (in NOK).

**Note:** All amounts stored as øre (1 NOK = 100 øre).

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  SUM(amount) / 100.0 as total_nok,
  COUNT(*) as tx_count,
  AVG(amount) / 100.0 as avg_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;
```

---

#### Transaction Volume by Type
**Definition:** Split by `remittance` vs `qr_payment`.

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  type,
  COUNT(*) as tx_count,
  SUM(amount) / 100.0 as total_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at), type
ORDER BY date DESC, type;
```

---

#### Average Transaction Value (ATV)
**Definition:** Mean transaction amount per completed transaction.

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  AVG(amount) / 100.0 as avg_tx_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;
```

---

### 2.3 Conversion Funnel

#### Funnel Stages
1. **Register** — User creates account (`/register` page, `register_success` audit action)
2. **Verify KYC** — User completes BankID verification (`kyc_approved` audit action)
3. **Link Bank** — User connects bank account via AISP (`bank_account_linked` audit action)
4. **First Transfer** — User completes first transaction (`first_transaction` audit action)

**Funnel SQL Query:**
```sql
WITH funnel AS (
  SELECT
    COUNT(DISTINCT CASE WHEN action = 'register_success' THEN user_id END) as registered,
    COUNT(DISTINCT CASE WHEN action = 'kyc_approved' THEN user_id END) as kyc_verified,
    COUNT(DISTINCT CASE WHEN action = 'bank_account_linked' THEN user_id END) as bank_linked,
    COUNT(DISTINCT CASE WHEN action = 'first_transaction' THEN user_id END) as first_tx
  FROM audit_log
  WHERE timestamp >= datetime('now', '-30 days')
)
SELECT
  registered,
  kyc_verified,
  ROUND(kyc_verified * 100.0 / registered, 2) as kyc_conversion_pct,
  bank_linked,
  ROUND(bank_linked * 100.0 / kyc_verified, 2) as bank_conversion_pct,
  first_tx,
  ROUND(first_tx * 100.0 / bank_linked, 2) as tx_conversion_pct,
  ROUND(first_tx * 100.0 / registered, 2) as overall_conversion_pct
FROM funnel;
```

**PostHog Funnel:**
- Event 1: `register_success`
- Event 2: `kyc_approved`
- Event 3: `bank_account_linked`
- Event 4: `first_transaction`

---

#### Time to First Transaction (TTFT)
**Definition:** Median time from registration to first completed transaction.

**SQL Query:**
```sql
WITH user_events AS (
  SELECT
    user_id,
    MIN(CASE WHEN action = 'register_success' THEN timestamp END) as registered_at,
    MIN(CASE WHEN action = 'first_transaction' THEN timestamp END) as first_tx_at
  FROM audit_log
  WHERE timestamp >= datetime('now', '-90 days')
  GROUP BY user_id
  HAVING first_tx_at IS NOT NULL
)
SELECT
  AVG(JULIANDAY(first_tx_at) - JULIANDAY(registered_at)) * 24 * 60 as avg_minutes,
  MIN(JULIANDAY(first_tx_at) - JULIANDAY(registered_at)) * 24 * 60 as min_minutes,
  MAX(JULIANDAY(first_tx_at) - JULIANDAY(registered_at)) * 24 * 60 as max_minutes
FROM user_events;
```

**Target:** <10 minutes (industry best practice for fintech onboarding)

---

### 2.4 Revenue Metrics

#### Gross Revenue
**Definition:** Total fees collected (before costs).

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  SUM(fee) / 100.0 as gross_revenue_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;
```

---

#### Revenue by Type
**Definition:** Fee breakdown by transaction type.

**SQL Query:**
```sql
SELECT
  type,
  SUM(fee) / 100.0 as revenue_nok,
  COUNT(*) as tx_count,
  AVG(fee) / 100.0 as avg_fee_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-30 days')
GROUP BY type;
```

---

#### Average Revenue Per User (ARPU)
**Definition:** Total revenue divided by active users in period.

**SQL Query:**
```sql
WITH revenue AS (
  SELECT SUM(fee) / 100.0 as total_revenue
  FROM transactions
  WHERE status = 'completed'
    AND created_at >= datetime('now', '-30 days')
),
active_users AS (
  SELECT COUNT(DISTINCT user_id) as mau
  FROM audit_log
  WHERE timestamp >= datetime('now', '-30 days')
)
SELECT
  total_revenue,
  mau,
  ROUND(total_revenue / mau, 2) as arpu_nok
FROM revenue, active_users;
```

---

### 2.5 Error Rate Metrics

#### API Error Rate (by endpoint)
**Definition:** Percentage of API requests returning 5xx errors.

**SQL Query (requires structured logging with `request_id`):**
```sql
-- Assumes logger.ts logs API requests to audit_log with details JSON
SELECT
  JSON_EXTRACT(details, '$.endpoint') as endpoint,
  COUNT(*) as total_requests,
  SUM(CASE WHEN JSON_EXTRACT(details, '$.status') >= 500 THEN 1 ELSE 0 END) as error_count,
  ROUND(SUM(CASE WHEN JSON_EXTRACT(details, '$.status') >= 500 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as error_rate_pct
FROM audit_log
WHERE action = 'api_request'
  AND timestamp >= datetime('now', '-1 day')
GROUP BY JSON_EXTRACT(details, '$.endpoint')
HAVING error_count > 0
ORDER BY error_rate_pct DESC;
```

**Sentry Integration:** Pull error counts from Sentry API (already implemented in Drop).

---

#### Transaction Failure Rate
**Definition:** Percentage of transactions that fail.

**SQL Query:**
```sql
SELECT
  DATE(created_at) as date,
  COUNT(*) as total_tx,
  SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tx,
  ROUND(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as failure_rate_pct
FROM transactions
WHERE created_at >= datetime('now', '-30 days')
GROUP BY DATE(created_at)
ORDER BY date DESC;
```

**Target:** <2% (industry benchmark for payment apps)

---

### 2.6 Retention Metrics

#### D1, D7, D30 Retention
**Definition:** Percentage of users who return 1, 7, or 30 days after registration.

**SQL Query (D7 retention):**
```sql
WITH cohort AS (
  SELECT
    user_id,
    DATE(MIN(timestamp)) as cohort_date
  FROM audit_log
  WHERE action = 'register_success'
  GROUP BY user_id
),
activity AS (
  SELECT
    user_id,
    DATE(timestamp) as activity_date
  FROM audit_log
  WHERE timestamp >= datetime('now', '-90 days')
)
SELECT
  cohort.cohort_date,
  COUNT(DISTINCT cohort.user_id) as cohort_size,
  COUNT(DISTINCT CASE
    WHEN JULIANDAY(activity.activity_date) - JULIANDAY(cohort.cohort_date) BETWEEN 6 AND 8
    THEN activity.user_id
  END) as retained_d7,
  ROUND(COUNT(DISTINCT CASE
    WHEN JULIANDAY(activity.activity_date) - JULIANDAY(cohort.cohort_date) BETWEEN 6 AND 8
    THEN activity.user_id
  END) * 100.0 / COUNT(DISTINCT cohort.user_id), 2) as retention_d7_pct
FROM cohort
LEFT JOIN activity ON cohort.user_id = activity.user_id
WHERE cohort.cohort_date >= datetime('now', '-90 days')
GROUP BY cohort.cohort_date
ORDER BY cohort.cohort_date DESC;
```

**PostHog:** Built-in retention analysis feature (preferred for visualization).

---

## 3. Event Taxonomy (PostHog)

### 3.1 Core Events

| Event Name | Triggered When | Properties |
|------------|----------------|------------|
| `app_opened` | User opens app (any session start) | `user_id`, `device_type`, `os_version` |
| `page_viewed` | User navigates to any page | `user_id`, `page_path`, `referrer` |
| `register_started` | User clicks "Register" button | `user_id`, `source` (organic, referral) |
| `register_success` | Registration API returns 201 | `user_id`, `phone_country_code` |
| `login_success` | Login API returns JWT | `user_id` |
| `login_failed` | Login API returns 401 | `reason` (invalid_pin, not_found) |
| `kyc_started` | User starts BankID flow | `user_id`, `kyc_method` (bankid) |
| `kyc_approved` | KYC status set to 'approved' | `user_id`, `kyc_method` |
| `bank_account_linked` | User links bank via AISP | `user_id`, `bank_name` |
| `transaction_initiated` | User submits payment form | `user_id`, `type` (remittance, qr_payment), `amount_nok` |
| `transaction_completed` | Transaction status = 'completed' | `user_id`, `tx_id`, `type`, `amount_nok`, `fee_nok` |
| `transaction_failed` | Transaction status = 'failed' | `user_id`, `tx_id`, `type`, `error_code` |
| `first_transaction` | User completes first-ever transaction | `user_id`, `type`, `amount_nok` |
| `notification_received` | Push notification sent | `user_id`, `notification_type` |
| `notification_clicked` | User opens notification | `user_id`, `notification_type` |
| `qr_scanned` | User scans QR code | `user_id`, `merchant_id` |
| `settings_changed` | User updates settings | `user_id`, `setting_key` |

### 3.2 Error Events

| Event Name | Triggered When | Properties |
|------------|----------------|------------|
| `api_error` | API returns 5xx error | `user_id`, `endpoint`, `status_code`, `error_message` |
| `network_error` | Client-side network failure | `user_id`, `endpoint` |
| `validation_error` | Form validation fails | `user_id`, `form_name`, `field_name` |

### 3.3 Custom Properties (User-level)

| Property | Source | Purpose |
|----------|--------|---------|
| `user_id` | DB `users.id` | Identify user across sessions |
| `kyc_status` | DB `users.kyc_status` | Segment by verification state |
| `role` | DB `users.role` | Segment by user/merchant |
| `risk_level` | DB `users.risk_level` | AML segmentation |
| `created_at` | DB `users.created_at` | Cohort analysis |
| `first_transaction_at` | DB custom query | Activation metric |

---

## 4. Database Schema (Analytics Extensions)

### 4.1 New Table: `analytics_events`

**Purpose:** Store custom events not captured in `audit_log` (e.g., client-side events before auth).

```sql
CREATE TABLE IF NOT EXISTS analytics_events (
  id TEXT PRIMARY KEY,
  event_name TEXT NOT NULL,
  user_id TEXT REFERENCES users(id), -- NULL for pre-auth events
  session_id TEXT,
  properties TEXT, -- JSON blob
  device_type TEXT,
  os_version TEXT,
  app_version TEXT,
  ip_address TEXT,
  user_agent TEXT,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_analytics_events_user ON analytics_events(user_id);
CREATE INDEX IF NOT EXISTS idx_analytics_events_event ON analytics_events(event_name);
CREATE INDEX IF NOT EXISTS idx_analytics_events_created ON analytics_events(created_at);
```

**GDPR Note:** `ip_address` is PII — anonymize last octet (e.g., `192.168.1.0`).

---

### 4.2 New View: `daily_metrics`

**Purpose:** Materialized view for fast dashboard queries (regenerate daily via cron).

```sql
CREATE VIEW IF NOT EXISTS daily_metrics AS
SELECT
  DATE(created_at) as date,
  COUNT(DISTINCT user_id) as dau,
  COUNT(DISTINCT CASE WHEN type = 'remittance' THEN id END) as remittance_count,
  COUNT(DISTINCT CASE WHEN type = 'qr_payment' THEN id END) as qr_payment_count,
  SUM(CASE WHEN status = 'completed' THEN amount ELSE 0 END) / 100.0 as total_volume_nok,
  SUM(CASE WHEN status = 'completed' THEN fee ELSE 0 END) / 100.0 as total_revenue_nok,
  AVG(CASE WHEN status = 'completed' THEN amount END) / 100.0 as avg_tx_nok,
  SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_tx_count,
  ROUND(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as failure_rate_pct
FROM transactions
WHERE created_at >= datetime('now', '-365 days')
GROUP BY DATE(created_at);
```

**Optimization:** For large datasets (>1M transactions), create a materialized table instead of a view.

---

### 4.3 New View: `funnel_stages`

**Purpose:** Pre-aggregated funnel data for dashboard.

```sql
CREATE VIEW IF NOT EXISTS funnel_stages AS
SELECT
  user_id,
  MIN(CASE WHEN action = 'register_success' THEN timestamp END) as registered_at,
  MIN(CASE WHEN action = 'kyc_approved' THEN timestamp END) as kyc_at,
  MIN(CASE WHEN action = 'bank_account_linked' THEN timestamp END) as bank_linked_at,
  MIN(CASE WHEN action = 'first_transaction' THEN timestamp END) as first_tx_at
FROM audit_log
WHERE action IN ('register_success', 'kyc_approved', 'bank_account_linked', 'first_transaction')
GROUP BY user_id;
```

---

## 5. API Endpoints (Admin Dashboard)

### 5.1 Admin Authentication

**Middleware:** Extend `requireAuth()` to check `role = 'admin'` (new role in `users` table).

**Route:** `/api/admin/*` — All admin endpoints protected by `requireAdmin()` middleware.

---

### 5.2 KPI Endpoints

#### GET `/api/admin/analytics/overview`

**Description:** High-level dashboard summary.

**Response:**
```json
{
  "data": {
    "dau": 1234,
    "wau": 5678,
    "mau": 10234,
    "stickiness_pct": 12.05,
    "total_users": 15234,
    "total_transactions": 45678,
    "total_volume_nok": 1234567.89,
    "total_revenue_nok": 12345.67,
    "avg_tx_nok": 270.45,
    "failure_rate_pct": 1.23
  }
}
```

---

#### GET `/api/admin/analytics/transactions?period=30d`

**Description:** Transaction metrics over time.

**Query Params:**
- `period` — `7d`, `30d`, `90d`, `1y`

**Response:**
```json
{
  "data": [
    {
      "date": "2026-02-17",
      "tx_count": 234,
      "volume_nok": 67890.12,
      "revenue_nok": 678.90,
      "avg_tx_nok": 290.17,
      "failure_rate_pct": 1.28
    },
    // ... more days
  ]
}
```

---

#### GET `/api/admin/analytics/funnel?period=30d`

**Description:** Conversion funnel metrics.

**Response:**
```json
{
  "data": {
    "registered": 1000,
    "kyc_verified": 850,
    "kyc_conversion_pct": 85.0,
    "bank_linked": 720,
    "bank_conversion_pct": 84.71,
    "first_tx": 640,
    "tx_conversion_pct": 88.89,
    "overall_conversion_pct": 64.0
  }
}
```

---

#### GET `/api/admin/analytics/retention?cohort_date=2026-01-01`

**Description:** Cohort retention analysis.

**Response:**
```json
{
  "data": {
    "cohort_date": "2026-01-01",
    "cohort_size": 500,
    "d1_retained": 350,
    "d1_retention_pct": 70.0,
    "d7_retained": 200,
    "d7_retention_pct": 40.0,
    "d30_retained": 150,
    "d30_retention_pct": 30.0
  }
}
```

---

#### GET `/api/admin/analytics/errors?period=24h`

**Description:** Error rate by endpoint.

**Response:**
```json
{
  "data": [
    {
      "endpoint": "/api/transactions",
      "total_requests": 5000,
      "error_count": 25,
      "error_rate_pct": 0.5
    },
    // ... more endpoints
  ]
}
```

---

### 5.3 Rate Limiting

**Admin endpoints:** 100 requests/minute per admin user (higher than public API).

**Implementation:** Reuse existing `rate_limits` table + middleware.

---

## 6. Dashboard Spec (Admin UI)

### 6.1 Route: `/admin/analytics`

**Access:** Role-based (only `role = 'admin'` users can access).

**Layout:**
```
┌─────────────────────────────────────────────────────┐
│ Drop Admin — Analytics                      [Export]│
├─────────────────────────────────────────────────────┤
│ Date Range: [Last 30 days ▼]                       │
├─────────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐   │
│ │ DAU     │ │ MAU     │ │ Tx Vol  │ │ Revenue │   │
│ │ 1,234   │ │ 10,234  │ │ 1.2M    │ │ 12.3K   │   │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘   │
├─────────────────────────────────────────────────────┤
│ Transaction Volume (Last 30 Days)                   │
│ [Line chart: Date → Tx Count]                       │
├─────────────────────────────────────────────────────┤
│ Conversion Funnel                                   │
│ Register → KYC → Bank → First Tx                    │
│ 1000 →85%→ 850 →85%→ 720 →89%→ 640                 │
├─────────────────────────────────────────────────────┤
│ Error Rate by Endpoint                              │
│ [Table: Endpoint, Requests, Errors, Rate %]         │
└─────────────────────────────────────────────────────┘
```

---

### 6.2 Chart Library

**Recommendation:** Recharts (React-based, lightweight, no external dependencies).

**Alternative:** Chart.js (more features, heavier).

**Install:**
```bash
npm install recharts
```

---

### 6.3 Export Feature

**Button:** "Export CSV" — Generates CSV of current view for Excel analysis.

**Implementation:** Client-side CSV generation (no server processing).

---

### 6.4 Filters

| Filter | Options |
|--------|---------|
| Date Range | Last 7 days, Last 30 days, Last 90 days, Custom |
| Transaction Type | All, Remittance, QR Payment |
| User Segment | All, Verified, Unverified |
| Country (Remittance) | All, RS (Serbia), BA (Bosnia), TR (Turkey), etc. |

---

## 7. Privacy & GDPR Compliance (Datatilsynet)

### 7.1 What's Tracked

#### Personal Data Tracked
| Data | Purpose | Legal Basis | Retention |
|------|---------|-------------|-----------|
| `user_id` | Product analytics | Legitimate interest (GDPR Art. 6(1)(f)) | Until account deletion |
| `ip_address` (anonymized) | Fraud prevention | Legitimate interest | 90 days |
| `device_type`, `os_version` | Product optimization | Legitimate interest | Until account deletion |
| `transaction_amount` | Business metrics | Contract performance (GDPR Art. 6(1)(b)) | 5 years (PSD2 requirement) |

#### Anonymized Data
- Last octet of IP address replaced with `0` (e.g., `192.168.1.123` → `192.168.1.0`)
- PostHog `$ip` property disabled (no IP tracking)
- Session recordings disabled by default (opt-in only for support cases)

---

### 7.2 What's NOT Tracked

**Explicitly excluded from analytics:**
- `password`, `pin` (never logged anywhere)
- `bank_account`, `iban` (PII, not needed for analytics)
- `national_id_hash` (sensitive PII)
- Full IP addresses (only anonymized)
- Email addresses (not needed for event tracking)

---

### 7.3 User Rights (GDPR)

#### Right to Access (Art. 15)
**Implementation:** Export all analytics events for a user via `/api/admin/analytics/user/{user_id}/export`.

**Format:** JSON file with all events, properties, timestamps.

---

#### Right to Erasure (Art. 17)
**Implementation:**
- Delete from `analytics_events` table: `DELETE FROM analytics_events WHERE user_id = ?`
- PostHog: Use PostHog API to delete user: `POST /api/person/{person_id}/delete`
- Audit log: Retain for 5 years (legal requirement), but anonymize `user_id` → `anonymized_{timestamp}`

---

#### Right to Object (Art. 21)
**Implementation:** Add `analytics_consent` field to `consents` table. If withdrawn, stop tracking events.

---

### 7.4 Datatilsynet Requirements

#### Data Processing Agreement (DPA)
**PostHog self-hosted:** No DPA needed (data stays in-house).

**PostHog Cloud (if used):** DPA required (PostHog provides standard agreement).

---

#### Data Localization
**Requirement:** PSD2 payment data must stay in EEA.

**Implementation:** PostHog self-hosted on Fly.io EU region (Frankfurt or Amsterdam).

---

#### Consent Banner
**Required:** Display cookie consent banner on landing page (already implemented in `src/components/cookie-consent.tsx`).

**Categories:**
- **Necessary:** Session cookies, auth tokens (no consent required)
- **Analytics:** PostHog tracking (opt-in required)
- **Marketing:** None (Drop doesn't use third-party ads)

---

### 7.5 Audit Trail (PSD2 Requirement)

**Requirement:** All analytics queries accessing financial data must be logged.

**Implementation:**
```sql
-- Log every admin analytics query
INSERT INTO audit_log (id, user_id, action, resource_type, details, ip_address)
VALUES (
  ?,
  ?, -- admin user_id
  'admin_analytics_query',
  'analytics',
  JSON_OBJECT('endpoint', '/api/admin/analytics/overview', 'params', '{}'),
  ?
);
```

**Retention:** 5 years (PSD2 Art. 97).

---

## 8. Real-time vs Batch Processing

### 8.1 Real-time Metrics

| Metric | Source | Latency |
|--------|--------|---------|
| DAU/WAU/MAU | `audit_log` query | <100ms |
| Transaction count (today) | `transactions` query | <100ms |
| Error rate (last hour) | Sentry API | ~5 seconds |
| Current active users | PostHog live view | Real-time |

**Implementation:** Direct SQL queries on every dashboard load (no caching needed for <100K transactions).

---

### 8.2 Batch Processing (Daily)

| Metric | Source | Schedule |
|--------|--------|----------|
| Daily aggregates (`daily_metrics` view) | Cron job | 00:05 UTC |
| Retention cohorts | Cron job | 01:00 UTC |
| Funnel snapshots | Cron job | 02:00 UTC |

**Implementation:**
```bash
# Cron job (runs inside Docker container)
0 0 * * * node /app/scripts/analytics/daily-aggregates.js
```

**Script:** Queries last 24h of data, writes to `daily_metrics` table (denormalized for speed).

---

### 8.3 Caching Strategy

**Admin dashboard:** Cache API responses for 5 minutes (Vercel Edge Cache or Redis).

**Reason:** Admin dashboards don't need real-time updates, 5-minute staleness is acceptable.

**Implementation:**
```typescript
// src/app/api/admin/analytics/overview/route.ts
export const revalidate = 300; // 5 minutes
```

---

## 9. Alerting (Integration with Existing Slack Alerts)

### 9.1 Anomaly Detection

**Triggered Alerts:**
1. **Transaction volume spike** — >2x daily average
2. **Transaction volume drop** — <50% daily average
3. **Error rate spike** — >5% failure rate
4. **Conversion funnel drop** — >20% decrease in any stage

**Implementation:** Cron job checks thresholds every hour, sends Slack alert via `src/lib/alerts.ts`.

---

### 9.2 Alert Format (Slack)

```
🚨 ALERT: Transaction Volume Spike
Volume: 5,234 transactions (avg: 2,100)
Spike: +150% from daily average
Time: 2026-02-17 14:35 UTC
Link: https://drop.no/admin/analytics
```

---

### 9.3 Thresholds (Configurable)

**Config file:** `src/config/analytics-alerts.json`

```json
{
  "transaction_volume_spike_multiplier": 2.0,
  "transaction_volume_drop_threshold": 0.5,
  "error_rate_threshold_pct": 5.0,
  "funnel_drop_threshold_pct": 20.0
}
```

---

## 10. Cost Analysis

### 10.1 PostHog Self-Hosted (Recommended)

**Infrastructure:**
- **Fly.io 2GB RAM instance:** ~€40/month
- **PostgreSQL (for PostHog):** Included (shared instance)
- **ClickHouse (analytics DB):** Included (single-node)

**Total:** ~€40-50/month for 10K MAU

**Scalability:** Up to 100K MAU on same instance (ClickHouse is highly efficient).

---

### 10.2 PostHog Cloud (Alternative)

**Pricing:** €299/month for 10K MAU (EU hosting).

**Pros:** Zero maintenance, auto-scaling.

**Cons:** 6x more expensive than self-hosted.

---

### 10.3 Custom Only (No PostHog)

**Cost:** €0 (runs on existing infrastructure).

**Effort:** 5 additional days to build funnel analysis, session tracking, etc.

**Recommendation:** Not worth the time savings — PostHog provides 90% of features out-of-the-box.

---

## 11. Implementation Plan (Phased Approach)

### Phase 1: Foundation (Day 1-2)
**Goal:** Basic KPI queries + Admin API endpoints.

**Tasks:**
1. Add `analytics_events` table to schema
2. Create `daily_metrics` and `funnel_stages` views
3. Implement 5 core API endpoints:
   - `/api/admin/analytics/overview`
   - `/api/admin/analytics/transactions`
   - `/api/admin/analytics/funnel`
   - `/api/admin/analytics/retention`
   - `/api/admin/analytics/errors`
4. Add `requireAdmin()` middleware

**Deliverable:** Working API endpoints (testable via curl/Postman).

---

### Phase 2: Admin Dashboard (Day 3-4)
**Goal:** Internal BI dashboard with charts.

**Tasks:**
1. Create `/admin/analytics` page
2. Implement 4 chart components (Recharts):
   - KPI summary cards
   - Transaction volume line chart
   - Conversion funnel bar chart
   - Error rate table
3. Add date range filter
4. Add CSV export button

**Deliverable:** Functional admin dashboard (internal use only).

---

### Phase 3: PostHog Integration (Day 5-6)
**Goal:** Self-hosted PostHog for user behavior tracking.

**Tasks:**
1. Deploy PostHog to Fly.io (Docker Compose)
2. Integrate PostHog client SDK (`posthog-js`)
3. Instrument 10 core events (see Event Taxonomy)
4. Set up funnel analysis in PostHog UI
5. Configure GDPR settings (disable IP tracking, session recordings opt-in)

**Deliverable:** PostHog dashboard with live user events.

---

### Phase 4: Alerting (Day 7)
**Goal:** Automated anomaly detection.

**Tasks:**
1. Create `scripts/analytics/anomaly-detector.js`
2. Implement threshold checks (see Alerting section)
3. Integrate with `src/lib/alerts.ts` (Slack)
4. Set up cron job (hourly)

**Deliverable:** Slack alerts for transaction spikes/drops.

---

### Phase 5: GDPR Compliance (Day 8)
**Goal:** User data export and deletion.

**Tasks:**
1. Implement `/api/admin/analytics/user/{user_id}/export`
2. Implement user deletion in `analytics_events` table
3. Add PostHog person deletion API call
4. Update cookie consent banner to include analytics opt-in
5. Document data retention policy

**Deliverable:** GDPR-compliant analytics system.

---

**Total Effort:** 8 days (1 developer)

**MVP (Phases 1-2 only):** 4 days (if PostHog is deferred)

---

## 12. Testing Strategy

### 12.1 Unit Tests

**File:** `src/lib/__tests__/analytics.test.ts`

**Coverage:**
- KPI calculation functions (DAU, MAU, ATV, etc.)
- SQL query builders
- Date range parsing

**Tool:** Vitest (already used in Drop).

---

### 12.2 Integration Tests

**File:** `tests/integration/admin-analytics.test.ts`

**Coverage:**
- `/api/admin/analytics/*` endpoints
- Response format validation
- Role-based access control (non-admin should get 403)

---

### 12.3 E2E Tests

**File:** `tests/e2e/admin-dashboard.spec.ts`

**Coverage:**
- Admin login → Navigate to `/admin/analytics`
- Charts render correctly
- Date range filter works
- CSV export downloads

**Tool:** Playwright (already used in Drop).

---

## 13. Monitoring & Observability

### 13.1 PostHog Self-Monitoring

**Health Check:** `/api/posthog/health` — Check PostHog is reachable.

**Metrics:**
- Event ingestion rate (events/second)
- Query latency (avg response time for dashboards)
- Database size (ClickHouse disk usage)

**Tool:** PostHog built-in `/instance/status` page.

---

### 13.2 Admin Dashboard Monitoring

**Sentry:** Track errors in admin dashboard (already integrated).

**Alert:** If admin API error rate >5%, send Slack alert.

---

## 14. Security Considerations

### 14.1 Admin Access Control

**Role:** Add `admin` role to `users` table.

**Migration:**
```sql
-- Add admin role to existing user
UPDATE users SET role = 'admin' WHERE email = 'alem@drop.no';
```

**Middleware:**
```typescript
export async function requireAdmin() {
  const { user, error } = await requireAuth();
  if (error) return error;
  if (user.role !== 'admin') {
    return jsonError('forbidden', 'Admin access required', 403);
  }
  return { user };
}
```

---

### 14.2 Sensitive Data Exposure

**Risk:** Admin dashboard exposes transaction amounts, user IDs.

**Mitigation:**
- Admin routes protected by `requireAdmin()` middleware
- No PII in chart labels (use aggregated data only)
- Audit log all admin queries (see GDPR section)

---

### 14.3 SQL Injection (Admin Queries)

**Risk:** Dynamic date range filtering could be exploited.

**Mitigation:**
- Use parameterized queries ONLY (no string concatenation)
- Whitelist allowed period values (`7d`, `30d`, `90d`, `1y`)

**Example (SAFE):**
```typescript
const allowedPeriods = { '7d': '-7 days', '30d': '-30 days' };
const period = allowedPeriods[req.query.period] || '-30 days';
const sql = `SELECT COUNT(*) FROM transactions WHERE created_at >= datetime('now', ?)`;
const result = await query(sql, [period]);
```

---

## 15. Future Enhancements (Post-MVP)

### 15.1 Predictive Analytics

**Goal:** Forecast transaction volume using ML.

**Tool:** TensorFlow.js (client-side) or Prophet (Python backend).

**Use Case:** Predict cash flow for liquidity planning (relevant when Drop holds funds in future).

---

### 15.2 Real-time Dashboard (WebSockets)

**Goal:** Live-updating dashboard without refresh.

**Tool:** Socket.io or Server-Sent Events (SSE).

**Use Case:** Monitor transaction spikes during marketing campaigns.

---

### 15.3 A/B Testing

**Goal:** Test onboarding flow variations.

**Tool:** PostHog Feature Flags + Experiments.

**Use Case:** Test "Skip KYC for low-value transactions" flow.

---

### 15.4 Custom Funnels (Dynamic)

**Goal:** Allow admins to create custom funnels in UI.

**Tool:** PostHog Funnels API.

**Use Case:** Analyze drop-off in specific remittance corridors (e.g., Norway → Serbia).

---

## 16. Success Metrics (How to Measure Analytics System)

| Metric | Target | Measurement |
|--------|--------|-------------|
| Dashboard load time | <2 seconds | Chrome DevTools Performance |
| API response time (p95) | <500ms | Sentry Performance Monitoring |
| PostHog event ingestion lag | <10 seconds | PostHog /instance/status |
| Admin dashboard uptime | >99.5% | UptimeRobot (monitor `/admin/analytics`) |
| GDPR compliance audit | 100% pass | Manual checklist (see section 7) |

---

## 17. References

### 17.1 Norwegian Regulations

- **Datatilsynet (Data Protection Authority):** https://www.datatilsynet.no/en/
- **PSD2 Audit Requirements:** Regulation (EU) 2018/389 (RTS on SCA and CSC)
- **Financial Supervision Authority (Finanstilsynet):** https://www.finanstilsynet.no/en/

---

### 17.2 Tools & Libraries

- **PostHog:** https://posthog.com/docs/self-host
- **Recharts:** https://recharts.org/en-US/
- **Sentry:** https://docs.sentry.io/platforms/javascript/guides/nextjs/

---

### 17.3 Industry Benchmarks

- **Fintech KPIs:** Andreessen Horowitz "16 Startup Metrics" (a16z.com)
- **Conversion Funnel Benchmarks:** Mixpanel "Product Benchmarks Report 2025"
- **Retention Standards:** Lenny's Newsletter "Retention Benchmarks by Industry"

---

## 18. Appendix: SQL Query Examples

### A. Top 10 Users by Transaction Volume (Last 30 Days)

```sql
SELECT
  u.id,
  u.first_name || ' ' || u.last_name as name,
  COUNT(t.id) as tx_count,
  SUM(t.amount) / 100.0 as total_volume_nok,
  SUM(t.fee) / 100.0 as total_fees_nok
FROM users u
JOIN transactions t ON u.id = t.user_id
WHERE t.status = 'completed'
  AND t.created_at >= datetime('now', '-30 days')
GROUP BY u.id
ORDER BY total_volume_nok DESC
LIMIT 10;
```

---

### B. Transaction Volume by Country (Remittance Corridors)

```sql
SELECT
  r.country,
  COUNT(t.id) as tx_count,
  SUM(t.amount) / 100.0 as total_sent_nok,
  AVG(t.amount) / 100.0 as avg_tx_nok
FROM transactions t
JOIN recipients r ON t.recipient_id = r.id
WHERE t.type = 'remittance'
  AND t.status = 'completed'
  AND t.created_at >= datetime('now', '-30 days')
GROUP BY r.country
ORDER BY tx_count DESC;
```

---

### C. Hourly Transaction Distribution (Peak Hours)

```sql
SELECT
  CAST(strftime('%H', created_at) AS INTEGER) as hour,
  COUNT(*) as tx_count,
  SUM(amount) / 100.0 as volume_nok
FROM transactions
WHERE status = 'completed'
  AND created_at >= datetime('now', '-7 days')
GROUP BY hour
ORDER BY hour;
```

---

## 19. Approval & Sign-off

**Prepared by:** architect agent (Sonnet 4.5)
**Reviewed by:** [Pending — John]
**Approved by:** [Pending — Alem]

**Next Steps:**
1. Review spec with Alem (prioritize phases)
2. Create MC tasks for each phase
3. Assign builder agent for Phase 1 implementation

---

**Document Status:** Draft (awaiting approval)
**Last Updated:** 2026-02-17
**Version:** 1.0

# drop-customer-support-spec

# Drop Customer Support System — Implementation Spec

**Project:** Drop Fintech App
**MC Task:** #1187
**Created:** 2026-02-17
**Architecture:** Software Architect Agent

---

## 1. Overview

This spec defines a built-in customer support ticket system for Drop. No external dependencies (no Zendesk, no Intercom). MVP scope.

**Scope:**
- Support ticket creation (user-initiated)
- Ticket conversation thread (user + admin messages)
- Admin ticket management dashboard
- Integration with audit logging
- Email notifications when admin responds
- Link tickets to transactions when relevant

**Non-goals (future):**
- Live chat
- Chatbot / AI responses
- Multi-language support in UI (Norwegian only for MVP)
- File attachments (future enhancement)
- Public knowledge base / FAQ system

---

## 2. Database Schema

### 2.1 support_tickets Table

```sql
CREATE TABLE IF NOT EXISTS support_tickets (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  subject TEXT NOT NULL,
  description TEXT NOT NULL,
  category TEXT NOT NULL CHECK(category IN ('transaction_issue','account_access','verification','general','dispute')),
  status TEXT DEFAULT 'open' CHECK(status IN ('open','in_progress','waiting_user','resolved','closed')),
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
  transaction_id TEXT REFERENCES transactions(id), -- optional link to transaction
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT,
  closed_at TEXT
);

CREATE INDEX IF NOT EXISTS idx_support_tickets_user ON support_tickets(user_id);
CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status);
CREATE INDEX IF NOT EXISTS idx_support_tickets_category ON support_tickets(category);
CREATE INDEX IF NOT EXISTS idx_support_tickets_created ON support_tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_support_tickets_transaction ON support_tickets(transaction_id);
```

**PostgreSQL version:**
- Replace `datetime('now')` with `CURRENT_TIMESTAMP`
- No other changes needed (TEXT and CHECK constraints work in both)

### 2.2 ticket_messages Table

```sql
CREATE TABLE IF NOT EXISTS ticket_messages (
  id TEXT PRIMARY KEY,
  ticket_id TEXT NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
  sender_type TEXT NOT NULL CHECK(sender_type IN ('user','admin')),
  sender_id TEXT, -- user_id or admin user_id (optional, for audit)
  message TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id);
CREATE INDEX IF NOT EXISTS idx_ticket_messages_created ON ticket_messages(created_at);
```

**PostgreSQL version:**
- Replace `datetime('now')` with `CURRENT_TIMESTAMP`

---

## 3. Status Flow

```
open → in_progress → waiting_user → resolved → closed
  ↓         ↓             ↓             ↓
  └─────────┴─────────────┴─────────────┘
          (can reopen if needed)
```

**Status definitions:**
- **open** — newly created, not assigned yet
- **in_progress** — admin is working on it
- **waiting_user** — admin replied, waiting for user response
- **resolved** — issue resolved, pending user confirmation
- **closed** — ticket closed (auto-close after 7 days resolved, or manual)

**Priority:**
- **urgent** — transaction blocked, account locked
- **high** — verification failed, payment issue
- **normal** — general inquiry, feature request
- **low** — informational

---

## 4. API Endpoints

### 4.1 User Endpoints

#### POST /api/support/tickets
Create new support ticket.

**Request:**
```json
{
  "subject": "Transaction failed but money was deducted",
  "description": "I tried to send 500 NOK to Serbia but...",
  "category": "transaction_issue",
  "transaction_id": "tx_rem_123" // optional
}
```

**Response (201):**
```json
{
  "data": {
    "id": "tkt_abc123",
    "subject": "...",
    "description": "...",
    "category": "transaction_issue",
    "status": "open",
    "priority": "normal",
    "created_at": "2026-02-17T10:30:00Z"
  }
}
```

**Validation:**
- subject: required, 5-200 chars, sanitized
- description: required, 20-2000 chars, sanitized
- category: must be one of enum values
- transaction_id: optional, must exist if provided

**Auto-priority logic:**
- category=dispute → priority=high
- category=account_access → priority=high
- category=transaction_issue → priority=normal
- category=verification → priority=normal
- category=general → priority=low

**Side effects:**
- Audit log: `support.ticket_created`
- Initial message in ticket_messages (sender_type=user, message=description)

---

#### GET /api/support/tickets
List user's tickets.

**Query params:**
- `page` (default: 1)
- `limit` (default: 10, max: 50)
- `status` (optional filter: open, in_progress, waiting_user, resolved, closed)

**Response:**
```json
{
  "data": [
    {
      "id": "tkt_abc123",
      "subject": "Transaction failed...",
      "category": "transaction_issue",
      "status": "in_progress",
      "priority": "normal",
      "created_at": "2026-02-17T10:30:00Z",
      "updated_at": "2026-02-17T11:00:00Z",
      "unread_messages": 2 // count of admin messages since last user visit
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 5,
    "totalPages": 1
  }
}
```

---

#### GET /api/support/tickets/[id]
Get ticket detail + conversation thread.

**Response:**
```json
{
  "data": {
    "ticket": {
      "id": "tkt_abc123",
      "subject": "Transaction failed...",
      "description": "I tried to send...",
      "category": "transaction_issue",
      "status": "in_progress",
      "priority": "normal",
      "transaction_id": "tx_rem_123",
      "created_at": "2026-02-17T10:30:00Z",
      "updated_at": "2026-02-17T11:00:00Z"
    },
    "messages": [
      {
        "id": "msg_1",
        "sender_type": "user",
        "message": "I tried to send...",
        "created_at": "2026-02-17T10:30:00Z"
      },
      {
        "id": "msg_2",
        "sender_type": "admin",
        "message": "Thank you for reporting. We are investigating...",
        "created_at": "2026-02-17T11:00:00Z"
      }
    ],
    "transaction": { /* if transaction_id is set */ }
  }
}
```

**Authorization:**
- User can only view their own tickets
- Return 404 if ticket belongs to another user

---

#### POST /api/support/tickets/[id]/messages
Add user message to conversation.

**Request:**
```json
{
  "message": "I tried again and it worked this time"
}
```

**Response (201):**
```json
{
  "data": {
    "id": "msg_3",
    "ticket_id": "tkt_abc123",
    "sender_type": "user",
    "message": "I tried again...",
    "created_at": "2026-02-17T12:00:00Z"
  }
}
```

**Side effects:**
- Update ticket.updated_at
- If status was 'waiting_user' or 'resolved', change to 'open'
- Audit log: `support.message_added`

---

### 4.2 Admin Endpoints

All admin endpoints require `requireAdmin()` middleware (TBD: define admin role or separate admin auth).

#### GET /api/admin/support/tickets
List ALL tickets with filters.

**Query params:**
- `page`, `limit`
- `status` (filter)
- `priority` (filter)
- `category` (filter)
- `sort` (created_at_desc, created_at_asc, updated_at_desc, priority_desc)

**Response:**
```json
{
  "data": [
    {
      "id": "tkt_abc123",
      "user_id": "usr_demo1",
      "user_email": "amir@example.com",
      "subject": "Transaction failed...",
      "category": "transaction_issue",
      "status": "open",
      "priority": "normal",
      "created_at": "2026-02-17T10:30:00Z",
      "updated_at": "2026-02-17T11:00:00Z"
    }
  ],
  "pagination": { ... }
}
```

---

#### PATCH /api/admin/support/tickets/[id]
Update ticket status, priority, or admin notes.

**Request:**
```json
{
  "status": "in_progress",
  "priority": "high"
}
```

**Response:**
```json
{
  "data": { /* updated ticket */ }
}
```

**Side effects:**
- Update ticket.updated_at
- If status changed to 'resolved', set resolved_at
- If status changed to 'closed', set closed_at
- Audit log: `support.ticket_updated`

---

#### POST /api/admin/support/tickets/[id]/messages
Admin reply to ticket.

**Request:**
```json
{
  "message": "Thank you for reporting. We have issued a refund.",
  "change_status": "resolved" // optional
}
```

**Response (201):**
```json
{
  "data": {
    "id": "msg_4",
    "ticket_id": "tkt_abc123",
    "sender_type": "admin",
    "message": "Thank you for reporting...",
    "created_at": "2026-02-17T13:00:00Z"
  }
}
```

**Side effects:**
- Update ticket.updated_at
- If change_status provided, update ticket.status
- Change status to 'waiting_user' if not explicitly set
- Audit log: `support.admin_reply`
- Email notification to user (see section 5)

---

## 5. Email Notifications

When admin replies, send email to user:

**Subject:** "Svar på din support-henvendelse [#{ticket_id}]"

**Body (plain text):**
```
Hei,

Vi har svart på din support-henvendelse:

Emne: {ticket.subject}
Svar: {admin_message}

Logg inn på Drop for å se hele samtalen:
{NEXT_PUBLIC_APP_URL}/support/tickets/{ticket_id}

Hvis du har flere spørsmål, kan du svare direkte i billetten.

Vennlig hilsen,
Drop Support
```

**Implementation:**
- Use existing MCP email integration pattern (see ~/system/tools/manifest.md)
- No need to implement email in MVP — create draft and leave as TODO for now
- Future: use `email-to-ticket.js` pattern from ALAI tools

---

## 6. UI Pages

### 6.1 /support — Help Center (User)

**Layout:**
- Header: "Trenger du hjelp?"
- FAQ section (static, hardcoded for MVP):
  - "Hvordan sender jeg penger?"
  - "Hvorfor ble betalingen min avvist?"
  - "Hvordan verifiserer jeg kontoen min?"
  - "Hva er gebyrene?"
  - Each FAQ expands to show answer (accordion)
- CTA button: "Opprett support-billett"

**Design:**
- Match existing Drop UI (see mockups/figma-make-export/)
- Green accent color (#00E5A0)
- Dark background (#09090b)

---

### 6.2 /support/tickets — Ticket List (User)

**Layout:**
- Header: "Mine support-billetter"
- Filter tabs: All | Open | Resolved | Closed
- Ticket list:
  - Subject (clickable)
  - Category badge
  - Status badge (color-coded)
  - Created date
  - Unread indicator if admin replied

**Empty state:**
- "Ingen billetter ennå"
- "Opprett billett" button

---

### 6.3 /support/tickets/[id] — Conversation (User)

**Layout:**
- Ticket header:
  - Subject
  - Status badge
  - Category
  - Created date
  - Transaction link (if transaction_id set): "Relatert transaksjon: [link]"
- Message thread (chat-style):
  - User messages (left-aligned, gray background)
  - Admin messages (right-aligned, green accent)
  - Timestamps
- Reply form (if status != closed):
  - Text area (5 rows)
  - "Send svar" button
  - Disabled if status=closed with message "Denne billetten er lukket"

---

### 6.4 /support/new — Create Ticket (User)

**Form fields:**
- Subject (text input, required)
- Category (dropdown, required)
  - "Transaksjonsproblem"
  - "Tilgangsproblem"
  - "Verifisering"
  - "Generelt"
  - "Tvist"
- Transaction (optional, dropdown from user's transactions):
  - "Velg transaksjon (valgfritt)"
  - Show recent 10 transactions
- Description (textarea, 5 rows, required)
- "Opprett billett" button

**Validation:**
- Subject: 5-200 chars
- Description: 20-2000 chars
- Show character count below textarea

**Success:**
- Redirect to /support/tickets/[new_id]
- Toast: "Billett opprettet. Vi svarer så snart som mulig."

---

### 6.5 /admin/support — Admin Dashboard

**Layout:**
- Header: "Support-billetter"
- Filter bar:
  - Status dropdown (all, open, in_progress, waiting_user, resolved, closed)
  - Priority dropdown (all, urgent, high, normal, low)
  - Category dropdown (all, transaction_issue, account_access, verification, general, dispute)
  - Sort dropdown (Nyeste, Eldste, Oppdatert sist, Prioritet)
- Ticket table:
  - ID (clickable)
  - User (email)
  - Subject
  - Category badge
  - Status badge
  - Priority badge
  - Created
  - Updated

**Click ticket:**
- Navigate to /admin/support/[id]

---

### 6.6 /admin/support/[id] — Admin Ticket Detail

**Layout:**
- Ticket header:
  - Subject
  - User info: email, phone, KYC status
  - Status dropdown (editable)
  - Priority dropdown (editable)
  - "Lagre endringer" button
- Transaction link (if set): "Se transaksjon" button
- Message thread (same as user view)
- Admin reply form:
  - Text area (10 rows)
  - "Endre status til:" dropdown (optional)
  - "Send svar" button

**Audit trail (bottom):**
- Show ticket_messages with timestamps
- Show status changes from audit_log

---

## 7. Integration Points

### 7.1 Audit Logging

All support actions logged to `audit_log` table:

| Action | Resource Type | Details |
|--------|---------------|---------|
| support.ticket_created | support_ticket | ticket_id, category, priority |
| support.message_added | ticket_message | ticket_id, sender_type |
| support.admin_reply | ticket_message | ticket_id, admin message preview |
| support.ticket_updated | support_ticket | ticket_id, old_status, new_status |

Implementation: Use `auditLog()` helper from `@/lib/middleware`.

---

### 7.2 Transaction Linking

When user creates ticket with transaction_id:
- Validate transaction exists and belongs to user
- Display transaction summary in ticket detail:
  - Type (remittance / qr_payment)
  - Amount
  - Recipient / Merchant
  - Status
  - Created date
  - Link to /transactions/[id]

---

### 7.3 Email Notifications (Future)

Placeholder implementation:
- Create `src/lib/email-support.ts`:
  ```typescript
  export async function notifyUserTicketReply(ticketId: string, userId: string, adminMessage: string) {
    // TODO: Integrate with MCP email (see ~/system/tools/manifest.md)
    console.log(`[Email] Notify user ${userId} about ticket ${ticketId}`);
  }
  ```
- Call from admin reply endpoint
- Move to MCP email when ready

---

## 8. File List

### 8.1 Database Migration

**File:** `src/lib/db.ts`
**Changes:**
- Add `support_tickets` and `ticket_messages` schemas to `SQLITE_SCHEMA` constant
- Add same schemas to `PG_SCHEMA` constant (replace `datetime('now')` with `CURRENT_TIMESTAMP`)
- No other changes needed

---

### 8.2 API Routes

#### User Endpoints
1. `src/app/api/support/tickets/route.ts` — GET (list), POST (create)
2. `src/app/api/support/tickets/[id]/route.ts` — GET (detail)
3. `src/app/api/support/tickets/[id]/messages/route.ts` — POST (add message)

#### Admin Endpoints
4. `src/app/api/admin/support/tickets/route.ts` — GET (list all)
5. `src/app/api/admin/support/tickets/[id]/route.ts` — PATCH (update status/priority)
6. `src/app/api/admin/support/tickets/[id]/messages/route.ts` — POST (admin reply)

---

### 8.3 UI Pages

#### User Pages
1. `src/app/support/page.tsx` — Help center (FAQ + create button)
2. `src/app/support/tickets/page.tsx` — Ticket list
3. `src/app/support/tickets/[id]/page.tsx` — Conversation view
4. `src/app/support/new/page.tsx` — Create ticket form

#### Admin Pages
5. `src/app/admin/support/page.tsx` — Admin dashboard (ticket table)
6. `src/app/admin/support/[id]/page.tsx` — Admin ticket detail

---

### 8.4 Components

1. `src/components/support/ticket-card.tsx` — Ticket list item (subject, status, date)
2. `src/components/support/message-bubble.tsx` — Chat message (user/admin differentiation)
3. `src/components/support/status-badge.tsx` — Status indicator (open, in_progress, resolved, closed)
4. `src/components/support/category-badge.tsx` — Category indicator
5. `src/components/support/priority-badge.tsx` — Priority indicator (urgent, high, normal, low)
6. `src/components/support/faq-accordion.tsx` — FAQ section (static hardcoded questions)

---

### 8.5 Types

**File:** `src/types/support.ts`

```typescript
export interface SupportTicket {
  id: string;
  user_id: string;
  subject: string;
  description: string;
  category: TicketCategory;
  status: TicketStatus;
  priority: TicketPriority;
  transaction_id: string | null;
  created_at: string;
  updated_at: string;
  resolved_at: string | null;
  closed_at: string | null;
}

export interface TicketMessage {
  id: string;
  ticket_id: string;
  sender_type: 'user' | 'admin';
  sender_id: string | null;
  message: string;
  created_at: string;
}

export type TicketCategory = 'transaction_issue' | 'account_access' | 'verification' | 'general' | 'dispute';
export type TicketStatus = 'open' | 'in_progress' | 'waiting_user' | 'resolved' | 'closed';
export type TicketPriority = 'low' | 'normal' | 'high' | 'urgent';
```

---

### 8.6 Utilities

**File:** `src/lib/support-utils.ts`

```typescript
import { TicketCategory, TicketPriority } from '@/types/support';

export function getAutoPriority(category: TicketCategory): TicketPriority {
  if (category === 'dispute') return 'high';
  if (category === 'account_access') return 'high';
  if (category === 'transaction_issue') return 'normal';
  if (category === 'verification') return 'normal';
  return 'low'; // general
}

export function getCategoryLabel(category: TicketCategory): string {
  const labels: Record<TicketCategory, string> = {
    transaction_issue: 'Transaksjonsproblem',
    account_access: 'Tilgangsproblem',
    verification: 'Verifisering',
    general: 'Generelt',
    dispute: 'Tvist',
  };
  return labels[category];
}

export function getStatusLabel(status: TicketStatus): string {
  const labels = {
    open: 'Åpen',
    in_progress: 'Under behandling',
    waiting_user: 'Venter på deg',
    resolved: 'Løst',
    closed: 'Lukket',
  };
  return labels[status];
}

export function getPriorityLabel(priority: TicketPriority): string {
  const labels = {
    urgent: 'Hastesak',
    high: 'Høy',
    normal: 'Normal',
    low: 'Lav',
  };
  return labels[priority];
}
```

---

## 9. Acceptance Criteria

### 9.1 Database
- [ ] support_tickets table created with all fields and constraints
- [ ] ticket_messages table created with foreign key to support_tickets
- [ ] Indexes created for performance (user_id, status, category, created_at, transaction_id)
- [ ] Schema works for both SQLite (dev) and PostgreSQL (staging)

### 9.2 API Endpoints
- [ ] POST /api/support/tickets creates ticket + initial message
- [ ] GET /api/support/tickets returns user's tickets with pagination
- [ ] GET /api/support/tickets/[id] returns ticket + messages
- [ ] POST /api/support/tickets/[id]/messages adds user message
- [ ] GET /api/admin/support/tickets returns all tickets (admin only)
- [ ] PATCH /api/admin/support/tickets/[id] updates status/priority (admin only)
- [ ] POST /api/admin/support/tickets/[id]/messages adds admin reply + sends email notification

### 9.3 Authorization
- [ ] Users can only view their own tickets (404 on unauthorized access)
- [ ] Admin endpoints require admin role (403 if not admin)
- [ ] CSRF protection via validateOrigin() middleware

### 9.4 UI Pages
- [ ] /support shows FAQ + create button
- [ ] /support/tickets shows user's tickets with status filter
- [ ] /support/tickets/[id] shows conversation thread + reply form
- [ ] /support/new shows create form with validation
- [ ] /admin/support shows all tickets with filters (status, priority, category)
- [ ] /admin/support/[id] shows ticket detail + admin reply form

### 9.5 Integration
- [ ] All support actions logged to audit_log
- [ ] Tickets can be linked to transactions
- [ ] Transaction summary displayed in ticket detail (if linked)
- [ ] Admin reply triggers email notification (placeholder implementation)

### 9.6 Validation
- [ ] Subject: 5-200 chars, sanitized
- [ ] Description: 20-2000 chars, sanitized
- [ ] Category: must be valid enum value
- [ ] Transaction ID: must exist and belong to user (if provided)
- [ ] Status transitions validated (open → in_progress → resolved → closed)

### 9.7 Edge Cases
- [ ] Closed tickets cannot receive new messages (UI shows message)
- [ ] Reopening resolved ticket changes status back to 'open'
- [ ] Empty ticket list shows "Ingen billetter" message
- [ ] Character count shown in textarea (5-200 for subject, 20-2000 for description)

---

## 10. Dependencies

### 10.1 Existing Infrastructure
- Database: `src/lib/db.ts` (SQLite/PostgreSQL dual driver)
- Auth: `src/lib/auth.ts` (getCurrentUser, JWT validation)
- Middleware: `src/lib/middleware.ts` (requireAuth, sanitizeText, auditLog)
- Utils: `src/lib/utils-server.ts` (randomId)

### 10.2 New Dependencies
None. All features use existing infrastructure.

### 10.3 Admin Role (TBD)

**Current state:** Users table has `role` field with values `user` or `merchant`.

**Requirement:** Add `admin` role for support staff.

**Two options:**

**Option A: Extend existing role enum**
```sql
ALTER TABLE users DROP CONSTRAINT users_role_check;
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK(role IN ('user','merchant','admin'));
```

**Option B: Separate admin table**
```sql
CREATE TABLE IF NOT EXISTS admins (
  id TEXT PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  password_hash TEXT NOT NULL,
  name TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now'))
);
```

**Recommendation:** Option A (extend role enum) for MVP. Simpler, uses existing auth.

**Implementation:**
1. Add migration to extend role enum
2. Create `requireAdmin()` middleware:
   ```typescript
   export async function requireAdmin(request?: NextRequest) {
     const { user, error } = await requireAuth(request);
     if (error) return { user: null, error };
     if ((user as Record<string, unknown>).role !== 'admin') {
       return { user: null, error: jsonError('forbidden', 'Admin role required', 403) };
     }
     return { user, error: null };
   }
   ```
3. Use in all admin endpoints

---

## 11. Testing Checklist

### 11.1 Unit Tests (Future)
- [ ] getAutoPriority() returns correct priority for each category
- [ ] getCategoryLabel() returns Norwegian labels
- [ ] Status validation logic

### 11.2 Integration Tests (Future)
- [ ] Create ticket → appears in user's ticket list
- [ ] Admin reply → user sees message in conversation
- [ ] Status change → audit log entry created
- [ ] Transaction link → transaction summary displayed

### 11.3 Manual Testing (MVP)
1. **User flow:**
   - Create ticket with subject, description, category
   - View ticket list (should show new ticket)
   - Open ticket detail (should show initial message)
   - Add reply message
   - Verify status changes to 'open' if was 'waiting_user'

2. **Admin flow:**
   - View all tickets dashboard
   - Filter by status, priority, category
   - Open ticket detail
   - Change status to 'in_progress'
   - Add admin reply
   - Verify user sees reply in conversation

3. **Authorization:**
   - User A cannot view User B's tickets (404)
   - Non-admin cannot access /admin/support (403)

4. **Transaction linking:**
   - Create ticket linked to transaction
   - Verify transaction summary shows in ticket detail

---

## 12. Implementation Order

### Phase 1: Database + Types (Day 1)
1. Add schemas to `src/lib/db.ts`
2. Create `src/types/support.ts`
3. Create `src/lib/support-utils.ts`
4. Add `requireAdmin()` to `src/lib/middleware.ts`

### Phase 2: User API (Day 1-2)
1. POST /api/support/tickets (create)
2. GET /api/support/tickets (list)
3. GET /api/support/tickets/[id] (detail)
4. POST /api/support/tickets/[id]/messages (reply)

### Phase 3: User UI (Day 2-3)
1. /support (help center)
2. /support/new (create form)
3. /support/tickets (list)
4. /support/tickets/[id] (conversation)
5. Components: ticket-card, message-bubble, status-badge, category-badge, priority-badge, faq-accordion

### Phase 4: Admin API (Day 3)
1. GET /api/admin/support/tickets (list all)
2. PATCH /api/admin/support/tickets/[id] (update)
3. POST /api/admin/support/tickets/[id]/messages (admin reply)

### Phase 5: Admin UI (Day 4)
1. /admin/support (dashboard)
2. /admin/support/[id] (detail)

### Phase 6: Integration (Day 4-5)
1. Audit logging for all actions
2. Transaction linking
3. Email notification (placeholder)

### Phase 7: Testing + Refinement (Day 5)
1. Manual testing of all flows
2. Edge case validation
3. UI polish (spacing, colors, responsive)

---

## 13. Future Enhancements (Out of Scope)

1. **File attachments** — allow users to upload screenshots
2. **Multi-language support** — English, Bosnian translations
3. **Public FAQ system** — CMS-backed knowledge base
4. **Live chat** — real-time messaging via WebSocket
5. **AI chatbot** — auto-respond to common questions
6. **SLA tracking** — response time targets per priority
7. **Auto-close old tickets** — after 7 days in 'resolved' status
8. **Admin assignment** — assign tickets to specific support staff
9. **Internal notes** — admin-only notes not visible to user
10. **Ticket templates** — pre-filled forms for common issues

---

## 14. Notes for Implementation

### 14.1 Design Consistency
- Match existing Drop UI patterns (see mockups/figma-make-export/)
- Use green accent (#00E5A0) for primary actions
- Dark theme (#09090b background)
- Ensure mobile responsive (all pages must work on 375px width)

### 14.2 Security
- All text inputs sanitized via `sanitizeText()` from middleware
- CSRF protection via `validateOrigin()` middleware
- Audit logging for all support actions
- Users cannot view other users' tickets (authorization check)

### 14.3 Performance
- Pagination on all list endpoints (default 10, max 50)
- Indexes on user_id, status, category, created_at for fast queries
- Lazy load transaction details only when needed

### 14.4 Norwegian Text
All UI text in Norwegian (nb):
- "Opprett billett" (Create ticket)
- "Mine support-billetter" (My support tickets)
- "Trenger du hjelp?" (Need help?)
- "Send svar" (Send reply)
- "Under behandling" (In progress)
- "Venter på deg" (Waiting for you)

---

## 15. Spec Version History

| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-02-17 | Initial spec — database, API, UI, integration |

---

**END OF SPEC**

# drop-dispute-handling-spec

# Drop Dispute & Chargeback Handling — Implementation Spec

**Project:** Drop Fintech App
**MC Task:** #1190
**Created:** 2026-02-17
**Author:** John (Software Architect Agent)
**Status:** DRAFT — Awaiting Alem approval

---

## Executive Summary

This specification defines comprehensive dispute and chargeback handling for Drop's PSD2 pass-through payment system. Drop operates as a PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.

**Unique challenges for PSD2 PISP:**
- **No merchant account** — Drop doesn't process card payments (no Visa/Mastercard chargebacks)
- **Bank-to-bank transfers** — Disputes go through banking system, not card networks
- **Regulatory framework** — PSD2 requires timely dispute resolution (1 business day for unauthorized, 8 weeks for authorized)
- **Limited control** — Bank makes final decision on refunds, not Drop
- **Trust is everything** — Users trust us with their bank access, disputes must be handled perfectly

**Core principles:**
1. **Transparency** — User always knows status of dispute
2. **Speed** — Respond within SLA (1 day unauthorized, 13 months time limit)
3. **Documentation** — Every dispute fully documented for compliance
4. **Bank coordination** — Work with user's bank and recipient bank
5. **User protection** — Unauthorized transactions refunded immediately (PSD2 requirement)

---

## 1. Regulatory Context (PSD2)

### 1.1 PSD2 Requirements for PISP Disputes

**Source:** Payment Services Directive 2 (EU 2015/2366), Articles 71-74

| Requirement | Value | Enforcement |
|-------------|-------|-------------|
| **Unauthorized transaction refund** | Within 1 business day | Mandatory — user's bank must refund |
| **Dispute window** | Up to 13 months from transaction date | User can dispute any time within window |
| **Complaint response** | Within 15 business days | PISP must respond with decision |
| **Escalation to external body** | Finansklagenemnda (FinKN) | If user not satisfied with resolution |
| **Documentation retention** | 5 years | Audit trail of all disputes |

**Key differences from card chargebacks:**
- **No chargeback network** (no Visa/Mastercard dispute system)
- **Bank-initiated refunds** — PISP requests refund from user's bank, not from merchant
- **Strong Customer Authentication (SCA)** — If SCA was used, liability shifted to bank (not PISP)
- **No recurring transaction liability** — PISP not liable for subscription fraud if SCA used on initial transaction

### 1.2 Drop's Liability Model

**Unauthorized transactions (fraud):**
- User claims they did NOT authorize the payment
- **Drop's liability:** €0 if SCA (BankID) was used correctly
- **Bank's liability:** Refund user within 1 business day
- **Drop's action:** Facilitate dispute with bank, provide transaction proof (SCA logs)

**Authorized but disputed (service issue):**
- User authorized payment but recipient didn't deliver goods/service
- **Drop's liability:** €0 — commercial dispute between user and recipient
- **Drop's action:** Provide transaction evidence, recommend user contact recipient directly
- **Escalation:** User can contact Finansklagenemnda if unsatisfied

**Technical failure (Drop's fault):**
- Payment failed due to Drop system error (e.g., wrong amount sent)
- **Drop's liability:** 100% — immediate refund + compensation
- **Drop's action:** Refund from Drop's operational account, file incident report

---

## 2. Dispute Types

### 2.1 Classification

| Type | User Claim | Drop's Role | Time Limit | Outcome |
|------|-----------|-------------|------------|---------|
| **Unauthorized** | "I didn't make this payment" | Facilitate bank refund | 13 months | Bank refunds (1 day) |
| **Incorrect amount** | "Wrong amount was sent" | Investigate + refund if Drop error | 13 months | Refund if Drop fault |
| **Duplicate payment** | "Charged twice" | Check idempotency logs | 13 months | Refund duplicate |
| **Service not received** | "Recipient didn't deliver" | Provide evidence only | 60 days (informal) | Commercial dispute |
| **Technical failure** | "Payment stuck/failed but money gone" | Investigate + reconcile | 13 months | Refund if Drop fault |
| **Refund request** | "Recipient agreed to refund" | Facilitate reverse transfer | No limit | Process reverse payment |

### 2.2 Priority Levels

| Priority | Definition | Response SLA | Examples |
|----------|-----------|--------------|----------|
| **Critical** | Unauthorized transaction, large amount (>10,000 NOK) | 4 hours | Fraud, account takeover |
| **High** | Unauthorized <10k NOK, technical failure | 24 hours | Wrong amount sent, duplicate charge |
| **Normal** | Service not received, refund request | 5 business days | Merchant didn't deliver, user wants refund |
| **Low** | Informational, general inquiry | 15 business days | "How do I dispute?", status check |

---

## 3. Database Schema

### 3.1 disputes Table

```sql
CREATE TABLE IF NOT EXISTS disputes (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  transaction_id TEXT NOT NULL REFERENCES transactions(id),
  dispute_type TEXT NOT NULL CHECK(dispute_type IN (
    'unauthorized',
    'incorrect_amount',
    'duplicate',
    'service_not_received',
    'technical_failure',
    'refund_request'
  )),
  status TEXT DEFAULT 'submitted' CHECK(status IN (
    'submitted',       -- User filed dispute
    'under_review',    -- Drop investigating
    'evidence_requested', -- Need more info from user
    'bank_contacted',  -- Sent to bank (unauthorized cases)
    'resolved_approved', -- Dispute valid, refund issued
    'resolved_denied', -- Dispute invalid, no refund
    'escalated',       -- Sent to FinKN (external complaint)
    'withdrawn'        -- User withdrew dispute
  )),
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('low','normal','high','critical')),

  -- Dispute details
  claimed_amount INTEGER NOT NULL, -- øre (amount user claims is wrong)
  actual_amount INTEGER NOT NULL,  -- øre (actual transaction amount)
  reason TEXT NOT NULL,            -- User's explanation (free text)

  -- Evidence
  evidence_files TEXT,             -- JSON array of file URLs (future)
  user_statement TEXT,             -- Detailed statement from user
  recipient_response TEXT,         -- If recipient contacted

  -- Resolution
  resolution_type TEXT CHECK(resolution_type IN (
    'refund_full',
    'refund_partial',
    'no_refund',
    'reversed_payment'
  )),
  refund_amount INTEGER,           -- øre (actual refund issued)
  refund_reference TEXT,           -- Bank reference or external_id
  resolution_reason TEXT,          -- Why resolved this way

  -- Timeline
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now')),
  responded_at TEXT,               -- When Drop first responded
  resolved_at TEXT,
  escalated_at TEXT,

  -- Compliance
  sla_deadline TEXT,               -- When response is due (24h for unauthorized)
  breach_sla INTEGER DEFAULT 0,    -- Did we miss deadline?
  external_case_id TEXT            -- FinKN case number if escalated
);

CREATE INDEX IF NOT EXISTS idx_disputes_user ON disputes(user_id);
CREATE INDEX IF NOT EXISTS idx_disputes_transaction ON disputes(transaction_id);
CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(status);
CREATE INDEX IF NOT EXISTS idx_disputes_priority ON disputes(priority);
CREATE INDEX IF NOT EXISTS idx_disputes_sla ON disputes(sla_deadline, status);
CREATE INDEX IF NOT EXISTS idx_disputes_created ON disputes(created_at);
```

**PostgreSQL version:**
- Replace `datetime('now')` with `CURRENT_TIMESTAMP`
- No other changes needed

### 3.2 dispute_messages Table

```sql
CREATE TABLE IF NOT EXISTS dispute_messages (
  id TEXT PRIMARY KEY,
  dispute_id TEXT NOT NULL REFERENCES disputes(id) ON DELETE CASCADE,
  sender_type TEXT NOT NULL CHECK(sender_type IN ('user','admin','system')),
  sender_id TEXT,                  -- user_id or admin_id (NULL for system)
  message TEXT NOT NULL,
  created_at TEXT DEFAULT (datetime('now')),

  -- Attachments (future)
  attachments TEXT                 -- JSON array of file URLs
);

CREATE INDEX IF NOT EXISTS idx_dispute_messages_dispute ON dispute_messages(dispute_id);
CREATE INDEX IF NOT EXISTS idx_dispute_messages_created ON dispute_messages(created_at);
```

**PostgreSQL version:**
- Replace `datetime('now')` with `CURRENT_TIMESTAMP`

### 3.3 dispute_actions Table (Audit Trail)

```sql
CREATE TABLE IF NOT EXISTS dispute_actions (
  id TEXT PRIMARY KEY,
  dispute_id TEXT NOT NULL REFERENCES disputes(id) ON DELETE CASCADE,
  action_type TEXT NOT NULL,       -- 'status_change', 'evidence_uploaded', 'bank_contacted', etc.
  performed_by TEXT,               -- user_id or admin_id
  performed_by_type TEXT CHECK(performed_by_type IN ('user','admin','system')),
  details TEXT,                    -- JSON details of action
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_dispute_actions_dispute ON dispute_actions(dispute_id);
CREATE INDEX IF NOT EXISTS idx_dispute_actions_type ON dispute_actions(action_type);
CREATE INDEX IF NOT EXISTS idx_dispute_actions_created ON dispute_actions(created_at);
```

**PostgreSQL version:**
- Replace `datetime('now')` with `CURRENT_TIMESTAMP`

---

## 4. Dispute Lifecycle

### 4.1 State Machine

```
┌──────────────┐
│  submitted   │────────────────┐
└──────┬───────┘                │
       │                        │ (withdraw)
       ▼                        ▼
┌──────────────┐          ┌───────────┐
│ under_review │          │ withdrawn │ (terminal)
└──────┬───────┘          └───────────┘
       │
       ├───────────────────┬───────────────────┬─────────────────┐
       │                   │                   │                 │
       ▼                   ▼                   ▼                 ▼
┌─────────────────┐  ┌──────────────┐  ┌──────────────────┐  ┌──────────────┐
│evidence_requested│  │bank_contacted│  │resolved_approved │  │resolved_denied│
└─────────┬───────┘  └──────┬───────┘  │   (terminal)     │  │  (terminal)  │
       │                   │            └──────────────────┘  └──────────────┘
       │                   │
       │                   ▼
       │            ┌──────────────┐
       └───────────▶│ under_review │
                    └──────┬───────┘
                           │
                           ▼
                    ┌──────────────┐
                    │  escalated   │ (terminal — sent to FinKN)
                    └──────────────┘
```

**Valid transitions:**
```typescript
const VALID_TRANSITIONS = {
  submitted: ["under_review", "withdrawn"],
  under_review: ["evidence_requested", "bank_contacted", "resolved_approved", "resolved_denied", "escalated"],
  evidence_requested: ["under_review", "withdrawn"],
  bank_contacted: ["under_review", "resolved_approved", "resolved_denied"],
  resolved_approved: [],  // terminal
  resolved_denied: ["escalated"],  // can only escalate after denial
  escalated: [],  // terminal
  withdrawn: [],  // terminal
};
```

### 4.2 SLA Deadlines

| Dispute Type | Response SLA | Resolution SLA |
|--------------|--------------|----------------|
| Unauthorized (critical) | 4 hours | 1 business day (bank) |
| Unauthorized (high) | 24 hours | 1 business day (bank) |
| Technical failure | 24 hours | 5 business days |
| Incorrect amount | 24 hours | 5 business days |
| Duplicate | 24 hours | 5 business days |
| Service not received | 5 business days | No formal SLA |
| Refund request | 5 business days | Depends on recipient |

**SLA calculation:**
```typescript
function calculateSlaDeadline(disputeType: string, priority: string, createdAt: Date): Date {
  const now = new Date(createdAt);

  // Business hours: Mon-Fri 09:00-17:00 CET
  // Skip weekends and Norwegian public holidays

  if (disputeType === 'unauthorized') {
    if (priority === 'critical') {
      return addBusinessHours(now, 4);  // 4 hours
    } else {
      return addBusinessHours(now, 24); // 1 business day
    }
  }

  if (['technical_failure', 'incorrect_amount', 'duplicate'].includes(disputeType)) {
    return addBusinessHours(now, 24);   // 1 business day
  }

  return addBusinessDays(now, 5);       // 5 business days
}
```

---

## 5. API Endpoints

### 5.1 User Endpoints

#### POST /api/disputes
Create new dispute.

**Request:**
```json
{
  "transactionId": "tx_rem_123",
  "disputeType": "unauthorized",
  "reason": "I did not authorize this payment. My BankID was stolen.",
  "claimedAmount": 50000  // øre (500 NOK)
}
```

**Response (201):**
```json
{
  "data": {
    "id": "dsp_abc123",
    "transactionId": "tx_rem_123",
    "disputeType": "unauthorized",
    "status": "submitted",
    "priority": "high",
    "claimedAmount": 50000,
    "createdAt": "2026-02-17T10:30:00Z",
    "slaDeadline": "2026-02-18T10:30:00Z"
  }
}
```

**Validation:**
- Transaction must exist and belong to user (404 if not found)
- Transaction must be completed (can't dispute pending/failed transactions)
- Dispute window: max 13 months since transaction (400 if expired)
- No duplicate dispute for same transaction (409 if exists)
- Reason: 20-2000 chars, sanitized

**Auto-priority logic:**
```typescript
if (disputeType === 'unauthorized' && amount > 1000000) priority = 'critical';  // >10k NOK
else if (disputeType === 'unauthorized') priority = 'high';
else if (['technical_failure', 'incorrect_amount', 'duplicate'].includes(disputeType)) priority = 'normal';
else priority = 'low';
```

**Side effects:**
- Calculate SLA deadline based on type + priority
- Create initial message in dispute_messages (sender_type=user, message=reason)
- Audit log: `dispute.created`
- If unauthorized → auto-transition to `bank_contacted` + notify bank (see Section 6.1)

---

#### GET /api/disputes
List user's disputes.

**Query params:**
- `page` (default: 1)
- `limit` (default: 10, max: 50)
- `status` (optional filter)
- `sort` (created_at_desc, created_at_asc, sla_deadline_asc)

**Response:**
```json
{
  "data": [
    {
      "id": "dsp_abc123",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "under_review",
      "priority": "high",
      "claimedAmount": 50000,
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false,
      "unreadMessages": 2  // count of admin/system messages since last user visit
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 10,
    "total": 5,
    "totalPages": 1
  }
}
```

---

#### GET /api/disputes/[id]
Get dispute detail + conversation.

**Response:**
```json
{
  "data": {
    "dispute": {
      "id": "dsp_abc123",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "bank_contacted",
      "priority": "high",
      "claimedAmount": 50000,
      "actualAmount": 50000,
      "reason": "I did not authorize this payment...",
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false
    },
    "transaction": {
      "id": "tx_rem_123",
      "type": "remittance",
      "amount": 50000,
      "currency": "NOK",
      "recipientName": "Mama Jasmina",
      "createdAt": "2026-02-10T14:00:00Z",
      "completedAt": "2026-02-10T14:01:23Z"
    },
    "messages": [
      {
        "id": "msg_1",
        "senderType": "user",
        "message": "I did not authorize this payment...",
        "createdAt": "2026-02-17T10:30:00Z"
      },
      {
        "id": "msg_2",
        "senderType": "system",
        "message": "We have contacted your bank to initiate a refund. You should receive the refund within 1 business day.",
        "createdAt": "2026-02-17T10:31:00Z"
      }
    ],
    "actions": [
      {
        "id": "act_1",
        "actionType": "status_change",
        "performedBy": "system",
        "details": "{\"from\":\"submitted\",\"to\":\"bank_contacted\"}",
        "createdAt": "2026-02-17T10:31:00Z"
      }
    ]
  }
}
```

**Authorization:**
- User can only view their own disputes (404 if not theirs)

---

#### POST /api/disputes/[id]/messages
Add user message (provide additional evidence).

**Request:**
```json
{
  "message": "I have contacted my bank and they confirmed my BankID was compromised on Feb 9."
}
```

**Response (201):**
```json
{
  "data": {
    "id": "msg_3",
    "disputeId": "dsp_abc123",
    "senderType": "user",
    "message": "I have contacted my bank...",
    "createdAt": "2026-02-17T12:00:00Z"
  }
}
```

**Side effects:**
- Update dispute.updated_at
- If status was `evidence_requested`, transition to `under_review`
- Audit log: `dispute.message_added`

---

#### POST /api/disputes/[id]/withdraw
User withdraws dispute.

**Request:**
```json
{
  "reason": "Resolved directly with recipient"
}
```

**Response:**
```json
{
  "data": {
    "id": "dsp_abc123",
    "status": "withdrawn",
    "withdrawnAt": "2026-02-17T13:00:00Z"
  }
}
```

**Side effects:**
- Transition to `withdrawn` status (terminal)
- Audit log: `dispute.withdrawn`
- System message: "User withdrew dispute: [reason]"

---

### 5.2 Admin Endpoints

All admin endpoints require `requireAdmin()` middleware.

#### GET /api/admin/disputes
List ALL disputes with filters.

**Query params:**
- `page`, `limit`
- `status` (filter)
- `priority` (filter)
- `disputeType` (filter)
- `breachSla` (boolean filter: show only SLA breaches)
- `sort` (created_at_desc, sla_deadline_asc, priority_desc)

**Response:**
```json
{
  "data": [
    {
      "id": "dsp_abc123",
      "userId": "usr_demo1",
      "userName": "Amir Hadžić",
      "userEmail": "amir@example.com",
      "transactionId": "tx_rem_123",
      "disputeType": "unauthorized",
      "status": "bank_contacted",
      "priority": "high",
      "claimedAmount": 50000,
      "createdAt": "2026-02-17T10:30:00Z",
      "slaDeadline": "2026-02-18T10:30:00Z",
      "breachSla": false
    }
  ],
  "pagination": { ... },
  "summary": {
    "total": 15,
    "byStatus": {
      "submitted": 3,
      "under_review": 5,
      "bank_contacted": 2,
      "resolved_approved": 4,
      "resolved_denied": 1
    },
    "breachSla": 0
  }
}
```

---

#### PATCH /api/admin/disputes/[id]
Update dispute status or priority.

**Request:**
```json
{
  "status": "under_review",
  "priority": "critical",
  "notes": "Escalating due to large amount"
}
```

**Response:**
```json
{
  "data": { /* updated dispute */ }
}
```

**Side effects:**
- Update dispute.updated_at
- If status changed to `resolved_approved` or `resolved_denied`, set resolved_at
- Audit log: `dispute.status_changed`
- System message added to dispute_messages

---

#### POST /api/admin/disputes/[id]/messages
Admin reply to user.

**Request:**
```json
{
  "message": "We have reviewed your case. Your bank has confirmed the refund will be processed within 24 hours.",
  "changeStatus": "resolved_approved"  // optional
}
```

**Response (201):**
```json
{
  "data": {
    "id": "msg_4",
    "disputeId": "dsp_abc123",
    "senderType": "admin",
    "message": "We have reviewed your case...",
    "createdAt": "2026-02-17T13:00:00Z"
  }
}
```

**Side effects:**
- Update dispute.updated_at
- If changeStatus provided, update dispute.status
- Set dispute.responded_at (first admin response)
- Audit log: `dispute.admin_reply`
- Email notification to user (subject: "Svar på din dispute #{id}")
- Push notification: "Drop har svart på din dispute"

---

#### POST /api/admin/disputes/[id]/resolve
Manually resolve dispute.

**Request:**
```json
{
  "resolutionType": "refund_full",
  "refundAmount": 50000,  // øre
  "refundReference": "bank_ref_12345",
  "resolutionReason": "Bank confirmed unauthorized transaction. Refund processed.",
  "status": "resolved_approved"
}
```

**Response:**
```json
{
  "data": {
    "id": "dsp_abc123",
    "status": "resolved_approved",
    "resolutionType": "refund_full",
    "refundAmount": 50000,
    "resolvedAt": "2026-02-17T14:00:00Z"
  }
}
```

**Side effects:**
- Update all resolution fields
- Set resolved_at
- Audit log: `dispute.resolved`
- System message: "Dispute resolved: [resolutionReason]"
- Email + push notification to user

---

#### POST /api/admin/disputes/[id]/escalate
Escalate to Finansklagenemnda (FinKN).

**Request:**
```json
{
  "reason": "User not satisfied with our decision to deny refund",
  "externalCaseId": "FINKN-2026-12345"  // optional, if already filed
}
```

**Response:**
```json
{
  "data": {
    "id": "dsp_abc123",
    "status": "escalated",
    "escalatedAt": "2026-02-17T15:00:00Z",
    "externalCaseId": "FINKN-2026-12345"
  }
}
```

**Side effects:**
- Transition to `escalated` (terminal)
- Set escalated_at + external_case_id
- Audit log: `dispute.escalated`
- Email user with FinKN contact info

---

## 6. Integration with Banking System

### 6.1 Unauthorized Transaction Flow

**Scenario:** User claims they didn't authorize payment (fraud).

**Drop's actions:**
1. **Immediate response** (within 4 hours for critical, 24h for high):
   - Transition dispute to `bank_contacted`
   - Notify user's bank via API (if integrated) or email (if manual)
   - System message: "We have contacted your bank to initiate a refund."

2. **Provide evidence to bank:**
   - Transaction timestamp
   - SCA (BankID) authentication logs
   - IP address, user agent, device ID
   - User's account activity (login history)
   - Any previous disputes from this user

3. **Bank investigation:**
   - Bank verifies SCA was used correctly
   - If SCA valid → bank liable, refunds user within 1 business day
   - If SCA compromised → bank investigates further

4. **Drop receives bank decision:**
   - If refund approved → transition to `resolved_approved`
   - If refund denied → transition to `resolved_denied`
   - Update dispute with bank's resolution_reason

**API integration (future):**
```typescript
// lib/services/bank-dispute.ts
export async function notifyBankOfDispute(params: {
  disputeId: string;
  transactionId: string;
  userId: string;
  bankId: string;
  evidence: {
    scaLogs: string;
    ipAddress: string;
    deviceId: string;
  };
}): Promise<{ caseId: string }> {
  // Call bank's dispute API (e.g., DNB, SpareBank1)
  // For MVP: send email to bank's fraud department

  const bankEmail = BANK_FRAUD_EMAILS[params.bankId] || "fraud@bank.no";

  await email.send({
    to: bankEmail,
    subject: `Drop PISP Dispute Notification - Transaction ${params.transactionId}`,
    template: "bank-dispute-notification",
    data: {
      disputeId: params.disputeId,
      transactionId: params.transactionId,
      userId: params.userId,
      evidence: params.evidence,
    },
  });

  return { caseId: `DROP-${params.disputeId}` };
}
```

### 6.2 Technical Failure Flow

**Scenario:** Payment failed due to Drop system error (e.g., wrong amount sent).

**Drop's liability:** 100% — immediate refund required.

**Actions:**
1. **Immediate acknowledgment** (within 4 hours):
   - Transition to `under_review`
   - System message: "We are investigating this issue."

2. **Investigation:**
   - Check transaction logs, audit trail
   - Verify actual vs claimed amount
   - Check if idempotency key was reused (duplicate)
   - Review PISP provider logs

3. **If Drop's fault confirmed:**
   - Transition to `resolved_approved`
   - Issue refund from Drop's operational account (not via bank)
   - Resolution type: `refund_full` or `refund_partial`
   - Create incident report in `admin_alerts` table
   - Notify ops team (Slack webhook)

4. **If not Drop's fault:**
   - Transition to `resolved_denied`
   - Explain to user what happened (e.g., "Bank declined payment, no money was charged")
   - Provide evidence (transaction status logs)

**Refund implementation (MVP):**
```typescript
// lib/services/refund.ts
export async function issueRefund(params: {
  disputeId: string;
  transactionId: string;
  amount: number;  // øre
  reason: string;
}): Promise<{ refundId: string; reference: string }> {
  // For MVP: Manual bank transfer
  // Future: Integrate with bank's refund API or PISP reverse payment

  const refundId = randomId("rfnd");

  // Create refund record
  await run(
    `INSERT INTO refunds (id, dispute_id, transaction_id, amount, reason, status, created_at)
     VALUES (?, ?, ?, ?, ?, 'pending', datetime('now'))`,
    [refundId, params.disputeId, params.transactionId, params.amount, params.reason]
  );

  // TODO: Call bank API to initiate reverse PISP payment
  // For MVP: Create admin task to process manual refund
  await createAdminTask({
    type: "manual_refund",
    priority: "high",
    title: `Process refund for dispute ${params.disputeId}`,
    description: `Amount: ${params.amount / 100} NOK\nReason: ${params.reason}`,
    assignee: "finance_team",
  });

  return { refundId, reference: `DROP-REFUND-${refundId}` };
}
```

### 6.3 Service Not Received Flow

**Scenario:** User authorized payment but recipient didn't deliver goods/service.

**Drop's liability:** €0 — commercial dispute between user and recipient.

**Actions:**
1. **Acknowledge** (within 5 business days):
   - Transition to `under_review`
   - System message: "We are reviewing your case."

2. **Provide transaction evidence:**
   - Recipient details (name, bank account, country)
   - Transaction timestamp and amount
   - Payment confirmation from PISP
   - Recommendation: "Contact recipient directly to request refund"

3. **Resolution:**
   - Transition to `resolved_denied`
   - Resolution reason: "This is a commercial dispute between you and the recipient. Drop cannot issue a refund as we do not hold funds. Please contact the recipient directly or seek legal advice."
   - Provide recipient contact info (if available)
   - Inform user of right to escalate to FinKN

**No refund from Drop** — user must resolve with recipient or pursue legal action.

---

## 7. UI Pages

### 7.1 /disputes — Dispute Center (User)

**Layout:**
- Header: "Mine tvister"
- Filter tabs: All | Active | Resolved | Escalated
- Dispute list:
  - Transaction summary (recipient/merchant, amount, date)
  - Dispute type badge
  - Status badge (color-coded)
  - SLA indicator (red if breached)
  - Created date
  - Unread indicator if admin replied

**Empty state:**
- "Ingen tvister"
- "Har du et problem med en betaling? Opprett en tvist"
- CTA button: "Opprett tvist"

---

### 7.2 /disputes/new — Create Dispute (User)

**Step 1: Select Transaction**
- Show user's completed transactions (last 13 months)
- Filter: Remittance | QR Payment
- Each transaction shows:
  - Recipient/merchant name
  - Amount + date
  - Status (completed)
  - "Tvist denne betalingen" button

**Step 2: Dispute Type**
- Radio buttons:
  - "Jeg autoriserte ikke denne betalingen" (unauthorized)
  - "Feil beløp ble sendt" (incorrect_amount)
  - "Jeg ble belastet to ganger" (duplicate)
  - "Jeg mottok ikke tjenesten/produktet" (service_not_received)
  - "Teknisk feil" (technical_failure)
  - "Jeg vil ha refusjon" (refund_request)

**Step 3: Details**
- Reason (textarea, 20-2000 chars, required)
- Claimed amount (prefilled with transaction amount, editable for incorrect_amount)
- Character count indicator
- Upload evidence (future — buttons disabled for MVP)

**Step 4: Review & Submit**
- Summary of dispute
- Transaction details
- Disclaimer (based on type):
  - Unauthorized: "Vi vil kontakte banken din umiddelbart. Du skal få refusjon innen 1 virkedag."
  - Service not received: "Dette er en kommersiell tvist mellom deg og mottakeren. Drop kan ikke refundere, men vi vil gi deg dokumentasjon."
  - Technical failure: "Vi vil undersøke dette og refundere hvis det er vår feil."
- "Send tvist" button

---

### 7.3 /disputes/[id] — Dispute Detail (User)

**Layout:**
- Dispute header:
  - Status badge + priority badge
  - Transaction summary (link to /transactions/[id])
  - Dispute type
  - Amount claimed
  - Created date
  - SLA deadline (if not resolved) — show countdown timer if < 24h
  - SLA breach indicator (if missed deadline)

- Timeline:
  - Initial submission
  - Status changes
  - Admin responses
  - Resolution (if applicable)

- Message thread (chat-style):
  - User messages (left-aligned)
  - Admin messages (right-aligned, green accent)
  - System messages (centered, gray)
  - Timestamps

- Action panel:
  - If status = `evidence_requested`: "Gi mer informasjon" button → message form
  - If status = `resolved_denied`: "Send til Finansklagenemnda" button (see Section 7.4)
  - If status = `submitted` or `under_review`: "Trekk tilbake tvist" button
  - If status = terminal: No actions

---

### 7.4 External Complaint (FinKN)

**Route:** `/disputes/[id]/escalate`

**Content:**
- Heading: "Send klage til Finansklagenemnda"
- Explanation:
  - "Hvis du ikke er fornøyd med vår avgjørelse, kan du sende klagen til Finansklagenemnda (FinKN)."
  - "FinKN er en uavhengig tvisteløsningsinstans for finansielle tjenester."
  - "Drop vil samarbeide fullt ut med FinKN i deres undersøkelse."

- FinKN contact info:
  - **Address:** Postboks 53 Skøyen, 0212 Oslo
  - **Website:** [finansklagenemnda.no](https://www.finansklagenemnda.no)
  - **Email:** post@finkn.no
  - **Phone:** +47 23 13 19 60

- "Send til FinKN" button:
  - Transitions dispute to `escalated` status
  - Sends email to user with FinKN contact info + case summary
  - Creates admin alert for ops team

---

### 7.5 /admin/disputes — Admin Dashboard

**Layout:**
- Header: "Dispute Management"
- Summary cards:
  - Active disputes (count)
  - SLA breaches (count, red if > 0)
  - Resolved last 7 days (count)
  - Average resolution time

- Filter bar:
  - Status dropdown (all, submitted, under_review, etc.)
  - Priority dropdown (all, critical, high, normal, low)
  - Dispute type dropdown (all, unauthorized, etc.)
  - SLA breach toggle (show only breached)
  - Sort dropdown (Newest, SLA Deadline, Priority)

- Dispute table:
  - ID (clickable)
  - User (name + email)
  - Transaction (ID + amount)
  - Dispute type badge
  - Status badge
  - Priority badge
  - Created
  - SLA deadline (color-coded: green >6h, yellow 1-6h, red <1h or breached)
  - Actions: "View", "Resolve"

---

### 7.6 /admin/disputes/[id] — Admin Detail

**Layout:**
- Dispute header (same as user view)
- User info: email, phone, KYC status, account created date
- Transaction info: type, amount, recipient, bank account, completed date
- Dispute details: reason, claimed amount, evidence

- Status & Priority controls:
  - Status dropdown (editable)
  - Priority dropdown (editable)
  - "Lagre endringer" button

- Message thread (same as user view)

- Admin action panel:
  - Text area for admin reply (10 rows)
  - "Change status to:" dropdown (optional)
  - "Send svar" button

- Resolution panel (if not yet resolved):
  - Resolution type: dropdown (refund_full, refund_partial, no_refund, reversed_payment)
  - Refund amount: input (øre)
  - Refund reference: input (bank ref)
  - Resolution reason: textarea
  - "Resolve Dispute" button

- Escalation panel:
  - "Escalate to FinKN" button
  - External case ID: input (optional)
  - Reason: textarea

- Audit trail (bottom):
  - All actions from dispute_actions table
  - Expandable JSON details

---

## 8. Email Notifications

### 8.1 Dispute Submitted (Auto-Confirmation)

**Subject:** "Tvist opprettet - #{dispute_id}"

**Body (Norwegian):**
```
Hei,

Vi har mottatt din tvist angående transaksjon #{transaction_id}.

Tvisttype: {dispute_type_label}
Beløp: {claimed_amount} NOK
Status: Under behandling

{type_specific_message}

Du kan følge statusen her:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Forventet responstid: {sla_deadline}

Vennlig hilsen,
Drop Kundestøtte
```

**Type-specific messages:**
- **Unauthorized:** "Vi har kontaktet banken din umiddelbart. Du skal motta refusjon innen 1 virkedag."
- **Technical failure:** "Vi undersøker saken og vil svare deg innen 24 timer."
- **Service not received:** "Dette er en kommersiell tvist. Vi vil gi deg dokumentasjon, men kan ikke refundere direkte."

---

### 8.2 Admin Response

**Subject:** "Svar på din tvist #{dispute_id}"

**Body:**
```
Hei,

Vi har svart på din tvist:

{admin_message}

Logg inn på Drop for å se hele samtalen:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Hvis du har flere spørsmål, kan du svare direkte i tvisten.

Vennlig hilsen,
Drop Kundestøtte
```

---

### 8.3 Dispute Resolved

**Subject (approved):** "Tvist godkjent - Refusjon behandles"
**Subject (denied):** "Tvist avslått - Se forklaring"

**Body (approved):**
```
Hei,

Din tvist har blitt godkjent.

Refusjon: {refund_amount} NOK
Referanse: {refund_reference}
Forklaring: {resolution_reason}

{refund_timeline_message}

Se detaljer:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}

Vennlig hilsen,
Drop Kundestøtte
```

**Body (denied):**
```
Hei,

Din tvist har blitt avslått.

Forklaring: {resolution_reason}

Hvis du ikke er fornøyd med avgjørelsen, kan du sende klagen til Finansklagenemnda (FinKN):
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}/escalate

Vennlig hilsen,
Drop Kundestøtte
```

---

## 9. Integration with Existing Systems

### 9.1 Link to Transaction Failure Spec

**Spec:** `/Users/makinja/system/specs/drop-transaction-failure-spec.md`

**Integration points:**
1. **Transaction stuck detection:**
   - If transaction stuck in `processing` or `timeout` for >24h → auto-create dispute with type=`technical_failure`
   - Priority: high
   - Reason: "Transaction failed to complete after 24 hours"

2. **Failed transaction with money deducted:**
   - If transaction status=`failed` BUT bank debited user's account → user can dispute
   - Dispute type: `technical_failure`
   - Drop investigates via reconciliation-worker.ts

3. **Partial failure compensation:**
   - If transaction has `compensation_status=failed` → auto-create dispute
   - Dispute type: `technical_failure`
   - Priority: critical
   - Reason: "Refund failed after partial payment"

**Implementation:**
```typescript
// In reconciliation-worker.ts (from transaction failure spec)
if (tx.status === 'processing' && hoursSinceCreated > 24) {
  await createAutoDispute({
    transactionId: tx.id,
    userId: tx.user_id,
    disputeType: 'technical_failure',
    priority: 'high',
    reason: 'Transaction failed to complete after 24 hours',
    claimedAmount: tx.amount,
  });
}
```

### 9.2 Link to Customer Support Spec

**Spec:** `/Users/makinja/system/specs/drop-customer-support-spec.md`

**Integration points:**
1. **Dispute from support ticket:**
   - If support ticket category=`dispute` → create dispute automatically
   - Copy ticket description to dispute reason
   - Link ticket to dispute (bidirectional)

2. **Support ticket from dispute:**
   - User can open support ticket from dispute detail page
   - "Trenger du hjelp?" button → creates ticket with context

3. **Shared message thread:**
   - Admin can view both support tickets and disputes in unified inbox
   - User's conversation history visible to support team

**Implementation:**
```typescript
// In support ticket creation (from customer support spec)
if (ticketCategory === 'dispute') {
  const dispute = await createDispute({
    transactionId: ticketData.transactionId,
    userId: ticketData.userId,
    disputeType: 'service_not_received',  // default, user can change
    reason: ticketData.description,
    claimedAmount: ticketData.amount,
  });

  // Link ticket to dispute
  await run(
    "UPDATE support_tickets SET dispute_id = ? WHERE id = ?",
    [dispute.id, ticketId]
  );
}
```

### 9.3 Link to Complaints System

**Table:** `complaints` (already exists in db.ts)

**Relationship:**
- Disputes are structured (transaction-specific, formal resolution)
- Complaints are unstructured (general feedback, service quality)

**Integration:**
1. **Complaint → Dispute:**
   - If complaint category=`transaction` → suggest creating dispute
   - "Opprett formell tvist" button in complaint detail page

2. **Dispute → Complaint:**
   - If user is unsatisfied with resolved_denied → can file general complaint
   - Complaint linked to original dispute for context

---

## 10. Acceptance Criteria

### 10.1 Database
- [ ] disputes table created with all fields and constraints
- [ ] dispute_messages table created with foreign key to disputes
- [ ] dispute_actions table created for audit trail
- [ ] Indexes created for performance (user_id, transaction_id, status, sla_deadline)
- [ ] Schema works for both SQLite (dev) and PostgreSQL (production)

### 10.2 API Endpoints
- [ ] POST /api/disputes creates dispute + calculates SLA deadline
- [ ] GET /api/disputes returns user's disputes with pagination
- [ ] GET /api/disputes/[id] returns dispute + messages + transaction + actions
- [ ] POST /api/disputes/[id]/messages adds user message
- [ ] POST /api/disputes/[id]/withdraw marks dispute withdrawn
- [ ] GET /api/admin/disputes returns all disputes (admin only)
- [ ] PATCH /api/admin/disputes/[id] updates status/priority (admin only)
- [ ] POST /api/admin/disputes/[id]/messages adds admin reply + sends email
- [ ] POST /api/admin/disputes/[id]/resolve resolves dispute with refund details
- [ ] POST /api/admin/disputes/[id]/escalate escalates to FinKN

### 10.3 Authorization
- [ ] Users can only view their own disputes (404 on unauthorized access)
- [ ] Admin endpoints require admin role (403 if not admin)
- [ ] Transaction ownership validated (can't dispute someone else's transaction)
- [ ] CSRF protection via validateOrigin() middleware

### 10.4 SLA Management
- [ ] SLA deadline calculated correctly based on dispute type + priority
- [ ] Business hours calculation (Mon-Fri 09:00-17:00, skip weekends + holidays)
- [ ] SLA breach flag set if deadline missed
- [ ] Admin dashboard shows SLA breaches prominently

### 10.5 UI Pages
- [ ] /disputes shows user's disputes with status filter
- [ ] /disputes/new shows multi-step dispute creation form
- [ ] /disputes/[id] shows conversation thread + action buttons
- [ ] /disputes/[id]/escalate shows FinKN contact info + escalation button
- [ ] /admin/disputes shows all disputes with filters (status, priority, type, SLA breach)
- [ ] /admin/disputes/[id] shows detail + admin action panel

### 10.6 Integration
- [ ] All dispute actions logged to audit_log table
- [ ] Disputes linked to transactions (bidirectional)
- [ ] Transaction detail page shows dispute status (if exists)
- [ ] Email notifications sent for dispute events (submitted, admin reply, resolved)
- [ ] Push notifications sent for status changes
- [ ] Auto-dispute created for stuck transactions (>24h)
- [ ] Support tickets can create disputes (if category=dispute)

### 10.7 Validation
- [ ] Transaction must be completed (can't dispute pending/failed)
- [ ] Dispute window: max 13 months since transaction
- [ ] No duplicate dispute for same transaction
- [ ] Reason: 20-2000 chars, sanitized
- [ ] Status transitions validated (finite state machine)

### 10.8 Edge Cases
- [ ] User can't create multiple disputes for same transaction
- [ ] Resolved disputes can't be reopened (only escalated if denied)
- [ ] Withdrawn disputes are final (can't un-withdraw)
- [ ] SLA deadline doesn't count weekends or Norwegian public holidays
- [ ] Email notifications handle missing user email gracefully

---

## 11. Implementation Order

### Phase 1: Database + Types (Day 1)
1. Add schemas to `src/lib/db.ts` (SQLite + PostgreSQL versions)
2. Create `src/types/dispute.ts`
3. Create `src/lib/dispute-utils.ts` (SLA calculation, status validation)
4. Add `requireAdmin()` to `src/lib/middleware.ts` (if not exists)

### Phase 2: User API (Day 1-2)
1. POST /api/disputes (create)
2. GET /api/disputes (list)
3. GET /api/disputes/[id] (detail)
4. POST /api/disputes/[id]/messages (add message)
5. POST /api/disputes/[id]/withdraw (withdraw)

### Phase 3: User UI (Day 2-3)
1. /disputes (list page)
2. /disputes/new (multi-step creation form)
3. /disputes/[id] (conversation view)
4. /disputes/[id]/escalate (FinKN escalation page)
5. Components: dispute-card, dispute-badge, message-bubble, sla-indicator

### Phase 4: Admin API (Day 3)
1. GET /api/admin/disputes (list all)
2. PATCH /api/admin/disputes/[id] (update status/priority)
3. POST /api/admin/disputes/[id]/messages (admin reply)
4. POST /api/admin/disputes/[id]/resolve (manual resolution)
5. POST /api/admin/disputes/[id]/escalate (escalate to FinKN)

### Phase 5: Admin UI (Day 4)
1. /admin/disputes (dashboard with filters)
2. /admin/disputes/[id] (detail + action panel)

### Phase 6: Integration (Day 4-5)
1. Audit logging for all dispute actions
2. Email notifications (submitted, admin reply, resolved)
3. Push notifications for status changes
4. Link to transaction failure spec (auto-dispute for stuck transactions)
5. Link to support ticket spec (dispute from ticket)

### Phase 7: Bank Integration (Day 5)
1. Unauthorized transaction flow (notify bank, provide evidence)
2. Technical failure flow (refund from Drop operational account)
3. Service not received flow (provide documentation, no refund)

### Phase 8: Testing + Refinement (Day 5-6)
1. Manual testing of all flows (user + admin)
2. Edge case validation
3. UI polish (spacing, colors, responsive)
4. SLA calculation accuracy test
5. Email template review

---

## 12. Dependencies

### 12.1 Existing Infrastructure
- Database: `src/lib/db.ts` (SQLite/PostgreSQL dual driver)
- Auth: `src/lib/auth.ts` (getCurrentUser, JWT validation)
- Middleware: `src/lib/middleware.ts` (requireAuth, sanitizeText, auditLog)
- Utils: `src/lib/utils-server.ts` (randomId)
- Email: Existing email service (to be determined)
- Push notifications: FCM (Firebase Cloud Messaging) or APNS

### 12.2 New Dependencies
None. All features use existing infrastructure.

### 12.3 External Services (Future)
- **Bank dispute API** — For automated refund requests (unauthorized transactions)
- **FinKN API** — For automated case escalation (if available)
- **SMS notifications** — For critical dispute updates (optional)

---

## 13. Testing Checklist

### 13.1 User Flows
1. **Create dispute (unauthorized):**
   - Select transaction
   - Choose "unauthorized" type
   - Submit reason
   - Verify auto-transition to `bank_contacted`
   - Verify email sent with SLA deadline

2. **Create dispute (service not received):**
   - Select transaction
   - Choose "service not received" type
   - Submit reason
   - Verify status = `submitted`
   - Verify email sent

3. **Add message to dispute:**
   - Open dispute detail
   - Add message
   - Verify status changes to `under_review` (if was `evidence_requested`)

4. **Withdraw dispute:**
   - Open dispute detail
   - Click "Trekk tilbake"
   - Confirm
   - Verify status = `withdrawn` (terminal)

5. **Escalate to FinKN:**
   - Open resolved_denied dispute
   - Click "Send til FinKN"
   - Verify status = `escalated`
   - Verify email with FinKN contact info

### 13.2 Admin Flows
1. **View all disputes dashboard:**
   - Filter by status, priority, type
   - Sort by SLA deadline
   - Verify SLA breach indicator

2. **Reply to dispute:**
   - Open dispute detail
   - Add admin message
   - Change status to `under_review`
   - Verify email sent to user

3. **Resolve dispute (approved):**
   - Open dispute detail
   - Fill resolution panel (refund_full, amount, reference, reason)
   - Click "Resolve Dispute"
   - Verify status = `resolved_approved`
   - Verify email sent to user

4. **Resolve dispute (denied):**
   - Open dispute detail
   - Fill resolution panel (no_refund, reason)
   - Click "Resolve Dispute"
   - Verify status = `resolved_denied`
   - Verify email sent to user with FinKN escalation option

5. **Escalate to FinKN (admin):**
   - Open dispute detail
   - Click "Escalate to FinKN"
   - Enter external case ID + reason
   - Verify status = `escalated`

### 13.3 Edge Cases
1. **Duplicate dispute:**
   - Try to create second dispute for same transaction
   - Verify 409 Conflict error

2. **Expired dispute window:**
   - Try to create dispute for transaction >13 months old
   - Verify 400 Bad Request error

3. **SLA deadline calculation:**
   - Create dispute on Friday 16:00
   - Verify SLA deadline is Monday 10:00 (skip weekend)

4. **Authorization:**
   - User A tries to view User B's dispute
   - Verify 404 Not Found

5. **Status transition validation:**
   - Try to transition from `resolved_approved` to `under_review`
   - Verify 400 Bad Request error

---

## 14. Future Enhancements (Out of Scope)

1. **File attachments** — Allow users to upload evidence (screenshots, receipts)
2. **Video evidence** — Record screen for fraud proof
3. **Multi-language support** — English, Bosnian translations
4. **AI dispute classification** — Auto-detect dispute type from user's description
5. **Automated refund triggers** — For specific patterns (e.g., duplicate transactions)
6. **Bank API integration** — Direct API calls instead of email for unauthorized disputes
7. **FinKN API integration** — Automated case filing
8. **Dispute templates** — Pre-filled forms for common issues
9. **Internal notes** — Admin-only notes not visible to user
10. **Dispute analytics** — Dashboard showing dispute trends, resolution rates, SLA performance

---

## 15. Compliance Notes

### 15.1 PSD2 Article 71 (Unauthorized Transactions)

**User's rights:**
- Report unauthorized transaction within 13 months
- Receive refund within 1 business day (from bank, not Drop)
- No liability if SCA (BankID) was compromised through no fault of user

**Drop's obligations:**
- Notify user's bank immediately (within 24 hours)
- Provide transaction evidence (SCA logs, IP, device ID)
- Keep audit trail for 5 years
- Do NOT delay refund process

**Liability shift:**
- If SCA was performed correctly → bank liable (not Drop)
- If SCA was not performed → Drop liable (refund from operational account)
- If user was grossly negligent (shared BankID) → user liable (no refund)

### 15.2 PSD2 Article 74 (Complaint Handling)

**Requirements:**
- Respond to complaint within 15 business days
- Provide clear explanation of decision
- Inform user of right to escalate to FinKN
- FinKN contact info must be easily accessible

**Drop's implementation:**
- SLA: 5 business days for normal, 1 business day for unauthorized
- Email notification with resolution reason
- Escalation button in UI after `resolved_denied`
- FinKN contact info on `/disputes/[id]/escalate` page

### 15.3 GDPR Compliance

**Data retention:**
- Dispute records: 5 years (regulatory requirement)
- User messages: 5 years
- Audit trail: 5 years
- After 5 years: Archive to cold storage or delete (per GDPR)

**User rights:**
- Right to access: User can view all disputes and messages
- Right to rectification: User can add messages to correct information
- Right to erasure: Limited (regulatory retention overrides)
- Right to data portability: User can export dispute data (future)

**Data minimization:**
- Only collect necessary information (reason, amount, transaction ID)
- No excessive evidence requests
- File attachments limited to 5MB each (future)

---

## 16. Monitoring & Alerting

### 16.1 Metrics to Track

| Metric | Threshold | Alert If |
|--------|-----------|----------|
| Active disputes (count) | 10 | > 50 |
| SLA breaches (count) | 0 | > 0 |
| Average resolution time (days) | 3 | > 7 |
| Unauthorized disputes (%) | 5% | > 15% |
| Dispute approval rate (%) | 70% | < 50% |
| Escalations to FinKN (count) | 1/month | > 5/month |

### 16.2 Dashboard Queries

**Active disputes:**
```sql
SELECT COUNT(*) FROM disputes
WHERE status NOT IN ('resolved_approved', 'resolved_denied', 'escalated', 'withdrawn');
```

**SLA breaches:**
```sql
SELECT COUNT(*) FROM disputes
WHERE breach_sla = 1
  AND status NOT IN ('resolved_approved', 'resolved_denied', 'escalated', 'withdrawn');
```

**Average resolution time:**
```sql
SELECT AVG(julianday(resolved_at) - julianday(created_at)) AS days
FROM disputes
WHERE resolved_at IS NOT NULL
  AND resolved_at > datetime('now', '-30 days');
```

### 16.3 Slack Alerts

**When to send:**
1. **SLA breach** (any dispute misses deadline)
   - Channel: #ops
   - Priority: high
   - Message: "Dispute #{id} missed SLA deadline (type: {type}, priority: {priority}, user: {email})"

2. **Critical unauthorized dispute** (>10k NOK)
   - Channel: #fraud
   - Priority: critical
   - Message: "High-value unauthorized dispute created: #{id} ({amount} NOK, user: {email})"

3. **Escalation to FinKN**
   - Channel: #ops
   - Priority: normal
   - Message: "Dispute #{id} escalated to FinKN by {user_email}. Case ID: {external_case_id}"

---

## 17. Open Questions (For Alem)

### Q1: Refund Implementation

**Question:** How should we handle refunds for technical failures?

**Options:**
- Option A: Manual bank transfer (MVP) — Admin processes refund via bank UI
- Option B: PISP reverse payment (future) — Integrate with bank API for automatic refund
- Option C: Drop holds refund balance (NOT ALLOWED — breaks pass-through model)

**Recommendation:** Option A for MVP, migrate to Option B when bank APIs available.

---

### Q2: Admin Role

**Question:** Who should have admin access to dispute dashboard?

**Options:**
- Option A: CEO (Alem) only
- Option B: CEO + finance team
- Option C: CEO + finance + support team

**Recommendation:** Option C — support team needs access to respond quickly.

---

### Q3: FinKN Escalation Process

**Question:** Should we automate FinKN escalation or keep it manual?

**Options:**
- Option A: Manual (user clicks button, we send email)
- Option B: Semi-automated (user clicks button, we pre-fill FinKN web form)
- Option C: Fully automated (API integration if available)

**Recommendation:** Option A for MVP. Check if FinKN has API.

---

### Q4: Dispute Notification Channels

**Question:** Email + push notifications both? Or only one?

**Options:**
- Option A: Email only (simpler, no push infra needed)
- Option B: Push only (faster, modern)
- Option C: Both (redundancy, user preference)

**Recommendation:** Option C. Email is fallback if user disabled push.

---

### Q5: Dispute Evidence (Future)

**Question:** Should we allow file uploads (screenshots, receipts)?

**Options:**
- Option A: No (text only, simpler MVP)
- Option B: Yes (better evidence, but needs file storage + moderation)

**Recommendation:** Option A for MVP. Add file uploads in Phase 2 when we have S3/Cloudflare R2 storage.

---

## 18. Next Steps

1. **Review this spec** with Alem
2. **Approve/reject sections** (or request changes)
3. **Answer open questions** (Q1-Q5)
4. **Prioritize phases** (which to implement first?)
5. **Assign to builder agent** (one phase at a time)
6. **Validation after each phase** (validator agent checks implementation)

**Estimated timeline:** 6 days for Phases 1-6, Phase 7-8 can run in parallel.

---

## Appendix A: State Transition Diagram (ASCII)

```
┌──────────────┐
│  submitted   │──────────────────────────────────┐
└──────┬───────┘                                  │
       │                                          │ (withdraw)
       ▼                                          ▼
┌──────────────┐                            ┌───────────┐
│ under_review │◄───────────┐               │ withdrawn │ (terminal)
└──────┬───────┘            │               └───────────┘
       │                    │
       ├────────────────────┼───────────────┬────────────────┐
       │                    │               │                │
       ▼                    │               ▼                ▼
┌─────────────────┐         │        ┌──────────────┐ ┌──────────────┐
│evidence_requested├─────────┘        │bank_contacted│ │resolved_denied│
└─────────┬───────┘                  └──────┬───────┘ └──────┬───────┘
       │                                   │                │
       │ (withdraw)                        │                │ (escalate)
       └──────────────────────┐            │                ▼
                              │            │          ┌──────────────┐
                              ▼            ▼          │  escalated   │ (terminal)
                        ┌───────────┐ ┌──────────────┐└──────────────┘
                        │ withdrawn │ │resolved_approved│
                        └───────────┘ │  (terminal)  │
                                      └──────────────┘
```

---

## Appendix B: Norwegian Translations

| English | Norwegian | Context |
|---------|-----------|---------|
| Dispute | Tvist | Formal complaint |
| Claim | Krav | Amount claimed |
| Unauthorized | Uautorisert | Fraud |
| Chargeback | Tilbakeføring | Refund |
| Resolution | Avgjørelse | Final decision |
| Escalate | Eskalere | Send to FinKN |
| Evidence | Dokumentasjon | Proof |
| Refund | Refusjon | Money back |
| Complaint | Klage | General issue |

---

## Appendix C: PSD2 Regulatory Sources

**Primary sources:**
1. [PSD2: Impacts and Compliance for Merchants](https://justt.ai/blog/psd2-effects-on-merchants/)
2. [What is PSD2 everything to know for compliance - Adyen](https://www.adyen.com/knowledge-hub/psd2)
3. [The Payment Services Contract: PSD2 Requirements and PSD3 Perspectives - ILP Abogados](https://www.ilpabogados.com/en/the-payment-services-contract-psd2-requirements-and-psd3-perspectives/)
4. [What is PSD2? How it Impacts Banks, Businesses & Chargebacks911](https://chargebacks911.com/psd2/)
5. [How European merchants can reduce chargebacks and protect revenue in 2026 | GR4VY](https://gr4vy.com/posts/how-european-merchants-can-reduce-chargebacks-and-protect-revenue-in-2026/)

**Regulatory bodies:**
- **Finanstilsynet** (Norway) — Financial regulator
- **Finansklagenemnda (FinKN)** — External dispute resolution

---

**END OF SPEC**

# drop-email-system-spec

# Drop Transactional Email System — Implementation Spec

**Project:** Drop (Fintech Payment App)
**Task:** MC #1188
**Date:** 2026-02-17
**Author:** John (AI Director)

---

## 1. Executive Summary

Drop currently has placeholder email templates but no email service layer. This spec defines a production-ready transactional email system covering:
- Email service abstraction (provider-agnostic)
- Token-based workflows (email verification, password reset)
- Transactional emails (welcome, transaction receipt, login alerts)
- Database schema for tokens and logs
- API endpoints for verification flows
- Integration points with existing auth and transaction systems

**MVP Recommendation:** Resend — Next.js native, simplest setup (5 min vs 30-60 min for SendGrid), generous free tier (3,000 emails/month), React Email integration, automatic DKIM/SPF/DMARC.

---

## 2. Provider Comparison

### Resend (RECOMMENDED for MVP)

**Pros:**
- Next.js/React native integration (React Email built-in)
- Automatic DKIM/SPF/DMARC setup (5 min vs 30-60 min for SendGrid)
- Free tier: 3,000 emails/month (100/day limit)
- Pro: $20/month for 50,000 emails (no daily limits)
- Modern developer experience
- Simple API (3 lines to send email)

**Cons:**
- Newer service (launched 2023, less mature than SendGrid)
- Lower volume ceiling (enterprise-scale needs SendGrid)
- Fewer features (no email validation, inbound parsing, visual template editor)

**Best for:** Drop MVP — we need transactional emails (not marketing campaigns), React templates, fast setup.

### SendGrid

**Pros:**
- Mature platform (better deliverability reputation)
- More features: email validation, inbound parsing, visual templates, marketing campaigns
- Higher volume handling (enterprise-scale)
- Free trial: 100 emails/day for 60 days
- Essentials: $19.95/month for 50,000-100,000 emails

**Cons:**
- Complex setup (30-60 min for DNS records)
- Separate pricing for marketing campaigns
- Dedicated IPs cost extra
- Heavier integration (more API complexity)

**Best for:** Post-MVP if we need email validation, marketing campaigns, or >100k emails/month.

### AWS SES

**Pros:**
- Cheapest at scale ($0.10 per 1,000 emails)
- No monthly minimums
- Built into AWS ecosystem (if we use AWS)

**Cons:**
- Requires AWS account and IAM setup
- Manual DNS configuration
- No template management
- No built-in analytics

**Best for:** High-volume, low-cost scenario (10M+ emails/year), already on AWS.

**MVP Decision:** Start with Resend. Migrate to SendGrid if we need email validation or marketing campaigns. All email logic abstracted via `src/lib/email.ts` — provider swap is 10 lines of code.

Sources:
- [Resend vs SendGrid (2026) - Developer Email API Comparison | Sequenzy](https://www.sequenzy.com/versus/resend-vs-sendgrid)
- [Resend vs Sendgrid Comparison (2026)](https://forwardemail.net/en/blog/resend-vs-sendgrid-email-service-comparison)
- [Resend vs SendGrid in 2026: Email APIs Compared | DevPick](https://www.devpick.io/blog/resend-vs-sendgrid-2026)

---

## 3. Architecture

### 3.1 Email Service Layer (`src/lib/email.ts`)

Provider-agnostic email abstraction. All email sending goes through this file.

**Current state:** Skeleton exists at `src/lib/services/email.ts` with demo-mode logging only.
**Action:** Replace with production implementation.

**Module Interface:**
```typescript
// src/lib/email.ts

export interface EmailParams {
  to: string;
  subject: string;
  htmlBody: string;
  textBody?: string;
}

export interface EmailResult {
  success: boolean;
  messageId?: string;
  error?: string;
}

// Core send function
export async function sendEmail(params: EmailParams): Promise<EmailResult>;

// Template-based helpers
export async function sendWelcomeEmail(userId: string): Promise<EmailResult>;
export async function sendVerificationEmail(email: string, token: string): Promise<EmailResult>;
export async function sendPasswordResetEmail(email: string, token: string): Promise<EmailResult>;
export async function sendTransactionReceipt(txId: string): Promise<EmailResult>;
export async function sendTransferReceivedEmail(userId: string, txId: string): Promise<EmailResult>;
export async function sendLoginAlertEmail(userId: string, ip: string, device: string): Promise<EmailResult>;
```

**Env Vars:**
```bash
# Provider selection
EMAIL_PROVIDER=resend       # resend | sendgrid | smtp
EMAIL_FROM="Drop <no-reply@getdrop.no>"

# Resend (recommended)
RESEND_API_KEY=re_xxxxx

# SendGrid (alternative)
SENDGRID_API_KEY=SG.xxxxx

# SMTP (fallback)
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.xxxxx
```

**Provider Implementations:**

#### Resend (Primary)
```typescript
// Resend SDK
import { Resend } from 'resend';

const resend = new Resend(process.env.RESEND_API_KEY);

async function sendViaResend(params: EmailParams): Promise<EmailResult> {
  const { data, error } = await resend.emails.send({
    from: process.env.EMAIL_FROM!,
    to: params.to,
    subject: params.subject,
    html: params.htmlBody,
    text: params.textBody,
  });

  if (error) {
    return { success: false, error: error.message };
  }

  return { success: true, messageId: data?.id };
}
```

#### SendGrid (Alternative)
```typescript
// SendGrid SDK
import sgMail from '@sendgrid/mail';

sgMail.setApiKey(process.env.SENDGRID_API_KEY!);

async function sendViaSendGrid(params: EmailParams): Promise<EmailResult> {
  const msg = {
    to: params.to,
    from: process.env.EMAIL_FROM!,
    subject: params.subject,
    html: params.htmlBody,
    text: params.textBody,
  };

  try {
    const [response] = await sgMail.send(msg);
    return { success: true, messageId: response.headers['x-message-id'] };
  } catch (error) {
    return { success: false, error: (error as Error).message };
  }
}
```

#### SMTP (Fallback)
```typescript
// Nodemailer
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: Number(process.env.SMTP_PORT),
  secure: false, // TLS
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASS,
  },
});

async function sendViaSMTP(params: EmailParams): Promise<EmailResult> {
  const info = await transporter.sendMail({
    from: process.env.EMAIL_FROM,
    to: params.to,
    subject: params.subject,
    html: params.htmlBody,
    text: params.textBody,
  });

  return { success: true, messageId: info.messageId };
}
```

**Rate Limiting:**
- Max 10 emails per user per hour (prevent abuse)
- Use existing `rate_limits` table with key `email:{userId}:{hour}`
- Helper: `async function checkEmailRateLimit(userId: string): Promise<boolean>`

**Retry Logic (Fire-and-Forget for MVP):**
- No retry for MVP (email service handles retries internally)
- Log failures to `email_log` table for manual review
- Post-MVP: Add job queue (BullMQ/Agenda) for guaranteed delivery

**Template Loading:**
```typescript
// Load from src/email-templates/*.html
import fs from 'fs';
import path from 'path';

function loadTemplate(name: string): string {
  const templatePath = path.join(process.cwd(), 'src/email-templates', `${name}.html`);
  return fs.readFileSync(templatePath, 'utf-8');
}

// Replace {{placeholders}} with values
function renderTemplate(template: string, data: Record<string, string>): string {
  return template.replace(/\{\{(\w+)\}\}/g, (_, key) => data[key] || '');
}
```

---

### 3.2 Email Templates

**Existing templates** in `src/email-templates/`:
- `welcome.html` — Welcome email (placeholder: `{{verifyUrl}}`)
- `transaction-receipt.html` — Transaction confirmation (placeholders: `{{transactionDate}}`, `{{amount}}`, `{{fromName}}`, `{{toName}}`, etc.)
- `password-reset.html` — Password reset (placeholders: `{{resetUrl}}`, `{{userEmail}}`)

**Templates to CREATE:**

#### 1. `email-verification.html`
**Purpose:** Verify email address (sent after registration)
**Placeholders:**
- `{{firstName}}` — User's first name
- `{{verifyUrl}}` — Verification link with token (1-hour expiry)
- `{{otpCode}}` — 6-digit OTP code (backup method if link doesn't work)

**Content:**
```html
<h1>Verifiser e-posten din</h1>
<p>Hei {{firstName}},</p>
<p>Klikk på lenken under for å verifisere e-postadressen din:</p>
<a href="{{verifyUrl}}" style="...">Verifiser e-post</a>
<p>Alternativt, skriv inn denne koden: <strong>{{otpCode}}</strong></p>
<p>Lenken utløper om 1 time.</p>
```

#### 2. `transfer-received.html`
**Purpose:** Notify user they received money
**Placeholders:**
- `{{firstName}}` — Recipient first name
- `{{amount}}` — Amount received (e.g., "kr 2 500,00")
- `{{currency}}` — Currency code (e.g., "NOK")
- `{{senderName}}` — Sender's name
- `{{transactionDate}}` — Timestamp
- `{{transactionId}}` — Transaction ID

**Content:**
```html
<h1>Du mottok penger</h1>
<p>Hei {{firstName}},</p>
<p>Du har mottatt <strong>{{amount}} {{currency}}</strong> fra {{senderName}}.</p>
<p>Dato: {{transactionDate}}</p>
<p>Transaksjons-ID: {{transactionId}}</p>
<a href="https://getdrop.no/dashboard" style="...">Åpne Drop</a>
```

#### 3. `login-alert.html`
**Purpose:** Security alert for new device/location login
**Placeholders:**
- `{{firstName}}` — User's first name
- `{{device}}` — Device/browser (e.g., "Chrome on macOS")
- `{{location}}` — IP or location (e.g., "Oslo, Norway")
- `{{timestamp}}` — Login time
- `{{securityUrl}}` — Link to account security settings

**Content:**
```html
<h1>Ny pålogging oppdaget</h1>
<p>Hei {{firstName}},</p>
<p>Vi har oppdaget en pålogging fra en ny enhet:</p>
<ul>
  <li>Enhet: {{device}}</li>
  <li>Plassering: {{location}}</li>
  <li>Tidspunkt: {{timestamp}}</li>
</ul>
<p>Hvis dette ikke var deg, <a href="{{securityUrl}}">endre passordet ditt</a> umiddelbart.</p>
```

#### 4. `support-ticket-update.html` (Future)
**Purpose:** Notify user their support ticket has a response
**Placeholders:**
- `{{firstName}}`, `{{ticketId}}`, `{{ticketSubject}}`, `{{updateUrl}}`

---

### 3.3 Database Schema

**New Tables:**

#### `email_verification_tokens`
```sql
-- SQLite
CREATE TABLE IF NOT EXISTS email_verification_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token TEXT UNIQUE NOT NULL,  -- UUID v4
  otp_code TEXT NOT NULL,       -- 6-digit code (backup method)
  expires_at TEXT NOT NULL,     -- ISO timestamp, 1 hour from creation
  used_at TEXT,                 -- NULL until verified
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_email_verify_user ON email_verification_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verify_token ON email_verification_tokens(token);

-- PostgreSQL
CREATE TABLE IF NOT EXISTS email_verification_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token TEXT UNIQUE NOT NULL,
  otp_code TEXT NOT NULL,
  expires_at TEXT NOT NULL,
  used_at TEXT,
  created_at TEXT DEFAULT (CURRENT_TIMESTAMP)
);
```

#### `password_reset_tokens`
```sql
-- SQLite
CREATE TABLE IF NOT EXISTS password_reset_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token TEXT UNIQUE NOT NULL,  -- UUID v4
  expires_at TEXT NOT NULL,     -- ISO timestamp, 1 hour from creation
  used_at TEXT,                 -- NULL until reset
  created_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_pwd_reset_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token);

-- PostgreSQL
CREATE TABLE IF NOT EXISTS password_reset_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  token TEXT UNIQUE NOT NULL,
  expires_at TEXT NOT NULL,
  used_at TEXT,
  created_at TEXT DEFAULT (CURRENT_TIMESTAMP)
);
```

#### `email_log`
```sql
-- SQLite
CREATE TABLE IF NOT EXISTS email_log (
  id TEXT PRIMARY KEY,
  user_id TEXT REFERENCES users(id),  -- NULL for non-user emails
  template TEXT NOT NULL,              -- Template name (e.g., "welcome", "password-reset")
  recipient TEXT NOT NULL,             -- Email address
  subject TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed')),
  message_id TEXT,                     -- Provider message ID
  error TEXT,                          -- Error message if failed
  sent_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_email_log_user ON email_log(user_id);
CREATE INDEX IF NOT EXISTS idx_email_log_status ON email_log(status);
CREATE INDEX IF NOT EXISTS idx_email_log_sent_at ON email_log(sent_at);

-- PostgreSQL
CREATE TABLE IF NOT EXISTS email_log (
  id TEXT PRIMARY KEY,
  user_id TEXT REFERENCES users(id),
  template TEXT NOT NULL,
  recipient TEXT NOT NULL,
  subject TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed')),
  message_id TEXT,
  error TEXT,
  sent_at TEXT DEFAULT (CURRENT_TIMESTAMP)
);
```

**Schema Migration:**
- Add to `src/lib/db.ts` in `SQLITE_SCHEMA` and `PG_SCHEMA` constants
- Tables auto-create on `initDb()` (existing pattern)

---

### 3.4 API Endpoints

#### POST `/api/auth/verify-email`
**Purpose:** Verify email with token or OTP code
**Request:**
```json
{
  "token": "uuid-v4-token",  // From email link
  "code": "123456"           // Optional: OTP code (if user can't click link)
}
```

**Response (200):**
```json
{
  "data": {
    "verified": true,
    "userId": "usr_xxx"
  }
}
```

**Errors:**
- 400: Missing token/code
- 404: Token not found
- 410: Token expired or already used

**Logic:**
1. Look up token in `email_verification_tokens`
2. Check expiry (`expires_at < now()` → error 410)
3. Check `used_at IS NOT NULL` → error 410
4. If `code` provided, validate `otp_code` matches
5. Update `used_at = now()`
6. Mark user as verified (add `email_verified` column to `users` table)
7. Log to audit log

**File:** `src/app/api/auth/verify-email/route.ts`

---

#### POST `/api/auth/forgot-password`
**Purpose:** Request password reset (sends email with token)
**Request:**
```json
{
  "email": "user@example.com"
}
```

**Response (200):**
```json
{
  "data": {
    "message": "If the email exists, a reset link has been sent."
  }
}
```

**Logic:**
1. Look up user by email
2. If user not found → return 200 anyway (security: don't leak account existence)
3. Generate UUID token, 1-hour expiry
4. Insert into `password_reset_tokens`
5. Send `password-reset.html` email with `{{resetUrl}}` = `/reset-password?token=xxx`
6. Log to `email_log`

**File:** `src/app/api/auth/forgot-password/route.ts`

---

#### POST `/api/auth/reset-password`
**Purpose:** Reset password with token
**Request:**
```json
{
  "token": "uuid-v4-token",
  "newPassword": "NewP@ssw0rd123"
}
```

**Response (200):**
```json
{
  "data": {
    "message": "Password reset successful. You can now log in."
  }
}
```

**Errors:**
- 400: Invalid password (reuse validation from `/api/auth/register`)
- 404: Token not found
- 410: Token expired or already used

**Logic:**
1. Look up token in `password_reset_tokens`
2. Check expiry and usage (same as verify-email)
3. Validate new password (8+ chars, 1 uppercase, 1 lowercase, 1 digit, 1 special)
4. Hash new password (`hashPassword()`)
5. Update `users.password_hash`
6. Mark token `used_at = now()`
7. Revoke all user sessions (security: force re-login)
8. Log to audit log

**File:** `src/app/api/auth/reset-password/route.ts`

---

#### POST `/api/auth/resend-verification`
**Purpose:** Resend verification email (if user didn't receive it)
**Request:**
```json
{
  "email": "user@example.com"
}
```

**Response (200):**
```json
{
  "data": {
    "message": "If the email exists, a verification email has been sent."
  }
}
```

**Logic:**
1. Look up user by email
2. If not found → return 200 anyway
3. If already verified → return 200 (idempotent)
4. Invalidate old tokens (`UPDATE email_verification_tokens SET used_at = now() WHERE user_id = ? AND used_at IS NULL`)
5. Generate new token and OTP
6. Send verification email
7. Rate limit: max 3 resends per hour

**File:** `src/app/api/auth/resend-verification/route.ts`

---

### 3.5 Integration Points

#### A. Register Flow (MODIFY: `src/app/api/auth/register/route.ts`)

**Current state:** Lines 107-133 generate OTP for SMS (not implemented).
**Action:** Add email verification.

**Additions:**
```typescript
// After user insert (line 92)
import { sendVerificationEmail } from '@/lib/email';
import crypto from 'crypto';

// Generate email verification token
const verifyTokenId = randomId('evt');
const verifyToken = crypto.randomUUID();
const otpCode = String(crypto.randomInt(100000, 1000000)); // 6 digits
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour

await run(
  `INSERT INTO email_verification_tokens (id, user_id, token, otp_code, expires_at)
   VALUES (?, ?, ?, ?, ?)`,
  [verifyTokenId, id, verifyToken, otpCode, expiresAt]
);

// Send verification email
await sendVerificationEmail(email!, verifyToken);
```

**Note:** Keep OTP SMS code (lines 109-133) as-is for phone verification. Email verification is separate.

---

#### B. Transaction Complete (MODIFY transaction routes)

**Files to modify:**
- `src/app/api/transactions/remittance/route.ts` (remittance)
- `src/app/api/transactions/qr-payment/route.ts` (QR payment)

**After transaction status = 'completed':**
```typescript
import { sendTransactionReceipt, sendTransferReceivedEmail } from '@/lib/email';

// Send receipt to sender
await sendTransactionReceipt(txId);

// If remittance, notify recipient (if they have a Drop account)
if (type === 'remittance' && recipientUserId) {
  await sendTransferReceivedEmail(recipientUserId, txId);
}
```

**Note:** Recipient email only if recipient has a Drop account. Otherwise, recipient gets money via bank transfer (no Drop account = no email notification from Drop).

---

#### C. Login Alert (MODIFY: `src/app/api/auth/login/route.ts`)

**Current state:** Sets auth cookie, no device tracking.
**Action:** Add login alert for new devices.

**Device fingerprint detection:**
```typescript
import { getClientIp } from '@/lib/middleware';
import { sendLoginAlertEmail } from '@/lib/email';
import crypto from 'crypto';

const ip = getClientIp(request);
const userAgent = request.headers.get('user-agent') || 'Unknown';

// Generate device fingerprint (hash of IP + User-Agent)
const deviceFingerprint = crypto.createHash('sha256')
  .update(`${ip}:${userAgent}`)
  .digest('hex');

// Check if device is new
const existingDevice = await getOne<{ id: string }>(
  "SELECT id FROM sessions WHERE user_id = ? AND device_fingerprint = ?",
  [userId, deviceFingerprint]
);

if (!existingDevice) {
  // New device → send alert
  await sendLoginAlertEmail(userId, ip, userAgent);
}

// Add device_fingerprint to session insert
```

**Schema change:**
```sql
-- Add to sessions table
ALTER TABLE sessions ADD COLUMN device_fingerprint TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_device ON sessions(device_fingerprint);
```

---

#### D. Support Ticket Response (FUTURE)

**Not in MVP.** When support ticket system is built:
- `POST /api/support/tickets/:id/respond` → sends `support-ticket-update.html`

---

## 4. Dependencies

**Add to `package.json`:**
```json
{
  "dependencies": {
    "resend": "^4.0.0",           // Resend SDK
    "@sendgrid/mail": "^8.1.0",   // SendGrid SDK (optional, for provider swap)
    "nodemailer": "^6.9.0"        // SMTP fallback
  },
  "devDependencies": {
    "@types/nodemailer": "^6.4.14"
  }
}
```

**Install:**
```bash
npm install resend @sendgrid/mail nodemailer
npm install -D @types/nodemailer
```

---

## 5. Env Vars

**Add to `.env.example`:**
```bash
# --- Email Service ---
# Provider: resend (recommended) | sendgrid | smtp
EMAIL_PROVIDER=resend
EMAIL_FROM="Drop <no-reply@getdrop.no>"

# Resend API key (get from resend.com)
RESEND_API_KEY=re_xxxxx

# SendGrid API key (alternative provider)
# SENDGRID_API_KEY=SG.xxxxx

# SMTP fallback
# SMTP_HOST=smtp.sendgrid.net
# SMTP_PORT=587
# SMTP_USER=apikey
# SMTP_PASS=SG.xxxxx
```

**Production setup (Resend):**
1. Sign up at [resend.com](https://resend.com)
2. Add domain: `getdrop.no`
3. Add DNS records (DKIM, SPF, DMARC) — Resend provides exact records
4. Generate API key
5. Set `RESEND_API_KEY` in production env

---

## 6. File List

**Files to CREATE:**
```
src/lib/email.ts                                    # Email service layer
src/email-templates/email-verification.html         # Email verification template
src/email-templates/transfer-received.html          # Transfer received template
src/email-templates/login-alert.html                # Login alert template
src/app/api/auth/verify-email/route.ts              # Email verification endpoint
src/app/api/auth/forgot-password/route.ts           # Password reset request endpoint
src/app/api/auth/reset-password/route.ts            # Password reset endpoint
src/app/api/auth/resend-verification/route.ts       # Resend verification endpoint
```

**Files to MODIFY:**
```
src/lib/db.ts                                       # Add email_verification_tokens, password_reset_tokens, email_log tables
src/app/api/auth/register/route.ts                  # Add email verification send
src/app/api/auth/login/route.ts                     # Add login alert for new devices
src/app/api/transactions/remittance/route.ts        # Add transaction receipt email
src/app/api/transactions/qr-payment/route.ts        # Add transaction receipt email
src/lib/services/email.ts                           # DELETE (replaced by src/lib/email.ts)
.env.example                                        # Add EMAIL_* env vars
package.json                                        # Add resend, @sendgrid/mail, nodemailer deps
```

**Total:** 8 new files, 7 modified files.

---

## 7. Acceptance Criteria

**Email Service Layer:**
- [ ] `sendEmail()` sends via Resend in production
- [ ] `sendEmail()` logs to console in demo mode
- [ ] `sendEmail()` falls back to SMTP if Resend fails
- [ ] All template helpers (`sendWelcomeEmail()`, etc.) render templates correctly
- [ ] Rate limiting blocks >10 emails/user/hour

**Database:**
- [ ] All tables created on `initDb()`
- [ ] Tokens auto-expire after 1 hour
- [ ] `email_log` table logs all sent/failed emails

**API Endpoints:**
- [ ] `POST /api/auth/verify-email` verifies token and OTP
- [ ] `POST /api/auth/verify-email` rejects expired tokens (410)
- [ ] `POST /api/auth/forgot-password` sends reset email
- [ ] `POST /api/auth/reset-password` updates password
- [ ] `POST /api/auth/resend-verification` resends email
- [ ] All endpoints rate limited (10 req/min per IP)

**Integration:**
- [ ] Registration sends verification email
- [ ] Transaction completion sends receipt to sender
- [ ] Transfer completion sends notification to recipient (if Drop user)
- [ ] Login from new device sends security alert

**Templates:**
- [ ] All templates render with correct placeholders
- [ ] All templates display correctly in Gmail, Outlook, Apple Mail
- [ ] All templates mobile-responsive (375px width)

**Provider Setup:**
- [ ] Resend domain verified (`getdrop.no`)
- [ ] DKIM, SPF, DMARC records added to DNS
- [ ] API key set in production env
- [ ] Test email sent successfully

---

## 8. Implementation Plan

**Phase 1: Email Service Layer (2h)**
- Create `src/lib/email.ts` with Resend integration
- Add Resend dependency
- Set up env vars
- Test send via Resend dashboard

**Phase 2: Database Schema (1h)**
- Add 3 tables to `src/lib/db.ts`
- Test `initDb()` creates tables correctly

**Phase 3: API Endpoints (3h)**
- Create 4 API routes (verify, forgot, reset, resend)
- Add validation and rate limiting
- Test with Postman/curl

**Phase 4: Templates (2h)**
- Create 3 new templates (email-verification, transfer-received, login-alert)
- Test rendering with placeholders
- Test display in Gmail/Outlook

**Phase 5: Integration (2h)**
- Modify register route (send verification email)
- Modify transaction routes (send receipts)
- Modify login route (send alert)
- Test end-to-end flows

**Phase 6: Production Setup (1h)**
- Set up Resend account
- Verify domain (getdrop.no)
- Add DNS records
- Generate API key
- Deploy to staging

**Total: 11 hours**

---

## 9. Rollout Strategy

**Staging:**
- Deploy to staging environment
- Test all email flows with real email addresses
- Verify DNS records propagated
- Check spam score (mail-tester.com)

**Production (Gradual):**
- Enable email verification for NEW users only
- Monitor `email_log` for failures
- After 1 week: enable password reset
- After 2 weeks: enable transaction receipts
- After 3 weeks: enable login alerts

**Monitoring:**
- Daily check of `SELECT * FROM email_log WHERE status = 'failed'`
- Alert if >5% failure rate
- Weekly review of Resend dashboard (bounce rate, spam rate)

---

## 10. Success Metrics

**Week 1:**
- [ ] 0 email send failures
- [ ] <1% bounce rate
- [ ] <0.1% spam rate

**Month 1:**
- [ ] >90% email open rate (verification, password reset)
- [ ] >50% email open rate (transaction receipts)
- [ ] 0 support tickets about missing emails

**Quarter 1:**
- [ ] Email verification integrated into BankID flow
- [ ] Transaction receipts sent for 100% of transactions
- [ ] Login alerts sent for 100% of new devices

---

**END OF SPEC**

# drop-fx-transparency-spec

# Drop FX Rate Transparency & Fee Breakdown Specification

**Task:** MC #1193
**Created:** 2026-02-17
**Author:** John (Architect Agent)
**Status:** DRAFT — Awaiting Alem approval
**Product:** Drop — Fintech Payment App (Pass-through PSD2 PISP model)

---

## Executive Summary

This specification defines the architecture for **real-time exchange rate transparency and fee breakdown** for Drop's remittance payment service. Drop operates as a PSD2 PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.

**Regulatory Requirement:** PSD2 Directive 2015/2366/EU Article 45 mandates that payment service providers disclose:
1. All charges payable by the payment service user with a breakdown
2. The actual or reference exchange rate to be applied to the payment transaction
3. Maximum execution time for the payment service

**Current State:** Drop uses mocked/seeded exchange rates from database (`exchange_rates` table). Rates refresh hourly from `EXCHANGE_RATE_API_URL` (env var, not set). Fee calculation is hardcoded at 0.5% for remittances.

**Goal:** Live FX rates (Norges Bank official data), transparent fee breakdown shown BEFORE user confirms transfer, full PSD2 compliance.

---

## 1. Business Context

### 1.1 Norwegian Context

- **Base Currency:** NOK (Norwegian Krone)
- **Target Users:** ALL residents in Norway (NOT limited to diaspora)
- **Corridors:** 30+ countries (seeded in `recipients` table — RSD, EUR, USD, GBP, SEK, DKK, PLN, etc.)
- **Regulatory Body:** Finanstilsynet (Norwegian Financial Supervisory Authority)
- **Reference Rate:** Norges Bank publishes daily exchange rates at ~16:00 CET for 40+ currencies

### 1.2 Competitive Landscape

**Wise (market leader):**
- Uses mid-market rate with 0.5-1% transparent fee
- Full breakdown shown BEFORE transfer: amount + fee + FX rate + total + "You send X, they get Y"
- Calculator on homepage (no login required)

**Remitly:**
- Variable FX markup 0.5-3% (not transparent, hidden in rate)
- Fee varies by speed (Express vs Economy), payment method, new vs returning user
- Less transparent than Wise — users complain about hidden fees

**Drop's Positioning:**
- **Transparent like Wise** — show mid-market reference rate + markup + fee separately
- **Norwegian-first** — NOK base, Norges Bank as reference
- **Cheaper than traditional banks** (DNB, Nordea charge 2-5%)

### 1.3 Pass-Through Model Implications

Drop does NOT:
- Hold customer money
- Perform FX conversion itself (that's the bank's job via PISP)
- Need forex license (we're PISP, not EMI)

Drop DOES:
- Show user the expected FX rate BEFORE transfer
- Charge a transparent service fee (0.5% for remittance)
- Display what recipient will receive (calculated estimate)
- Send payment instruction to user's bank via Open Banking

**Key Point:** FX rate shown to user is **reference/estimate**. Actual conversion happens at user's bank. Drop must disclose this clearly (PSD2 Article 45).

---

## 2. Current Implementation Analysis

### 2.1 What Exists (Good Foundation)

**Database Schema:**
```sql
-- exchange_rates table (seeded with 30+ currencies)
CREATE TABLE exchange_rates (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  from_currency TEXT NOT NULL,
  to_currency TEXT NOT NULL,
  rate REAL NOT NULL,
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  UNIQUE(from_currency, to_currency)
);
```

**Service Layer:** `/src/lib/services/rates.ts`
- `getExchangeRates()` — returns all rates from DB
- `getRate(from, to)` — returns single rate
- `refreshRatesIfStale()` — fetches from `EXCHANGE_RATE_API_URL` if last update > 1 hour
- Graceful degradation — falls back to cached rates on API failure

**API Endpoints:**
- `GET /api/rates` — returns all rates
- `GET /api/rates/[currency]` — returns single rate
- `POST /api/transactions/disclosure` — calculates fee + FX preview (BEFORE user confirms)

**UI Component:** `/src/components/pre-payment-disclosure.tsx`
- Shows breakdown: amount + fee + FX rate + receive amount + total + ETA
- Modal dialog — user must confirm before transaction submits
- Norwegian language, good UX (matches Wise pattern)

**Fee Calculation:** Hardcoded in `/src/app/api/transactions/remittance/route.ts`
```typescript
const feePercent = 0.005; // 0.5%
const fee = Math.round(amount * feePercent * 100) / 100;
```

### 2.2 What's Missing (Critical Gaps)

❌ **Live FX Rate Source:** `EXCHANGE_RATE_API_URL` not configured, no real provider
❌ **Norges Bank Integration:** Not using official Norwegian reference rates
❌ **FX Markup Transparency:** No distinction between mid-market rate and Drop's rate
❌ **Fee Configuration:** Fees hardcoded, no corridor-specific fees, no volume tiers
❌ **Rate Locking:** No guarantee user gets the rate they saw (can change between preview and execution)
❌ **Stale Rate Detection:** 1-hour threshold too long for high-volume corridors (EUR, USD)
❌ **Rate Drift Monitoring:** No alerts when rates deviate significantly from Norges Bank reference
❌ **Historical Rate Tracking:** No log of which rate was used for completed transactions
❌ **PSD2 Disclosure Language:** Component exists but needs exact regulatory wording

---

## 3. Data Sources

### 3.1 Norges Bank API (Primary Reference)

**Official Source:** [Norges Bank Data Warehouse](https://www.norges-bank.no/en/topics/Statistics/open-data/)

**API Base URL:** `https://data.norges-bank.no/api/data/EXR/`

**Example Request:**
```bash
# Daily exchange rates for EUR, USD, GBP against NOK
# B = Business day frequency, SP = Spot (daily reference rate)
curl "https://data.norges-bank.no/api/data/EXR/B..NOK.SP?startPeriod=2026-02-17&endPeriod=2026-02-17&format=sdmx-json&locale=en"
```

**Key Features:**
- **Free:** No API key required, fully open
- **Authoritative:** Official central bank rates used by Norwegian businesses
- **Daily Updates:** Published ~16:00 CET
- **40+ Currencies:** Covers all major Drop corridors
- **SDMX Standard:** Industry-standard format for statistical data

**Limitations:**
- **Daily Frequency:** Not real-time (updated once per business day)
- **No Weekends:** Last Friday rate used on weekends
- **Spot Rate Only:** No forward rates, no historical intraday

**Cost:** $0 (completely free)

### 3.2 Commercial FX Provider (Real-Time Fallback)

For high-volume corridors (EUR, USD, GBP) where users expect near-real-time rates:

**Option A: ExchangeRate-API.com**
- **Free Tier:** 1,500 requests/month, hourly updates
- **Paid Tier:** $9/month for 100k requests, 10-min updates
- **Currencies:** 160+
- **Format:** Simple JSON: `{ "NOK": { "EUR": 0.0867 } }`
- **Reliability:** 99.9% uptime SLA (paid tier)

**Recommendation:** Start with **Norges Bank (free) + ExchangeRate-API.com free tier** (1,500 requests/month = 50/day, enough for MVP). Migrate to paid tier when transaction volume > 50/day.

**Hybrid Strategy:**
1. **Norges Bank:** Daily reference rate for all corridors (free, authoritative)
2. **ExchangeRate-API:** Real-time updates for EUR, USD, GBP (high volume)
3. **Fallback:** Cached DB rates (stale threshold: 4 hours for major corridors, 24 hours for minor)

**Cost Estimate:** $0-9/month (depending on traffic)

---

## 4. Database Schema

### 4.1 Enhanced `exchange_rates` Table

```sql
-- Add columns to existing table
ALTER TABLE exchange_rates ADD COLUMN source TEXT DEFAULT 'seed';
  -- 'norges_bank', 'exchangerate_api', 'fixer', 'seed'

ALTER TABLE exchange_rates ADD COLUMN rate_type TEXT DEFAULT 'spot';
  -- 'spot', 'mid_market', 'buy', 'sell'

ALTER TABLE exchange_rates ADD COLUMN markup REAL DEFAULT 0.0;
  -- Drop's markup percentage (0.0 = no markup, 0.005 = 0.5%)

ALTER TABLE exchange_rates ADD COLUMN external_id TEXT;
  -- Provider's ID for this rate (if applicable)

ALTER TABLE exchange_rates ADD COLUMN is_stale INTEGER DEFAULT 0;
  -- 0 = fresh, 1 = stale (triggers refresh)

ALTER TABLE exchange_rates ADD COLUMN last_refresh_attempt TEXT;
  -- Timestamp of last API fetch attempt (for monitoring)

CREATE INDEX idx_rates_stale ON exchange_rates(is_stale, updated_at);
CREATE INDEX idx_rates_source ON exchange_rates(source);
```

### 4.2 New Table: `fx_rate_history`

Track which rate was shown to user AND which rate was actually used by bank:

```sql
CREATE TABLE fx_rate_history (
  id TEXT PRIMARY KEY,
  transaction_id TEXT REFERENCES transactions(id) ON DELETE CASCADE,
  from_currency TEXT NOT NULL,
  to_currency TEXT NOT NULL,
  shown_rate REAL NOT NULL,       -- Rate displayed to user at disclosure
  shown_markup REAL NOT NULL,     -- Markup at disclosure time
  shown_source TEXT NOT NULL,     -- 'norges_bank', 'exchangerate_api', etc.
  actual_rate REAL,               -- Actual bank rate (if known from bank statement)
  rate_locked_at TEXT,            -- Timestamp when rate was locked (if locking enabled)
  rate_locked_until TEXT,         -- Expiry time for locked rate
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX idx_fx_history_tx ON fx_rate_history(transaction_id);
CREATE INDEX idx_fx_history_locked ON fx_rate_history(rate_locked_at);
```

**Purpose:**
- **Compliance:** PSD2 requires we log what rate we showed user
- **Transparency:** User can compare promised rate vs actual rate
- **Dispute Resolution:** Evidence for customer complaints ("you said 10.5, I got 10.3")
- **Rate Locking (Future):** Store locked rate + expiry

### 4.3 New Table: `fee_configs`

Make fees configurable instead of hardcoded:

```sql
CREATE TABLE fee_configs (
  id TEXT PRIMARY KEY,
  corridor TEXT NOT NULL UNIQUE, -- 'NOK-RSD', 'NOK-EUR', '*' (default)
  fee_type TEXT NOT NULL CHECK(fee_type IN ('percentage', 'flat', 'tiered')),
  fee_percentage REAL,           -- For percentage type (0.005 = 0.5%)
  fee_flat REAL,                 -- For flat type (25 NOK)
  fee_tiers TEXT,                -- JSON for tiered: [{"max":1000,"rate":0.01},{"max":null,"rate":0.005}]
  min_fee REAL DEFAULT 0,        -- Minimum fee (NOK)
  max_fee REAL,                  -- Maximum fee cap (NOK, NULL = no cap)
  effective_from TEXT NOT NULL DEFAULT (datetime('now')),
  effective_until TEXT,          -- NULL = indefinite
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);

-- Seed default config
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_default', '*', 'percentage', 0.005, 10.0); -- 0.5%, min 10 NOK

-- Corridor-specific example (cheaper EUR corridor)
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_eur', 'NOK-EUR', 'percentage', 0.003, 5.0); -- 0.3%, min 5 NOK
```

**Benefits:**
- **A/B Testing:** Change fees without code deploy
- **Promotional Pricing:** Set temporary fee discounts (effective_from/until)
- **Corridor Optimization:** Cheaper fees for high-volume corridors (EUR, USD)
- **Tiered Pricing:** Lower fee percentage for large transfers (e.g., >10k NOK)

### 4.4 New Table: `fx_rate_alerts`

Monitor rate drift and API failures:

```sql
CREATE TABLE fx_rate_alerts (
  id TEXT PRIMARY KEY,
  alert_type TEXT NOT NULL CHECK(alert_type IN ('stale_rate', 'rate_drift', 'api_failure', 'missing_rate')),
  severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high', 'critical')),
  from_currency TEXT NOT NULL,
  to_currency TEXT,              -- NULL for API failure alerts
  details TEXT,                  -- JSON: {"expected":10.5,"actual":11.2,"drift_pct":6.7}
  resolved INTEGER DEFAULT 0,    -- 0 = open, 1 = resolved
  created_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT
);

CREATE INDEX idx_fx_alerts_unresolved ON fx_rate_alerts(alert_type, resolved, created_at);
```

**Alert Examples:**
- `stale_rate`: EUR/NOK not updated in 6 hours (should refresh hourly)
- `rate_drift`: Norges Bank shows 11.5, ExchangeRate-API shows 11.9 (3.5% drift — investigate)
- `api_failure`: ExchangeRate-API returned 503 for 3 consecutive attempts
- `missing_rate`: User tried to transfer to THB, no rate in DB

---

## 5. API Endpoints

### 5.1 Enhanced `GET /api/rates`

**Current:** Returns all rates from DB
**New:** Add metadata, source attribution, staleness indicator

**Request:**
```http
GET /api/rates?base=NOK&symbols=EUR,USD,RSD
```

**Response:**
```json
{
  "base": "NOK",
  "rates": {
    "EUR": {
      "rate": 0.0867,
      "markup": 0.005,
      "effectiveRate": 0.0871,
      "source": "norges_bank",
      "updatedAt": "2026-02-17T16:00:00Z",
      "isStale": false
    },
    "USD": {
      "rate": 0.0923,
      "markup": 0.005,
      "effectiveRate": 0.0928,
      "source": "exchangerate_api",
      "updatedAt": "2026-02-17T14:30:00Z",
      "isStale": false
    }
  },
  "updatedAt": "2026-02-17T14:30:00Z"
}
```

**Fields:**
- `rate`: Mid-market reference rate (from Norges Bank or provider)
- `markup`: Drop's markup percentage
- `effectiveRate`: `rate * (1 + markup)` — what user actually pays
- `source`: Which API provided this rate
- `isStale`: True if rate hasn't refreshed within threshold

**Caching:** Cache-Control: public, max-age=300 (5 minutes)

### 5.2 Enhanced `POST /api/transactions/disclosure`

**Current:** Calculates fee + FX preview
**New:** Return full PSD2-compliant disclosure, log to `fx_rate_history`

**Request:**
```json
{
  "type": "remittance",
  "amount": 5000,
  "recipientId": "rec_abc123"
}
```

**Response:**
```json
{
  "sendAmount": 5000,
  "sendCurrency": "NOK",
  "fee": 25,
  "feePercentage": 0.5,
  "totalCost": 5025,
  "exchangeRate": {
    "reference": 10.1700,
    "source": "Norges Bank (16:00 CET)",
    "markup": 0.5,
    "effectiveRate": 10.2209,
    "updatedAt": "2026-02-17T16:00:00Z"
  },
  "receiveAmount": 51104.50,
  "receiveCurrency": "RSD",
  "estimatedDelivery": "1-2 business days",
  "rateValidUntil": "2026-02-17T17:00:00Z",
  "psd2Disclosure": {
    "en": "This is an estimate. Your bank will apply its own exchange rate at the time of transfer. The final amount received may differ.",
    "no": "Dette er et estimat. Din bank vil bruke sin egen valutakurs ved overføring. Endelig beløp mottatt kan avvike."
  },
  "disclosureId": "disc_xyz789"
}
```

---

## 6. Rate Refresh Strategy

### 6.1 Refresh Schedule

| Corridor | Source | Refresh Frequency | Stale Threshold | Fallback |
|----------|--------|-------------------|-----------------|----------|
| **NOK-EUR** | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| **NOK-USD** | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| **NOK-GBP** | ExchangeRate-API | 10 min | 1 hour | Norges Bank daily |
| **NOK-RSD** | Norges Bank | Daily (16:00 CET) | 24 hours | Cached DB |
| **NOK-SEK** | Norges Bank | Daily (16:00 CET) | 24 hours | Cached DB |
| **All Others** | Norges Bank | Daily (16:00 CET) | 48 hours | Cached DB |

**Rationale:**
- **High-Volume Corridors (EUR, USD, GBP):** Users expect near-real-time rates. 10-min refresh acceptable.
- **Regional Corridors (RSD, SEK, PLN):** Daily Norges Bank rate sufficient (low volatility).
- **Long-Tail Corridors:** 48-hour stale threshold (rarely used, rate changes minimal).

### 6.2 Refresh Triggers

**Automatic:**
1. **Cron Job:** Every 10 min, check stale rates and refresh high-volume corridors
2. **API Request:** When user requests `/api/transactions/disclosure`, check if corridor rate is stale → refresh synchronously (max 5s timeout)
3. **Daily Batch:** At 16:30 CET (30 min after Norges Bank publishes), fetch all 40 currencies

**Manual:**
- Admin endpoint: `POST /api/admin/fx/refresh` (force refresh all rates)

---

## 7. Fee Calculation Engine

### 7.1 Fee Structure

**Fee Types:**
1. **Percentage:** `amount * fee_percentage` (most common)
2. **Flat:** Fixed amount (e.g., 25 NOK for all transfers)
3. **Tiered:** Different percentage based on amount brackets

**Example Tiered Fee (NOK-RSD):**
```json
[
  { "max": 1000, "rate": 0.01 },   // 0-1000 NOK: 1%
  { "max": 5000, "rate": 0.007 },  // 1001-5000 NOK: 0.7%
  { "max": null, "rate": 0.005 }   // >5000 NOK: 0.5%
]
```

**Implementation:**
```typescript
// /lib/services/fees.ts
export interface FeeConfig {
  corridor: string;
  feeType: 'percentage' | 'flat' | 'tiered';
  feePercentage?: number;
  feeFlat?: number;
  feeTiers?: { max: number | null; rate: number }[];
  minFee: number;
  maxFee?: number;
}

export async function calculateFee(
  amount: number,
  fromCurrency: string,
  toCurrency: string
): Promise<{ fee: number; config: FeeConfig }> {
  const corridor = `${fromCurrency}-${toCurrency}`;

  // 1. Get corridor-specific config (or default '*')
  let config = await getOne<FeeConfig>(
    `SELECT * FROM fee_configs
     WHERE corridor = ?
       AND (effective_from <= datetime('now'))
       AND (effective_until IS NULL OR effective_until > datetime('now'))
     ORDER BY effective_from DESC LIMIT 1`,
    [corridor]
  );

  if (!config) {
    config = await getOne<FeeConfig>(
      `SELECT * FROM fee_configs WHERE corridor = '*' LIMIT 1`,
      []
    );
  }

  if (!config) {
    throw new Error('No fee configuration found');
  }

  // 2. Calculate fee based on type
  let fee = 0;

  if (config.feeType === 'percentage') {
    fee = amount * (config.feePercentage || 0);
  } else if (config.feeType === 'flat') {
    fee = config.feeFlat || 0;
  } else if (config.feeType === 'tiered') {
    const tiers = JSON.parse(config.feeTiers || '[]');
    for (const tier of tiers) {
      if (tier.max === null || amount <= tier.max) {
        fee = amount * tier.rate;
        break;
      }
    }
  }

  // 3. Apply min/max caps
  if (config.minFee && fee < config.minFee) {
    fee = config.minFee;
  }
  if (config.maxFee && fee > config.maxFee) {
    fee = config.maxFee;
  }

  // 4. Round to 2 decimals
  fee = Math.round(fee * 100) / 100;

  return { fee, config };
}
```

---

## 8. UI Component Spec

### 8.1 Pre-Payment Disclosure (Enhanced)

**Location:** `/src/components/pre-payment-disclosure.tsx`

**Required Changes:**

```tsx
interface PrePaymentDisclosureProps {
  amount: number;
  fee: number;
  feeConfig: FeeConfig; // NEW: show fee structure
  exchangeRate: {
    reference: number;
    source: string; // "Norges Bank (16:00 CET)"
    markup: number;
    effectiveRate: number;
    updatedAt: string;
  };
  receiveAmount: number;
  receiveCurrency: string;
  estimatedDelivery: string;
  psd2Disclosure: { no: string; en: string }; // NEW: regulatory text
  rateValidUntil: string; // NEW: countdown timer
  onConfirm: () => void;
  onCancel: () => void;
}
```

**Layout Additions:**

1. **Exchange Rate Breakdown Section:**
```tsx
<div className="bg-[#F8FAFC] rounded-xl p-4 space-y-2">
  <div className="flex items-center justify-between">
    <span className="text-xs text-[#64748B]">Referansekurs (Norges Bank)</span>
    <span className="text-sm font-medium text-[#1E293B]">1 NOK = 10.1700 RSD</span>
  </div>
  <div className="flex items-center justify-between">
    <span className="text-xs text-[#64748B]">Drop's påslag (0.5%)</span>
    <span className="text-sm font-medium text-[#1E293B]">+ 0.0509 RSD</span>
  </div>
  <div className="pt-2 border-t border-[#E2E8F0] flex items-center justify-between">
    <span className="text-sm font-bold text-[#0F172A]">Effektiv kurs</span>
    <span className="text-sm font-bold text-[#0B6E35]">1 NOK = 10.2209 RSD</span>
  </div>
</div>
```

2. **PSD2 Regulatory Disclosure:**
```tsx
<div className="p-3 bg-[#FFFBEB] border border-[#FCD34D] rounded-xl">
  <p className="text-xs text-[#92400E] leading-relaxed">
    <strong>Viktig informasjon:</strong> Dette er et estimat basert på dagens valutakurs.
    Din bank vil bruke sin egen valutakurs ved overføring. Endelig beløp mottatt kan avvike.
  </p>
</div>
```

---

## 9. PSD2 Compliance Checklist

### 9.1 Article 45: Information Before Payment Execution

**Requirements (from [PSD2 Directive](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32015L2366)):**

✅ **Maximum execution time:**
- Drop: "1-2 business days" (EEA) or "2-4 business days" (non-EEA)
- Shown in disclosure modal + transaction receipt

✅ **All charges payable with breakdown:**
- Drop: "Gebyr (0.5%): 25 NOK" + "Total kostnad: 5025 NOK"
- Breakdown: base amount + fee = total

✅ **Actual or reference exchange rate:**
- Drop: Shows Norges Bank reference rate + markup + effective rate
- Disclosure: "Referansekurs (Norges Bank): 1 NOK = 10.1700 RSD, Drop's påslag (0.5%): + 0.0509 RSD, Effektiv kurs: 1 NOK = 10.2209 RSD"

### 9.2 Disclosure Language (Norwegian + English)

**PSD2-Compliant Text:**

**Norwegian (Primary):**
```
VIKTIG INFORMASJON OM VALUTAVEKSLING

Dette er et estimat basert på dagens valutakurs fra Norges Bank (oppdatert [TIMESTAMP]).

Din bank vil bruke sin egen valutakurs ved gjennomføring av betalingen.
Det endelige beløpet mottaker får kan avvike fra dette estimatet.

Drop legger til et påslag på [X]% på referansekursen. Dette er inkludert i "Effektiv kurs" ovenfor.

Gebyret på [FEE] NOK er fast og vil ikke endres.

Ved å bekrefte godtar du at totalkostnaden på [TOTAL] NOK trekkes fra din bankkonto.
```

**English (Secondary):**
```
IMPORTANT INFORMATION ABOUT CURRENCY CONVERSION

This is an estimate based on today's exchange rate from Norges Bank (updated [TIMESTAMP]).

Your bank will apply its own exchange rate when executing the payment.
The final amount received may differ from this estimate.

Drop applies a markup of [X]% on the reference rate. This is included in the "Effective rate" above.

The fee of [FEE] NOK is fixed and will not change.

By confirming, you agree that the total cost of [TOTAL] NOK will be debited from your bank account.
```

---

## 10. Monitoring & Alerting

### 10.1 Metrics to Track

| Metric | Threshold | Alert If | Action |
|--------|-----------|----------|--------|
| **Stale rate count** | 0 | > 5 | Investigate API provider |
| **Rate drift (vs Norges Bank)** | <1% | >3% | Check provider, notify admin |
| **API failure rate** | <1% | >5% | Switch to fallback source |
| **Rate refresh latency** | <5s | >10s | Optimize API calls |
| **Fee revenue (daily)** | N/A | Sudden drop >50% | Check fee config changes |

### 10.2 Slack Alerts

**Webhook URL:** `SLACK_WEBHOOK_URL` env var

**Alert Conditions:**
```typescript
// /lib/services/fx-alerts.ts
export async function checkRateDrift(
  from: string,
  to: string,
  norgesBankRate: number,
  providerRate: number
) {
  const driftPercent = Math.abs((providerRate - norgesBankRate) / norgesBankRate) * 100;

  if (driftPercent > 3) {
    const alertId = randomId('alert');
    await run(`
      INSERT INTO fx_rate_alerts (
        id, alert_type, severity, from_currency, to_currency, details
      ) VALUES (?, 'rate_drift', 'high', ?, ?, ?)
    `, [
      alertId,
      from,
      to,
      JSON.stringify({ norgesBankRate, providerRate, driftPercent })
    ]);

    await sendSlackAlert({
      severity: 'high',
      title: `Rate drift detected: ${from}-${to}`,
      message: `Norges Bank: ${norgesBankRate}, Provider: ${providerRate} (${driftPercent.toFixed(2)}% drift)`,
      alertId,
    });
  }
}
```

---

## 11. Implementation Phases

### Phase 1: Foundation (Week 1) — Core Infrastructure

**Deliverables:**
- Enhanced `exchange_rates` table schema (source, markup, is_stale columns)
- `fee_configs` table + seeded data
- `fx_rate_history` table
- `fx_rate_alerts` table
- Fee calculation engine (`/lib/services/fees.ts`)
- Enhanced rate service with staleness detection
- Unit tests for fee calculation + rate retrieval

**Acceptance Criteria:**
- `calculateFee()` correctly handles percentage, flat, tiered fees
- `getRate()` detects stale rates and flags them
- Database migrations run successfully on SQLite + PostgreSQL

**Effort:** 3 days (1 builder agent)

---

### Phase 2: Norges Bank Integration (Week 1) — Primary Data Source

**Deliverables:**
- Norges Bank API client (`/lib/providers/norges-bank.ts`)
- SDMX-JSON parser for Norges Bank response format
- Daily refresh cron job (16:30 CET)
- Seed all 40 currencies from Norges Bank
- Integration tests (mock Norges Bank API responses)

**Acceptance Criteria:**
- All 30+ Drop corridors have rates from Norges Bank
- Rates update automatically every business day at 16:30 CET
- Parser handles SDMX-JSON format correctly
- Graceful handling of Norges Bank API downtime (use cached rates)

**Effort:** 2 days (1 builder agent)

---

### Phase 3: Commercial Provider Integration (Week 2) — Real-Time Fallback

**Deliverables:**
- ExchangeRate-API client (`/lib/providers/exchangerate-api.ts`)
- Hybrid refresh strategy (Norges Bank daily + ExchangeRate-API 10-min for EUR/USD/GBP)
- 10-minute cron job for high-volume corridors
- Rate drift detection (compare Norges Bank vs ExchangeRate-API)
- Admin alert on >3% drift

**Acceptance Criteria:**
- EUR, USD, GBP rates refresh every 10 minutes
- Other corridors use Norges Bank daily rate
- Drift alerts appear in `fx_rate_alerts` table
- Slack webhook fires for high drift (>3%)

**Effort:** 2 days (1 builder agent)

**Cost:** $0 (free tier: 1,500 requests/month = 50/day, sufficient for MVP)

---

### Phase 4: Enhanced Disclosure UI (Week 2) — Frontend

**Deliverables:**
- Enhanced `pre-payment-disclosure.tsx` component
  - Rate breakdown section (reference + markup + effective)
  - Rate validity countdown timer
  - PSD2 regulatory text
  - Fee breakdown (tiered if applicable)
- Homepage rate calculator widget
- Updated `/api/transactions/disclosure` endpoint (return full breakdown)

**Acceptance Criteria:**
- Disclosure modal shows: reference rate, markup, effective rate, source, last updated
- Countdown timer shows "Rate valid for X minutes"
- PSD2 text in Norwegian + English
- Calculator works without login (public API route)

**Effort:** 3 days (1 builder + 1 designer agent)

---

### Phase 5: Admin Tools & Monitoring (Week 3) — Ops Dashboard

**Deliverables:**
- `/admin/fx-monitoring` dashboard page
- `GET /api/admin/fx/alerts` endpoint
- `POST /api/admin/fx/refresh` manual refresh endpoint
- Slack webhook integration for alerts
- Rate drift monitoring (background job every 10 min)
- Stale rate detection (background job every 10 min)

**Acceptance Criteria:**
- Dashboard shows all rates, staleness, drift, alerts
- Manual refresh button works (triggers immediate API fetch)
- Slack alerts received for high/critical issues
- Unresolved alerts badge in admin nav

**Effort:** 2 days (1 builder agent)

---

### Phase 6: Rate Locking (Future — Deferred) — Advanced Feature

**Deliverables:**
- `POST /api/fx/lock-rate` endpoint
- Lock expiry background job (expire after 10 min)
- UI: "Lock this rate" button in disclosure modal (for transfers >10k NOK)
- Backend: Verify `lockId` during transaction submission

**Acceptance Criteria:**
- User can lock rate for 10 minutes
- Locked rate guaranteed during checkout
- Expired locks show error message
- Lock usage logged in `fx_rate_history`

**Effort:** 2 days (1 builder agent)

**Deferral Reason:** Not MVP-critical. Can ship without rate locking. Add when user feedback indicates need.

---

## 12. Cost Analysis

### 12.1 Infrastructure Costs

| Component | Provider | Cost | Notes |
|-----------|----------|------|-------|
| **Norges Bank API** | Norges Bank | $0 | Free, open API |
| **ExchangeRate-API (Free Tier)** | ExchangeRate-API.com | $0 | 1,500 requests/month (50/day) |
| **ExchangeRate-API (Paid Tier)** | ExchangeRate-API.com | $9/month | 100k requests (when traffic grows) |
| **Cron Jobs (Vercel)** | Vercel | $0 | Included in Hobby/Pro plan |
| **Slack Webhook** | Slack | $0 | Free tier sufficient |

**Total Monthly Cost (MVP):** $0
**Total Monthly Cost (Production >50 txs/day):** $9

### 12.2 Revenue Impact

**Fee Revenue Projection:**

| Scenario | Avg Transfer | Fee % | Txs/Month | Monthly Revenue |
|----------|-------------|-------|-----------|-----------------|
| **MVP (Beta)** | 2,000 NOK | 0.5% | 100 | 1,000 NOK ($95) |
| **Growth** | 3,000 NOK | 0.5% | 500 | 7,500 NOK ($710) |
| **Scale** | 4,000 NOK | 0.5% | 2,000 | 40,000 NOK ($3,800) |

**Break-Even:** 10 transactions/month covers $9 API cost (at 0.5% fee on 2,000 NOK avg)

---

## 13. Acceptance Criteria

### 13.1 Functional Requirements

- [ ] **Live Rates:** EUR, USD, GBP refresh every 10 minutes from ExchangeRate-API
- [ ] **Official Rates:** All other corridors use Norges Bank daily rate (16:00 CET)
- [ ] **Fee Transparency:** User sees breakdown: amount + fee (%) + total BEFORE confirming
- [ ] **FX Transparency:** User sees: reference rate (Norges Bank) + markup + effective rate
- [ ] **Source Attribution:** Disclosure shows "Rate from Norges Bank, updated X minutes ago"
- [ ] **Rate Validity:** User told "Rate valid for 10 minutes" with countdown timer
- [ ] **PSD2 Compliance:** Disclosure text matches Article 45 requirements (Norwegian + English)
- [ ] **Configurable Fees:** Admin can change fees via `fee_configs` table (no code deploy)
- [ ] **Corridor-Specific Fees:** Different fees for different corridors (e.g., EUR cheaper than RSD)

### 13.2 Non-Functional Requirements

- [ ] **Performance:** Disclosure endpoint responds in <2s (including rate fetch)
- [ ] **Availability:** Graceful degradation if API fails (use cached rates + show staleness warning)
- [ ] **Accuracy:** Rate shown to user logged in `fx_rate_history` for audit
- [ ] **Monitoring:** Slack alerts for rate drift >3%, API failures, stale rates >6h
- [ ] **Security:** API keys stored in env vars (never hardcoded), admin endpoints require auth

### 13.3 Compliance Requirements

- [ ] **PSD2 Article 45:** All charges + exchange rate disclosed BEFORE payment execution
- [ ] **PSD2 Record-Keeping:** `fx_rate_history` retained for 5+ years (never auto-deleted)
- [ ] **Finanstilsynet Compliance:** Norges Bank used as official reference (Norwegian requirement)
- [ ] **Consumer Protection:** Clear language ("This is an estimate, your bank may use different rate")

---

## 14. Open Questions (For Alem)

### Q1: Rate Locking Priority
**Question:** Should we implement rate locking in Phase 1 (MVP) or defer to Phase 2?

**Recommendation:** **Defer**. Rate locking adds complexity and is not required for PSD2 compliance. Ship MVP faster, validate user demand first.

---

### Q2: Commercial FX Provider Choice
**Question:** Which paid FX API should we use?

**Recommendation:** **ExchangeRate-API.com** — Best price/performance ratio ($9/month), simplest integration, Norwegian NOK supported as base currency.

---

### Q3: Fee Structure
**Question:** What fee structure should we launch with?

**Recommendation:** **Flat 0.5%** for MVP, then A/B test tiered pricing after 100 transactions. Simple beats clever for launch.

---

### Q4: Homepage Calculator
**Question:** Should we show fee breakdown in homepage calculator (public, no login)?

**Recommendation:** **Yes**. Full transparency, matches Wise UX. Show: "Total cost: 5025 NOK (includes 25 NOK fee)" even before login.

---

## 15. Next Steps

1. **Review this spec** with Alem (approve/request changes)
2. **Answer Open Questions** (Q1-Q4 above)
3. **Prioritize phases** (which to implement first?)
4. **Assign Phase 1 to builder agent** (schema changes + fee calculation engine)
5. **Validate after Phase 1** (validator agent checks DB schema + tests)
6. **Iterate through Phases 2-5** (one phase at a time, validate each)

**Estimated Timeline:**
- **Phase 1:** 3 days (schema + fee engine)
- **Phase 2:** 2 days (Norges Bank integration)
- **Phase 3:** 2 days (ExchangeRate-API integration)
- **Phase 4:** 3 days (UI + disclosure enhancements)
- **Phase 5:** 2 days (admin dashboard + monitoring)

**Total:** 12 days (~2.5 weeks) for full PSD2-compliant FX transparency system

---

## Sources

- [Norges Bank Exchange Rates](https://www.norges-bank.no/en/topics/statistics/exchange_rates/)
- [Norges Bank Data Warehouse](https://www.norges-bank.no/en/topics/Statistics/open-data/)
- [PSD2 Directive 2015/2366/EU](https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=CELEX:32015L2366)
- [PSD2 Article 45 Information Requirements](https://www.lewik.org/term/16512/information-and-conditions-article-45-psd2/)
- [Wise Remitly Fee Comparison](https://wise.com/us/blog/remitly-fees)
- [ExchangeRate-API Documentation](https://exchangeratesapi.io/)
- [Remitly vs Wise Transparency Analysis](https://www.xflowpay.com/blog/wise-vs-remitly)

---

**End of Specification**

# drop-load-testing-spec

# Drop Load Testing & Performance Benchmarks Specification

**Version:** 1.0
**Date:** 2026-02-17
**Author:** architect (Sonnet 4.5)
**Status:** Draft
**MC Task:** #1200

---

## 1. Executive Summary

This specification defines the load testing strategy, performance benchmarks, and capacity planning for the Drop fintech application. The goal is to establish baseline performance metrics, identify bottlenecks, and ensure the system can handle realistic user load before production scale deployment.

**Tool Choice:** **k6** (Grafana k6) — open-source, JavaScript-based, excellent Grafana ecosystem integration, active community.

**Performance Target:** Support 100 concurrent users (baseline), 500 concurrent users (peak), 1000 concurrent users (stress test) with P95 API response times < 300ms and P99 < 500ms.

**Timeline:** Phased implementation over 5 days (see Section 11).

---

## 2. Tool Selection: k6 vs Artillery vs Gatling

### 2.1 Comparison Matrix

| Criterion | k6 | Artillery | Gatling | Winner |
|-----------|----|-----------|---------|----|
| **Language** | JavaScript (Go runtime) | Node.js/YAML/JS | Scala DSL | k6 |
| **Learning Curve** | Medium (JS familiarity) | Low (YAML config) | High (Scala) | Artillery |
| **Performance** | Excellent (Go runtime) | Good (Node.js) | Excellent (JVM) | k6/Gatling |
| **Scripting Flexibility** | High (JS API) | High (JS + YAML) | Medium (Scala DSL) | k6 |
| **CI/CD Integration** | Native support | Native support | Native support | Tie |
| **Reporting** | Grafana, JSON, HTML | JSON, HTML, plugins | HTML, Gatling Cloud | k6 |
| **Open Banking/PSD2 Testing** | Full control (HTTP/headers) | Full control | Full control | Tie |
| **Next.js 16 Compatibility** | Excellent (HTTP/REST) | Excellent | Excellent | Tie |
| **Local + Cloud** | Yes (k6 + k6 Cloud) | Yes (Artillery + Cloud) | Yes | Tie |
| **Community & Docs** | Strong (Grafana Labs) | Strong (Artillery.io) | Strong (Gatling Corp) | Tie |
| **Cost** | Free (OSS) | Free (OSS) | Free (OSS) | Tie |

### 2.2 Recommendation: **k6**

**Rationale:**
1. **JavaScript-based scripting** — Drop team already uses JS/TS (Next.js, React), no new language learning required
2. **Grafana ecosystem** — Drop will use Grafana for production monitoring (future), k6 integrates natively
3. **Performance** — Go runtime provides excellent performance for simulating 1000+ concurrent users on a single machine
4. **Flexibility** — Full HTTP control for PSD2 API testing (custom headers, SCA flows, OAuth)
5. **Active development** — Grafana Labs maintains k6, frequent updates, strong community
6. **CI/CD ready** — GitHub Actions integration out of the box

**Artillery** is a close second (easier YAML config), but k6's Grafana integration and Go performance edge win for a fintech app that will scale.

**Gatling** is excellent but requires Scala knowledge (team uses JS/TS stack).

**Sources:**
- [Load Testing PoC: k6 vs Artillery vs Locust vs Gatling](https://medium.com/@dorangao/load-testing-poc-k6-vs-artillery-vs-locust-vs-gatling-node-js-express-target-f056094ffbef)
- [Artillery vs k6 - Fork My Brain](https://notes.nicolevanderhoeven.com/Artillery+vs+k6)
- [k6 Documentation](https://k6.io/docs/)

---

## 3. Performance Requirements (SLA Targets)

### 3.1 API Response Time Targets

Based on PSD2 best practices and fintech industry standards:

| Endpoint Type | P50 | P95 | P99 | Rationale |
|---------------|-----|-----|-----|-----------|
| **Auth** (login, register, logout) | < 150ms | < 250ms | < 400ms | Critical path, user expects instant |
| **Payments** (remittance, QR payment) | < 200ms | < 300ms | < 500ms | PSD2 real-time payment expectation |
| **Account Info** (AISP balance read) | < 100ms | < 200ms | < 350ms | Cached data, should be fast |
| **Transaction History** | < 150ms | < 250ms | < 400ms | Read-heavy, paginated |
| **Admin** (audit log, KYC review) | < 300ms | < 500ms | < 800ms | Lower priority, internal use |
| **Health Check** | < 50ms | < 100ms | < 150ms | Uptime monitoring |

**Note on PSD2 Latency:**
- PSD3 (upcoming) emphasizes API uptime, latency, and error rate standards (PSD2 had API performance issues)
- Real-time payment initiation expected under PSD3
- Target: **95% of payment initiations complete within 300ms** (excluding external bank processing time)

**Sources:**
- [What Should You Expect from the New PSD3 Rules?](https://sis-id.com/en/new-psd3-rules/)
- [PSD2 Compliance: Impacts & Solutions](https://www.bluesnap.com/psd2-compliance/)

### 3.2 Page Load Time Targets

| Metric | Target | Tool |
|--------|--------|------|
| **FCP** (First Contentful Paint) | < 1.5s | Lighthouse |
| **LCP** (Largest Contentful Paint) | < 2.5s | Lighthouse |
| **TTFB** (Time to First Byte) | < 200ms | k6, Lighthouse |
| **TTI** (Time to Interactive) | < 3.5s | Lighthouse |
| **CLS** (Cumulative Layout Shift) | < 0.1 | Lighthouse |

**Next.js 16 Real-World Benchmark:**
- Mobile LCP reduced from 26.4s to 0.9s after Next.js 16 migration (218% boost)
- Turbopack dev server: instant HMR

**Sources:**
- [I Migrated to Next.js 16 and Got 218% Performance Boost](https://medium.com/@desertwebdesigns/i-migrated-a-react-app-to-next-js-16-and-got-a-218-performance-boost-on-mobile-8ae35ee2a739)
- [Next.js Performance Optimization Guide](https://www.debugbear.com/blog/nextjs-performance)

### 3.3 Concurrent User Capacity

| Load Profile | Concurrent Users | Duration | Success Criteria |
|-------------|-----------------|----------|------------------|
| **Baseline** | 100 | 10 min | P95 < 300ms, 0% errors |
| **Peak** | 500 | 10 min | P95 < 300ms, < 1% errors |
| **Stress** | 1000 | 5 min | P95 < 500ms, < 5% errors |
| **Spike** | 0→500 in 30s | 5 min | P95 < 400ms, < 2% errors |

**Norwegian Market Context:**
- Population: ~5.5M
- Realistic Year 1 target: 10K-100K active users
- Peak concurrent (0.5% of active): 50-500 users
- **100 concurrent = realistic baseline, 500 = peak, 1000 = stress test**

### 3.4 Database Performance Targets

| Metric | SQLite (Demo) | PostgreSQL (Prod) |
|--------|--------------|-------------------|
| **Query P95** | < 10ms | < 5ms |
| **Concurrent Writes** | 1 (serialized) | 100+ (MVCC) |
| **Max Connections** | 1 | 100 (pgBouncer pool) |
| **Throughput** | ~1K writes/sec | ~10K writes/sec |

**SQLite Limitation:**
- SQLite uses file-level locking → **only 1 write at a time** (WAL mode allows concurrent reads)
- Under load: write contention causes SQLITE_BUSY errors
- **Conclusion:** SQLite is fine for demo/MVP (< 50 concurrent users), PostgreSQL required for production (> 100 concurrent users)

**Sources:**
- [SQLite vs PostgreSQL Performance](https://www.restack.io/p/postgresql-vs-sqlite-answer-performance-cat-ai)
- [PostgreSQL vs SQLite Concurrency](https://risingwave.com/blog/postgresql-vs-sqlite-which-database-should-you-choose/)

---

## 4. Load Test Scenarios

### 4.1 Scenario 1: User Registration & Login Flow

**User Journey:**
1. User visits `/register`
2. Submits phone + PIN + name + DOB
3. Receives OTP (mocked)
4. Verifies OTP → account created
5. Redirected to `/login`
6. Logs in with phone + PIN
7. Receives JWT in httpOnly cookie
8. Redirected to `/dashboard`

**k6 Script:** `tests/load/scenarios/auth-flow.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '2m', target: 100 }, // Ramp up to 100 users
    { duration: '5m', target: 100 }, // Stay at 100 users
    { duration: '2m', target: 0 },   // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<250'], // 95% under 250ms
    errors: ['rate<0.01'],             // Error rate < 1%
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export default function () {
  const phone = `+4740${Math.floor(Math.random() * 1000000).toString().padStart(6, '0')}`;
  const pin = '1234';

  // Step 1: Register
  let registerRes = http.post(`${BASE_URL}/api/auth/register`, JSON.stringify({
    phone,
    pin,
    firstName: 'Load',
    lastName: 'Test',
    dateOfBirth: '1990-01-01',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(registerRes, {
    'register status 200': (r) => r.status === 200,
  }) || errorRate.add(1);

  sleep(1);

  // Step 2: Verify OTP (mocked — in demo, OTP is auto-verified)
  // Skip for demo

  // Step 3: Login
  let loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    phone,
    pin,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  check(loginRes, {
    'login status 200': (r) => r.status === 200,
    'has JWT cookie': (r) => r.cookies.token !== undefined,
  }) || errorRate.add(1);

  sleep(2);
}
```

**Expected Results:**
- **100 users:** P95 < 250ms, 0% errors
- **500 users:** P95 < 300ms, < 1% errors (SQLite may show write contention)
- **1000 users:** Expect SQLITE_BUSY errors (write serialization)

---

### 4.2 Scenario 2: Send Money (Remittance) Flow

**User Journey:**
1. User logged in (JWT cookie)
2. Visits `/send`
3. Selects recipient (or creates new)
4. Enters amount + currency (NOK → RSD, BAM, EUR, etc.)
5. Reviews exchange rate quote
6. Confirms transfer (PISP initiated)
7. Receives transaction confirmation
8. Redirected to `/transactions`

**k6 Script:** `tests/load/scenarios/send-money-flow.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 100 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<300'],
    errors: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export function setup() {
  // Create a test user and get JWT
  const phone = '+4740999999';
  const pin = '1234';

  const registerRes = http.post(`${BASE_URL}/api/auth/register`, JSON.stringify({
    phone,
    pin,
    firstName: 'Load',
    lastName: 'Test',
    dateOfBirth: '1990-01-01',
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    phone,
    pin,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  const token = loginRes.cookies.token[0].value;
  return { token };
}

export default function (data) {
  const { token } = data;

  // Step 1: Get exchange rate quote
  let rateRes = http.get(`${BASE_URL}/api/rates/RSD`, {
    headers: { Cookie: `token=${token}` },
  });

  check(rateRes, {
    'rate status 200': (r) => r.status === 200,
  }) || errorRate.add(1);

  const rate = JSON.parse(rateRes.body).rate;

  sleep(1);

  // Step 2: Create recipient (or reuse existing)
  let recipientRes = http.post(`${BASE_URL}/api/recipients`, JSON.stringify({
    name: 'Test Recipient',
    country: 'RS',
    currency: 'RSD',
    bankAccount: '160-123456-78',
    bankName: 'Banca Intesa',
  }), {
    headers: {
      'Content-Type': 'application/json',
      Cookie: `token=${token}`,
    },
  });

  check(recipientRes, {
    'recipient created': (r) => r.status === 200 || r.status === 201,
  }) || errorRate.add(1);

  const recipientId = JSON.parse(recipientRes.body).id;

  sleep(1);

  // Step 3: Initiate transfer (PISP)
  let transferRes = http.post(`${BASE_URL}/api/transactions/remittance`, JSON.stringify({
    recipientId,
    sendAmount: 1000, // NOK
    sendCurrency: 'NOK',
    receiveCurrency: 'RSD',
    exchangeRate: rate,
  }), {
    headers: {
      'Content-Type': 'application/json',
      Cookie: `token=${token}`,
    },
  });

  check(transferRes, {
    'transfer status 200': (r) => r.status === 200,
    'transaction created': (r) => JSON.parse(r.body).id !== undefined,
  }) || errorRate.add(1);

  sleep(2);
}
```

**Expected Results:**
- **100 users:** P95 < 300ms, 0% errors
- **500 users:** P95 < 400ms, < 1% errors (SQLite write contention)
- **1000 users:** High error rate (SQLITE_BUSY), P95 > 500ms

---

### 4.3 Scenario 3: QR Payment Flow

**User Journey:**
1. User logged in
2. Merchant generates QR code (merchant dashboard)
3. User visits `/scan`
4. Scans QR code (camera permission)
5. Reviews payment amount
6. Confirms payment (PISP initiated)
7. Receives confirmation
8. Merchant dashboard updates (real-time)

**k6 Script:** `tests/load/scenarios/qr-payment-flow.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '1m', target: 50 },
    { duration: '5m', target: 100 },
    { duration: '1m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<300'],
    errors: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export function setup() {
  // Create merchant account + user account
  // Merchant generates QR code
  // Return { userToken, merchantQrCode }

  // Simplified for demo: assume QR code exists
  const phone = '+4740888888';
  const pin = '1234';

  const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    phone,
    pin,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  const token = loginRes.cookies.token[0].value;

  // Mock QR code (merchant ID + amount)
  const qrCode = 'merchant123:amount500:currency:NOK';

  return { token, qrCode };
}

export default function (data) {
  const { token, qrCode } = data;

  // Step 1: Decode QR (client-side in real app, server validation here)
  const [merchantId, amountStr, , currency] = qrCode.split(':');
  const amount = parseFloat(amountStr.replace('amount', ''));

  sleep(1);

  // Step 2: Initiate QR payment (PISP)
  let paymentRes = http.post(`${BASE_URL}/api/transactions/qr-payment`, JSON.stringify({
    merchantId,
    amount,
    currency,
  }), {
    headers: {
      'Content-Type': 'application/json',
      Cookie: `token=${token}`,
    },
  });

  check(paymentRes, {
    'payment status 200': (r) => r.status === 200,
    'transaction created': (r) => JSON.parse(r.body).id !== undefined,
  }) || errorRate.add(1);

  sleep(2);
}
```

**Expected Results:**
- **100 users:** P95 < 300ms, 0% errors
- **500 users:** P95 < 400ms, < 2% errors
- **1000 users:** High error rate (SQLite limit)

---

### 4.4 Scenario 4: Bank Account Sync (AISP Call Simulation)

**User Journey:**
1. User logged in
2. Visits `/accounts`
3. App triggers AISP balance sync (background)
4. Balance updated in `bank_accounts` table
5. Dashboard shows fresh balance

**k6 Script:** `tests/load/scenarios/bank-sync-flow.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '2m', target: 200 },
    { duration: '5m', target: 200 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<200'], // Cached reads should be fast
    errors: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export function setup() {
  // Login user
  const phone = '+4740777777';
  const pin = '1234';

  const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    phone,
    pin,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  const token = loginRes.cookies.token[0].value;
  return { token };
}

export default function (data) {
  const { token } = data;

  // Step 1: GET bank accounts (triggers sync in background)
  let accountsRes = http.get(`${BASE_URL}/api/user/account`, {
    headers: { Cookie: `token=${token}` },
  });

  check(accountsRes, {
    'accounts status 200': (r) => r.status === 200,
    'has balance': (r) => JSON.parse(r.body).balance !== undefined,
  }) || errorRate.add(1);

  sleep(5); // User stays on page
}
```

**Expected Results:**
- **200 users:** P95 < 200ms, 0% errors (reads are concurrent in SQLite WAL mode)
- **500 users:** P95 < 250ms, < 0.5% errors
- **1000 users:** P95 < 300ms, < 1% errors

---

### 4.5 Scenario 5: Dashboard Load (Mixed Read Endpoints)

**User Journey:**
1. User logs in
2. Dashboard loads:
   - User account info (`GET /api/auth/me`)
   - Bank account balance (`GET /api/user/account`)
   - Last 5 transactions (`GET /api/transactions?limit=5`)
   - Notifications (`GET /api/notifications?limit=10`)
3. All parallel requests (simulates real dashboard load)

**k6 Script:** `tests/load/scenarios/dashboard-load.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate } from 'k6/metrics';

const errorRate = new Rate('errors');

export const options = {
  stages: [
    { duration: '2m', target: 300 },
    { duration: '5m', target: 300 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<250'],
    errors: ['rate<0.01'],
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

export function setup() {
  const phone = '+4740666666';
  const pin = '1234';

  const loginRes = http.post(`${BASE_URL}/api/auth/login`, JSON.stringify({
    phone,
    pin,
  }), {
    headers: { 'Content-Type': 'application/json' },
  });

  const token = loginRes.cookies.token[0].value;
  return { token };
}

export default function (data) {
  const { token } = data;
  const headers = { Cookie: `token=${token}` };

  // Parallel requests (batch)
  const responses = http.batch([
    ['GET', `${BASE_URL}/api/auth/me`, null, { headers }],
    ['GET', `${BASE_URL}/api/user/account`, null, { headers }],
    ['GET', `${BASE_URL}/api/transactions?limit=5`, null, { headers }],
    ['GET', `${BASE_URL}/api/notifications?limit=10`, null, { headers }],
  ]);

  check(responses[0], { 'me status 200': (r) => r.status === 200 }) || errorRate.add(1);
  check(responses[1], { 'account status 200': (r) => r.status === 200 }) || errorRate.add(1);
  check(responses[2], { 'transactions status 200': (r) => r.status === 200 }) || errorRate.add(1);
  check(responses[3], { 'notifications status 200': (r) => r.status === 200 }) || errorRate.add(1);

  sleep(10); // User stays on dashboard
}
```

**Expected Results:**
- **300 users:** P95 < 250ms, 0% errors (all reads, SQLite handles well)
- **500 users:** P95 < 300ms, < 0.5% errors
- **1000 users:** P95 < 400ms, < 1% errors

---

## 5. Load Profiles (Realistic Traffic Patterns)

### 5.1 Baseline Load (100 Concurrent Users)

**Scenario:** Normal weekday traffic
**Duration:** 30 minutes
**Ramp:** Linear over 5 minutes

```javascript
export const options = {
  stages: [
    { duration: '5m', target: 100 },  // Ramp up
    { duration: '20m', target: 100 }, // Sustain
    { duration: '5m', target: 0 },    // Ramp down
  ],
};
```

**Expected Behavior:**
- All endpoints P95 < 300ms
- 0% error rate
- CPU < 50%, Memory < 2GB
- Database: no contention

---

### 5.2 Peak Load (500 Concurrent Users)

**Scenario:** Weekend evening peak (users sending remittances)
**Duration:** 15 minutes
**Ramp:** Linear over 3 minutes

```javascript
export const options = {
  stages: [
    { duration: '3m', target: 500 },
    { duration: '10m', target: 500 },
    { duration: '2m', target: 0 },
  ],
};
```

**Expected Behavior:**
- API endpoints P95 < 300ms
- Write-heavy endpoints (remittance, QR payment) may see P95 < 400ms (SQLite write contention)
- Error rate < 1%
- CPU 60-80%, Memory 3-4GB
- Database: SQLite write serialization causes occasional SQLITE_BUSY (PostgreSQL recommended)

---

### 5.3 Stress Test (1000 Concurrent Users)

**Scenario:** Black Friday sale (merchant QR payments spike)
**Duration:** 10 minutes
**Ramp:** Linear over 2 minutes

```javascript
export const options = {
  stages: [
    { duration: '2m', target: 1000 },
    { duration: '5m', target: 1000 },
    { duration: '3m', target: 0 },
  ],
};
```

**Expected Behavior:**
- **SQLite will break** — expect high error rate (> 5%)
- API endpoints P95 > 500ms
- Database: SQLITE_BUSY errors on writes
- **Conclusion:** PostgreSQL required for 1000+ concurrent users

---

### 5.4 Spike Test (0 → 500 in 30 Seconds)

**Scenario:** Marketing campaign goes viral (sudden traffic surge)
**Duration:** 10 minutes

```javascript
export const options = {
  stages: [
    { duration: '30s', target: 500 }, // Spike
    { duration: '5m', target: 500 },  // Sustain
    { duration: '2m', target: 0 },
  ],
};
```

**Expected Behavior:**
- Initial spike: P95 may exceed 400ms (cold start, cache warming)
- After 1 minute: P95 stabilizes < 300ms
- Error rate < 2% during spike, < 1% after stabilization
- **Key metric:** How quickly does the system recover from sudden load?

---

## 6. Database Performance Considerations

### 6.1 SQLite Limitations

| Aspect | SQLite Behavior | Impact at Scale |
|--------|----------------|-----------------|
| **Write Concurrency** | 1 write at a time (file lock) | SQLITE_BUSY under load |
| **Read Concurrency** | Unlimited (WAL mode) | Good read performance |
| **Connection Pool** | N/A (file-based) | No connection overhead |
| **Transactions** | Serialized | Bottleneck for payments |
| **Max Throughput** | ~1K writes/sec (SSD) | Insufficient for 500+ users |

**Recommendation:**
- **Demo/MVP (< 50 concurrent users):** SQLite is fine
- **Production (> 100 concurrent users):** Migrate to PostgreSQL

---

### 6.2 PostgreSQL Optimizations

| Optimization | Configuration | Impact |
|--------------|--------------|--------|
| **Connection Pooling** | pgBouncer (100 connections) | Reduces connection overhead |
| **MVCC** | Built-in (PostgreSQL default) | Concurrent reads + writes |
| **Indexes** | Composite indexes on (user_id, created_at) | Faster transaction queries |
| **WAL** | Enabled by default | Crash recovery + replication |
| **Vacuum** | Autovacuum enabled | Prevents table bloat |

**Expected Performance:**
- **100 users:** P95 < 200ms, 0% errors
- **500 users:** P95 < 250ms, 0% errors
- **1000 users:** P95 < 300ms, < 0.5% errors

**Connection Pool Config (pgBouncer):**
```ini
[databases]
drop = host=localhost port=5432 dbname=drop

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20
reserve_pool_size = 5
```

---

## 7. Bottleneck Identification Strategy

### 7.1 What to Measure

| Layer | Metrics | Tools |
|-------|---------|-------|
| **HTTP** | Request rate, response time, error rate | k6 |
| **Application** | CPU, memory, event loop lag | Node.js metrics, PM2 |
| **Database** | Query time, lock wait time, connection count | SQLite EXPLAIN QUERY PLAN, PostgreSQL pg_stat_statements |
| **Network** | Bandwidth, latency, packet loss | k6, Docker stats |

---

### 7.2 Common Bottlenecks & Symptoms

| Bottleneck | Symptom | Solution |
|------------|---------|----------|
| **Database Write Lock** | SQLITE_BUSY errors, high P95 on writes | Migrate to PostgreSQL |
| **CPU Bound** | 100% CPU, slow response times | Horizontal scaling, optimize hot paths |
| **Memory Leak** | OOM crashes, gradual memory increase | Profile with Node.js heap snapshots |
| **Event Loop Blocking** | High event loop lag, slow all endpoints | Move heavy computation to background jobs |
| **Connection Pool Exhausted** | "No connections available" errors | Increase pool size, use pgBouncer |
| **Slow Queries** | High database query time | Add indexes, optimize JOIN queries |

---

### 7.3 Profiling Commands

**SQLite Query Analysis:**
```bash
sqlite3 data/drop.db
sqlite> EXPLAIN QUERY PLAN SELECT * FROM transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT 10;
```

**PostgreSQL Query Analysis:**
```sql
-- Enable query stats
CREATE EXTENSION pg_stat_statements;

-- View slow queries
SELECT query, calls, total_exec_time, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```

**Node.js CPU Profiling:**
```bash
node --cpu-prof src/drop-app/server.js
# Generates CPU profile → analyze with Chrome DevTools
```

**Memory Profiling:**
```bash
node --inspect src/drop-app/server.js
# Open chrome://inspect → take heap snapshot
```

---

## 8. Monitoring During Load Tests

### 8.1 Real-Time Metrics (k6 Dashboard)

**k6 Built-in Metrics:**
- `http_req_duration` — Response time (P50, P95, P99)
- `http_req_failed` — Error rate (%)
- `http_reqs` — Request rate (req/s)
- `vus` — Virtual users (current)
- `iterations` — Total iterations completed

**Custom Metrics (add to scripts):**
```javascript
import { Trend, Counter } from 'k6/metrics';

const authDuration = new Trend('auth_duration');
const paymentErrors = new Counter('payment_errors');

// In test:
authDuration.add(loginRes.timings.duration);
paymentErrors.add(transferRes.status !== 200 ? 1 : 0);
```

---

### 8.2 System Metrics (Parallel Monitoring)

**During k6 test, run in parallel:**

```bash
# Terminal 1: k6 test
k6 run --out json=results.json tests/load/scenarios/send-money-flow.js

# Terminal 2: Docker stats (if running in container)
docker stats drop-app

# Terminal 3: SQLite database monitoring
watch -n 1 "sqlite3 data/drop.db 'SELECT COUNT(*) FROM transactions'"

# Terminal 4: Application logs
tail -f logs/app.log | grep ERROR
```

**Grafana Integration (Future):**
- k6 can export metrics to InfluxDB → Grafana dashboards
- Real-time visualization of load test results
- Alerting on threshold breaches

---

### 8.3 Key Metrics to Track

| Metric | Warning Threshold | Critical Threshold |
|--------|------------------|-------------------|
| **P95 Response Time** | > 300ms | > 500ms |
| **Error Rate** | > 1% | > 5% |
| **CPU Usage** | > 70% | > 90% |
| **Memory Usage** | > 80% | > 95% |
| **Database Query Time** | > 50ms (P95) | > 100ms |
| **Database Connections** | > 80% pool | 100% pool |

---

## 9. CI Integration (Performance Regression Tests)

### 9.1 GitHub Actions Workflow

**File:** `.github/workflows/load-test.yml`

```yaml
name: Load Test

on:
  pull_request:
    branches: [main, staging]
  schedule:
    - cron: '0 2 * * 1' # Weekly Monday 2 AM

jobs:
  load-test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci
        working-directory: src/drop-app

      - name: Build app
        run: npm run build
        working-directory: src/drop-app

      - name: Start app (background)
        run: |
          npm run start &
          sleep 10
        working-directory: src/drop-app

      - name: Install k6
        run: |
          sudo gpg -k
          sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
          echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
          sudo apt-get update
          sudo apt-get install k6

      - name: Run load test (baseline)
        run: k6 run --out json=results.json tests/load/scenarios/dashboard-load.js
        env:
          BASE_URL: http://localhost:3000

      - name: Check thresholds
        run: |
          # k6 exits with code 99 if thresholds fail
          if [ $? -eq 99 ]; then
            echo "Load test thresholds FAILED"
            exit 1
          fi

      - name: Upload results
        uses: actions/upload-artifact@v4
        with:
          name: load-test-results
          path: results.json
```

**Trigger:** PR to main/staging, or weekly schedule
**Purpose:** Catch performance regressions before merge
**Threshold:** If P95 > 300ms or error rate > 1%, fail the build

---

### 9.2 Baseline Results Storage

**First run (before optimization):**
```bash
k6 run --out json=baseline-results.json tests/load/scenarios/dashboard-load.js
```

**Store in repo:**
```
tests/load/baselines/
├── dashboard-load-baseline.json
├── send-money-baseline.json
├── qr-payment-baseline.json
└── auth-flow-baseline.json
```

**Regression detection:**
- Compare current test results to baseline
- Alert if P95 increases > 20%
- Fail CI if P95 increases > 50%

---

## 10. Next.js 16 Performance Optimizations

### 10.1 Server Components (Default in Next.js 16)

**Impact:**
- Reduced client-side JavaScript bundle size
- Faster initial page load (no React hydration for server components)
- Data fetching happens server-side (lower TTFB)

**Usage:**
```tsx
// app/dashboard/page.tsx (Server Component by default)
export default async function DashboardPage() {
  const user = await getUser(); // Server-side fetch
  const transactions = await getTransactions(); // Parallel fetch

  return (
    <div>
      <UserInfo user={user} /> {/* Server Component */}
      <TransactionList transactions={transactions} /> {/* Server Component */}
    </div>
  );
}
```

**Load Test Impact:**
- Dashboard load: P95 reduced by 30-40% (less client-side rendering)
- FCP improved by 50%

---

### 10.2 Caching with "use cache" Directive (New in Next.js 16)

**Impact:**
- Explicit cache control for pages, components, and functions
- Reduced API calls (cached responses)

**Usage:**
```tsx
// app/transactions/page.tsx
'use cache';

export default async function TransactionsPage() {
  const transactions = await getTransactions();
  return <TransactionList transactions={transactions} />;
}
```

**Load Test Impact:**
- Repeated requests: P95 reduced by 70-80% (cache hit)
- Database load: 90% reduction (cached queries)

**Sources:**
- [Next.js 16 Features](https://strapi.io/blog/next-js-16-features)
- [Next.js Performance Optimization Guide](https://medium.com/@shirkeharshal210/next-js-performance-optimization-app-router-a-practical-guide-a24d6b3f5db2)

---

### 10.3 React Compiler (Stable in Next.js 16)

**Impact:**
- Automatic memoization of components
- Reduced re-renders
- Better runtime performance

**Configuration:**
```javascript
// next.config.ts
export default {
  experimental: {
    reactCompiler: true,
  },
};
```

**Load Test Impact:**
- Client-side rendering: 20-30% faster
- Lighthouse Performance score: +10 points

**Sources:**
- [Next.js 16 Performance Boost](https://medium.com/@desertwebdesigns/i-migrated-a-react-app-to-next-js-16-and-got-a-218-performance-boost-on-mobile-8ae35ee2a739)

---

### 10.4 Turbopack (Fast Dev Server)

**Impact:**
- Instant HMR (Hot Module Replacement)
- Faster builds

**Usage:**
```bash
npm run dev -- --turbo
```

**Load Test Impact:**
- Development iteration speed: 3x faster
- Build time: 2x faster

---

## 11. Capacity Planning (Norwegian Market Projections)

### 11.1 User Growth Projections

| Year | Active Users | Peak Concurrent (0.5%) | Required Capacity |
|------|--------------|------------------------|-------------------|
| **Year 1 (MVP)** | 10K | 50 | 100 (2x buffer) |
| **Year 2** | 50K | 250 | 500 (2x buffer) |
| **Year 3** | 100K | 500 | 1000 (2x buffer) |
| **Year 5** | 500K | 2500 | 5000 (2x buffer) |

**Norwegian Market Context:**
- Population: 5.5M
- Digital payment penetration: 95% (Vipps, BankID)
- Realistic market share Year 1: 0.2% → 10K users
- Realistic market share Year 3: 2% → 100K users

---

### 11.2 Infrastructure Scaling Plan

| Users | Database | App Instances | Load Balancer |
|-------|----------|---------------|---------------|
| < 50 | SQLite (demo) | 1x Next.js | None |
| 50-500 | PostgreSQL (single) | 2x Next.js | Nginx |
| 500-5K | PostgreSQL (primary + replica) | 4x Next.js | AWS ALB |
| 5K-50K | PostgreSQL (RDS Multi-AZ) | 8x Next.js | AWS ALB + Auto Scaling |
| 50K+ | PostgreSQL (Aurora) | 16x Next.js | AWS ALB + Auto Scaling + CDN |

---

### 11.3 Cost Estimation (Vercel + Supabase/Neon)

**Scenario:** 10K active users, 50 concurrent peak

| Service | Plan | Cost/Month |
|---------|------|-----------|
| **Vercel** (Next.js hosting) | Pro | $20 |
| **Neon** (PostgreSQL) | Launch | $20 |
| **Sentry** (Error tracking) | Team | $26 |
| **Total** | | **$66/month** |

**Scenario:** 100K active users, 500 concurrent peak

| Service | Plan | Cost/Month |
|---------|------|-----------|
| **Vercel** (Next.js hosting) | Enterprise | $500+ |
| **AWS RDS** (PostgreSQL Multi-AZ) | db.r6g.large | $300 |
| **AWS ALB** | Standard | $20 |
| **Sentry** | Business | $80 |
| **Total** | | **$900/month** |

---

## 12. Implementation Plan (Phased Approach)

### Phase 1: Setup & Baseline (Day 1)

**Tasks:**
1. Install k6: `brew install k6` (macOS) or `apt install k6` (Linux)
2. Create test directory: `src/drop-app/tests/load/`
3. Write baseline script: `tests/load/scenarios/dashboard-load.js`
4. Run first test: `k6 run tests/load/scenarios/dashboard-load.js`
5. Document baseline results: `tests/load/baselines/dashboard-load-baseline.json`

**Deliverable:** Baseline performance metrics (P50, P95, P99) for dashboard load

---

### Phase 2: Core Flow Scripts (Day 2-3)

**Tasks:**
1. Write auth flow script (register + login)
2. Write send money script (remittance)
3. Write QR payment script
4. Write bank sync script
5. Run all scripts @ 100 concurrent users
6. Document results

**Deliverable:** 5 load test scripts covering all critical user journeys

---

### Phase 3: Load Profiles (Day 4)

**Tasks:**
1. Run baseline load (100 users, 30 min)
2. Run peak load (500 users, 15 min)
3. Run stress test (1000 users, 10 min)
4. Run spike test (0→500 in 30s)
5. Identify bottlenecks (likely: SQLite write contention)

**Deliverable:** Load profile results + bottleneck analysis report

---

### Phase 4: Optimization & Retest (Day 5)

**Tasks:**
1. Implement optimizations:
   - Add database indexes
   - Enable Next.js caching
   - Optimize slow queries
2. Rerun all load tests
3. Compare before/after results
4. Document performance improvements

**Deliverable:** Optimization report (before/after metrics)

---

### Phase 5: CI Integration (Day 6)

**Tasks:**
1. Create GitHub Actions workflow (`.github/workflows/load-test.yml`)
2. Add baseline threshold checks
3. Test workflow on PR
4. Store results as artifacts

**Deliverable:** Automated load testing in CI pipeline

---

## 13. Success Criteria

| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| **Baseline (100 users)** | P95 < 300ms, 0% errors | TBD | ⏳ |
| **Peak (500 users)** | P95 < 400ms, < 1% errors | TBD | ⏳ |
| **Stress (1000 users)** | P95 < 500ms, < 5% errors | TBD | ⏳ |
| **Dashboard Load** | P95 < 250ms | TBD | ⏳ |
| **Auth Flow** | P95 < 250ms | TBD | ⏳ |
| **Send Money** | P95 < 300ms | TBD | ⏳ |
| **QR Payment** | P95 < 300ms | TBD | ⏳ |
| **Bank Sync** | P95 < 200ms | TBD | ⏳ |

---

## 14. Appendix: k6 Script Template

**File:** `tests/load/template.js`

```javascript
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

// Custom metrics
const errorRate = new Rate('errors');
const customDuration = new Trend('custom_duration');

// Test configuration
export const options = {
  stages: [
    { duration: '2m', target: 100 }, // Ramp up
    { duration: '5m', target: 100 }, // Sustain
    { duration: '2m', target: 0 },   // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<300'], // 95% under 300ms
    errors: ['rate<0.01'],             // Error rate < 1%
  },
};

const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';

// Setup (runs once before test)
export function setup() {
  // Login, create test data, etc.
  return { token: 'example-jwt' };
}

// Main test function (runs for each VU iteration)
export default function (data) {
  const { token } = data;

  // HTTP request
  const res = http.get(`${BASE_URL}/api/endpoint`, {
    headers: { Cookie: `token=${token}` },
  });

  // Validation
  const success = check(res, {
    'status 200': (r) => r.status === 200,
    'has data': (r) => JSON.parse(r.body).data !== undefined,
  });

  if (!success) {
    errorRate.add(1);
  }

  customDuration.add(res.timings.duration);

  // Think time
  sleep(1);
}

// Teardown (runs once after test)
export function teardown(data) {
  // Cleanup test data
}
```

---

## 15. Sources & References

### Load Testing Tools
- [Load Testing PoC: k6 vs Artillery vs Locust vs Gatling](https://medium.com/@dorangao/load-testing-poc-k6-vs-artillery-vs-locust-vs-gatling-node-js-express-target-f056094ffbef)
- [Artillery vs k6 Comparison](https://notes.nicolevanderhoeven.com/Artillery+vs+k6)
- [k6 Official Documentation](https://k6.io/docs/)
- [k6 Examples Repository](https://k6.io/docs/examples/)
- [Synthetic Testing Next.js with k6 Cloud](https://medium.com/automation-with-donald/synthetic-testing-a-next-js-app-using-grafana-k6-cloud-4cb263307ee0)
- [GitHub: PM2 + Next.js + k6 Example](https://github.com/j-hit/pm2-nextjs-k6)

### PSD2 Compliance & Performance
- [What Should You Expect from PSD3 Rules?](https://sis-id.com/en/new-psd3-rules/)
- [PSD2 Compliance Guide](https://www.bluesnap.com/psd2-compliance/)
- [What is PSD2? - Nordea](https://www.nordea.com/en/news/what-is-psd2)
- [PSD2 Technical Security Requirements](https://cyscale.com/blog/PSD2-technical-requirements/)

### Next.js 16 Performance
- [Next.js 16 Migration: 218% Performance Boost](https://medium.com/@desertwebdesigns/i-migrated-a-react-app-to-next-js-16-and-got-a-218-performance-boost-on-mobile-8ae35ee2a739)
- [Next.js 16 Features Overview](https://strapi.io/blog/next-js-16-features)
- [Next.js Performance Optimization Guide](https://www.debugbear.com/blog/nextjs-performance)
- [Next.js Performance Best Practices](https://medium.com/@shirkeharshal210/next-js-performance-optimization-app-router-a-practical-guide-a24d6b3f5db2)
- [Next.js Production Checklist](https://nextjs.org/docs/app/guides/production-checklist)

### Database Performance
- [SQLite vs PostgreSQL Performance](https://www.restack.io/p/postgresql-vs-sqlite-answer-performance-cat-ai)
- [PostgreSQL vs SQLite Comparison](https://risingwave.com/blog/postgresql-vs-sqlite-which-database-should-you-choose/)
- [SQLite vs PostgreSQL - Airbyte](https://airbyte.com/data-engineering-resources/sqlite-vs-postgresql)
- [PostgreSQL vs SQLite 2026 Comparison](https://www.selecthub.com/relational-database-solutions/postgresql-vs-sqlite/)

---

## 16. Approval

**Reviewed by:** Alem (CEO)
**Status:** Pending
**Next Steps:** Implement Phase 1 (Setup & Baseline)

# drop-localization-spec

# Drop Localization (i18n) — Architect Specification

**Project:** Drop Fintech Payment App
**Component:** Internationalization (i18n) — NO/EN/SV
**Author:** John (Architect Agent)
**Date:** 2026-02-17
**Status:** Draft for Review

---

## Executive Summary

This specification defines the complete internationalization (i18n) architecture for Drop, enabling support for Norwegian Bokmål (primary/default), English, and Swedish. The system will extract all hardcoded Norwegian strings from the current codebase, implement a type-safe translation framework using next-intl (the de facto standard for Next.js 16 App Router), and establish a sustainable translation workflow.

**Key Decisions:**
- **Framework:** next-intl (App Router native, type-safe, 0 client-side JS for translations)
- **Primary Language:** Norwegian Bokmål (`nb-NO`) — default, required by law for legal documents
- **Additional Languages:** English (`en`), Swedish (`sv`)
- **Routing Strategy:** Subdirectory-based (`/no/`, `/en/`, `/sv/`) with automatic language detection
- **Migration Approach:** Phased rollout (Phase 1: Framework + UI, Phase 2: Email templates, Phase 3: Legal docs)

---

## 1. Framework Selection

### 1.1 Evaluation Criteria

| Library | Next.js 16 App Router | Type Safety | Bundle Size | Maintenance | Verdict |
|---------|----------------------|-------------|-------------|-------------|---------|
| **next-intl** | ✅ Native support | ✅ Full TS support | ~5KB gzipped | ✅ Active (2024-2026) | **RECOMMENDED** |
| react-i18next | ❌ Client-side only | ⚠️ Partial (manual) | ~18KB gzipped | ✅ Active | Not suitable |
| next-international | ✅ App Router support | ✅ Full TS support | ~8KB gzipped | ⚠️ Smaller community | Alternative |
| react-intl | ❌ Requires workarounds | ⚠️ Partial | ~19KB gzipped | ✅ Active | Not suitable |

**Source:** [next-intl vs react-i18next comparison](https://medium.com/@isurusasanga1999/why-i-chose-next-intl-for-internationalization-in-my-next-js-66c9e49dd486), [i18n library comparison](https://npm-compare.com/next-international,react-i18next,react-intl,react-intl-universal)

### 1.2 Why next-intl?

1. **App Router Native:** Built specifically for Next.js 16 App Router with server component support ([next-intl App Router guide](https://next-intl.dev/docs/getting-started/app-router))
2. **Zero Client-Side JS:** Translations preloaded server-side, sent as props to server components
3. **Type Safety:** Auto-completion for message keys, compile-time checks for missing translations
4. **Routing Built-In:** `[locale]` dynamic segment integration out of the box ([routing setup](https://next-intl.dev/docs/routing/setup))
5. **Format Functions:** ICU message syntax, date/time formatting, number formatting per locale
6. **Production-Ready:** Used by Node.js official website, Sitecore SDK, Vercel templates

**Reference:** [Official next-intl documentation](https://next-intl.dev/docs), [Next.js 16 i18n guide](https://i18nexus.com/tutorials/nextjs/next-intl)

---

## 2. Language Support Matrix

| Locale Code | Language | Variant | Priority | Default | Legal Required | Notes |
|-------------|----------|---------|----------|---------|----------------|-------|
| `nb-NO` | Norwegian | Bokmål | **P0** | ✅ Yes | ✅ Yes | 85-90% of Norwegian population uses Bokmål ([source](https://www.simultrans.com/blog/norwegian-localization-bokmal-or-nynorsk)) |
| `en` | English | Generic | **P1** | ❌ No | ❌ No | International users, diaspora secondary language |
| `sv` | Swedish | Generic | **P2** | ❌ No | ❌ No | Scandinavia expansion (future) |

**Nynorsk Exclusion Rationale:** Drop targets urban areas and general Norwegian population. Bokmål is the standard for fintech/banking in Norway. Nynorsk is primarily rural/western Norway (10% usage) and would require separate legal review. ([source](https://www.simultrans.com/blog/norwegian-localization-bokmal-or-nynorsk))

**Future Expansion:** Arabic, Somali, Polish (after MVP — diaspora remittance corridors)

---

## 3. Current Codebase Analysis

### 3.1 Hardcoded Norwegian Strings Identified

**Total Files with Norwegian Text:** 36 files (from Grep scan)

**Categories:**

| Category | Example Strings | File Count | Complexity |
|----------|----------------|------------|------------|
| **UI Labels** | "Hjem", "Kontoer", "Historikk", "Profil", "Send", "Skann" | ~15 | Low |
| **Form Validation** | "E-post og passord er påkrevd", "Ugyldig e-postadresse" | ~8 | Low |
| **Dashboard Content** | "God morgen", "God ettermiddag", "God kveld", "Brukskonto" | ~5 | Medium |
| **Email Templates** | "Velkommen til Drop", "Verifiser konto", "Send penger internasjonalt" | 3 | High |
| **Legal Documents** | "Vilkår for bruk", "Om tjenesten", "Krav til brukere" | 3 | High |
| **API Error Messages** | "Too many requests", "Invalid credentials", "Email and password required" | ~10 | Medium |
| **Notifications** | "Vipps-innlogging kommer snart!", "Oppdatert via BankID" | ~5 | Low |

**Files Requiring Extraction (High Priority):**

1. **UI Components:**
   - `src/components/bottom-nav.tsx` — Navigation labels (Hjem, Kontoer, Historikk, Profil)
   - `src/app/dashboard/page.tsx` — Greetings, account labels
   - `src/app/login/page.tsx` — Form labels, validation errors, button text
   - `src/app/register/page.tsx` — Registration flow text
   - `src/app/send/page.tsx` — Remittance form
   - `src/app/scan/page.tsx` — QR payment UI

2. **API Routes:**
   - `src/app/api/auth/login/route.ts` — "Invalid credentials", "Email and password required"
   - `src/app/api/transactions/*/route.ts` — Transaction error messages

3. **Email Templates:**
   - `src/email-templates/welcome.html` — Full Norwegian welcome email
   - `src/email-templates/transaction-receipt.html` — Receipt email
   - `src/email-templates/password-reset.html` — Password reset email

4. **Legal Pages:**
   - `src/app/terms/page.tsx` — Full terms of service (Norwegian)
   - `src/app/privacy/page.tsx` — Privacy policy
   - `src/app/fees/page.tsx` — Fee schedule

### 3.2 Special Cases

**Currency Formatting:**
- Current: `user.totalBalance.toLocaleString("nb-NO", { minimumFractionDigits: 0 })` (hardcoded locale)
- New: Use next-intl's `useFormatter()` hook for locale-aware formatting

**Date Formatting:**
- Current: No date formatting found (transactions show ISO strings in tests)
- New: Implement `formatDateTime()` from next-intl

**Number Formatting:**
- Current: Hardcoded "kr" currency symbol, hardcoded "NOK" suffix
- New: `formatNumber(value, {style: 'currency', currency: 'NOK'})`

---

## 4. Translation File Structure

### 4.1 Directory Layout

```
src/drop-app/
├── messages/                  # Translation files (JSON)
│   ├── nb-NO.json            # Norwegian Bokmål (default)
│   ├── en.json               # English
│   ├── sv.json               # Swedish
│   └── README.md             # Translation guidelines
├── i18n/                     # i18n configuration
│   ├── config.ts             # Locale definitions, default locale
│   └── request.ts            # next-intl request config (App Router)
├── middleware.ts             # Locale detection middleware
└── app/
    └── [locale]/             # Locale-based routing
        ├── layout.tsx        # Root layout with NextIntlClientProvider
        ├── page.tsx          # Redirects to /dashboard
        ├── dashboard/
        ├── login/
        └── ...               # All existing routes nested under [locale]
```

### 4.2 Namespace Strategy

**Single JSON file per locale (initial approach):**
For Drop MVP, all translations in one file per locale. Future: split into namespaces as app grows.

**File:** `messages/nb-NO.json`

```json
{
  "common": {
    "app_name": "Drop",
    "tagline": "Enklere betalinger. Lavere gebyrer.",
    "loading": "Laster...",
    "error": "Noe gikk galt",
    "retry": "Prøv igjen",
    "cancel": "Avbryt",
    "confirm": "Bekreft",
    "save": "Lagre"
  },
  "nav": {
    "home": "Hjem",
    "accounts": "Kontoer",
    "scan": "Skann",
    "transactions": "Historikk",
    "profile": "Profil"
  },
  "login": {
    "title": "Logg inn",
    "email_label": "E-post",
    "password_label": "Passord",
    "submit": "Logg inn",
    "error_required": "E-post og passord er påkrevd",
    "error_invalid_email": "Ugyldig e-postadresse",
    "error_invalid_credentials": "Feil e-post eller passord",
    "bankid_button": "BankID",
    "vipps_button": "Vipps",
    "vipps_coming_soon": "Vipps-innlogging kommer snart!"
  },
  "dashboard": {
    "greeting_morning": "God morgen",
    "greeting_afternoon": "God ettermiddag",
    "greeting_evening": "God kveld",
    "account_label": "{bankName} Brukskonto",
    "account_updated": "Oppdatert via BankID",
    "action_send": "Send",
    "action_scan": "Skann",
    "action_accounts": "Kontoer",
    "action_history": "Historikk"
  },
  "validation": {
    "required": "Dette feltet er påkrevd",
    "invalid_email": "Ugyldig e-postadresse",
    "invalid_phone": "Ugyldig telefonnummer",
    "min_age": "Du må være minst 18 år",
    "invalid_amount": "Ugyldig beløp"
  },
  "email": {
    "welcome_subject": "Velkommen til Drop!",
    "welcome_body": "Vi er glade for å ha deg med. Med Drop kan du sende penger internasjonalt og betale i butikk – enklere og billigere enn noen gang.",
    "verify_cta": "Verifiser konto",
    "support_email": "support@getdrop.no"
  },
  "legal": {
    "terms_title": "Vilkår for bruk",
    "privacy_title": "Personvernerklæring",
    "fees_title": "Gebyrer og priser"
  },
  "errors": {
    "rate_limited": "For mange forsøk. Prøv igjen senere.",
    "unauthorized": "Du må logge inn for å fortsette",
    "not_found": "Siden finnes ikke",
    "server_error": "Noe gikk galt. Prøv igjen senere."
  }
}
```

**File:** `messages/en.json`

```json
{
  "common": {
    "app_name": "Drop",
    "tagline": "Easier payments. Lower fees.",
    "loading": "Loading...",
    "error": "Something went wrong",
    "retry": "Try again",
    "cancel": "Cancel",
    "confirm": "Confirm",
    "save": "Save"
  },
  "nav": {
    "home": "Home",
    "accounts": "Accounts",
    "scan": "Scan",
    "transactions": "History",
    "profile": "Profile"
  },
  "login": {
    "title": "Log in",
    "email_label": "Email",
    "password_label": "Password",
    "submit": "Log in",
    "error_required": "Email and password required",
    "error_invalid_email": "Invalid email address",
    "error_invalid_credentials": "Invalid email or password",
    "bankid_button": "BankID",
    "vipps_button": "Vipps",
    "vipps_coming_soon": "Vipps login coming soon!"
  }
  // ... rest of translations
}
```

---

## 5. Key Translation Categories

### 5.1 UI Text (Priority 1)

**Scope:** All visible text in React components, buttons, labels, placeholders, tooltips.

**Extraction Method:**
1. Search for JSX text content: `<span>Text</span>` → `<span>{t('key')}</span>`
2. Search for string literals in className, title, aria-label
3. Replace hardcoded strings with `t()` calls

**Tools:** Manual extraction + ESLint rule to prevent future hardcoded strings

**Example (Before):**
```tsx
<span className="text-xs text-[#1E293B]">Hjem</span>
```

**Example (After):**
```tsx
<span className="text-xs text-[#1E293B]">{t('nav.home')}</span>
```

### 5.2 Error Messages (Priority 1)

**Scope:** API route error responses, form validation errors, toast notifications.

**Current State:** Mix of English and Norwegian error messages in API routes.

**Strategy:**
- Backend API routes return error **keys** (not localized strings)
- Frontend translates error keys using next-intl
- Fallback to English for unknown keys

**Example (API Route — Before):**
```typescript
return jsonError("unauthorized", "Invalid credentials", 401);
```

**Example (API Route — After):**
```typescript
return jsonError("unauthorized", "errors.invalid_credentials", 401);
// Note: Second param is translation KEY, not message
```

**Example (Frontend):**
```tsx
const errorMessage = t(`errors.${errorKey}`);
toast.error(errorMessage);
```

### 5.3 Email Templates (Priority 2)

**Scope:** 3 email templates (welcome.html, transaction-receipt.html, password-reset.html)

**Challenge:** HTML email templates don't support React components.

**Solution:**
1. Create **template functions** that accept locale parameter
2. Store email translations in same `messages/*.json` files under `email.*` namespace
3. Server-side template rendering with locale-specific strings

**Current:** `src/email-templates/welcome.html` (static Norwegian HTML)

**New:** `src/lib/email-templates.ts`

```typescript
import { getTranslations } from 'next-intl/server';

export async function renderWelcomeEmail(locale: string, data: {verifyUrl: string}) {
  const t = await getTranslations({locale, namespace: 'email'});

  return `
    <!DOCTYPE html>
    <html lang="${locale}">
    <head><title>${t('welcome_subject')}</title></head>
    <body>
      <h1>${t('welcome_subject')}</h1>
      <p>${t('welcome_body')}</p>
      <a href="${data.verifyUrl}">${t('verify_cta')}</a>
    </body>
    </html>
  `;
}
```

**Email Sending:**
```typescript
const html = await renderWelcomeEmail(user.preferredLanguage || 'nb-NO', {verifyUrl});
await sendEmail({to: user.email, subject: t('email.welcome_subject'), html});
```

### 5.4 Legal Documents (Priority 3 — Manual Translation Required)

**Scope:** Terms of Service, Privacy Policy, Fee Schedule

**Legal Requirement:** Norwegian version MUST exist and be primary ([PSD2 Norway implementation](https://www.lexology.com/library/detail.aspx?g=26ea2eec-8839-48b9-b43a-769444290aa1)). English/Swedish versions are optional.

**Strategy:**
1. **Phase 1 (MVP):** Norwegian-only legal docs (current state)
2. **Phase 2 (Post-MVP):** Professional translation of legal docs to English (external translator)
3. Store legal content as **Markdown files** in `messages/legal/[locale]/` directory
4. Render Markdown server-side using `@next/mdx` or similar

**Structure:**
```
messages/
└── legal/
    ├── nb-NO/
    │   ├── terms.md
    │   ├── privacy.md
    │   └── fees.md
    ├── en/
    │   ├── terms.md
    │   ├── privacy.md
    │   └── fees.md
    └── sv/
        └── ...
```

**Legal Page Component:**
```tsx
import fs from 'fs/promises';
import { compileMDX } from 'next-mdx-remote/rsc';

export default async function TermsPage({params}: {params: {locale: string}}) {
  const locale = params.locale || 'nb-NO';
  const source = await fs.readFile(`messages/legal/${locale}/terms.md`, 'utf8');
  const {content} = await compileMDX({source});
  return <div className="prose">{content}</div>;
}
```

**Fallback:** If English/Swedish legal docs don't exist, show Norwegian version with disclaimer: "Legal documents available in Norwegian only."

---

## 6. Formatting Standards

### 6.1 Currency Formatting

**Norwegian Locale (nb-NO):**
- Format: `1 234,56 kr` (space as thousand separator, comma as decimal, suffix "kr")
- Alternative (banking): `NOK 1 234,56` (ISO code prefix)
- **Drop Standard:** Use `1 234 kr` (no decimals for whole amounts) to match UX mockups

**English Locale (en):**
- Format: `NOK 1,234.56` (ISO code, comma thousand separator, period decimal)

**Swedish Locale (sv):**
- Format: `1 234,56 kr` (same as Norwegian)

**Implementation (next-intl):**
```tsx
import {useFormatter} from 'next-intl';

const format = useFormatter();
const formatted = format.number(1234.56, {
  style: 'currency',
  currency: 'NOK',
  minimumFractionDigits: 0, // Drop shows whole kroner
  maximumFractionDigits: 0
});
// nb-NO: "1 235 kr"
// en: "NOK 1,235"
```

**Reference:** [Microsoft Currency Formatting Guide](https://learn.microsoft.com/en-us/globalization/locale/currency-formats), [Norwegian Bokmål locale formatting](https://leap.hcldoc.com/help/topic/SSS28S_8.2.1/XFDL_Specification/i_xfdl_r_formats_nb_NO.html)

### 6.2 Date & Time Formatting

**Norwegian (nb-NO):**
- Short date: `17.02.2026` (DD.MM.YYYY)
- Long date: `17. februar 2026`
- Time: `14:30` (24-hour clock)

**English (en):**
- Short date: `02/17/2026` (MM/DD/YYYY) or `17/02/2026` (international)
- Long date: `February 17, 2026`
- Time: `2:30 PM` (12-hour clock)

**Implementation:**
```tsx
const format = useFormatter();
const date = new Date('2026-02-17T14:30:00Z');

format.dateTime(date, {dateStyle: 'short'});
// nb-NO: "17.02.2026"
// en: "2/17/2026"

format.dateTime(date, {dateStyle: 'long', timeStyle: 'short'});
// nb-NO: "17. februar 2026 kl. 14:30"
// en: "February 17, 2026 at 2:30 PM"
```

### 6.3 Number Formatting

**Norwegian (nb-NO):**
- Decimal separator: `,` (comma)
- Thousand separator: ` ` (non-breaking space)
- Example: `1 234 567,89`

**English (en):**
- Decimal separator: `.` (period)
- Thousand separator: `,` (comma)
- Example: `1,234,567.89`

**Implementation:**
```tsx
format.number(1234567.89, {maximumFractionDigits: 2});
// nb-NO: "1 234 567,89"
// en: "1,234,567.89"
```

---

## 7. Translation Workflow

### 7.1 Roles & Responsibilities

| Role | Responsibility | Tools |
|------|---------------|-------|
| **Developer** | Extract strings to translation files, add translation keys to code | VSCode, ESLint |
| **Content Lead (Alem)** | Review Norwegian translations for accuracy, approve final content | GitHub PR review |
| **External Translator** | Translate `nb-NO.json` → `en.json`, `sv.json` (Phase 2) | JSON editor, CAT tool |
| **Legal Team** | Translate legal documents (terms, privacy, fees) | Markdown editor |
| **QA** | Test all locales, verify formatting, check for missing translations | Browser, Playwright |

### 7.2 Translation Process

**Phase 1: Initial Extraction (Developer)**

1. Create `messages/nb-NO.json` from existing Norwegian strings
2. Structure translation keys by namespace (`common`, `nav`, `login`, etc.)
3. Replace hardcoded strings in components with `t('key')` calls
4. Run build to verify no missing keys (TypeScript will catch errors)
5. Test Norwegian locale thoroughly (should match current behavior exactly)

**Phase 2: English Translation (External Translator)**

1. Export `messages/nb-NO.json`
2. Translator creates `messages/en.json` (JSON structure preserved, values translated)
3. Developer imports `en.json`, runs build
4. QA tests English locale

**Phase 3: Swedish Translation (Future)**

Same process as Phase 2.

### 7.3 Quality Assurance

**Pre-Deployment Checklist:**

- [ ] All UI screens tested in all 3 locales (nb-NO, en, sv)
- [ ] Currency formatting correct for each locale
- [ ] Date formatting correct for each locale
- [ ] No missing translation keys (TypeScript build passes)
- [ ] Email templates render correctly in all locales
- [ ] Legal documents exist for required locales (nb-NO mandatory)
- [ ] Language switcher works (Profile → Language)
- [ ] URL routing works (`/no/dashboard`, `/en/dashboard`, `/sv/dashboard`)
- [ ] Fallback to Norwegian works if user selects unsupported locale
- [ ] Browser language detection works (first visit)

**Automated Tests:**

1. **Playwright E2E:** Test key user flows in all 3 locales
2. **Unit Tests:** Test `formatCurrency()`, `formatDate()` with mock locales
3. **Snapshot Tests:** Compare rendered output in different locales

---

## 8. Database Content Localization

### 8.1 User-Generated Content

**Scope:** User names, recipient names, custom notes on transactions.

**Strategy:** **NOT translated.** User-generated content stored as-is in database.

### 8.2 System-Generated Content

**Scope:** Transaction status messages, notification content, system emails.

**Strategy:**
- Store **translation keys** in database, not localized text
- Render localized text at display time based on user's preferred language

**Example:**

**Database:**
```sql
INSERT INTO notifications (user_id, message_key, data_json) VALUES
(123, 'notification.transfer_completed', '{"amount": 500, "recipient": "John Doe"}');
```

**Display (React component):**
```tsx
const t = useTranslations('notification');
const notification = await getNotification(id);
const message = t(notification.message_key, JSON.parse(notification.data_json));
// nb-NO: "Overføring på 500 kr til John Doe er fullført"
// en: "Transfer of NOK 500 to John Doe completed"
```

**Translation File:**
```json
{
  "notification": {
    "transfer_completed": "{amount, number, ::currency/NOK} til {recipient} er fullført"
  }
}
```

### 8.3 Static Reference Data

**Scope:** Country names, bank names, currency names.

**Strategy:**
- Store in separate `reference-data` namespace
- Pre-translate all reference data (drop countries, supported currencies)

**Example:**
```json
{
  "countries": {
    "NO": "Norge",
    "SE": "Sverige",
    "PL": "Polen"
  },
  "currencies": {
    "NOK": "Norske kroner",
    "SEK": "Svenske kroner",
    "EUR": "Euro"
  }
}
```

---

## 9. URL & Routing Strategy

### 9.1 Subdirectory-Based Routing (Recommended)

**Structure:** `/[locale]/[route]`

**Examples:**
- `/no/dashboard` — Norwegian
- `/en/dashboard` — English
- `/sv/dashboard` — Swedish
- `/` — Root, redirects to `/no/` (default)

**Pros:**
- SEO-friendly (separate URL per language)
- Shareable links preserve language
- Server-side rendering compatible
- No cookie/session required for language persistence

**Cons:**
- URL changes when switching language (acceptable trade-off)

**Implementation:**

**File:** `src/middleware.ts`
```typescript
import createMiddleware from 'next-intl/middleware';

export default createMiddleware({
  locales: ['nb-NO', 'en', 'sv'],
  defaultLocale: 'nb-NO',
  localePrefix: 'as-needed' // /nb-NO/dashboard → /dashboard (default), /en/dashboard (explicit)
});

export const config = {
  matcher: ['/', '/(nb-NO|en|sv)/:path*']
};
```

**File:** `src/app/[locale]/layout.tsx`
```tsx
import {NextIntlClientProvider} from 'next-intl';
import {notFound} from 'next/navigation';

export default async function LocaleLayout({
  children,
  params: {locale}
}: {
  children: React.ReactNode;
  params: {locale: string};
}) {
  let messages;
  try {
    messages = (await import(`@/messages/${locale}.json`)).default;
  } catch (error) {
    notFound();
  }

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider locale={locale} messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}
```

### 9.2 Language Detection

**Priority:**
1. URL locale (`/en/dashboard` → `en`)
2. User profile `preferred_language` (if logged in)
3. Browser `Accept-Language` header (first visit)
4. Fallback to `nb-NO` (default)

**Implementation:**

**File:** `src/i18n/config.ts`
```typescript
import {getRequestConfig} from 'next-intl/server';
import {headers} from 'next/headers';

export default getRequestConfig(async ({locale}) => {
  // Locale from URL (/en/dashboard) or middleware detection
  return {
    messages: (await import(`../messages/${locale}.json`)).default
  };
});
```

**User Preference Storage:**
```sql
ALTER TABLE users ADD COLUMN preferred_language TEXT DEFAULT 'nb-NO';
```

**API Route to Update Preference:**
```typescript
// POST /api/settings/language
await db.run('UPDATE users SET preferred_language = ? WHERE id = ?', [locale, userId]);
```

### 9.3 Language Switcher UI

**Location:** Profile → Language Settings (`/[locale]/profile/language`)

**UI:**
```tsx
import {useRouter, usePathname} from 'next/navigation';
import {useLocale} from 'next-intl';

export function LanguageSwitcher() {
  const router = useRouter();
  const pathname = usePathname();
  const currentLocale = useLocale();

  const switchLocale = (newLocale: string) => {
    const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
    router.push(newPath);
  };

  return (
    <select value={currentLocale} onChange={(e) => switchLocale(e.target.value)}>
      <option value="nb-NO">🇳🇴 Norsk</option>
      <option value="en">🇬🇧 English</option>
      <option value="sv">🇸🇪 Svenska</option>
    </select>
  );
}
```

---

## 10. Legal Requirements (PSD2 / Finanstilsynet)

### 10.1 Mandatory Norwegian Disclosure

**Requirement:** Payment service providers in Norway must comply with **host state rules** on disclosure obligations and customer protection ([PSD2 Norway implementation](https://svw.no/en/insights/norway-finally-implements-psd2)).

**Implication:** Terms of Service, Privacy Policy, and Fee Schedule **MUST** be available in Norwegian.

**Compliance:**
- Norwegian legal documents are **mandatory** (P0)
- English/Swedish legal documents are **optional** (P2, improves UX for non-Norwegian speakers)
- If non-Norwegian user selects English, show English UI but link to Norwegian legal docs with disclaimer:
  > "Legal documents are provided in Norwegian only, as required by Norwegian law. For an unofficial translation, please use a translation service."

### 10.2 Consent & Agreement Language

**Requirement:** User must **consent** in a language they understand.

**Strategy:**
- During onboarding, detect user's language preference
- Display Terms of Service in user's language (if available)
- If not available, show Norwegian with disclaimer + user confirms they understand
- Log consent with `language_of_consent` in database

**Database:**
```sql
ALTER TABLE users ADD COLUMN language_of_consent TEXT;
-- Example: 'nb-NO', 'en', 'sv'
```

### 10.3 Customer Support Language

**Requirement:** Not legally mandated, but best practice.

**Strategy:**
- Support email (`support@getdrop.no`) responds in **Norwegian** (primary) and **English** (secondary)
- Swedish support on-demand (Google Translate fallback initially)

---

## 11. Testing Strategy

### 11.1 Unit Tests

**Framework:** Vitest (already in use)

**Test Files:**
- `src/lib/i18n/formatters.test.ts` — Currency, date, number formatting
- `src/lib/i18n/translations.test.ts` — Translation key coverage

**Example:**
```typescript
import {formatCurrency} from '@/lib/formatters';

describe('formatCurrency', () => {
  it('formats NOK in Norwegian locale', () => {
    expect(formatCurrency(1234, 'nb-NO')).toBe('1 234 kr');
  });

  it('formats NOK in English locale', () => {
    expect(formatCurrency(1234, 'en')).toBe('NOK 1,234');
  });
});
```

### 11.2 Integration Tests

**Framework:** Playwright (already in use)

**Test Scenarios:**
1. **Language Switcher:** Switch from Norwegian → English → Swedish, verify UI updates
2. **Currency Formatting:** Check dashboard balance shows correct format per locale
3. **Date Formatting:** Check transaction history dates render correctly
4. **Email Templates:** Generate email in each locale, verify content
5. **Legal Pages:** Load terms/privacy in each locale, verify fallback if missing

**Example:**
```typescript
test('dashboard shows correct currency format for Norwegian locale', async ({page}) => {
  await page.goto('/nb-NO/dashboard');
  await expect(page.locator('text=/\\d+ kr/')).toBeVisible(); // "1 234 kr" format
});

test('dashboard shows correct currency format for English locale', async ({page}) => {
  await page.goto('/en/dashboard');
  await expect(page.locator('text=/NOK \\d+/')).toBeVisible(); // "NOK 1,234" format
});
```

### 11.3 Manual QA Checklist

**Pre-Release Testing (All Locales):**

| Test Case | nb-NO | en | sv | Notes |
|-----------|-------|----|----|-------|
| Login page renders correctly | ☐ | ☐ | ☐ | Check labels, buttons, errors |
| Dashboard shows greeting in correct language | ☐ | ☐ | ☐ | "God morgen" vs "Good morning" |
| Currency formatting matches locale | ☐ | ☐ | ☐ | "1 234 kr" vs "NOK 1,234" |
| Transaction history dates formatted correctly | ☐ | ☐ | ☐ | DD.MM.YYYY vs MM/DD/YYYY |
| Email templates render in correct language | ☐ | ☐ | ☐ | Send test emails |
| Legal pages load without errors | ☐ | ☐ | ☐ | Check fallback for missing translations |
| Language switcher changes UI language | ☐ | ☐ | ☐ | From profile settings |
| URL routing works for all locales | ☐ | ☐ | ☐ | /nb-NO/, /en/, /sv/ |

---

## 12. Migration Plan (Phased Rollout)

### Phase 1: Framework + Core UI (Week 1-2)

**Goal:** Replace all hardcoded UI strings with next-intl translations. No new languages yet (Norwegian-only, but structured for future).

**Tasks:**

1. **Install next-intl:** `npm install next-intl`
2. **Create translation files:**
   - `messages/nb-NO.json` (copy all existing Norwegian strings)
   - Extract strings from:
     - Navigation (`bottom-nav.tsx`)
     - Login (`login/page.tsx`)
     - Dashboard (`dashboard/page.tsx`)
     - Profile (`profile/page.tsx`)
     - All form validation errors
3. **Setup routing:**
   - Create `app/[locale]/` directory
   - Move all existing routes under `[locale]/`
   - Add middleware for locale detection
4. **Update components:**
   - Replace `"Hjem"` with `t('nav.home')`
   - Replace `toLocaleString("nb-NO")` with `format.number()`
5. **Test:** Verify app works exactly as before (Norwegian-only, but via next-intl)

**Deliverables:**
- `messages/nb-NO.json` (complete)
- All UI components use `useTranslations()` hook
- Zero hardcoded Norwegian strings in TSX files
- Build passes, Playwright tests pass

**Success Criteria:** App looks/behaves identical to current version, but all strings come from translation file.

---

### Phase 2: English + Email Templates (Week 3)

**Goal:** Add English locale, translate email templates.

**Tasks:**

1. **Translate UI to English:**
   - Create `messages/en.json` (external translator or Alem review)
   - Add "English" option to language switcher
2. **Refactor email templates:**
   - Convert `email-templates/*.html` to `lib/email-templates.ts` functions
   - Add `email.*` namespace to translation files
   - Update email sending logic to accept locale parameter
3. **Test emails:**
   - Send test emails in Norwegian and English
   - Verify formatting (date/currency in emails)

**Deliverables:**
- `messages/en.json` (complete)
- Email templates support both locales
- Language switcher functional

**Success Criteria:** Users can switch to English, all UI + emails render correctly in English.

---

### Phase 3: Swedish + Legal Docs (Week 4)

**Goal:** Add Swedish locale, translate legal documents (or defer if not ready).

**Tasks:**

1. **Translate UI to Swedish:**
   - Create `messages/sv.json`
   - Add "Svenska" to language switcher
2. **Legal document strategy:**
   - Option A: Professional translation of terms/privacy/fees to English (defer Swedish)
   - Option B: Keep Norwegian-only legal docs, show disclaimer for EN/SV users
3. **Production deployment:**
   - Deploy with all 3 locales
   - Monitor for missing translations (Sentry alerts)

**Deliverables:**
- `messages/sv.json` (complete)
- Legal document translation strategy finalized
- Production-ready i18n system

**Success Criteria:** All 3 locales functional, legal compliance maintained.

---

### Phase 4: Polish & Optimization (Week 5+)

**Goal:** Refine translations, add missing edge cases, optimize bundle size.

**Tasks:**

1. **Translation review:**
   - Native speakers review Norwegian/Swedish translations
   - Collect user feedback on clarity
2. **Namespace splitting:**
   - Split large `nb-NO.json` into `common.json`, `auth.json`, `dashboard.json`, etc.
   - Lazy-load translation namespaces for faster initial load
3. **ESLint rule:**
   - Add ESLint rule to prevent future hardcoded strings:
     ```javascript
     // .eslintrc.js
     rules: {
       'no-restricted-syntax': [
         'error',
         {
           selector: 'JSXText[value=/[a-zæøåA-ZÆØÅ]{3,}/]',
           message: 'Hardcoded text not allowed. Use useTranslations() hook.'
         }
       ]
     }
     ```
4. **Performance audit:**
   - Measure bundle size impact of next-intl
   - Verify server-side rendering works (translations in initial HTML)

**Success Criteria:** i18n system stable, maintainable, performant.

---

## 13. Implementation Estimates

| Phase | Tasks | Effort | Owner |
|-------|-------|--------|-------|
| **Phase 1: Framework + Core UI** | Install next-intl, extract strings, setup routing, update components | 2-3 days | Builder agent |
| **Phase 2: English + Email Templates** | Translate UI, refactor email templates, test | 1-2 days | Builder + Translator |
| **Phase 3: Swedish + Legal Docs** | Translate UI, legal doc strategy, deploy | 1-2 days | Builder + Legal |
| **Phase 4: Polish & Optimization** | Review, namespace split, ESLint rule, audit | 1-2 days | Builder + Validator |
| **Total** | Full i18n implementation | **5-9 days** | Team |

**Assumptions:**
- Developer familiar with next-intl (1 day learning curve included)
- External translator available for English (1 day turnaround)
- Legal document translation deferred to post-MVP (Phase 3 can proceed with Norwegian-only legal docs)

---

## 14. Risks & Mitigation

| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| **Missing translations break production** | High | Medium | TypeScript type-checking catches missing keys at build time. Add runtime fallback to Norwegian. |
| **Translation quality poor (machine translation)** | Medium | High | Use professional translator for English. Native speaker review for Swedish. |
| **Legal documents not compliant in English** | High | Low | Keep Norwegian legal docs as source of truth. English is "best effort" unofficial translation. |
| **Performance regression (bundle size)** | Low | Low | next-intl adds ~5KB gzipped. Negligible for Drop's use case. |
| **URL routing breaks SEO** | Medium | Low | Subdirectory routing (`/en/`) is SEO-friendly. Submit all locales to Google Search Console. |
| **User confusion with language switcher** | Low | Medium | Add clear UI labels, remember user preference in profile. |
| **Email templates render incorrectly in some locales** | Medium | Medium | Test emails in all locales before production. Use email testing tool (Litmus, Email on Acid). |

---

## 15. Success Metrics

**Post-Deployment (30 days):**

| Metric | Target | Measurement |
|--------|--------|-------------|
| **Locale adoption (English)** | >10% of users | Analytics: % of users with `/en/` routes |
| **Translation coverage** | 100% of UI strings | Automated script: check all keys exist in all locales |
| **Zero missing translation errors** | 0 Sentry errors | Sentry: no `TranslationKeyNotFound` errors |
| **Email deliverability (all locales)** | >95% delivery rate | Email service provider metrics |
| **Legal compliance** | 100% Norwegian terms visible | Manual audit: all users see Norwegian legal docs |

---

## 16. Future Enhancements (Post-MVP)

1. **Right-to-Left (RTL) Support:** Arabic, Somali for diaspora remittance corridors
2. **Translation Management Platform:** Use [Locize](https://locize.com/) or [Crowdin](https://crowdin.com/) for professional translation workflow
3. **A/B Testing:** Test Norwegian vs English CTAs for conversion optimization
4. **Voice of Customer:** Collect user feedback on translation quality, iterate
5. **Automatic Language Detection:** Use IP geolocation to suggest locale (Norway → Norwegian, USA → English)

---

## 17. References

### Documentation
- [next-intl Official Docs](https://next-intl.dev/docs)
- [next-intl App Router Setup](https://next-intl.dev/docs/getting-started/app-router)
- [Next.js 16 i18n Tutorial](https://i18nexus.com/tutorials/nextjs/next-intl)
- [Norwegian Bokmål Locale Formatting](https://leap.hcldoc.com/help/topic/SSS28S_8.2.1/XFDL_Specification/i_xfdl_r_formats_nb_NO.html)

### Legal & Compliance
- [PSD2 Implementation in Norway](https://svw.no/en/insights/norway-finally-implements-psd2)
- [PSD2 Legal Requirements (Lexology)](https://www.lexology.com/library/detail.aspx?g=26ea2eec-8839-48b9-b43a-769444290aa1)
- [Finanstilsynet PSD2 Guidance](https://www.finanstilsynet.no/tema/psd-2---eus-reviderte-betalingstjenestedirektiv/psd2---presiseringer-og-avklaringer-om-regelverket/)

### Localization Best Practices
- [Norwegian Bokmål vs Nynorsk Localization](https://www.simultrans.com/blog/norwegian-localization-bokmal-or-nynorsk)
- [Currency Formatting for Localization](https://www.sitetran.com/blog/localization-for-growth/Currency-Formatting-for-Localization-Success)
- [Microsoft Currency Formatting Guide](https://learn.microsoft.com/en-us/globalization/locale/currency-formats)

### Library Comparisons
- [next-intl vs react-i18next Comparison](https://medium.com/@isurusasanga1999/why-i-chose-next-intl-for-internationalization-in-my-next-js-66c9e49dd486)
- [i18n Libraries Comparison](https://npm-compare.com/next-international,react-i18next,react-intl,react-intl-universal)

---

## 18. Appendix: Translation File Examples

### A. Complete nb-NO.json (Sample)

See **Section 4.2** for full structure.

### B. ESLint Rule for Hardcoded Strings

```javascript
// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-syntax': [
      'error',
      {
        selector: 'JSXText[value=/[a-zæøåA-ZÆØÅ]{3,}/]',
        message: 'Hardcoded text in JSX not allowed. Use useTranslations() hook from next-intl.'
      }
    ]
  }
};
```

### C. next-intl Configuration Files

**File:** `src/i18n/config.ts`
```typescript
export const locales = ['nb-NO', 'en', 'sv'] as const;
export type Locale = typeof locales[number];
export const defaultLocale: Locale = 'nb-NO';
```

**File:** `src/i18n/request.ts`
```typescript
import {getRequestConfig} from 'next-intl/server';

export default getRequestConfig(async ({locale}) => {
  return {
    messages: (await import(`../messages/${locale}.json`)).default
  };
});
```

---

**End of Specification**

**Next Steps:**
1. Review this spec with Alem for approval
2. Create MC task for Phase 1 implementation
3. Assign to builder agent with this spec as reference
4. Schedule external translator for Phase 2

**Questions for Alem:**
1. Approve next-intl as framework choice?
2. Defer Swedish to post-MVP or include in initial release?
3. Budget for professional legal document translation (English)?
4. Timeline preference: Fast rollout (2 weeks) vs thorough rollout (4 weeks)?

# drop-make-integration-plan

# Plan: Drop Figma Make Frontend Integration

## Research Summary

### Make Export (source: ~/ALAI/products/Drop/mockups/figma-make-export/)
- **10 screens**, all custom Tailwind styling (NO shadcn imports despite package.json having them)
- **react-router** navigation (Link, useLocation, useState)
- **All hardcoded mock data** — no API calls
- **2 shared components:** BottomNav (5 items: Hjem, Kontoer, Skann, Historikk, Profil), Logo (gradient square + wordmark)
- **lucide-react** icons throughout
- **Norwegian text**, DM Sans + Fraunces fonts, #0B6E35 green + #D4A017 gold
- Total: ~1,200 lines across 12 component files

### Existing Drop (source: ~/ALAI/products/Drop/src/drop-app/)
- **10 routes**, all "use client" components with real API integration
- **API calls:** auth (login/register), transactions (list/create), recipients, rates, cards, merchants
- **Auth:** useAuth hook, JWT httpOnly cookies
- **Feature flags:** cards gated behind useFeatureFlag()
- **shadcn/ui components** used (Card, Button, Badge, Tabs, ScrollArea, etc.)
- **BottomNav** with different icons (Home, Send, QrScan, CreditCard, User)
- **DropLogo** components (broken SVG from task #947 — needs replacement)
- Total: ~2,350 lines across page components

### Key Difference
Make export = **beautiful UI with mock data**. Existing Drop = **ugly UI with working backend**. Goal: merge both — Make's design + Drop's API integration.

### Conversion Needs Per Screen
| Make Screen | react-router → Next.js | Mock Data → API | Lines |
|-------------|----------------------|-----------------|-------|
| Login | Link → next/link, form → POST /api/auth/login | Remove mock, add auth | 80 |
| Onboarding | Link → next/link, form → POST /api/auth/register | Remove mock, add validation | 117 |
| Dashboard | Link → next/link | Mock txns → GET /api/transactions, mock balance → GET /api/bank-accounts | 129 |
| SendMoney | Link → next/link, useState kept | Mock recipients → GET /api/recipients, mock rates → GET /api/rates, submit → POST /api/transactions/remittance | 168 |
| BankAccounts | Link → next/link | Mock accounts → GET /api/bank-accounts | 82 |
| TransactionHistory | Link → next/link, useState kept | Mock txns → GET /api/transactions | 181 |
| ScanQR | Link → next/link, useState kept | Mock merchant → POST /api/transactions/qr-payment | 125 |
| Profile | Link → next/link | Mock user → GET /api/auth/me, logout → POST /api/auth/logout | 113 |
| Notifications | Link → next/link | Mock notifs → static for MVP (no API yet) | 96 |
| MerchantDashboard | useState kept | Mock stats → GET /api/merchants/dashboard, mock txns → GET /api/merchants/transactions | 147 |

## Objective

Replace all 10 Drop frontend page components with Figma Make export designs, converting from Vite/react-router to Next.js App Router, and wiring mock data to existing Drop API endpoints. Backend stays 100% untouched.

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type | Model |
|----|------|------|------------|-------|
| B1 | foundation-builder | Setup shared components + theme | builder | sonnet |
| V1 | foundation-validator | Validate shared components | validator | sonnet |
| B2 | screens-builder-1 | Convert Login, Onboarding, Dashboard, Profile | builder | sonnet |
| B3 | screens-builder-2 | Convert SendMoney, BankAccounts, TransactionHistory | builder | sonnet |
| B4 | screens-builder-3 | Convert ScanQR, Notifications, MerchantDashboard | builder | sonnet |
| V2 | screens-validator | Validate all 10 screens build + API wiring | validator | sonnet |
| B5 | integration-builder | Final integration, build test, fix issues | builder | sonnet |
| V3 | final-validator | Full app build + visual check | validator | sonnet |

### Step-by-Step Tasks

---

#### Phase 1: Foundation (shared components + theme)

**Task 1: Replace shared components and merge theme**
- Owner: B1
- BlockedBy: none
- Files owned:
  - `src/components/bottom-nav.tsx` — REPLACE with Make's BottomNav (convert react-router Link/useLocation → next/link + usePathname)
  - `src/components/drop-logo.tsx` — REPLACE with Make's Logo component
  - `src/app/globals.css` — MERGE Make's theme.css variables into existing globals.css (keep existing Tailwind @theme inline, add missing CSS vars from Make)
  - `src/components/ui/` — keep all existing shadcn components, no changes
- Read FIRST:
  - Make: `mockups/figma-make-export/src/app/components/BottomNav.tsx`
  - Make: `mockups/figma-make-export/src/app/components/Logo.tsx`
  - Make: `mockups/figma-make-export/src/styles/theme.css`
  - Existing: `src/components/bottom-nav.tsx`
  - Existing: `src/components/drop-logo.tsx`
  - Existing: `src/app/globals.css`
- Conversion rules:
  - `import { Link } from 'react-router'` → `import Link from 'next/link'`
  - `import { useLocation } from 'react-router'` → `import { usePathname } from 'next/navigation'`
  - `const { pathname } = useLocation()` → `const pathname = usePathname()`
  - `to=` → `href=`
  - Add `"use client"` at top of each file
  - BottomNav: 5 items — Hjem(/dashboard), Kontoer(/accounts), Skann QR(/scan, center green circle), Historikk(/transactions), Profil(/profile)
- Acceptance:
  - [ ] bottom-nav.tsx uses next/link + usePathname, 5 items, center QR button elevated
  - [ ] drop-logo.tsx renders green gradient square + gold dot + "drop" wordmark
  - [ ] globals.css has all Make theme variables merged (--primary-green, --gold-accent, etc.)
  - [ ] No react-router imports remain
  - [ ] `npx tsc --noEmit` passes for changed files

**Task 2: Validate foundation**
- Owner: V1
- BlockedBy: 1
- Acceptance:
  - [ ] bottom-nav.tsx: no react-router, uses next/link + usePathname, "use client" present
  - [ ] drop-logo.tsx: renders correctly, no broken SVG paths, "use client" present
  - [ ] globals.css: valid CSS, no duplicate custom properties, Tailwind @theme inline still present
  - [ ] All existing imports of BottomNav and DropLogo from other files still resolve

---

#### Phase 2: Screen Conversion (parallel — 3 builders)

**Task 3: Convert Login, Onboarding, Dashboard, Profile**
- Owner: B2
- BlockedBy: 1
- Files owned:
  - `src/app/login/page.tsx` — REPLACE with Make's Login.tsx + add POST /api/auth/login
  - `src/app/register/page.tsx` — REPLACE with Make's Onboarding.tsx + add POST /api/auth/register (keep age validation, OTP, PIN from existing)
  - `src/app/dashboard/page.tsx` — REPLACE with Make's Dashboard.tsx + add GET /api/transactions + GET /api/bank-accounts
  - `src/app/profile/page.tsx` — REPLACE with Make's Profile.tsx + add GET /api/auth/me + POST /api/auth/logout
- Read FIRST:
  - Make screens: Login.tsx, Onboarding.tsx, Dashboard.tsx, Profile.tsx
  - Existing pages: login/page.tsx, register/page.tsx, dashboard/page.tsx, profile/page.tsx
  - API spec: ~/ALAI/products/Drop/project/architecture/api-specification.md
- Conversion rules (SAME for all screens):
  - `import { Link } from 'react-router'` → `import Link from 'next/link'`
  - `to=` → `href=`
  - Add `"use client"` at top
  - Replace hardcoded mock data with `useState` + `useEffect` + `fetch('/api/...')` from existing pages
  - Import BottomNav from `@/components/bottom-nav`
  - Import Logo from `@/components/drop-logo` (use DropLogo/DropLogoFull as appropriate)
  - Keep Make's visual layout/structure, replace data layer
  - Login: add form onSubmit → POST /api/auth/login, router.push('/dashboard') on success
  - Register: keep existing multi-step validation (age 18+, password strength, OTP) but use Make's visual design
  - Dashboard: fetch transactions + bank accounts on mount, show real data in Make's layout
  - Profile: show real user data from /api/auth/me, logout → POST /api/auth/logout + router.push('/login')
- Acceptance:
  - [ ] All 4 pages render with Make's visual design
  - [ ] Login form submits to /api/auth/login
  - [ ] Register validates age 18+, password, creates account
  - [ ] Dashboard shows real API data (transactions, bank accounts)
  - [ ] Profile shows real user, logout works
  - [ ] No react-router imports, all use next/link
  - [ ] `npx tsc --noEmit` passes

**Task 4: Convert SendMoney, BankAccounts, TransactionHistory**
- Owner: B3
- BlockedBy: 1
- Files owned:
  - `src/app/send/page.tsx` — REPLACE with Make's SendMoney.tsx + add GET /api/recipients, GET /api/rates, POST /api/transactions/remittance
  - `src/app/accounts/page.tsx` — REPLACE with Make's BankAccounts.tsx + add GET /api/bank-accounts
  - `src/app/transactions/page.tsx` — REPLACE with Make's TransactionHistory.tsx + add GET /api/transactions
- Read FIRST:
  - Make screens: SendMoney.tsx, BankAccounts.tsx, TransactionHistory.tsx
  - Existing pages: send/page.tsx, accounts/page.tsx, transactions/page.tsx
  - API spec: ~/ALAI/products/Drop/project/architecture/api-specification.md
- Conversion rules: Same as Task 3 + specific:
  - SendMoney: 4-step flow (recipient → amount → review → success), real recipients from API, real exchange rates, POST remittance on confirm
  - BankAccounts: real bank accounts from GET /api/bank-accounts, no mock data
  - TransactionHistory: real transactions with filters (all/sendt/qr), grouped by date
- Acceptance:
  - [ ] SendMoney fetches real recipients + rates, creates real remittance
  - [ ] BankAccounts shows real linked accounts from API
  - [ ] TransactionHistory shows real transactions with filter tabs
  - [ ] No react-router imports
  - [ ] `npx tsc --noEmit` passes

**Task 5: Convert ScanQR, Notifications, MerchantDashboard**
- Owner: B4
- BlockedBy: 1
- Files owned:
  - `src/app/scan/page.tsx` — REPLACE with Make's ScanQR.tsx + add POST /api/transactions/qr-payment
  - `src/app/notifications/page.tsx` — REPLACE with Make's Notifications.tsx (keep mock data for MVP — no notifications API yet)
  - `src/app/(merchant)/merchant/page.tsx` or appropriate route — REPLACE with Make's MerchantDashboard.tsx + add GET /api/merchants/dashboard + GET /api/merchants/transactions
- Read FIRST:
  - Make screens: ScanQR.tsx, Notifications.tsx, MerchantDashboard.tsx
  - Existing pages: scan/page.tsx, notifications/page.tsx
  - API spec sections 7, 9 (QR Payment, Merchants)
- Conversion rules: Same as Task 3 + specific:
  - ScanQR: keep demo simulation mode, POST /api/transactions/qr-payment on confirm, show bank account info
  - Notifications: keep Make's mock data (no backend API for notifications in MVP)
  - MerchantDashboard: real stats from /api/merchants/dashboard, real transactions from /api/merchants/transactions
- Acceptance:
  - [ ] ScanQR simulates scan, creates real QR payment
  - [ ] Notifications renders with demo data
  - [ ] MerchantDashboard shows real merchant stats
  - [ ] No react-router imports
  - [ ] `npx tsc --noEmit` passes

**Task 6: Validate all 10 screens**
- Owner: V2
- BlockedBy: 3, 4, 5
- Acceptance:
  - [ ] All 10 page.tsx files exist and compile
  - [ ] No react-router imports anywhere in src/
  - [ ] All pages have "use client" directive
  - [ ] All API calls match api-specification.md endpoints
  - [ ] BottomNav appears on all pages except Login and Onboarding
  - [ ] All text is Norwegian
  - [ ] Brand colors (#0B6E35, #D4A017) used consistently
  - [ ] `npx tsc --noEmit` passes for entire project

---

#### Phase 3: Integration & Build

**Task 7: Build test + fix issues**
- Owner: B5
- BlockedBy: 6
- Steps:
  - Run `npm run build` (or `next build`)
  - Fix any TypeScript errors
  - Fix any import path issues
  - Fix any missing dependencies
  - Ensure all routes accessible
  - Remove unused old components if any
  - Remove /cards route or keep as feature-flagged placeholder
- Files owned: Any file that needs fixing from build errors
- Acceptance:
  - [ ] `next build` succeeds with 0 errors
  - [ ] `next dev` starts without errors
  - [ ] All 10 routes load in browser
  - [ ] No console errors on any page

**Task 8: Final validation**
- Owner: V3
- BlockedBy: 7
- Steps:
  - Verify `next build` passes
  - Check each route renders (curl localhost:3000/login, /dashboard, etc.)
  - Verify API endpoints still work (GET /api/auth/me, GET /api/transactions)
  - Check no old broken SVG logos remain
  - Verify BottomNav navigation works between pages
- Acceptance:
  - [ ] Build passes
  - [ ] All 10 routes return 200
  - [ ] API health check passes
  - [ ] No broken imports or missing components

## Validation Commands

```bash
# TypeScript check
cd ~/ALAI/products/Drop/src/drop-app && npx tsc --noEmit

# Build
cd ~/ALAI/products/Drop/src/drop-app && npm run build

# Dev server
cd ~/ALAI/products/Drop/src/drop-app && npm run dev

# Check no react-router imports remain
grep -r "from 'react-router" ~/ALAI/products/Drop/src/drop-app/src/ || echo "CLEAN"
grep -r "from \"react-router" ~/ALAI/products/Drop/src/drop-app/src/ || echo "CLEAN"

# Check all pages exist
ls ~/ALAI/products/Drop/src/drop-app/src/app/{login,register,dashboard,send,accounts,transactions,scan,profile,notifications}/page.tsx
```

## Risk Mitigation

1. **Backup first:** `cp -r src/drop-app/src src/drop-app/src-backup-pre-make` before any changes
2. **Register vs Onboarding:** Existing register has complex multi-step validation (age 18+, OTP, PIN). Make's Onboarding is simpler. MUST keep existing validation logic, only replace visual layer.
3. **Cards route:** Make export has no Cards screen (replaced with BankAccounts per pass-through model). Keep /cards as feature-flagged placeholder or remove.
4. **Merchant route:** May need new route directory if doesn't exist. Check first.
5. **Landing page (/):** NOT touched. Existing landing page stays as-is.

## Estimated Scope

- **Phase 1:** 3 files changed (foundation)
- **Phase 2:** 10 files replaced (screens) — 3 builders in parallel
- **Phase 3:** Bug fixes from build
- **Total:** ~13 files modified, 0 new files, backend untouched

# drop-marketing-infra-spec

# Drop Marketing Infrastructure — Implementation Spec

**Project:** Drop (Fintech Payment App)
**Task:** MC #1197
**Date:** 2026-02-17
**Author:** John (AI Director)
**Status:** Draft — Awaiting Approval

---

## 1. Executive Summary

Drop currently has a basic landing page (getdrop.no) with waitlist functionality but lacks comprehensive marketing infrastructure to support user acquisition, retention, and viral growth. This spec defines the complete marketing technology stack covering:

- Landing page optimization and conversion tracking
- SEO strategy for Norwegian fintech market
- App Store/Play Store listing preparation
- Referral system with fraud prevention
- Campaign tracking (UTM parameters and attribution)
- Email marketing integration
- A/B testing framework
- Analytics and reporting

**Core Principles:**
- **Norwegian-first:** All copy, SEO, and campaigns prioritize Norwegian market
- **GDPR + markedsføringsloven compliant:** Strict consent management
- **Pass-through branding:** Drop never holds money — emphasize transparency
- **Viral growth:** Referral program as primary acquisition channel
- **Data-driven:** Every decision backed by metrics

---

## 2. Current State Assessment

### 2.1 What Exists

**Landing Page (getdrop.no):**
- Single-page marketing site with hero, features, how-it-works, CTA
- Waitlist form (`POST /api/waitlist`) with honeypot anti-spam
- Mobile-responsive design (DM Sans + Fraunces fonts)
- Basic SEO meta tags (title, description, og:tags)
- Trust signals: "Regulert i Norge", "0.5% gebyr", "30+ land"
- Deployed to Vercel (pages/ directory for static content)

**SEO Meta Tags Present:**
```html
<title>Drop — Send penger. Enklere. Billigere.</title>
<meta name="description" content="Drop er den nye standarden for internasjonale overforinger i Skandinavia. 0,5% gebyr. QR-betaling i butikk. Regulert i Norge.">
<meta property="og:title" content="Drop — Enklere betalinger. Lavere gebyrer.">
<meta property="og:description" content="Send penger til 30+ land med 0,5% gebyr. QR-betaling i butikk. Regulert i Norge.">
<meta property="og:image" content="https://getdrop.no/drop-logo.png">
```

**Branding:**
- Logo: Green rounded square with white $ icon + gold dot
- Colors: `--drop-green: #0B6E35`, `--drop-gold: #D4A017`
- Tagline: "Enklere betalinger. Lavere gebyrer."
- Value props: 0.5% remittance fee, 1% merchant QR fee, 30+ countries

**Domain:**
- Primary: getdrop.no (registered via one.com)
- drop.no owned by TV2 (not available)

### 2.2 What's Missing

**Critical Gaps:**
- No conversion tracking (Google Analytics, Plausible, or similar)
- No UTM parameter handling for campaign attribution
- No App Store/Play Store listings (not launched)
- No referral system (database schema or logic)
- No email marketing integration (Resend configured but no campaigns)
- No A/B testing framework
- No SEO sitemap or robots.txt
- No structured data (JSON-LD) for rich snippets
- No blog/content marketing infrastructure
- No social media presence (@dropnorge handles not claimed)

**Legal/Compliance Gaps:**
- Marketing consent collection not integrated with email system
- No cookie consent banner (required for analytics tracking)
- No marketing-specific terms in vilkår.html

---

## 3. Landing Page Optimization

### 3.1 Conversion Optimization

**Goal:** Convert 25% of visitors to waitlist signups (industry benchmark: 15-20%)

**Improvements:**

#### A. Hero Section
Current state: Generic CTA "Last ned gratis" (app not available yet)

**Optimization:**
```html
<!-- Replace generic CTA with value-focused copy -->
<div class="hero-actions">
  <a class="btn-gradient" href="#cta">Bli med på ventelisten</a>
  <span class="hero-cta-subtext">De første 1000 får 5 gratis overføringer</span>
</div>
```

**Value Prop Enhancement:**
- Add "Ingen binding. Ingen skjulte kostnader." under CTA
- Add trust badge: "Regulert av Finanstilsynet" (when applicable)
- Replace phone mockup with actual app screenshot from Figma Make export

#### B. Social Proof
Current state: Generic testimonial placeholders

**Add Real Metrics (when available):**
```html
<div class="trust-bar">
  <div class="trust-item">
    <div class="trust-number">2,340</div>
    <div class="trust-label">På ventelisten</div>
  </div>
  <div class="trust-item">
    <div class="trust-number">kr 12M</div>
    <div class="trust-label">Spart i gebyrer (demo)</div>
  </div>
  <div class="trust-item">
    <div class="trust-number">4.8★</div>
    <div class="trust-label">Snittrating (beta)</div>
  </div>
</div>
```

**Source:** Pull waitlist count from database, update dynamically

#### C. Features Section
Current state: 3 feature cards (Send, QR, Wallet)

**Optimization:**
- Add FAQ accordion below features: "Er Drop en bank?" → "Nei, vi er en betalingstjeneste..."
- Add comparison table: Drop vs. Western Union vs. Wise vs. Banks

**Comparison Table:**
```html
<table class="comparison-table">
  <thead>
    <tr><th>Tjeneste</th><th>Gebyr</th><th>Hastighet</th><th>Lojalitet</th></tr>
  </thead>
  <tbody>
    <tr><td><strong>Drop</strong></td><td>0.5%</td><td>Minutter</td><td>Bonuspoeng</td></tr>
    <tr><td>Western Union</td><td>5-10%</td><td>1-3 dager</td><td>—</td></tr>
    <tr><td>Wise</td><td>0.7-1.2%</td><td>1-2 dager</td><td>—</td></tr>
    <tr><td>Tradisjonell bank</td><td>50-200 kr + FX</td><td>3-5 dager</td><td>—</td></tr>
  </tbody>
</table>
```

#### D. CTA Section
Current state: Email input + submit button

**Optimization:**
- Add GDPR-compliant marketing consent checkbox
- Add "Hva skjer etter?" explainer below form:
  ```
  1. Du får bekreftelses-epost
  2. Vi varsler deg når appen lanseres (Mars 2026)
  3. Du får tidlig tilgang før alle andre
  ```
- Add exit-intent popup (trigger on mouse leave): "Vent! Ta med 100 kr gratis når du registrerer deg nå."

### 3.2 Mobile Optimization

**Issues:**
- Phone mockup too large on mobile (600px → overflow)
- Nav menu doesn't collapse properly

**Fixes:**
```css
@media (max-width: 480px) {
  .phone { width: 240px; height: 480px; } /* Already implemented */
  .hero { padding-top: 100px; } /* Reduce top padding */
  .hero h1 { font-size: 28px; line-height: 1.2; }
  .hero p { font-size: 15px; }
}
```

**Mobile CTA Sticky Bar:**
Add persistent bottom bar on mobile with CTA:
```html
<div class="mobile-cta-bar">
  <a href="#cta" class="btn-gradient">Registrer deg gratis</a>
</div>
```

### 3.3 Page Speed

**Current Performance (estimate):**
- FCP (First Contentful Paint): ~1.5s
- LCP (Largest Contentful Paint): ~2.5s
- CLS (Cumulative Layout Shift): 0.05

**Optimizations:**
- Preload hero fonts: `<link rel="preload" href="..." as="font">`
- Lazy load phone mockup below fold
- Add `loading="lazy"` to all images except hero
- Minify CSS (inline critical CSS, defer non-critical)
- Add service worker for offline caching (PWA)

**Target Metrics:**
- FCP: < 1.2s
- LCP: < 2.0s
- CLS: < 0.1
- Lighthouse Score: 95+

---

## 4. SEO Strategy

### 4.1 Target Keywords (Norwegian)

**Primary Keywords (High Volume, High Intent):**
| Keyword | Monthly Searches (est.) | Difficulty | Priority |
|---------|------------------------|------------|----------|
| send penger utlandet | 1,200 | Medium | HIGH |
| billig pengeoverføring | 800 | Low | HIGH |
| internasjonale overføringer | 600 | Medium | HIGH |
| qr betaling norge | 400 | Low | MEDIUM |
| vipps alternativ | 300 | High | MEDIUM |
| western union alternativ | 250 | Low | HIGH |
| wise alternativ | 200 | Low | MEDIUM |
| mobilbetaling | 1,500 | High | LOW |

**Long-tail Keywords:**
- "hvor mye koster det å sende penger til utlandet"
- "billigste måte å sende penger til utlandet"
- "qr kode betaling butikk"
- "send penger uten gebyr"

**English Keywords (Secondary — expat market):**
- "send money from norway"
- "international money transfer norway"
- "cheap remittance norway"

### 4.2 On-Page SEO Implementation

#### A. Meta Tags Enhancement

**Homepage:**
```html
<title>Send Penger Utlandet — 0.5% Gebyr | Drop Norge</title>
<meta name="description" content="Send penger til 30+ land med bare 0.5% gebyr. Raskere enn banker, billigere enn Western Union. Regulert i Norge. Registrer deg gratis.">
<meta name="keywords" content="send penger utlandet, billig pengeoverføring, internasjonale overføringer, qr betaling, vipps alternativ">
<link rel="canonical" href="https://getdrop.no/">
```

**Subpages (need to be created):**
- `/send-penger` — Dedicated remittance page
- `/qr-betaling` — QR payment explainer
- `/priser` — Pricing comparison
- `/hvordan-det-virker` — How it works (detailed)
- `/sikkerhet` — Security explainer

#### B. Structured Data (JSON-LD)

**Organization Schema:**
```json
{
  "@context": "https://schema.org",
  "@type": "FinancialService",
  "name": "Drop",
  "alternateName": "Drop Norge",
  "url": "https://getdrop.no",
  "logo": "https://getdrop.no/drop-logo.png",
  "description": "Internasjonale pengeoverføringer og QR-betaling i Norge",
  "address": {
    "@type": "PostalAddress",
    "addressCountry": "NO"
  },
  "sameAs": [
    "https://facebook.com/dropnorge",
    "https://instagram.com/dropnorge",
    "https://linkedin.com/company/drop-norge"
  ]
}
```

**FAQ Schema (for FAQ section):**
```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Er Drop en bank?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Nei, Drop er ikke en bank. Vi er en betalingstjeneste som bruker din eksisterende bankkonto via BankID. Pengene dine forblir i din bank."
      }
    },
    {
      "@type": "Question",
      "name": "Hvor mye koster det å sende penger?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Drop tar 0.5% gebyr på internasjonale overføringer. Ingen skjulte kostnader. Du ser alltid totalprisen før du sender."
      }
    }
  ]
}
```

#### C. Sitemap.xml

**Generate dynamic sitemap:**
```xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://getdrop.no/</loc>
    <lastmod>2026-02-17</lastmod>
    <changefreq>weekly</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>https://getdrop.no/pages/send-penger.html</loc>
    <changefreq>monthly</changefreq>
    <priority>0.9</priority>
  </url>
  <url>
    <loc>https://getdrop.no/pages/qr-betaling.html</loc>
    <changefreq>monthly</changefreq>
    <priority>0.8</priority>
  </url>
  <url>
    <loc>https://getdrop.no/pages/priser.html</loc>
    <changefreq>weekly</changefreq>
    <priority>0.9</priority>
  </url>
  <!-- Add all subpages -->
</urlset>
```

**Submit to:**
- Google Search Console
- Bing Webmaster Tools

#### D. Robots.txt

```
User-agent: *
Allow: /
Disallow: /api/

Sitemap: https://getdrop.no/sitemap.xml
```

### 4.3 Off-Page SEO

#### A. Backlink Strategy

**Target Sources:**
1. **Norwegian fintech blogs:** kryptografen.no, shifter.no, digi.no
2. **Business directories:** proff.no, 1881.no (ALAI Holding AS listing → link to Drop)
3. **Press releases:** mynewsdesk.com, newswire.no
4. **Partner pages:** SpareBank 1 partner page (if approved)
5. **LinkedIn articles:** Alem's personal posts about Drop

**Content for Outreach:**
- "Slik sparer nordmenn penger på internasjonale overføringer"
- "Drop vs. Western Union: Detaljert sammenligning"
- "Hvorfor vi bygde et nytt betalingsalternativ for Skandinavia"

#### B. Local SEO

**Google My Business (when applicable):**
- List Drop under ALAI Holding AS
- Category: "Financial Service"
- Add getdrop.no as website
- Upload Drop logo

**Local Citations:**
- Register on gulesider.no (if relevant)
- Add to fintech.no directory

### 4.4 Technical SEO

**Checklist:**
- [x] HTTPS enabled (Vercel default)
- [ ] Canonical tags on all pages
- [ ] Hreflang tags for NO/EN (if English landing page added)
- [ ] XML sitemap submitted
- [ ] robots.txt configured
- [ ] 404 page with CTA to homepage
- [ ] Redirect strategy (301 for old Zica URLs if any)
- [ ] Mobile-friendly test passed
- [ ] Core Web Vitals optimized

**Hreflang Example (if English page added):**
```html
<link rel="alternate" hreflang="no" href="https://getdrop.no/" />
<link rel="alternate" hreflang="en" href="https://getdrop.no/en/" />
<link rel="alternate" hreflang="x-default" href="https://getdrop.no/" />
```

---

## 5. App Store / Play Store Listing Preparation

### 5.1 App Store Optimization (ASO) Strategy

**Goal:** Rank in top 5 for "send penger" and "pengeoverføring" in Norwegian App Store

**Primary Keywords (App Store):**
- send penger (High volume)
- pengeoverføring (Medium volume)
- qr betaling (Medium volume)
- internasjonale overføringer (Low volume)

**Secondary Keywords:**
- billig, lavere gebyrer, vipps alternativ, remittance, money transfer

### 5.2 App Store Listing (iOS)

#### A. App Name (30 chars)
```
Drop: Send Penger & QR Betal
```

**Rationale:** Includes primary keyword "Send Penger" + brand

#### B. Subtitle (30 chars)
```
0.5% gebyr. Regulert i Norge
```

**Rationale:** USP (0.5% fee) + trust signal (regulated)

#### C. Promotional Text (170 chars)
```
De første 1000 brukerne får 5 gratis overføringer. 🎉

Send penger til 30+ land med bare 0.5% gebyr. Betal i butikk med QR. Enklere enn Vipps. Billigere enn Western Union.
```

**Rationale:** Urgency + value props + social proof comparison

#### D. Description (4000 chars)
```markdown
# Send Penger. Enklere. Billigere.

Drop er den nye standarden for internasjonale pengeoverføringer i Skandinavia. Vi gjør det enkelt, trygt og billig å sende penger til familie og venner i utlandet.

## Hvorfor Drop?

✅ **0.5% gebyr** — Ikke 5%, ikke 10%. Bare 0.5% på alle overføringer.
✅ **30+ land** — Send til Europa, Asia, Afrika, Amerika.
✅ **Minutter, ikke dager** — Pengene er fremme på minutter, ikke 3-5 virkedager.
✅ **Regulert i Norge** — Trygt og lovlig. Pengene dine er sikre.
✅ **Ingen skjulte kostnader** — Hva du ser er hva du betaler.

## Slik fungerer det

1. **Registrer deg** med BankID (tar 2 minutter)
2. **Velg mottaker** og land
3. **Skriv inn beløp** — se gebyret med en gang
4. **Send** direkte fra din bankkonto
5. **Ferdig** — mottaker får pengene på minutter

## QR-betaling i butikk

Betal i butikk med QR-kode. Ingen kort. Ingen kontanter. Bare skann og betal.

- Støtter alle butikker med Drop QR
- 1% merchant-gebyr (billigere enn kortterminaler)
- Kvittering direkte i appen

## Sikkerhet

- BankID-pålogging (samme sikkerhet som nettbanken din)
- Pengene forblir i din bank (Drop holder aldri på pengene dine)
- Kryptert kommunikasjon (samme standard som banker)
- To-faktor autentisering på alle transaksjoner

## Drop vs. konkurrentene

| Tjeneste | Gebyr | Hastighet |
|----------|-------|-----------|
| Drop | 0.5% | Minutter |
| Western Union | 5-10% | 1-3 dager |
| Wise | 0.7-1.2% | 1-2 dager |
| Tradisjonell bank | 50-200 kr + FX | 3-5 dager |

## Pass-through modell

Drop er IKKE en bank. Vi holder aldri på pengene dine. Alt går direkte fra din bankkonto til mottaker via Open Banking (PSD2). Dette gjør oss tryggere og billigere enn tradisjonelle tjenester.

## Hvem er Drop for?

- Nordmenn som sender penger hjem til familie i utlandet
- Expats som betaler regninger i hjemlandet
- Studenter som får penger fra foreldre i utlandet
- Alle som er lei av høye bankgebyrer

## Presse

"Drop kan bli det nordmenn trenger for billigere pengeoverføringer."
— [Placeholder for actual press quote]

## Kontakt oss

support@getdrop.no
https://getdrop.no

---

Drop er et produkt av ALAI Holding AS (org.nr 932 516 136).
Lanseres i Norge 2026.
```

#### E. Keywords (100 chars)
```
send penger,pengeoverføring,qr betaling,internasjonale,billig,vipps,remittance,money transfer,gebyr
```

**Rationale:** Mix of Norwegian (primary) and English (expat market)

#### F. Screenshots (6.7" iPhone)

**Order of Screenshots (psychological funnel):**

1. **Hero/Dashboard** — Shows total balance + "Send" and "QR Betal" buttons
   - Caption: "Din banksaldo. Alle transaksjoner. Én app."

2. **Send Money Flow** — Shows "Velg land" → "Skriv beløp" → "Se gebyr" → "Send"
   - Caption: "Send til 30+ land med 0.5% gebyr"

3. **Fee Comparison** — Visual chart: Drop (0.5%) vs Western Union (7%) vs Wise (1%)
   - Caption: "Spar opptil 90% på gebyrer"

4. **QR Payment** — Shows QR scanner screen + "Skann og betal"
   - Caption: "Betal i butikk med QR-kode"

5. **Transaction History** — Shows recent transactions with dates, amounts, statuses
   - Caption: "Full kontroll over alle transaksjoner"

6. **Security Screen** — Shows BankID logo + lock icon + "Pengene forblir i din bank"
   - Caption: "Trygt med BankID og PSD2"

**Design Notes:**
- Use actual Figma Make export screenshots (from mockups/figma-make-export/)
- Add captions in white text with green background
- Ensure all text is legible (16pt minimum)
- No placeholder content ("Lorem ipsum") — real data only

#### G. App Preview Video (30 sec)

**Storyboard:**
1. **0-5s:** Problem — "Sender du penger hjem?" → show high Western Union fees
2. **5-10s:** Solution — "Drop gir deg 0.5% gebyr" → show app interface
3. **10-15s:** Demo — Show send flow (select country → amount → confirm)
4. **15-20s:** QR feature — Show QR scan + payment confirmation
5. **20-25s:** Social proof — "2,340 nordmenn på ventelisten"
6. **25-30s:** CTA — "Last ned gratis i dag" → App Store badge

**Voiceover Script (Norwegian):**
```
Sender du penger hjem? Western Union tar opptil 10% gebyr.

Med Drop tar vi bare 0.5%. Pengene er fremme på minutter.

Velg land. Skriv beløp. Send. Ferdig.

Betal i butikk med QR. Ingen kort. Ingen kontanter.

Over 2,000 nordmenn bruker allerede Drop.

Last ned gratis i dag.
```

### 5.3 Google Play Store Listing

**Differences from App Store:**
- **Title:** 30 chars (same as iOS)
- **Short Description:** 80 chars
  ```
  Send penger til 30+ land med 0.5% gebyr. QR-betaling i butikk. Regulert i Norge.
  ```
- **Full Description:** 4000 chars (same as iOS)
- **Graphic Assets:**
  - Feature Graphic: 1024x500 (banner)
  - Icon: 512x512 (already have drop-logo.png)
  - Screenshots: 1080x1920 (Android ratio)

**Category:**
- Primary: Finance
- Secondary: Payments

**Content Rating:**
- Everyone (PSD2 compliance → 18+ enforced in-app, not store rating)

### 5.4 Localization Strategy

**Phase 1 (Launch):** Norwegian only
**Phase 2 (3 months post-launch):** Add English for expats
**Phase 3 (6 months):** Swedish, Danish (Nordic expansion)

**Translation Requirements:**
- App Store/Play Store listings
- In-app UI strings (already in Norwegian via Figma Make)
- Support docs (FAQ, Help Center)
- Email templates (already covered in email spec)

---

## 6. Referral System

### 6.1 Referral Mechanics

**Goal:** 65% of new users from referrals (Revolut benchmark)

**Incentive Structure (Dual-Sided):**

| Action | Referrer Reward | Referee Reward |
|--------|----------------|----------------|
| Sign up + verify BankID | 0 kr | 0 kr |
| First transfer (min 500 kr) | 50 kr credit | 50 kr credit |
| Second transfer (within 30 days) | +25 kr bonus | — |

**Credit Usage:**
- Credits apply to next transfer fee (reduces 0.5% fee)
- 50 kr credit = free transfer up to 10,000 kr (0.5% of 10k = 50 kr)
- Credits expire after 90 days (encourages usage)

**Cap:** Max 500 kr in credits per user per month (prevents farming)

**Example Flow:**
1. **Referrer (Maria)** shares link: `getdrop.no?ref=maria123`
2. **Referee (Sara)** clicks → signs up → sees banner: "Maria inviterte deg. Dere får begge 50 kr når du sender din første overføring."
3. **Sara** verifies BankID → completes first transfer (1,000 kr) → gets 50 kr credit
4. **Maria** gets notification: "Sara brukte din lenke. Du fikk 50 kr!" → Maria's next transfer is discounted

### 6.2 Database Schema

#### A. `referral_codes` Table

```sql
CREATE TABLE IF NOT EXISTS referral_codes (
  id TEXT PRIMARY KEY,                -- ref_abc123
  user_id TEXT NOT NULL UNIQUE,       -- Owner of code
  code TEXT UNIQUE NOT NULL,          -- Short code (6 chars, alphanumeric)
  referral_link TEXT NOT NULL,        -- https://getdrop.no?ref={code}
  clicks INTEGER DEFAULT 0,           -- Link clicks
  signups INTEGER DEFAULT 0,          -- Successful signups
  conversions INTEGER DEFAULT 0,      -- First transfer completions
  total_earned_credits REAL DEFAULT 0,-- Total credits earned (kr)
  created_at TEXT DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_referral_codes_user ON referral_codes(user_id);
CREATE INDEX idx_referral_codes_code ON referral_codes(code);
```

**Code Generation Logic:**
```typescript
// Generate 6-char alphanumeric code (no ambiguous chars: 0, O, I, l)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 6; i++) {
  code += chars[Math.floor(Math.random() * chars.length)];
}
// Example: "MK3P7Z"
```

#### B. `referral_tracking` Table

```sql
CREATE TABLE IF NOT EXISTS referral_tracking (
  id TEXT PRIMARY KEY,                -- reft_abc123
  referrer_id TEXT NOT NULL,          -- User who referred
  referee_id TEXT NOT NULL,           -- New user
  referral_code TEXT NOT NULL,        -- Code used
  signup_at TEXT NOT NULL,            -- When referee signed up
  first_transfer_at TEXT,             -- When referee completed first transfer
  referrer_reward REAL DEFAULT 0,     -- Credits given to referrer (kr)
  referee_reward REAL DEFAULT 0,      -- Credits given to referee (kr)
  status TEXT NOT NULL CHECK(status IN ('pending','completed','cancelled')),
  -- pending = signed up, not transferred yet
  -- completed = first transfer done, rewards issued
  -- cancelled = referee deleted account before transfer
  created_at TEXT DEFAULT (datetime('now')),
  FOREIGN KEY (referrer_id) REFERENCES users(id) ON DELETE CASCADE,
  FOREIGN KEY (referee_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_referral_tracking_referrer ON referral_tracking(referrer_id);
CREATE INDEX idx_referral_tracking_referee ON referral_tracking(referee_id);
CREATE INDEX idx_referral_tracking_status ON referral_tracking(status);
```

#### C. `referral_credits` Table

```sql
CREATE TABLE IF NOT EXISTS referral_credits (
  id TEXT PRIMARY KEY,                -- refc_abc123
  user_id TEXT NOT NULL,
  amount REAL NOT NULL,               -- Credit amount (kr)
  reason TEXT NOT NULL,               -- 'referral_signup', 'referral_bonus', 'promo'
  source_id TEXT,                     -- referral_tracking.id or promo code
  expires_at TEXT NOT NULL,           -- 90 days from creation
  used_at TEXT,                       -- NULL until applied to transfer
  used_for_tx_id TEXT,                -- Transaction ID where credit was applied
  created_at TEXT DEFAULT (datetime('now')),
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_referral_credits_user ON referral_credits(user_id);
CREATE INDEX idx_referral_credits_expires ON referral_credits(expires_at);
```

### 6.3 Referral Flow Logic

#### A. Code Generation (On First Login)

**Endpoint:** `POST /api/referrals/generate-code`

**Logic:**
```typescript
async function generateReferralCode(userId: string): Promise<string> {
  // Check if user already has a code
  const existing = await getOne(
    "SELECT code FROM referral_codes WHERE user_id = ?",
    [userId]
  );
  if (existing) return existing.code;

  // Generate unique 6-char code
  let code = '';
  let attempts = 0;
  while (attempts < 10) {
    code = generateCode(); // Random 6 chars
    const duplicate = await getOne(
      "SELECT id FROM referral_codes WHERE code = ?",
      [code]
    );
    if (!duplicate) break;
    attempts++;
  }

  if (attempts === 10) throw new Error("Failed to generate unique code");

  // Insert code
  const link = `https://getdrop.no?ref=${code}`;
  await run(
    `INSERT INTO referral_codes (id, user_id, code, referral_link)
     VALUES (?, ?, ?, ?)`,
    [randomId('ref'), userId, code, link]
  );

  return code;
}
```

#### B. Referral Click Tracking

**Endpoint:** `GET /?ref={code}`

**Logic:**
```typescript
// Landing page detects ref param
const urlParams = new URLSearchParams(window.location.search);
const refCode = urlParams.get('ref');

if (refCode) {
  // Store in localStorage (persist across session)
  localStorage.setItem('drop_referral_code', refCode);

  // Track click (fire-and-forget)
  fetch('/api/referrals/track-click', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code: refCode })
  });
}
```

**Backend (track-click):**
```typescript
// Increment clicks counter
await run(
  "UPDATE referral_codes SET clicks = clicks + 1 WHERE code = ?",
  [code]
);
```

#### C. Signup Attribution

**Endpoint:** `POST /api/auth/register` (modify existing)

**Add to registration logic:**
```typescript
// After user created
const refCode = request.body.referralCode; // From localStorage → sent in signup payload

if (refCode) {
  const referrer = await getOne(
    "SELECT user_id FROM referral_codes WHERE code = ?",
    [refCode]
  );

  if (referrer) {
    // Create tracking entry
    await run(
      `INSERT INTO referral_tracking (id, referrer_id, referee_id, referral_code, signup_at, status)
       VALUES (?, ?, ?, ?, datetime('now'), 'pending')`,
      [randomId('reft'), referrer.user_id, newUserId, refCode]
    );

    // Increment signups counter
    await run(
      "UPDATE referral_codes SET signups = signups + 1 WHERE code = ?",
      [refCode]
    );

    // Show banner: "Du ble invitert av [FirstName]. Dere får begge 50 kr når du sender din første overføring!"
  }
}
```

#### D. First Transfer Reward Issuance

**Endpoint:** `POST /api/transactions/remittance` (modify existing)

**Add after transaction status = 'completed':**
```typescript
// Check if this is user's first transfer
const firstTransfer = await getOne(
  `SELECT COUNT(*) as count FROM transactions
   WHERE user_id = ? AND status = 'completed' AND type = 'remittance'`,
  [userId]
);

if (firstTransfer.count === 1) { // This is the first
  // Check if user was referred
  const referral = await getOne(
    `SELECT id, referrer_id, referral_code FROM referral_tracking
     WHERE referee_id = ? AND status = 'pending'`,
    [userId]
  );

  if (referral) {
    const referrerReward = 50; // kr
    const refereeReward = 50; // kr
    const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); // 90 days

    // Issue credit to referrer
    await run(
      `INSERT INTO referral_credits (id, user_id, amount, reason, source_id, expires_at)
       VALUES (?, ?, ?, 'referral_signup', ?, ?)`,
      [randomId('refc'), referral.referrer_id, referrerReward, referral.id, expiresAt]
    );

    // Issue credit to referee
    await run(
      `INSERT INTO referral_credits (id, user_id, amount, reason, source_id, expires_at)
       VALUES (?, ?, ?, 'referral_signup', ?, ?)`,
      [randomId('refc'), userId, refereeReward, referral.id, expiresAt]
    );

    // Update tracking
    await run(
      `UPDATE referral_tracking
       SET status = 'completed', first_transfer_at = datetime('now'),
           referrer_reward = ?, referee_reward = ?
       WHERE id = ?`,
      [referrerReward, refereeReward, referral.id]
    );

    // Update referral code stats
    await run(
      `UPDATE referral_codes
       SET conversions = conversions + 1, total_earned_credits = total_earned_credits + ?
       WHERE code = ?`,
      [referrerReward, referral.referral_code]
    );

    // Send notifications
    await sendNotification(referral.referrer_id, {
      type: 'referral_reward',
      title: 'Du fikk 50 kr! 🎉',
      body: '[FirstName] brukte din lenke og sendte sin første overføring.',
    });

    await sendNotification(userId, {
      type: 'referral_reward',
      title: 'Du fikk 50 kr! 🎉',
      body: 'Takk for at du brukte Drop. Din neste overføring er billigere.',
    });
  }
}
```

#### E. Credit Application (During Transfer)

**Logic:**
```typescript
// In transfer fee calculation
const baseFee = amount * 0.005; // 0.5%

// Check available credits
const credits = await getAll(
  `SELECT id, amount FROM referral_credits
   WHERE user_id = ? AND used_at IS NULL AND expires_at > datetime('now')
   ORDER BY expires_at ASC`,
  [userId]
);

let totalCredits = credits.reduce((sum, c) => sum + c.amount, 0);
let discount = Math.min(totalCredits, baseFee); // Can't discount more than fee

const finalFee = baseFee - discount;

// Mark credits as used (FIFO)
let remaining = discount;
for (const credit of credits) {
  if (remaining <= 0) break;
  const used = Math.min(credit.amount, remaining);
  await run(
    `UPDATE referral_credits
     SET used_at = datetime('now'), used_for_tx_id = ?
     WHERE id = ?`,
    [txId, credit.id]
  );
  remaining -= used;
}

// Show in UI: "Gebyr: 50 kr (100 kr - 50 kr kreditt)"
```

### 6.4 Fraud Prevention

**Rule 1: No Self-Referrals**
```typescript
// Check if referee IP/device matches referrer
const referrerFingerprint = await getOne(
  "SELECT device_fingerprint, ip_address FROM sessions WHERE user_id = ?",
  [referrerId]
);

const refereeFingerprint = hashDeviceFingerprint(ip, userAgent);

if (referrerFingerprint.device_fingerprint === refereeFingerprint) {
  // Block referral, log fraud attempt
  await logAudit({
    userId: referrerId,
    action: 'referral.fraud_attempt',
    details: { reason: 'self_referral', referee_id: refereeId }
  });
  return; // No reward
}
```

**Rule 2: Minimum Transfer Amount**
```typescript
// First transfer must be >= 500 kr to qualify
if (amount < 500) {
  // Don't issue rewards
  return;
}
```

**Rule 3: Monthly Credit Cap**
```typescript
// Max 500 kr credits earned per user per month
const monthlyEarned = await getOne(
  `SELECT SUM(amount) as total FROM referral_credits
   WHERE user_id = ? AND reason = 'referral_signup'
     AND created_at > date('now', 'start of month')`,
  [userId]
);

if (monthlyEarned.total >= 500) {
  // Block additional rewards this month
  return;
}
```

**Rule 4: Velocity Check**
```typescript
// Max 10 referrals per user per day
const dailyReferrals = await getOne(
  `SELECT COUNT(*) as count FROM referral_tracking
   WHERE referrer_id = ? AND signup_at > datetime('now', '-1 day')`,
  [userId]
);

if (dailyReferrals.count >= 10) {
  // Flag account for review
  await run(
    "UPDATE users SET referral_abuse_flag = 1 WHERE id = ?",
    [userId]
  );
}
```

**Rule 5: BankID Verification Required**
```typescript
// Only verified users can earn rewards
if (!referee.bankid_verified) {
  // Rewards are pending until BankID verification
  // Update tracking: status = 'pending_verification'
}
```

### 6.5 Referral Dashboard (In-App)

**UI Location:** `/profile/referrals`

**Components:**

#### A. Referral Link Card
```tsx
<div className="referral-card">
  <h3>Inviter venner — få 50 kr per venn</h3>
  <p>Dere får begge 50 kr når de sender sin første overføring.</p>

  <div className="referral-link">
    <input
      value="https://getdrop.no?ref=MK3P7Z"
      readOnly
      onClick={(e) => e.target.select()}
    />
    <button onClick={copyToClipboard}>Kopier</button>
  </div>

  <div className="share-buttons">
    <button onClick={shareViaWhatsApp}>
      <WhatsAppIcon /> WhatsApp
    </button>
    <button onClick={shareViaSMS}>
      <MessageIcon /> SMS
    </button>
    <button onClick={shareViaEmail}>
      <EmailIcon /> E-post
    </button>
  </div>
</div>
```

#### B. Stats Card
```tsx
<div className="referral-stats">
  <div className="stat">
    <div className="stat-value">24</div>
    <div className="stat-label">Venner invitert</div>
  </div>
  <div className="stat">
    <div className="stat-value">12</div>
    <div className="stat-label">Har sendt penger</div>
  </div>
  <div className="stat">
    <div className="stat-value">600 kr</div>
    <div className="stat-label">Totalt tjent</div>
  </div>
</div>
```

#### C. Credits Balance
```tsx
<div className="credits-balance">
  <div className="balance-amount">150 kr</div>
  <div className="balance-label">Tilgjengelig kreditt</div>
  <p className="balance-expires">50 kr utløper om 12 dager</p>
</div>
```

#### D. Referral History
```tsx
<div className="referral-history">
  <h4>Dine invitasjoner</h4>
  {referrals.map(ref => (
    <div className="referral-row" key={ref.id}>
      <div>
        <div className="referral-name">{ref.refereeName}</div>
        <div className="referral-date">{ref.signupDate}</div>
      </div>
      <div className="referral-status">
        {ref.status === 'pending' && '⏳ Venter på overføring'}
        {ref.status === 'completed' && '✅ +50 kr'}
      </div>
    </div>
  ))}
</div>
```

### 6.6 Share Templates

#### WhatsApp Message
```
Hei! 👋

Jeg bruker Drop for å sende penger hjem. De tar bare 0.5% gebyr (Western Union tar 10%!).

Vi får begge 50 kr hvis du registrerer deg og sender din første overføring:
https://getdrop.no?ref=MK3P7Z

Helt gratis å prøve. Tar 2 minutter å sette opp med BankID.

Mvh,
[FirstName]
```

#### Email Template
```
Subject: Spar 90% på gebyrer når du sender penger

Hei!

Jeg bruker Drop for internasjonale pengeoverføringer. De tar bare 0.5% gebyr — ikke 5-10% som Western Union og banker.

Vi får begge 50 kr hvis du registrerer deg via min lenke og sender din første overføring:
👉 https://getdrop.no?ref=MK3P7Z

Drop er regulert i Norge og bruker BankID for sikkerhet. Pengene er fremme på minutter, ikke dager.

Helt gratis å prøve. Ingen binding.

Mvh,
[FirstName]
```

#### SMS Template
```
Hei! Prøv Drop for pengeoverføringer (0.5% gebyr). Vi får begge 50 kr: getdrop.no?ref=MK3P7Z
```

---

## 7. Campaign Tracking & Attribution

### 7.1 UTM Parameter Strategy

**Standard UTM Structure:**
```
https://getdrop.no?utm_source={source}&utm_medium={medium}&utm_campaign={campaign}&utm_content={content}&utm_term={term}
```

**Campaign Matrix:**

| Campaign Type | Source | Medium | Example |
|--------------|--------|--------|---------|
| Facebook Ads | facebook | cpc | utm_source=facebook&utm_medium=cpc&utm_campaign=launch_2026 |
| Instagram Ads | instagram | cpc | utm_source=instagram&utm_medium=cpc&utm_campaign=qr_promo |
| Google Ads | google | cpc | utm_source=google&utm_medium=cpc&utm_campaign=send_penger |
| LinkedIn Organic | linkedin | social | utm_source=linkedin&utm_medium=social&utm_campaign=alem_post |
| Email Newsletter | email | newsletter | utm_source=email&utm_medium=newsletter&utm_campaign=waitlist_launch |
| Partner Link (SB1) | sparebank1 | partner | utm_source=sparebank1&utm_medium=partner&utm_campaign=coop |
| Press Article | shifter | pr | utm_source=shifter&utm_medium=pr&utm_campaign=launch_coverage |
| Referral Program | referral | organic | (handled separately via ?ref= param) |

**Naming Conventions:**
- `utm_source`: Platform name (lowercase, no spaces)
- `utm_medium`: Traffic type (cpc, social, email, partner, pr, organic, display)
- `utm_campaign`: Campaign identifier (snake_case, descriptive)
- `utm_content`: Ad variation (ad_1, ad_2, hero_cta, footer_link)
- `utm_term`: Keyword (Google Ads only)

### 7.2 UTM Tracking Implementation

#### A. Client-Side Capture (Landing Page)

**Add to `index.html` (before waitlist form):**
```javascript
// Capture all UTM params + referral code
const urlParams = new URLSearchParams(window.location.search);
const utm = {
  source: urlParams.get('utm_source') || 'direct',
  medium: urlParams.get('utm_medium') || 'none',
  campaign: urlParams.get('utm_campaign') || '',
  content: urlParams.get('utm_content') || '',
  term: urlParams.get('utm_term') || '',
  referral: urlParams.get('ref') || ''
};

// Store in localStorage (persist across pages)
localStorage.setItem('drop_attribution', JSON.stringify(utm));

// Track landing (fire-and-forget)
fetch('/api/analytics/track', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    event: 'page_view',
    page: window.location.pathname,
    utm: utm,
    timestamp: new Date().toISOString()
  })
});
```

#### B. Waitlist Signup Attribution

**Modify `/api/waitlist` to capture UTM:**
```typescript
const utm = request.body.utm; // Sent from frontend

await run(
  `INSERT INTO waitlist (id, email, utm_source, utm_medium, utm_campaign, utm_content, utm_term, referral_code, created_at)
   VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`,
  [
    randomId('wait'),
    email,
    utm.source,
    utm.medium,
    utm.campaign,
    utm.content,
    utm.term,
    utm.referral
  ]
);
```

**Database Schema Update:**
```sql
ALTER TABLE waitlist ADD COLUMN utm_source TEXT;
ALTER TABLE waitlist ADD COLUMN utm_medium TEXT;
ALTER TABLE waitlist ADD COLUMN utm_campaign TEXT;
ALTER TABLE waitlist ADD COLUMN utm_content TEXT;
ALTER TABLE waitlist ADD COLUMN utm_term TEXT;
ALTER TABLE waitlist ADD COLUMN referral_code TEXT;
```

#### C. User Registration Attribution

**Modify `/api/auth/register` to capture UTM:**
```typescript
const utm = request.body.utm; // Retrieved from localStorage → sent in signup payload

await run(
  `UPDATE users SET
    utm_source = ?, utm_medium = ?, utm_campaign = ?, utm_content = ?, utm_term = ?,
    referral_code = ?
   WHERE id = ?`,
  [utm.source, utm.medium, utm.campaign, utm.content, utm.term, utm.referral, userId]
);
```

**Database Schema Update:**
```sql
ALTER TABLE users ADD COLUMN utm_source TEXT;
ALTER TABLE users ADD COLUMN utm_medium TEXT;
ALTER TABLE users ADD COLUMN utm_campaign TEXT;
ALTER TABLE users ADD COLUMN utm_content TEXT;
ALTER TABLE users ADD COLUMN utm_term TEXT;
```

### 7.3 Attribution Reporting

**Query: Waitlist Signups by Source**
```sql
SELECT
  utm_source,
  utm_medium,
  utm_campaign,
  COUNT(*) as signups,
  COUNT(*) * 100.0 / (SELECT COUNT(*) FROM waitlist) as percentage
FROM waitlist
WHERE created_at > date('now', '-30 days')
GROUP BY utm_source, utm_medium, utm_campaign
ORDER BY signups DESC;
```

**Query: User Registrations by Source**
```sql
SELECT
  utm_source,
  utm_medium,
  COUNT(*) as registrations,
  SUM(CASE WHEN kyc_status = 'approved' THEN 1 ELSE 0 END) as verified_users,
  ROUND(100.0 * SUM(CASE WHEN kyc_status = 'approved' THEN 1 ELSE 0 END) / COUNT(*), 2) as verification_rate
FROM users
WHERE created_at > date('now', '-30 days')
GROUP BY utm_source, utm_medium
ORDER BY registrations DESC;
```

**Query: Campaign ROI (Revenue per Source)**
```sql
SELECT
  u.utm_source,
  u.utm_campaign,
  COUNT(DISTINCT u.id) as users,
  COUNT(DISTINCT t.id) as transactions,
  SUM(t.amount * 0.005) as revenue_generated, -- 0.5% fee
  revenue_generated / COUNT(DISTINCT u.id) as revenue_per_user
FROM users u
LEFT JOIN transactions t ON t.user_id = u.id
WHERE u.created_at > date('now', '-30 days')
GROUP BY u.utm_source, u.utm_campaign
ORDER BY revenue_generated DESC;
```

### 7.4 Conversion Pixels

#### Facebook Pixel

**Add to `<head>` of landing page:**
```html
<!-- Facebook Pixel Code -->
<script>
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', 'YOUR_PIXEL_ID');
fbq('track', 'PageView');
</script>
<noscript>
<img height="1" width="1" style="display:none"
     src="https://www.facebook.com/tr?id=YOUR_PIXEL_ID&ev=PageView&noscript=1"/>
</noscript>
```

**Track Waitlist Signup:**
```javascript
// In waitlist form submit handler
if (response.ok) {
  fbq('track', 'Lead'); // Facebook standard event
}
```

**Track Registration:**
```javascript
// In /api/auth/register success callback
fbq('track', 'CompleteRegistration'); // Facebook standard event
```

#### Google Ads Conversion Tracking

**Add to `<head>`:**
```html
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=AW-XXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());
  gtag('config', 'AW-XXXXX');
</script>
```

**Track Conversion:**
```javascript
// In waitlist form submit handler
if (response.ok) {
  gtag('event', 'conversion', {
    'send_to': 'AW-XXXXX/CONVERSION_LABEL',
    'value': 1.0,
    'currency': 'NOK'
  });
}
```

---

## 8. Email Marketing Integration

### 8.1 Campaign Types

**Note:** Email service layer already specified in `drop-email-system-spec.md`. This section defines marketing-specific campaigns.

#### A. Waitlist Nurture Series (Pre-Launch)

**Trigger:** User signs up for waitlist

**Email 1 (Immediate):** Confirmation
- Subject: "Du er på listen! 🎉"
- Content: Confirm signup, set expectations (launch date), share referral link
- CTA: "Inviter venner — hopp køen"

**Email 2 (Day 7):** Educational
- Subject: "Derfor tar banker så høye gebyrer"
- Content: Explain remittance market, Western Union fees, Drop's pass-through model
- CTA: "Les mer om Drop"

**Email 3 (Day 14):** Social Proof
- Subject: "2,000+ nordmenn venter allerede"
- Content: Waitlist count, testimonials (when available), SpareBank 1 partnership mention
- CTA: "Del med venner"

**Email 4 (Day 21):** Product Demo
- Subject: "Se hvordan Drop fungerer (90 sekunder)"
- Content: Embed demo video, screenshot walkthrough
- CTA: "Se videoen"

**Email 5 (Pre-Launch -7 days):** Launch Countdown
- Subject: "Drop lanseres om 7 dager!"
- Content: Countdown timer, "first 1000 get 5 free transfers" reminder
- CTA: "Sett påminnelse"

**Email 6 (Launch Day):** App Available
- Subject: "Drop er her! Last ned nå."
- Content: App Store/Play Store links, onboarding guide
- CTA: "Last ned gratis"

#### B. Onboarding Series (Post-Registration)

**Email 1 (After Registration):** Welcome
- Subject: "Velkommen til Drop!"
- Content: What to expect, link BankID reminder, first transfer incentive
- CTA: "Fullfør profilen din"

**Email 2 (Day 3, if no BankID):** Nudge
- Subject: "Koble BankID — ta 2 minutter"
- Content: Why BankID is required, security explanation
- CTA: "Koble BankID"

**Email 3 (Day 7, if no first transfer):** First Transfer Incentive
- Subject: "Send din første overføring — få 50 kr"
- Content: Referral credit reminder, fee comparison
- CTA: "Send penger nå"

#### C. Re-engagement Series (Dormant Users)

**Trigger:** User hasn't sent a transfer in 30 days

**Email 1 (Day 30):** Gentle Reminder
- Subject: "Vi savner deg!"
- Content: "It's been a while..." + new features announcement
- CTA: "Send penger igjen"

**Email 2 (Day 60):** Win-back Offer
- Subject: "25 kr gratis på din neste overføring"
- Content: Exclusive promo code (one-time use)
- CTA: "Bruk koden"

**Email 3 (Day 90):** Feedback Request
- Subject: "Hvorfor sluttet du å bruke Drop?"
- Content: Survey link (3 questions max)
- CTA: "Del tilbakemelding"

### 8.2 Marketing Consent Management

**Consent Collection (During Registration):**
```html
<label>
  <input type="checkbox" name="marketingConsent" value="1">
  Jeg ønsker å motta tips, tilbud og nyheter fra Drop (valgfritt)
</label>
<p class="consent-note">
  Du kan avslutte når som helst. Se vår <a href="/personvern">personvernerklæring</a>.
</p>
```

**Database Schema (already in onboarding spec):**
```sql
-- consents table already defined
INSERT INTO consents (id, user_id, consent_type, granted, granted_at, ip_address)
VALUES (?, ?, 'marketing', ?, datetime('now'), ?);
```

**Email Unsubscribe Link (Footer):**
```html
<p style="font-size: 12px; color: #6B7280; text-align: center;">
  Vil du ikke lenger motta e-poster fra Drop?
  <a href="https://getdrop.no/unsubscribe?token={{unsubscribeToken}}" style="color: #0B6E35;">
    Avslutt abonnement
  </a>
</p>
```

**Unsubscribe Endpoint:**
```typescript
// GET /unsubscribe?token={jwt}
const { userId } = verifyUnsubscribeToken(token);

await run(
  `UPDATE consents SET granted = 0, withdrawn_at = datetime('now')
   WHERE user_id = ? AND consent_type = 'marketing'`,
  [userId]
);

// Show confirmation page: "Du er fjernet fra listen."
```

### 8.3 Email Automation Triggers

**Trigger Table:**

| Event | Delay | Campaign | Condition |
|-------|-------|----------|-----------|
| Waitlist signup | 0s | Waitlist Email 1 | — |
| Waitlist signup | 7d | Waitlist Email 2 | If not registered |
| Waitlist signup | 14d | Waitlist Email 3 | If not registered |
| User registered | 0s | Onboarding Email 1 | — |
| User registered | 3d | Onboarding Email 2 | If no BankID |
| User registered | 7d | Onboarding Email 3 | If no transfer |
| First transfer | 0s | First Transfer Receipt | — |
| Last transfer | 30d | Re-engagement Email 1 | If no transfer in 30d |
| Last transfer | 60d | Re-engagement Email 2 | If no transfer in 60d |

**Implementation:**
- Use cron job or scheduled task (runs every hour)
- Query database for users matching trigger conditions
- Call `sendEmail()` from email service layer
- Mark email as sent in `email_log` table

---

## 9. Analytics & Reporting

### 9.1 Analytics Platform Selection

**Options:**

| Platform | Pros | Cons | Cost |
|----------|------|------|------|
| **Plausible** (RECOMMENDED) | GDPR-compliant, no cookies, simple, Norwegian data center | Fewer features than GA4 | €9/mo (10k pageviews) |
| Google Analytics 4 | Free, comprehensive, industry standard | Cookie consent required, complex | Free |
| Mixpanel | Event tracking, funnel analysis, cohorts | Expensive at scale | $20/mo (1k users) |
| PostHog | Self-hosted option, session replay | Complex setup | $0 (self-hosted) |

**Recommendation:** Start with **Plausible** for landing page (no cookie consent banner needed), add Mixpanel for in-app analytics post-launch.

### 9.2 Plausible Setup (Landing Page)

**Add to `<head>`:**
```html
<script defer data-domain="getdrop.no" src="https://plausible.io/js/script.js"></script>
```

**Track Custom Events:**
```javascript
// Waitlist signup
plausible('Waitlist Signup', { props: { source: utm.source } });

// Referral link click
plausible('Referral Link Click', { props: { code: refCode } });

// CTA button click
plausible('CTA Click', { props: { location: 'hero' } });
```

**Plausible Goals (Configure in Dashboard):**
- Waitlist Signup (pageview: /api/waitlist success)
- App Store Click (custom event)
- Play Store Click (custom event)
- Referral Link Click (custom event)
- Video Play (custom event)

### 9.3 In-App Analytics (Mixpanel)

**Events to Track:**

#### Acquisition
- `user_registered` (properties: utm_source, utm_medium, referral_code)
- `bankid_verified` (properties: time_to_verify_seconds)
- `kyc_approved` (properties: time_to_approve_seconds)

#### Activation
- `first_transfer_completed` (properties: amount, currency, destination_country)
- `qr_payment_completed` (properties: amount, merchant_name)
- `bank_account_linked` (properties: bank_name)

#### Engagement
- `app_opened` (properties: session_count)
- `screen_viewed` (properties: screen_name)
- `transfer_initiated` (properties: amount, currency)
- `transfer_cancelled` (properties: step, reason)

#### Retention
- `transfer_completed` (properties: amount, transfer_count, days_since_last_transfer)
- `push_notification_received` (properties: type)
- `push_notification_clicked` (properties: type)

#### Referral
- `referral_link_shared` (properties: channel: whatsapp|sms|email)
- `referral_link_clicked` (properties: referrer_id)
- `referral_reward_earned` (properties: amount, referee_id)

#### Revenue
- `fee_charged` (properties: amount_nok, transaction_type)
- `credit_applied` (properties: amount, source)

**Mixpanel People Properties:**
```typescript
mixpanel.people.set({
  "$email": user.email,
  "$name": `${user.firstName} ${user.lastName}`,
  "signup_date": user.createdAt,
  "utm_source": user.utmSource,
  "referral_code": user.referralCode,
  "total_transfers": user.transferCount,
  "total_revenue": user.totalFeesPaid,
  "kyc_status": user.kycStatus,
  "bankid_verified": user.bankidVerified
});
```

### 9.4 Key Metrics Dashboard

**Acquisition Metrics:**
- Landing page visitors (unique)
- Waitlist signups
- Waitlist → Registration conversion rate
- Registration → BankID verification rate
- BankID → KYC approval rate
- Overall conversion (Visitor → Verified User)

**Activation Metrics:**
- Time to first transfer (median)
- First transfer completion rate (within 7 days of registration)
- Average first transfer amount
- QR payment adoption rate (% of users who scan at least 1 QR)

**Engagement Metrics:**
- DAU (Daily Active Users)
- WAU (Weekly Active Users)
- MAU (Monthly Active Users)
- Transfers per user per month
- Average transaction value

**Retention Metrics:**
- Day 1, 7, 30, 90 retention
- Cohort analysis (users by signup month)
- Churn rate (users with no transfer in 90 days)

**Referral Metrics:**
- Referral rate (% of users who share link)
- Referral conversion rate (signups per shared link)
- Viral coefficient (new users per existing user)
- Referral ROI (LTV of referred users vs. non-referred)

**Revenue Metrics:**
- Total fee revenue (kr)
- Revenue per user (kr)
- Revenue per transfer (kr)
- Credit redemption rate (% of credits used)

**Campaign Performance:**
- Cost per waitlist signup (CPA)
- Cost per registration (CPA)
- Cost per verified user (CPA)
- ROAS (Return on Ad Spend) by channel

---

## 10. A/B Testing Framework

### 10.1 Testing Strategy

**Goal:** Optimize conversion at every funnel step

**Test Prioritization (ICE Framework):**
| Test | Impact | Confidence | Ease | ICE Score |
|------|--------|------------|------|-----------|
| Hero CTA copy ("Bli med" vs "Registrer deg") | 8 | 9 | 10 | 27 |
| Waitlist incentive ("5 free transfers" vs "50 kr credit") | 9 | 7 | 10 | 26 |
| Feature order (Remittance first vs QR first) | 6 | 8 | 9 | 23 |
| Phone mockup vs real screenshot | 7 | 6 | 8 | 21 |
| Social proof placement (hero vs below features) | 5 | 7 | 9 | 21 |

**Test Velocity:** 1 test every 2 weeks (minimum 2 weeks per test for statistical significance)

### 10.2 A/B Testing Tools

**Options:**

| Tool | Pros | Cons | Cost |
|------|------|------|------|
| **Vercel Edge Middleware** (RECOMMENDED) | Fast, server-side, free | Manual implementation | Free |
| Google Optimize (Deprecated) | — | Sunset 2023 | — |
| Optimizely | Visual editor, advanced targeting | Expensive | $50k+/year |
| VWO | Visual editor, heatmaps | Complex setup | $199/mo |
| PostHog | Open source, feature flags | Self-hosted complexity | Free (self-hosted) |

**Recommendation:** Use **Vercel Edge Middleware** for simple A/B tests (free, fast, server-side). Upgrade to PostHog if feature flags needed.

### 10.3 Implementation (Vercel Edge Middleware)

**File:** `middleware.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Only run A/B test on homepage
  if (pathname !== '/') return NextResponse.next();

  // Check if user already has variant assigned
  let variant = request.cookies.get('ab_hero_cta')?.value;

  if (!variant) {
    // Assign variant (50/50 split)
    variant = Math.random() < 0.5 ? 'A' : 'B';
    const response = NextResponse.next();
    response.cookies.set('ab_hero_cta', variant, {
      maxAge: 30 * 24 * 60 * 60, // 30 days
      httpOnly: true,
      sameSite: 'lax'
    });
    return response;
  }

  return NextResponse.next();
}
```

**Frontend (index.html):**
```javascript
// Read variant from cookie
const variant = document.cookie
  .split('; ')
  .find(row => row.startsWith('ab_hero_cta='))
  ?.split('=')[1] || 'A';

// Update CTA based on variant
const ctaButton = document.querySelector('.btn-gradient');
if (variant === 'B') {
  ctaButton.textContent = 'Registrer deg gratis'; // Variant B
} else {
  ctaButton.textContent = 'Bli med på ventelisten'; // Variant A (control)
}

// Track variant in analytics
plausible('pageview', { props: { ab_hero_cta: variant } });
```

**Track Conversion:**
```javascript
// In waitlist form submit handler
plausible('Waitlist Signup', {
  props: {
    ab_hero_cta: variant,
    utm_source: utm.source
  }
});
```

**Analysis:**
```sql
-- Conversion rate by variant
SELECT
  ab_variant,
  COUNT(*) as views,
  SUM(CASE WHEN converted = 1 THEN 1 ELSE 0 END) as conversions,
  ROUND(100.0 * SUM(CASE WHEN converted = 1 THEN 1 ELSE 0 END) / COUNT(*), 2) as conversion_rate
FROM analytics_events
WHERE event_type = 'pageview'
  AND created_at > date('now', '-14 days')
GROUP BY ab_variant;
```

**Statistical Significance Calculator:**
- Use https://abtestguide.com/calc/ or similar
- Minimum sample size: 385 per variant (95% confidence, 5% margin of error)
- Run test until both variants reach minimum sample size

---

## 11. Content Marketing (Blog)

### 11.1 Blog Infrastructure

**Goal:** SEO traffic + thought leadership

**Platform Options:**

| Option | Pros | Cons |
|--------|------|------|
| **Next.js MDX** (RECOMMENDED) | Same codebase, fast, full control | Manual implementation |
| Substack | Easy setup, newsletter integration | External platform, limited branding |
| Medium | Built-in audience | No domain control, paywall |
| WordPress | SEO plugins, familiar | Separate hosting, slower |

**Recommendation:** Next.js MDX (markdown files → static pages)

### 11.2 Blog Structure

**Directory:**
```
/landing/blog/
  ├── index.html          # Blog homepage (list of posts)
  ├── slik-sender-du-penger-til-tyrkia.html
  ├── drop-vs-western-union.html
  ├── hvorfor-banker-tar-hoye-gebyrer.html
  └── qr-betaling-forklart.html
```

**Blog Post Template:**
```html
<!DOCTYPE html>
<html lang="no">
<head>
  <meta charset="UTF-8">
  <title>[Post Title] — Drop Blog</title>
  <meta name="description" content="[Excerpt]">
  <meta property="og:type" content="article">
  <meta property="article:published_time" content="2026-03-15T10:00:00Z">
  <meta property="article:author" content="Drop Team">
  <link rel="canonical" href="https://getdrop.no/blog/[slug]">
  <script type="application/ld+json">
  {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "headline": "[Post Title]",
    "datePublished": "2026-03-15",
    "author": {"@type": "Organization", "name": "Drop"},
    "publisher": {"@type": "Organization", "name": "Drop", "logo": "..."},
    "image": "https://getdrop.no/blog/images/[cover].jpg"
  }
  </script>
</head>
<body>
  <article class="blog-post">
    <h1>[Post Title]</h1>
    <div class="meta">Publisert 15. mars 2026 · 5 min lesing</div>
    <img src="images/[cover].jpg" alt="[Alt text]">
    <div class="content">
      [Post content in HTML]
    </div>
    <div class="cta-box">
      <h3>Prøv Drop gratis</h3>
      <p>Send penger til 30+ land med bare 0.5% gebyr.</p>
      <a href="/#cta" class="btn-gradient">Registrer deg</a>
    </div>
  </article>
</body>
</html>
```

### 11.3 Content Calendar (First 90 Days)

**Goal:** 2 posts per week (8 posts/month)

**Week 1-2 (SEO Foundation):**
1. "Slik sender du penger til Tyrkia fra Norge" (target: "send penger til tyrkia")
2. "Drop vs Western Union: Detaljert sammenligning" (target: "western union alternativ")

**Week 3-4 (Educational):**
3. "Hvorfor tar banker så høye gebyrer på pengeoverføringer?" (thought leadership)
4. "QR-betaling forklart: Slik fungerer det" (target: "qr betaling")

**Week 5-6 (Comparison):**
5. "Drop vs Wise: Hva er forskjellen?" (target: "wise alternativ")
6. "De beste måtene å sende penger til utlandet i 2026" (target: "billigste pengeoverføring")

**Week 7-8 (Use Cases):**
7. "Slik sender du penger hjem til familie i utlandet" (emotional, storytelling)
8. "QR-betaling for småbedrifter: Guiden" (merchant-focused)

**Content Distribution:**
- Publish on blog
- Share on LinkedIn (Alem's profile)
- Send to waitlist (weekly digest)
- Submit to relevant subreddits (r/norway, r/norge — carefully, no spam)

---

## 12. Social Media Strategy

### 12.1 Platform Prioritization

| Platform | Priority | Audience | Content Type | Frequency |
|----------|----------|----------|--------------|-----------|
| **LinkedIn** | HIGH | Business audience, partnerships, press | Company updates, thought leadership | 3x/week |
| **Instagram** | MEDIUM | Consumers, visual storytelling | Features, user stories, behind-the-scenes | 5x/week |
| **Facebook** | MEDIUM | Broader consumer base, ads | Features, blog posts, ads | 3x/week |
| **Twitter/X** | LOW | Tech audience, customer support | Quick updates, support responses | As needed |
| **TikTok** | LOW (Future) | Young audience | Short-form video | 0x/week (post-launch) |

### 12.2 Account Setup

**Handles (Claim ASAP):**
- Instagram: @dropnorge
- Facebook: facebook.com/dropnorge
- LinkedIn: linkedin.com/company/drop-norge
- Twitter: @dropnorge (or @drop_norge if taken)

**Bio Template (Instagram):**
```
Drop — Enklere betalinger. Lavere gebyrer.

💸 0.5% gebyr på internasjonale overføringer
📱 QR-betaling i butikk
🇳🇴 Regulert i Norge

⬇️ Registrer deg for tidlig tilgang
```

**Link in Bio:**
- getdrop.no?utm_source=instagram&utm_medium=social&utm_campaign=bio_link

### 12.3 Content Pillars

**Pillar 1: Product Education (40%)**
- How Drop works (carousel posts)
- Fee comparison infographics
- Feature announcements
- Tutorial videos

**Pillar 2: Customer Stories (30%)**
- User testimonials (when available)
- "How I saved X kr with Drop" posts
- Behind-the-scenes (team, product development)

**Pillar 3: Industry Insights (20%)**
- Fintech news commentary
- Regulatory updates (Finanstilsynet)
- Market trends (remittance, payments)

**Pillar 4: Culture & Values (10%)**
- Team introductions
- Company values
- Community engagement

### 12.4 Content Calendar (First Month)

**Week 1:**
- Mon: Introduce Drop (carousel: What is Drop?)
- Wed: Fee comparison infographic (Drop vs banks)
- Fri: Behind-the-scenes (team building the app)

**Week 2:**
- Mon: Feature spotlight (QR payment)
- Wed: Blog post share (Why banks charge high fees)
- Fri: Waitlist milestone ("1,000 people signed up!")

**Week 3:**
- Mon: FAQ carousel (Is Drop a bank? No.)
- Wed: Partnership announcement (SpareBank 1, if approved)
- Fri: User story (when available, or placeholder)

**Week 4:**
- Mon: Tutorial video (How to send money with Drop)
- Wed: Industry news commentary (PSD2 update)
- Fri: Countdown to launch (if applicable)

### 12.5 Paid Social Ads (Post-Launch)

**Budget:** 10,000 NOK/month (testing phase)

**Campaign 1: Waitlist Signups (Pre-Launch)**
- Platform: Facebook + Instagram
- Objective: Lead generation
- Audience: Norway, age 25-45, interests: fintech, travel, expat groups
- Creative: Carousel (3 slides): Problem → Solution → CTA
- CTA: "Registrer deg gratis"
- Landing page: getdrop.no?utm_source=facebook&utm_medium=cpc&utm_campaign=waitlist_signups

**Campaign 2: App Installs (Post-Launch)**
- Platform: Google App Campaigns
- Objective: App installs
- Audience: Norway, keywords: "send penger", "pengeoverføring", "western union alternativ"
- Ad copy: "Send penger med 0.5% gebyr. Last ned Drop gratis."
- Bid strategy: Target CPA (50 NOK per install)

**Campaign 3: Retargeting (Post-Launch)**
- Platform: Facebook + Instagram
- Objective: Conversions (first transfer)
- Audience: Custom audience (registered users who haven't transferred)
- Creative: "You're one step away. Complete your first transfer and get 50 kr."
- Landing page: Deep link to app (drop://send)

---

## 13. Legal & Compliance

### 13.1 Markedsføringsloven Compliance

**Key Requirements:**

**1. Identification of Marketing:**
All marketing communications must be clearly identifiable as such. Commercial emails must include "Annonse" or "Reklame" in subject line.

**Example:**
```
Subject: [Annonse] Spar opptil 90% på pengeoverføringer
```

**2. Opt-In for Electronic Marketing:**
Electronic marketing (email, SMS) to natural persons requires **prior consent** (opt-in). Exception: existing customer relationship + same product category.

**Implementation:**
- Checkbox on registration: "Jeg ønsker å motta tilbud fra Drop" (unchecked by default)
- Record consent in `consents` table with timestamp and IP
- Unsubscribe link in every email

**3. Misleading Marketing Prohibition:**
Cannot make false or misleading claims. All claims must be substantiable.

**Examples of Compliant Claims:**
- ✅ "0.5% gebyr" (true, documented in pricing)
- ✅ "Regulert i Norge" (true when PSD2 license obtained)
- ❌ "Billigst i Norge" (unsubstantiated, use "Fra 0.5% gebyr" instead)

**4. Comparative Marketing:**
Can compare with competitors (Western Union, Wise) if factually accurate and verifiable.

**Example:**
```
Drop: 0.5% gebyr
Western Union: 5-10% gebyr*

*Basert på gjennomsnittlig gebyr for 5,000 NOK overføring til Tyrkia, per Western Union's offentlige prisliste (februar 2026).
```

**5. Environmental Claims:**
Cannot make environmental claims ("grønn", "klimavennlig") without documentation.

**Drop's Claims:** None planned.

### 13.2 GDPR Marketing Compliance

**Cookie Consent:**
Marketing cookies (Facebook Pixel, Google Ads) require **explicit consent** before loading.

**Implementation (Cookie Consent Banner):**
```html
<div id="cookie-banner" style="position: fixed; bottom: 0; width: 100%; background: white; border-top: 1px solid #E5E7EB; padding: 20px; z-index: 1000; display: none;">
  <div style="max-width: 1200px; margin: 0 auto; display: flex; justify-content: space-between; align-items: center;">
    <div>
      <p style="font-size: 14px; color: #1A1A1A; margin-bottom: 8px;">
        <strong>Vi bruker informasjonskapsler</strong>
      </p>
      <p style="font-size: 13px; color: #6B7280;">
        Vi bruker informasjonskapsler for å forbedre din opplevelse og for å måle effektiviteten av våre markedsføringskampanjer.
        <a href="/pages/cookies.html" style="color: #0B6E35; text-decoration: underline;">Les mer</a>
      </p>
    </div>
    <div style="display: flex; gap: 12px;">
      <button onclick="rejectCookies()" style="padding: 10px 20px; border: 1px solid #E5E7EB; background: white; border-radius: 8px; cursor: pointer;">
        Avvis
      </button>
      <button onclick="acceptCookies()" class="btn-gradient" style="padding: 10px 20px;">
        Godta
      </button>
    </div>
  </div>
</div>

<script>
  function showCookieBanner() {
    const consent = localStorage.getItem('drop_cookie_consent');
    if (!consent) {
      document.getElementById('cookie-banner').style.display = 'block';
    } else if (consent === 'accepted') {
      loadMarketingCookies();
    }
  }

  function acceptCookies() {
    localStorage.setItem('drop_cookie_consent', 'accepted');
    document.getElementById('cookie-banner').style.display = 'none';
    loadMarketingCookies();
  }

  function rejectCookies() {
    localStorage.setItem('drop_cookie_consent', 'rejected');
    document.getElementById('cookie-banner').style.display = 'none';
  }

  function loadMarketingCookies() {
    // Load Facebook Pixel
    if (typeof fbq === 'undefined') {
      // Insert Facebook Pixel script
    }
    // Load Google Ads
    if (typeof gtag === 'undefined') {
      // Insert Google Ads script
    }
  }

  showCookieBanner();
</script>
```

**Privacy Policy Update:**
Add section on marketing data usage:
```markdown
## Markedsføring

Vi bruker din e-postadresse for å sende deg markedsføringskommunikasjon kun hvis du har gitt samtykke. Du kan når som helst trekke tilbake ditt samtykke ved å klikke på "Avslutt abonnement" nederst i enhver e-post.

Vi bruker informasjonskapsler fra Facebook og Google for å måle effektiviteten av våre annonser. Du kan blokkere disse informasjonskapslene ved å avvise dem i vår informasjonskapselbanner.
```

---

## 14. Cost Analysis

### 14.1 Tool Costs (Monthly)

| Tool | Purpose | Cost (NOK) | Notes |
|------|---------|------------|-------|
| **Domain (getdrop.no)** | Domain registration | 100 kr/year | Already paid via one.com |
| **Vercel Hosting** | Landing page hosting | 0 kr | Free tier (sufficient for landing page) |
| **Plausible Analytics** | Landing page analytics | 90 kr | €9/mo, GDPR-compliant |
| **Resend** | Email service | 0 kr (MVP) | Free tier: 3,000 emails/mo (sufficient for waitlist) |
| **Mixpanel** | In-app analytics | 200 kr | $20/mo for 1k users |
| **Social Media Ads** | Facebook + Instagram + Google | 10,000 kr | Testing budget (can scale up/down) |
| **Design Tools (Canva Pro)** | Social media graphics | 150 kr | For non-Figma assets |
| **Mailchimp** (Optional) | Email campaigns | 400 kr | If Resend not enough (10k contacts) |

**Total Monthly Cost (MVP):** ~440 kr
**Total with Ads:** ~10,440 kr

### 14.2 Referral Program Budget

**Assumptions:**
- 1,000 users in first 3 months
- 50% referred by existing users (500 referrals)
- 80% complete first transfer (400 conversions)
- 50 kr reward per conversion (both referrer + referee)

**Calculation:**
```
400 conversions × 50 kr (referrer) = 20,000 kr
400 conversions × 50 kr (referee) = 20,000 kr
Total referral rewards: 40,000 kr
```

**ROI:**
- Cost per acquisition: 40,000 kr / 400 users = 100 kr/user
- LTV estimate (conservative): 500 kr/user (10 transfers × 50 kr avg fee)
- ROI: 500 kr / 100 kr = 5x

**Budget Allocation:**
- Referral rewards: 40,000 kr (Q1)
- Paid ads: 30,000 kr (Q1)
- Tools: 1,320 kr (Q1)
- **Total Q1 Marketing Budget: 71,320 kr**

---

## 15. Implementation Plan

### 15.1 Phase 1: Landing Page Optimization (Week 1-2)

**Owner:** John + Frontend builder agent

**Tasks:**
1. Add conversion tracking (Plausible)
   - [ ] Sign up for Plausible account
   - [ ] Add tracking script to index.html
   - [ ] Configure goals (waitlist signup, CTA clicks)
   - [ ] Test tracking (verify events in Plausible dashboard)

2. Optimize hero section
   - [ ] Replace "Last ned gratis" with "Bli med på ventelisten"
   - [ ] Add "De første 1000 får 5 gratis overføringer" subtext
   - [ ] Replace phone mockup with real screenshot from Figma Make

3. Add FAQ section
   - [ ] Create FAQ accordion component
   - [ ] Add 5 common questions (Er Drop en bank?, Hvor mye koster det?, etc.)
   - [ ] Add FAQ structured data (JSON-LD)

4. Add comparison table
   - [ ] Create Drop vs Western Union vs Wise vs Bank table
   - [ ] Add styling (match landing page design)

5. Mobile optimization
   - [ ] Add sticky CTA bar on mobile
   - [ ] Test on iPhone/Android (Safari, Chrome)

6. Page speed optimization
   - [ ] Preload hero fonts
   - [ ] Lazy load images below fold
   - [ ] Minify CSS
   - [ ] Test with Lighthouse (target: 95+ score)

**Acceptance:**
- [ ] Plausible tracking works (waitlist signups logged)
- [ ] Hero CTA updated with incentive
- [ ] FAQ section renders correctly
- [ ] Comparison table displays on desktop + mobile
- [ ] Mobile sticky bar appears below 768px width
- [ ] Lighthouse score >= 95

---

### 15.2 Phase 2: SEO Foundation (Week 3-4)

**Owner:** John + Content agent (Ollama)

**Tasks:**
1. Add structured data
   - [ ] Add Organization schema to homepage
   - [ ] Add FAQ schema to FAQ section
   - [ ] Validate with Google Rich Results Test

2. Create sitemap.xml
   - [ ] Generate sitemap with all pages
   - [ ] Submit to Google Search Console
   - [ ] Submit to Bing Webmaster Tools

3. Create robots.txt
   - [ ] Allow all except /api/
   - [ ] Add sitemap reference

4. Create blog infrastructure
   - [ ] Create /blog/index.html (blog homepage)
   - [ ] Create blog post template
   - [ ] Write first 2 blog posts (Tyrkia guide, Drop vs WU)

5. Subpage creation
   - [ ] Create /send-penger.html (dedicated remittance page)
   - [ ] Create /qr-betaling.html (QR explainer)
   - [ ] Create /priser.html (pricing comparison)

**Acceptance:**
- [ ] Structured data validates in Google Rich Results Test
- [ ] Sitemap submitted to Google + Bing
- [ ] robots.txt accessible at /robots.txt
- [ ] Blog homepage lists 2 posts
- [ ] 3 subpages created and indexed

---

### 15.3 Phase 3: UTM & Attribution (Week 5-6)

**Owner:** John + Backend builder agent

**Tasks:**
1. Implement UTM tracking
   - [ ] Add client-side UTM capture to index.html
   - [ ] Store UTM in localStorage
   - [ ] Modify /api/waitlist to save UTM params
   - [ ] Add utm_* columns to waitlist table

2. Update registration flow
   - [ ] Modify /api/auth/register to capture UTM from localStorage
   - [ ] Add utm_* columns to users table
   - [ ] Test registration with UTM params

3. Create attribution reports
   - [ ] SQL query: Waitlist signups by source
   - [ ] SQL query: User registrations by source
   - [ ] SQL query: Revenue by source (post-launch)

4. Add conversion pixels
   - [ ] Add Facebook Pixel to landing page
   - [ ] Add Google Ads conversion tracking
   - [ ] Test pixel firing (Facebook Events Manager)

**Acceptance:**
- [ ] UTM params saved to waitlist table
- [ ] UTM params saved to users table
- [ ] Attribution reports run successfully
- [ ] Facebook Pixel tracks PageView + Lead
- [ ] Google Ads tracks conversions

---

### 15.4 Phase 4: Referral System (Week 7-10)

**Owner:** John + Backend builder agent

**Tasks:**
1. Database schema
   - [ ] Create referral_codes table
   - [ ] Create referral_tracking table
   - [ ] Create referral_credits table
   - [ ] Create indexes

2. Backend endpoints
   - [ ] POST /api/referrals/generate-code
   - [ ] POST /api/referrals/track-click
   - [ ] Modify /api/auth/register (signup attribution)
   - [ ] Modify /api/transactions/remittance (reward issuance)

3. Referral dashboard UI
   - [ ] Create /profile/referrals page
   - [ ] Referral link card
   - [ ] Stats card (invites, conversions, earnings)
   - [ ] Credits balance
   - [ ] Referral history

4. Share functionality
   - [ ] WhatsApp share button
   - [ ] SMS share button
   - [ ] Email share button
   - [ ] Copy link button

5. Fraud prevention
   - [ ] Implement self-referral check (device fingerprint)
   - [ ] Implement minimum transfer amount (500 kr)
   - [ ] Implement monthly credit cap (500 kr)
   - [ ] Implement velocity check (10/day)

**Acceptance:**
- [ ] Referral code auto-generated on first login
- [ ] Referral link click tracked
- [ ] Signup attributed to referrer
- [ ] First transfer triggers reward issuance
- [ ] Referral dashboard displays stats
- [ ] Share buttons open WhatsApp/SMS/Email
- [ ] Fraud checks block abuse

---

### 15.5 Phase 5: App Store Listings (Week 11-12)

**Owner:** John + Designer agent (Ollama) + QA

**Tasks:**
1. iOS App Store listing
   - [ ] Write app name, subtitle, description
   - [ ] Select keywords
   - [ ] Generate 6 screenshots from Figma Make export
   - [ ] Create app preview video (30 sec)
   - [ ] Submit for review (when app ready)

2. Google Play Store listing
   - [ ] Write title, short description, full description
   - [ ] Create feature graphic (1024x500)
   - [ ] Generate 4 screenshots (Android ratio)
   - [ ] Submit for review (when app ready)

3. ASO optimization
   - [ ] Research keywords in App Store Connect
   - [ ] A/B test icon variants (internal testing)
   - [ ] Localize for NO + EN

**Acceptance:**
- [ ] iOS listing drafted (not submitted until app ready)
- [ ] Android listing drafted
- [ ] Screenshots match Figma design
- [ ] App preview video rendered and reviewed
- [ ] Keyword research documented

---

### 15.6 Phase 6: Email Campaigns (Week 13-14)

**Owner:** John + Backend builder agent

**Tasks:**
1. Waitlist nurture series
   - [ ] Create 6 email templates (confirmation → launch)
   - [ ] Set up automation triggers (day 0, 7, 14, 21, pre-launch -7, launch)
   - [ ] Test emails (send to test@getdrop.no)

2. Onboarding series
   - [ ] Create 3 email templates (welcome → first transfer nudge)
   - [ ] Set up automation triggers (day 0, 3, 7)
   - [ ] Test emails

3. Re-engagement series
   - [ ] Create 3 email templates (gentle → win-back → survey)
   - [ ] Set up automation triggers (day 30, 60, 90)
   - [ ] Test emails

4. Marketing consent
   - [ ] Add checkbox to registration form
   - [ ] Record consent in consents table
   - [ ] Add unsubscribe link to all emails
   - [ ] Create unsubscribe endpoint

**Acceptance:**
- [ ] All email templates render correctly in Gmail/Outlook
- [ ] Automation triggers fire correctly (test with dummy users)
- [ ] Marketing consent checkbox works
- [ ] Unsubscribe link works

---

### 15.7 Phase 7: Analytics & Reporting (Week 15-16)

**Owner:** John + Data analyst agent (Ollama)

**Tasks:**
1. Mixpanel setup
   - [ ] Create Mixpanel account
   - [ ] Add Mixpanel SDK to app
   - [ ] Implement 15 core events (user_registered, first_transfer_completed, etc.)
   - [ ] Set People properties (email, name, utm_source, etc.)

2. Dashboards
   - [ ] Create Acquisition Dashboard (visitors → verified users)
   - [ ] Create Activation Dashboard (first transfer rate)
   - [ ] Create Retention Dashboard (D1, D7, D30 retention)
   - [ ] Create Referral Dashboard (viral coefficient)
   - [ ] Create Revenue Dashboard (fee revenue, revenue per user)

3. Reports
   - [ ] Weekly funnel report (automated, sent to Alem)
   - [ ] Monthly cohort analysis
   - [ ] Campaign performance report (ROI by channel)

**Acceptance:**
- [ ] Mixpanel events tracked in app
- [ ] 5 dashboards created in Mixpanel
- [ ] Weekly report automation set up

---

### 15.8 Phase 8: Social Media Launch (Week 17-18)

**Owner:** John + Marketer agent (Ollama)

**Tasks:**
1. Account setup
   - [ ] Create @dropnorge Instagram
   - [ ] Create facebook.com/dropnorge
   - [ ] Create linkedin.com/company/drop-norge
   - [ ] Design profile pictures + cover photos

2. Content calendar
   - [ ] Create 30-day content calendar (spreadsheet)
   - [ ] Design 12 Instagram posts (Canva)
   - [ ] Write captions + hashtags

3. First month execution
   - [ ] Schedule posts (Buffer or Hootsuite)
   - [ ] Engage with comments
   - [ ] Cross-post to LinkedIn

4. Paid ads (when budget available)
   - [ ] Set up Facebook Ads Manager
   - [ ] Create waitlist signup campaign
   - [ ] Set budget: 5,000 kr/month (testing)
   - [ ] Monitor daily, optimize weekly

**Acceptance:**
- [ ] All 3 social accounts live
- [ ] 30-day content calendar completed
- [ ] First 12 posts designed
- [ ] Posts scheduled for first week

---

## 16. Success Metrics (3 Months Post-Launch)

### 16.1 Acquisition

| Metric | Target | Measurement |
|--------|--------|-------------|
| Waitlist signups | 5,000 | Waitlist table count |
| Waitlist → Registration | 40% | (Registrations / Waitlist signups) × 100 |
| Registration → BankID | 80% | (BankID verified / Registrations) × 100 |
| BankID → KYC Approved | 90% | (KYC approved / BankID verified) × 100 |
| **Overall Conversion** | **30%** | **(Verified users / Waitlist signups) × 100** |

### 16.2 Activation

| Metric | Target | Measurement |
|--------|--------|-------------|
| First transfer (within 7 days) | 60% | (Users with 1+ transfer / Verified users) × 100 |
| QR payment adoption | 20% | (Users with 1+ QR payment / Verified users) × 100 |
| Average first transfer | 3,000 kr | AVG(first transfer amount) |

### 16.3 Engagement

| Metric | Target | Measurement |
|--------|--------|-------------|
| DAU/MAU ratio | 25% | (Daily active users / Monthly active users) × 100 |
| Transfers per user per month | 2.5 | Total transfers / Active users |
| QR payments per user per month | 5 | Total QR payments / Active QR users |

### 16.4 Retention

| Metric | Target | Measurement |
|--------|--------|-------------|
| Day 1 retention | 60% | % of users who return day after signup |
| Day 7 retention | 40% | % of users who return within 7 days |
| Day 30 retention | 25% | % of users who return within 30 days |

### 16.5 Referral

| Metric | Target | Measurement |
|--------|--------|-------------|
| Referral rate | 30% | (Users who shared link / Total users) × 100 |
| Referral conversion | 50% | (Signups from referrals / Referral link clicks) × 100 |
| **Viral coefficient** | **0.65** | **(Referral rate × Referral conversion) = 30% × 50% = 15%** (Note: Revolut's 65% from referrals means 0.65 new users per existing user) |

### 16.6 Revenue

| Metric | Target | Measurement |
|--------|--------|-------------|
| Total fee revenue | 100,000 kr | SUM(transaction fee) |
| Revenue per user | 100 kr | Total revenue / Active users |
| Revenue per transfer | 40 kr | Total revenue / Total transfers |

### 16.7 Marketing

| Metric | Target | Measurement |
|--------|--------|-------------|
| Organic traffic (SEO) | 2,000 visitors/mo | Plausible analytics |
| Social media followers | 1,500 | Instagram + Facebook + LinkedIn |
| Blog subscribers | 500 | Email list opt-ins from blog |
| Cost per acquisition (CPA) | 150 kr | Total ad spend / New users |

---

## 17. Risk Mitigation

### 17.1 Key Risks

**Risk 1: Low Referral Adoption**
- **Likelihood:** Medium
- **Impact:** High (referral is primary acquisition channel)
- **Mitigation:**
  - Increase incentive from 50 kr to 75 kr (test)
  - Add gamification (leaderboard: "Top 10 referrers get 500 kr bonus")
  - Make sharing easier (one-tap WhatsApp share)
  - Add social proof ("2,340 people referred their friends")

**Risk 2: High Customer Acquisition Cost (CAC)**
- **Likelihood:** Medium
- **Impact:** High (unprofitable if CAC > LTV)
- **Mitigation:**
  - Start with organic channels (SEO, content, social) before paid ads
  - Set strict CPA targets (max 150 kr per user)
  - Pause ads if CPA exceeds target 2 weeks in a row
  - Focus on viral growth (referral program)

**Risk 3: Poor App Store Visibility**
- **Likelihood:** Medium
- **Impact:** High (low organic installs)
- **Mitigation:**
  - ASO optimization (keyword research, compelling screenshots)
  - Incentivize reviews (push notification after 3rd transfer: "Enjoying Drop? Leave a review")
  - App Store ads (Apple Search Ads) with strict CPA
  - Cross-promote on landing page

**Risk 4: Email Deliverability Issues**
- **Likelihood:** Low
- **Impact:** Medium (users miss onboarding emails)
- **Mitigation:**
  - Use reputable ESP (Resend) with good sender reputation
  - Authenticate domain (SPF, DKIM, DMARC)
  - Monitor bounce rate (<5% acceptable)
  - Avoid spam triggers (no ALL CAPS, excessive exclamation marks)

**Risk 5: Referral Fraud**
- **Likelihood:** Medium
- **Impact:** High (budget drain)
- **Mitigation:**
  - Implement all fraud checks (self-referral, velocity, cap)
  - Monitor referral patterns weekly
  - Flag accounts with >10 referrals in 24h for manual review
  - BankID verification required before rewards

---

## 18. Acceptance Criteria

### 18.1 Landing Page

- [ ] Hero CTA updated with incentive ("De første 1000...")
- [ ] FAQ section added (5 questions min)
- [ ] Comparison table added (Drop vs WU vs Wise vs Bank)
- [ ] Mobile sticky CTA bar added
- [ ] Plausible tracking installed and tested
- [ ] Lighthouse score >= 95

### 18.2 SEO

- [ ] Sitemap.xml created and submitted to Google/Bing
- [ ] robots.txt created
- [ ] Structured data (Organization + FAQ) added and validated
- [ ] 3 subpages created (/send-penger, /qr-betaling, /priser)
- [ ] 2 blog posts published
- [ ] All pages have unique title + meta description

### 18.3 UTM & Attribution

- [ ] UTM params captured from URL
- [ ] UTM params stored in waitlist + users tables
- [ ] Attribution reports created (SQL queries)
- [ ] Facebook Pixel installed and tracking
- [ ] Google Ads conversion tracking installed

### 18.4 Referral System

- [ ] Database schema created (3 tables + indexes)
- [ ] Referral code auto-generated on first login
- [ ] Referral link click tracked
- [ ] Signup attribution works
- [ ] First transfer triggers reward issuance
- [ ] Referral dashboard UI complete
- [ ] Share buttons functional (WhatsApp, SMS, Email)
- [ ] Fraud prevention rules implemented

### 18.5 App Store Listings

- [ ] iOS listing drafted (name, subtitle, description, keywords, screenshots, video)
- [ ] Android listing drafted (title, short desc, full desc, graphics, screenshots)
- [ ] ASO keyword research completed
- [ ] All screenshots match Figma design

### 18.6 Email Marketing

- [ ] Waitlist nurture series created (6 emails)
- [ ] Onboarding series created (3 emails)
- [ ] Re-engagement series created (3 emails)
- [ ] Marketing consent checkbox added to registration
- [ ] Unsubscribe link works
- [ ] All emails tested in Gmail + Outlook

### 18.7 Analytics

- [ ] Plausible installed (landing page)
- [ ] Mixpanel installed (app)
- [ ] 15 core events tracked (user_registered, first_transfer, etc.)
- [ ] 5 dashboards created (Acquisition, Activation, Retention, Referral, Revenue)
- [ ] Weekly funnel report automated

### 18.8 Social Media

- [ ] Instagram, Facebook, LinkedIn accounts created
- [ ] Profile pictures + bios complete
- [ ] 30-day content calendar created
- [ ] First 12 posts designed and scheduled
- [ ] Paid ads campaign created (5,000 kr budget)

---

## 19. Rollout Timeline

**Pre-Launch (Weeks 1-10):**
- Weeks 1-2: Landing page optimization
- Weeks 3-4: SEO foundation
- Weeks 5-6: UTM & attribution
- Weeks 7-10: Referral system

**Launch Week (Week 11):**
- Submit app to App Store + Play Store
- Send launch email to waitlist
- Publish launch blog post
- Post on social media (organic)
- Start paid ads (small budget: 2,000 kr/week)

**Post-Launch (Weeks 12-16):**
- Week 12: Monitor metrics daily, optimize landing page CTA (A/B test)
- Week 13: Scale ads if CPA < 150 kr
- Week 14: Publish 2nd + 3rd blog posts
- Week 15: Analyze first month data, create reports
- Week 16: Iterate on referral incentives based on data

---

## 20. Sources

This specification was informed by research on Norwegian fintech regulations, app store optimization best practices, and referral program patterns from successful fintech companies:

**Norwegian Regulations:**
- [Fintech Laws and Regulations Report 2025-2026 Norway](https://iclg.com/practice-areas/fintech-laws-and-regulations/norway)
- [Marketing - regjeringen.no](https://www.regjeringen.no/en/topics/consumers/marketing/id670317/)
- [Act relating to the control of marketing and contract terms and conditions, etc - Lovdata](https://lovdata.no/dokument/NLE/lov/2009-01-09-2)
- [Electronic marketing in Norway - Data Protection Laws of the World](https://www.dlapiperdataprotection.com/index.html?t=electronic-marketing&c=NO)

**App Store Optimization:**
- [Top ASO tips and best practices for 2026, brought to you by ASO experts](https://www.apptweak.com/en/aso-blog/app-store-optimization-aso-best-practices)
- [App Store Optimization (ASO) Best Practices for 2026](https://www.wildnetedge.com/blogs/app-store-optimization-aso-best-practices)
- [App Store Optimization Tips for Fintech Designers](https://www.telerik.com/blogs/app-store-optimization-tips-fintech-designers)
- [A short guide to optimising ASO strategies for fintech apps](https://appfollow.io/blog/5-aso-tips-to-optimise-your-fintech-app)

**Referral Programs & Fraud Prevention:**
- [How Revolut Turned Referrals into a $4B Growth Machine](https://www.one-fs.com/p/inside-revoluts-viral-growth-engine)
- [How to start a referral program for your fintech startup](https://blogs.referralrocket.io/start-referral-program-for-fintech-startup/)
- [Design a Fraud-Proof Referral Program](https://help.impact.com/en/support/solutions/articles/155000001538-design-a-fraud-proof-referral-program)
- [Revolut Referral Rewards: Refer-a-Friend Bonus and Referral Discounts 2026](https://referralcodes.com/shop/revolut-referral)

---

**END OF SPEC**

**Next Steps:**
1. Review this spec with Alem for approval
2. Create implementation tasks in Mission Control
3. Assign tasks to builder agents (Phase 1 → Phase 8)
4. Begin Phase 1 (Landing Page Optimization)

**Questions for Alem:**
1. Approve referral incentive structure (50 kr dual-sided)?
2. Approve social media ad budget (10,000 kr/month testing)?
3. Preferred analytics platform (Plausible + Mixpanel recommended)?
4. Timeline for app launch (determines when to prepare App Store listings)?
5. Any specific SEO keywords to prioritize beyond the list in Section 4.1?

# drop-mvp-pipeline-plan

# Plan: Drop MVP Pipeline A-Z

## Research Summary

### Actual State (13.02.2026)

**Phase 4 (Implementation) — 95% done, NOT 80% as PIPELINE.md says:**
- 12 frontend pages: ALL wired to real API routes via fetch()
- 24+ API routes: ALL working (auth, transactions, cards, merchants, rates, etc.)
- SQLite DB: 6 tables + seed data, parameterized queries
- Auth: JWT httpOnly cookies, rate limiting
- Validation: hardened (backend + frontend), 18+ age check
- Services: mock-swan/stripe/sumsub with config toggle (expected for MVP)
- Rebrand: ALL pages rebranded to Stitch design
- Tests: 126 unit + 91 e2e = 217 ALL GREEN

**What's actually missing for MVP-ready:**
1. Full remittance E2E flow test (register → login → send money → verify)
2. .env.example + environment config for production
3. Docker build verification (Dockerfile exists but untested)
4. SQLite → needs volume mount for persistence in Docker
5. 5 test iterations per testing.md standard
6. Deploy to staging (Railway/Hetzner — cost analysis says 10-170 NOK/mo)
7. Domain config (getdrop.no)
8. Landing page deploy (static HTML, separate from app)
9. PIPELINE.md outdated — needs update

**Infrastructure already built:**
- Dockerfile: 3-stage build, standalone output ✅
- docker-compose.yml: app + postgres, healthchecks ✅
- next.config.ts: standalone, CSP headers, security headers ✅
- Cloud cost analysis: done ✅

**NOTE:** docker-compose.yml has postgres service but app uses SQLite.
For MVP: deploy with SQLite + volume (Railway/Fly persistent disk).
PostgreSQL migration is Phase 2 (200+ users) per cost analysis.

## Objective

Take Drop from "works on localhost" to "deployed MVP on staging with full test coverage."
4 phases, no detours, no cosmetics.

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | flow-test-builder | Write full remittance E2E flow + missing flow tests | builder |
| V1 | test-validator | Run all 5 test levels, 5 iterations, verify coverage | validator |
| B2 | deploy-prep-builder | Create .env.example, fix docker-compose for SQLite, test Docker build | builder |
| V2 | deploy-validator | Verify Docker image builds and runs correctly | validator |
| B3 | staging-builder | Deploy to Railway/Hetzner, configure domain, SSL | builder |
| V3 | staging-validator | Verify staging is live, all pages load, API responds | validator |
| B4 | pipeline-closer | Update PIPELINE.md, close MC tasks, create post-mortem | builder |

### Step-by-Step Tasks

#### Phase 1: Complete Implementation (finish Phase 4)

**Task 1:** Write missing E2E flow tests + update PIPELINE.md status
- Owner: B1
- BlockedBy: none
- Files:
  - `tests/e2e/full-flows.spec.ts` (NEW)
  - `project/PIPELINE.md` (UPDATE)
  - `.env.example` (NEW)
- Instructions:
  1. Read existing test files to understand patterns
  2. Write full E2E tests for:
     - Complete registration → login → dashboard flow
     - Login → send money (remittance) → verify transaction in history
     - Login → scan QR → pay → verify in history
     - Login → view cards → order virtual card
     - Login → view accounts → check balances
     - Login → profile → logout → redirect to login
  3. Create `.env.example` with all required vars:
     ```
     JWT_SECRET=change-me-in-production
     NODE_ENV=production
     NEXT_PUBLIC_SERVICE_MODE=mock
     ```
  4. Update PIPELINE.md Phase 4 status to 100%
  5. Run tests, fix any failures
- Acceptance:
  - [ ] full-flows.spec.ts has 6+ complete user journey tests
  - [ ] All E2E tests pass (existing + new)
  - [ ] .env.example exists with documented vars
  - [ ] PIPELINE.md Phase 4 marked complete

#### Phase 2: Testing (5 iterations per testing.md)

**Task 2:** Run 5 test iterations across all levels, fix failures
- Owner: V1
- BlockedBy: 1
- Instructions:
  1. Read ~/system/rules/testing.md for requirements
  2. Run iteration 1: `npx vitest run` + `npx playwright test`
  3. Log results to `tests/logs/iteration-1.txt`
  4. Fix any failures found
  5. Repeat for iterations 2-5
  6. Check coverage: `npx vitest run --coverage` (target 80%+)
  7. Run regression tests: `npx vitest run tests/regression/`
  8. Run performance tests: `npx vitest run tests/performance/`
  9. Final summary of all 5 iterations
- Acceptance:
  - [ ] 5 iterations logged in tests/logs/
  - [ ] ALL tests pass on iteration 5
  - [ ] Coverage report generated
  - [ ] No regressions
  - [ ] Performance benchmarks baselined

#### Phase 3: Deploy to Staging

**Task 3:** Prepare Docker for SQLite deployment
- Owner: B2
- BlockedBy: 2
- Files:
  - `docker-compose.yml` (UPDATE — add SQLite volume, remove postgres for MVP)
  - `docker-compose.production.yml` (NEW — for future postgres)
  - `Dockerfile` (UPDATE if needed)
- Instructions:
  1. Read existing Dockerfile and docker-compose.yml
  2. Create `docker-compose.mvp.yml` for SQLite deployment:
     - App service with SQLite volume mount
     - JWT_SECRET from env
     - Health check on /api/health
     - No postgres (not needed for MVP)
  3. Keep existing docker-compose.yml as `docker-compose.production.yml` (postgres version)
  4. Test: `docker build -t drop-app .` — must succeed
  5. Test: `docker run -p 3000:3000 -e JWT_SECRET=test drop-app` — must serve pages
  6. Verify /api/health, /login, /onboarding all respond 200
- Acceptance:
  - [ ] Docker image builds successfully
  - [ ] Docker container starts and serves pages
  - [ ] /api/health returns 200
  - [ ] SQLite data persists across container restart (volume)
  - [ ] docker-compose.mvp.yml works with `docker-compose -f docker-compose.mvp.yml up`

**Task 4:** Validate Docker deployment
- Owner: V2
- BlockedBy: 3
- Acceptance:
  - [ ] Docker build < 2 minutes
  - [ ] Image size < 500MB
  - [ ] Container starts in < 10s
  - [ ] All API endpoints respond correctly
  - [ ] No security warnings in container logs

**Task 5:** Deploy to staging (Railway or Hetzner)
- Owner: B3
- BlockedBy: 4
- Instructions:
  1. Read cloud-cost-analysis.md for recommended approach
  2. Option A (Railway): `railway init` + `railway up` — easiest, persistent disk
  3. Option B (Hetzner): Docker deploy to existing VPS if available
  4. Configure:
     - JWT_SECRET (generate secure random)
     - NODE_ENV=production
     - Domain: staging.getdrop.no or drop-staging.alai.no
  5. Set up SSL (Let's Encrypt / Cloudflare)
  6. Verify all pages load on staging URL
  7. Deploy landing page (static HTML) to same domain or subdomain
- Acceptance:
  - [ ] Staging URL accessible via HTTPS
  - [ ] All 12 app pages load correctly
  - [ ] Login + registration flow works end-to-end
  - [ ] API health check passes
  - [ ] Landing page live

**Task 6:** Validate staging deployment
- Owner: V3
- BlockedBy: 5
- Instructions:
  1. Hit staging URL — verify HTTPS, correct domain
  2. Test all pages load (no 404, no 500)
  3. Test registration flow end-to-end
  4. Test login with demo credentials
  5. Test send money flow
  6. Test QR scan flow
  7. Check security headers (CSP, HSTS, X-Frame-Options)
  8. Check mobile responsiveness (375px viewport)
- Acceptance:
  - [ ] All pages load via HTTPS
  - [ ] Registration + login works
  - [ ] Core flows work (send, scan, cards)
  - [ ] Security headers present
  - [ ] Mobile-friendly

#### Phase 4: Close Pipeline

**Task 7:** Update PIPELINE.md, close tasks, create summary
- Owner: B4
- BlockedBy: 6
- Instructions:
  1. Update PIPELINE.md:
     - Phase 4: ✅ Done
     - Phase 5: ✅ Done (5 iterations, all pass)
     - Phase 6: ✅ Done (staging live at URL)
     - Phase 7: 🔄 Monitoring
  2. Close related MC tasks: #526, #608, #791-793 (update status)
  3. Create post-mortem summary:
     - What was built
     - Test results
     - Staging URL
     - Known limitations (mock services, SQLite, no BankID)
     - Next steps for production (partner agreements, PostgreSQL, Finanstilsynet)
  4. Post summary to HiveMind
- Acceptance:
  - [ ] PIPELINE.md fully updated
  - [ ] MC tasks updated
  - [ ] Post-mortem written
  - [ ] HiveMind updated
  - [ ] Alem can access staging URL

## Validation Commands

```bash
# Phase 1 — Tests
cd ~/ALAI/products/Drop/src/drop-app
npx vitest run                    # Unit tests
npx playwright test               # E2E tests

# Phase 2 — Coverage
npx vitest run --coverage

# Phase 3 — Docker
docker build -t drop-app .
docker run -p 3001:3000 -e JWT_SECRET=test123 drop-app
curl http://localhost:3001/api/health

# Phase 4 — Staging
curl -I https://staging.getdrop.no
curl https://staging.getdrop.no/api/health
```

## Risk Register

| Risk | Impact | Mitigation |
|------|--------|------------|
| Docker build fails (native deps) | Blocks deploy | better-sqlite3 needs python/g++ in Dockerfile — already there |
| Railway free tier limits | Slow staging | Upgrade to $5/mo Starter — within budget |
| SQLite concurrent writes | Data corruption under load | MVP only, < 200 users. PostgreSQL in Phase 2 |
| No real BankID | Can't verify real users | Expected for MVP. Mock BankID with DOB field |
| No partner APIs (Swan/Stripe/Sumsub) | All transactions are mock | Expected. Partner agreements are business tasks, not code |

## Timeline

| Phase | Tasks | Parallel? | Estimated |
|-------|-------|-----------|-----------|
| 1: Complete impl | Task 1 | Solo | 1 builder |
| 2: Testing | Task 2 | Solo (blocked by 1) | 1 validator |
| 3: Deploy | Tasks 3-6 | B2→V2→B3→V3 sequential | 2 builders + 2 validators |
| 4: Close | Task 7 | Solo (blocked by 6) | 1 builder |

**Total: 4 builders + 3 validators = 7 agent tasks**

# drop-onboarding-flow-spec

# Drop User Onboarding Flow Specification

**Version:** 1.0
**Date:** 2026-02-17
**Author:** John (AI Director)
**Project:** Drop Fintech Payment App
**MC Task:** #1192
**Status:** Draft — Awaiting Approval

---

## 1. Executive Summary

This specification defines the complete user onboarding flow for Drop, a fintech payment app that provides remittance and QR payment services using PSD2 pass-through architecture. The flow must enforce legal requirements (18+ age, Norwegian residency), implement BankID verification, and guide users through KYC compliance before enabling transactions.

**Key Constraints:**
- **Pass-through model:** Drop NEVER holds customer money. AISP reads balance, PISP initiates payments from user's bank account.
- **Legal requirement:** Users must be 18+ and Norwegian residents (from `landing/pages/vilkar.html`)
- **BankID mandatory:** Required before any transaction (PSD2 SCA compliance)
- **KYC compliance:** Sumsub integration for identity verification (auto-approved in demo mode)

---

## 2. Flow Overview

### 2.1 High-Level Journey

```
Landing Page → Register → Phone OTP → Onboarding Tour → BankID Verification → KYC Check → Dashboard
     (1)         (2)         (3)            (4)                (5)              (6)         (7)
```

### 2.2 State Diagram

```
┌─────────────┐
│   VISITOR   │
└──────┬──────┘
       │
       ▼
┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  REGISTER   │────▶│  PHONE_OTP   │────▶│  ONBOARDING │
└─────────────┘     └──────────────┘     └──────┬──────┘
                                                 │
                                                 ▼
┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│  DASHBOARD  │◀────│  KYC_CHECK   │◀────│   BANKID    │
└─────────────┘     └──────────────┘     └─────────────┘
       │
       ▼
┌─────────────┐
│  VERIFIED   │ (can transact)
└─────────────┘
```

### 2.3 User States

| State | Description | Can Transact? | Next Action |
|-------|-------------|---------------|-------------|
| `visitor` | Not registered | No | Register |
| `registered` | Account created, no phone verification | No | Verify phone OTP |
| `phone_verified` | Phone verified, no BankID | No | Complete onboarding tour |
| `onboarded` | Tour complete, no BankID | No | Link BankID |
| `bankid_linked` | BankID verified, pending KYC | No | Wait for KYC approval |
| `kyc_approved` | Fully verified | Yes | Full access |
| `kyc_pending` | KYC review in progress | No | Wait for approval |
| `kyc_rejected` | KYC failed | No | Contact support |

**State persistence:** Stored in `users` table:
- `kyc_status` enum: 'pending', 'approved', 'rejected'
- `phone_verified` boolean
- `bankid_verified` boolean
- `onboarding_completed` boolean (new field)

---

## 3. Detailed Flow Steps

### Step 1: Landing Page → Register

**Route:** `/` → `/register`
**Entry:** User clicks "Opprett konto" on landing page
**UI Reference:** `mockups/figma-make-export/src/components/Login.tsx` (register section)

#### Frontend (register/page.tsx)

**Current Implementation:**
- ✅ Form: First name, last name, email, phone (+47), date of birth, password
- ✅ Client-side validation: email format, password complexity (8+ chars, upper/lower/digit)
- ✅ Age validation: DOB must result in age >= 18
- ✅ XSS prevention: Blocks `<script`, `javascript:`, `onerror=` in name fields
- ✅ Visual step indicator: "Steg 1 av 3"
- ❌ Missing: BankID/Vipps login options (shown but BankID redirects, Vipps disabled)

**Validation Rules:**
```typescript
// Age check
const dob = new Date(dateOfBirth);
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
if (today.getMonth() < dob.getMonth() ||
   (today.getMonth() === dob.getMonth() && today.getDate() < dob.getDate())) {
  age--;
}
if (age < 18) return "Du må være minst 18 år for å bruke Drop";

// Password complexity
if (password.length < 8) error();
if (!/[A-Z]/.test(password)) error("uppercase");
if (!/[a-z]/.test(password)) error("lowercase");
if (!/\d/.test(password)) error("digit");

// Phone format
if (!phone.startsWith("+47")) error("Norwegian phone required");
```

**Step Indicator:**
```tsx
<div className="flex items-center gap-2 mb-2">
  <div className="w-8 h-8 bg-[#0B6E35] text-white rounded-full">1</div>
  <div className="w-8 h-8 bg-[#E2E8F0] text-[#64748B] rounded-full">2</div>
  <div className="w-8 h-8 bg-[#E2E8F0] text-[#64748B] rounded-full">3</div>
</div>
<p className="text-sm text-[#64748B]">Steg 1 av 3</p>
```

#### Backend (api/auth/register/route.ts)

**Endpoint:** `POST /api/auth/register`

**Request Body:**
```json
{
  "email": "user@example.no",
  "password": "SecureP@ss123",
  "firstName": "Alem",
  "lastName": "Basic",
  "phone": "+4712345678",
  "dateOfBirth": "1990-01-01"
}
```

**Validation:**
```typescript
// Server-side (route.ts lines 33-72)
if (!validateEmail(email)) errors.push("Valid email required");

// Password complexity (8 chars, upper, lower, digit, special)
if (password.length < 8) errors.push("at least 8 characters");
if (!/[A-Z]/.test(password)) errors.push("uppercase letter");
if (!/[a-z]/.test(password)) errors.push("lowercase letter");
if (!/\d/.test(password)) errors.push("digit");
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) errors.push("special character");

// Name validation
if (!validateName(firstName)) errors.push("First name required");

// Phone validation
if (!phoneClean.startsWith("+47")) errors.push("Norwegian phone number required");

// Age check (lines 59-71)
const dob = new Date(dateOfBirth);
let age = today.getFullYear() - dob.getFullYear();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) age--;
if (age < 18) errors.push("Du må være minst 18 år for å bruke Drop");
```

**Database Insert:**
```sql
INSERT INTO users (
  id, email, password_hash, first_name, last_name,
  phone, date_of_birth, kyc_status, phone_verified,
  bankid_verified, onboarding_completed
) VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', 0, 0, 0);
```

**Success Response:**
```json
{
  "data": {
    "id": "usr_abc123",
    "email": "user@example.no",
    "firstName": "Alem",
    "lastName": "Basic",
    "dateOfBirth": "1990-01-01",
    "kycStatus": "pending",
    "createdAt": "2026-02-17T12:00:00Z"
  }
}
```

**OTP Generation:**
```typescript
// Generate 6-digit OTP (lines 109-133)
const otpCode = String(crypto.randomInt(100000, 1000000)); // e.g., "842759"
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes

await run(
  "INSERT INTO otp_codes (id, user_id, code, expires_at, used) VALUES (?, ?, ?, ?, 0)",
  [otpId, userId, otpCode, expiresAt]
);

// TODO: Send via SMS provider (Twilio/MessageBird)
logger.info("OTP generated", { userId, phone });
```

**Audit Log:**
```typescript
logAudit({
  userId: id,
  action: AuditAction.REGISTER,
  resourceType: "user",
  resourceId: id,
  details: { email },
  ipAddress: ip,
  userAgent: request.headers.get("user-agent"),
  requestId,
});
```

**Error Cases:**
| Error | HTTP | Reason |
|-------|------|--------|
| `validation_error` | 422 | Missing fields, invalid format, age < 18 |
| `conflict` | 409 | Email already registered |
| `rate_limited` | 429 | Too many registration attempts (10/min per IP) |

**Rate Limiting:**
```typescript
if (!(await rateLimit(ip, 10))) {
  return jsonError("rate_limited", "Too many requests", 429);
}
```

---

### Step 2: Register → Phone OTP Verification

**Route:** `/register` (step: "verify")
**Trigger:** Successful registration
**UI Reference:** `mockups/figma-make-export/src/components/Login.tsx` (OTP screen)

#### Frontend (register/page.tsx)

**Current Implementation:**
```tsx
// State machine (lines 9-10, 84-86)
type Step = "info" | "verify" | "pin" | "success";
const [step, setStep] = useState<Step>("info");

// After registration success
if (res.ok) {
  setStep("verify");
}

// OTP Input (lines 291-328)
<input
  value={otp}
  onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").slice(0, 6))}
  placeholder="000000"
  maxLength={6}
  className="h-14 w-full rounded-xl text-center text-2xl tracking-[0.5em] font-mono"
/>

// Validation
const handleVerify = () => {
  if (otp.length === 6) {
    setStep("pin");
  }
};
```

**UI Elements:**
- Phone number display: "Vi sendte en 6-sifret kode til +47 12345678"
- 6-digit input field (numeric only, monospace font, letter-spaced)
- Expiry notice: "Koden er gyldig i 5 minutter"
- Shield icon (security indicator)
- "Bekreft" button (disabled until 6 digits entered)

#### Backend (api/auth/verify-otp/route.ts)

**Endpoint:** `POST /api/auth/verify-otp`

**Request Body:**
```json
{
  "phone": "+4712345678",
  "otp": "842759"
}
```

**Validation Flow:**
```typescript
// 1. Rate limiting: 5 attempts per minute per IP
if (!(await rateLimit(ip, 5, 60000))) {
  return jsonError("rate_limited", "Too many OTP attempts", 429);
}

// 2. Find user by phone
const user = await getOne<{ id: string }>(
  "SELECT id FROM users WHERE phone = ?",
  [phone]
);

if (!user) {
  // Generic error to prevent user enumeration
  return jsonError("invalid_otp", "Invalid or expired code", 400);
}

// 3. Find valid, unused OTP for this user
const otpRecord = await getOne(
  `SELECT id, code, expires_at FROM otp_codes
   WHERE user_id = ? AND used = 0 AND expires_at > ?
   ORDER BY created_at DESC LIMIT 1`,
  [user.id, now]
);

// 4. Verify OTP match
if (!otpRecord || otpRecord.code !== otp) {
  logAudit({ userId: user.id, action: "otp.verify_failed" });
  return jsonError("invalid_otp", "Invalid or expired code", 400);
}

// 5. Mark OTP as used
await run("UPDATE otp_codes SET used = 1 WHERE id = ?", [otpRecord.id]);

// 6. Update user phone verification status
await run("UPDATE users SET phone_verified = 1 WHERE id = ?", [user.id]);

// 7. Audit log
logAudit({
  userId: user.id,
  action: "otp.verified",
  resourceType: "otp",
  resourceId: otpRecord.id,
});
```

**Success Response:**
```json
{
  "data": { "verified": true }
}
```

**Error Cases:**
| Error | HTTP | Reason |
|-------|------|--------|
| `invalid_otp` | 400 | Wrong code, expired (>5 min), or already used |
| `rate_limited` | 429 | Too many OTP attempts (5/min per IP) |
| `bad_request` | 400 | Invalid OTP format (not 6 digits) |

**Security Considerations:**
- Generic error messages prevent user enumeration attacks
- OTP codes expire after 5 minutes
- One-time use enforced via `used` flag
- Rate limiting prevents brute-force attacks (5 attempts/min)
- Audit trail for all verification attempts

**Edge Cases:**
1. **OTP expires:** User must request new OTP (requires re-registering or resend endpoint)
2. **Wrong OTP 5 times:** Rate limited for 1 minute
3. **User closes tab:** OTP still valid for 5 minutes, can return and verify
4. **Multiple OTPs generated:** Only latest unused OTP is valid

---

### Step 3: Phone OTP → PIN Setup

**Route:** `/register` (step: "pin")
**Trigger:** OTP verification success
**UI Reference:** `mockups/figma-make-export/src/components/Login.tsx` (PIN screen)

#### Frontend (register/page.tsx)

**Current Implementation:**
```tsx
// PIN State (lines 22)
const [pin, setPin] = useState("");

// PIN Input Handler (lines 107-116)
const handlePinInput = (key: string) => {
  if (key === "\u232B") { // Backspace symbol
    setPin((prev) => prev.slice(0, -1));
  } else if (key && pin.length < 4) {
    const newPin = pin + key;
    setPin(newPin);
    if (newPin.length === 4) {
      setTimeout(() => setStep("success"), 300);
    }
  }
};

// PIN Pad (lines 119, 360-373)
const pinPad = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "\u232B"];

<div className="grid grid-cols-3 gap-3 max-w-[280px] mx-auto">
  {pinPad.map((key, i) => (
    <button
      key={i}
      onClick={() => handlePinInput(key)}
      disabled={!key}
      className={`h-14 rounded-xl border bg-white text-xl font-semibold
                  ${!key ? "invisible" : ""}`}
    >
      {key}
    </button>
  ))}
</div>
```

**UI Elements:**
- Lock icon (security indicator)
- Heading: "Lag din PIN-kode"
- Subtitle: "4-sifret PIN for rask tilgang"
- 4 dots showing PIN entry progress (filled dots scale up)
- 3x4 numeric keypad (1-9, 0, backspace)
- No submit button (auto-submits on 4th digit)

**PIN Indicator:**
```tsx
<div className="flex justify-center gap-4 my-6">
  {[0, 1, 2, 3].map((i) => (
    <div
      key={i}
      className={`h-4 w-4 rounded-full border-2 transition-all ${
        pin.length > i
          ? "bg-[#0B6E35] border-[#0B6E35] scale-110"
          : "border-[#E2E8F0]"
      }`}
    />
  ))}
</div>
```

#### Backend Implementation

**Missing Backend Endpoint:** No `/api/auth/set-pin` route exists. Current implementation only sets PIN in frontend state.

**Required Implementation:**

**Endpoint:** `POST /api/auth/set-pin`

**Request Body:**
```json
{
  "userId": "usr_abc123",
  "pin": "1234"
}
```

**Backend Logic:**
```typescript
// Validation
if (!pin || typeof pin !== "string" || !/^\d{4}$/.test(pin)) {
  return jsonError("bad_request", "PIN must be 4 digits", 400);
}

// Security checks
if (pin === "0000" || pin === "1234" || pin === "1111") {
  return jsonError("weak_pin", "PIN er for svak. Velg et annet nummer.", 400);
}

// Check sequential patterns (1234, 4321)
if (pin === "1234" || pin === "4321" || pin === "5678") {
  return jsonError("weak_pin", "PIN kan ikke være en sekvens", 400);
}

// Check repeating digits (1111, 2222)
if (/^(\d)\1{3}$/.test(pin)) {
  return jsonError("weak_pin", "PIN kan ikke være gjentatte siffer", 400);
}

// Hash PIN (bcrypt)
const pinHash = await bcrypt.hash(pin, 12);

// Update user
await run(
  "UPDATE users SET pin_hash = ?, pin_set_at = datetime('now') WHERE id = ?",
  [pinHash, userId]
);

// Audit log
logAudit({
  userId,
  action: "pin.set",
  resourceType: "user",
  resourceId: userId,
});

return NextResponse.json({ data: { success: true } });
```

**Database Schema Update:**
```sql
ALTER TABLE users ADD COLUMN pin_hash TEXT;
ALTER TABLE users ADD COLUMN pin_set_at TEXT;
```

**Error Cases:**
| Error | HTTP | Reason |
|-------|------|--------|
| `weak_pin` | 400 | PIN is 0000, 1234, 1111, or sequential |
| `bad_request` | 400 | PIN is not 4 digits |
| `unauthorized` | 401 | User not authenticated |

**Security Considerations:**
- PIN stored as bcrypt hash, never plaintext
- Weak PIN patterns rejected (0000, 1234, 1111, 1234, 4321)
- PIN used for quick app unlock (not primary auth)
- Audit trail for PIN changes

**Gap Analysis:**
- ❌ Backend endpoint missing
- ❌ Frontend doesn't call backend (auto-advances to success without server confirmation)
- ❌ No weak PIN validation
- ❌ No database schema for `pin_hash`

---

### Step 4: PIN Setup → Onboarding Tour

**Route:** `/register` (step: "success") → `/onboarding`
**Trigger:** PIN set successfully
**UI Reference:** `mockups/figma-make-export/src/components/Onboarding.tsx`

#### Frontend (onboarding/page.tsx)

**Current Implementation:**
```tsx
// 4-step carousel (lines 8-181)
const STEPS = [
  {
    title: "Velkommen til Drop!",
    description: "Enklere betalinger. Lavere gebyrer.",
    content: <WelcomeScreen />, // Features: Remittance, QR, Security
  },
  {
    title: "Dine fordeler",
    description: "Hvorfor velge Drop?",
    content: <BenefitsScreen />, // Lave gebyrer (0.5%), Raske transaksjoner, Direkte fra bank
  },
  {
    title: "BankID-tilgang",
    description: "Koble til din bank",
    content: <BankIDScreen />, // BankID/Vipps security info
  },
  {
    title: "Ferdig!",
    description: "Du er klar til å bruke Drop",
    content: <ReadyScreen />, // Next actions: Send money, Scan QR, View balance
  },
];

// Navigation (lines 184-211)
const [currentStep, setCurrentStep] = useState(0);
const handleNext = () => {
  if (isLastStep) {
    router.push("/dashboard");
  } else {
    setCurrentStep((prev) => prev + 1);
  }
};

const handleSkip = () => {
  router.push("/dashboard");
};
```

**Progress Indicator:**
```tsx
<div className="flex items-center gap-2 mb-2">
  {STEPS.map((_, index) => (
    <div
      key={index}
      className={`h-2 rounded-full flex-1 transition-colors ${
        index <= currentStep ? "bg-[#0B6E35]" : "bg-[#E2E8F0]"
      }`}
    />
  ))}
</div>
<p className="text-sm text-[#64748B]">
  Steg {currentStep + 1} av {STEPS.length}
</p>
```

**Content Structure:**

**Screen 1: Welcome**
- Drop logo and tagline
- 3 feature cards with icons:
  - 🌍 Send penger til utlandet (30+ land, lave gebyrer)
  - 📱 Betal med QR-kode (skann, betal fra bankkonto)
  - 🛡️ Trygt og sikkert (BankID koblet)

**Screen 2: Benefits**
- 3 benefit cards:
  - 💰 Lave gebyrer (gradient card) — "Kun halv prosent gebyr på remittance"
  - ⚡ Raske transaksjoner — "Pengene er fremme innen 1-2 virkedager"
  - 🏦 Direkte fra din bank — "Ingen mellomkonto. Pengene dine forblir i din bank"

**Screen 3: BankID Connection**
- Security explanation
- BankID + Vipps logos
- 3 checkmarks:
  - ✅ Kun du har tilgang til dine kontoer
  - ✅ Vi kan aldri flytte penger uten ditt samtykke
  - ✅ All data er kryptert og sikret

**Screen 4: Ready**
- ✅ Success icon (green circle with checkmark)
- "Alt klart!" heading
- Next actions list:
  - ✉️ Send penger til utlandet
  - 🔍 Skann QR for å betale
  - 💼 Se saldo fra dine kontoer

**Navigation Controls:**
- Back button (chevron left) — visible except on first screen
- "Hopp over" button (top right) — hidden on last screen
- "Fortsett" button (bottom) — changes to "Gå til Dashboard" on last screen

#### Backend Implementation

**Missing Backend Logic:** No backend tracking of onboarding completion.

**Required Implementation:**

**Endpoint:** `POST /api/onboarding/complete`

**Request Body:**
```json
{
  "userId": "usr_abc123"
}
```

**Backend Logic:**
```typescript
// Verify user is authenticated
const { userId } = await getAuthUser(request);

// Mark onboarding complete
await run(
  "UPDATE users SET onboarding_completed = 1, onboarding_completed_at = datetime('now') WHERE id = ?",
  [userId]
);

// Audit log
logAudit({
  userId,
  action: "onboarding.completed",
  resourceType: "user",
  resourceId: userId,
});

return NextResponse.json({ data: { success: true } });
```

**Database Schema Update:**
```sql
ALTER TABLE users ADD COLUMN onboarding_completed INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT;
```

**Skip Handling:**
```typescript
// Allow skip but still mark as completed
// This is UX flexibility — user chose to skip educational content
```

**Gap Analysis:**
- ❌ Backend endpoint missing
- ❌ No database tracking of onboarding completion
- ❌ Cannot prevent users from accessing dashboard without completing onboarding
- ✅ Frontend flow works correctly (4 screens, navigation, skip)

---

### Step 5: Onboarding Tour → BankID Verification

**Route:** `/onboarding` → `/dashboard` → BankID modal/redirect
**Trigger:** User clicks "Gå til Dashboard" on onboarding screen 4
**UI Reference:** `mockups/figma-make-export/src/components/Login.tsx` (BankID button)

#### Frontend Flow

**Current Implementation:**

**Login Page BankID Button (login/page.tsx lines 7-21):**
```tsx
function BankIDButton() {
  return (
    <a
      href="/api/auth/bankid"
      className="flex-1 py-3 px-4 border rounded-xl font-medium"
    >
      <svg><!-- BankID logo --></svg>
      BankID
    </a>
  );
}
```

**Missing in Dashboard:**
- No BankID verification prompt
- No blocking UI for unverified users
- No "Koble BankID" button or modal

**Required Implementation:**

**Dashboard BankID Prompt (dashboard/page.tsx):**
```tsx
// Check user verification status
const { user } = useAuth();

if (!user.bankid_verified) {
  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-white rounded-2xl p-6 max-w-md mx-4">
        <div className="w-16 h-16 bg-[#0B6E35]/10 rounded-full flex items-center justify-center mx-auto mb-4">
          <Shield className="w-8 h-8 text-[#0B6E35]" />
        </div>
        <h2 className="text-2xl font-bold text-center mb-2">Koble til BankID</h2>
        <p className="text-[#64748B] text-center mb-6">
          For å bruke Drop må du koble din bankkonto via BankID. Dette er påkrevd for sikkerhet.
        </p>
        <a
          href="/api/auth/bankid"
          className="w-full bg-[#0B6E35] text-white py-3 rounded-xl font-medium flex items-center justify-center gap-2"
        >
          <svg><!-- BankID logo --></svg>
          Koble BankID
        </a>
      </div>
    </div>
  );
}

// Normal dashboard content...
```

**Blocking Strategy:**
- Modal overlay (non-dismissable)
- Blocks all dashboard features until BankID verified
- Clear explanation of why it's required
- Single CTA: "Koble BankID"

#### Backend (api/auth/bankid/route.ts)

**Endpoint:** `GET /api/auth/bankid`

**Current Implementation:**
```typescript
// Demo mode check
if (isDemoMode()) {
  return NextResponse.json({
    error: "bankid_unavailable",
    message: "BankID er ikke tilgjengelig i demo-modus",
  }, { status: 400 });
}

// Production: OAuth2 OIDC flow
const clientId = process.env.BANKID_CLIENT_ID;
const redirectUri = process.env.BANKID_CALLBACK_URL;
const authorizeUrl = process.env.BANKID_AUTHORIZE_URL;

// Generate CSRF tokens
const state = randomUUID();
const nonce = randomUUID();

// Store state in httpOnly cookie (5 min expiry)
cookies().set("bankid_state", state, {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "lax",
  maxAge: 5 * 60, // 5 minutes
  path: "/",
});

// Build OAuth authorize URL
const params = new URLSearchParams({
  client_id: clientId,
  redirect_uri: redirectUri,
  response_type: "code",
  scope: "openid profile",
  state,
  nonce,
});

return NextResponse.json({
  redirectUrl: `${authorizeUrl}?${params.toString()}`,
});
```

**OAuth Flow:**
```
User clicks "Koble BankID"
  ↓
GET /api/auth/bankid
  ↓
Generates state + nonce (CSRF protection)
  ↓
Stores state in httpOnly cookie (5 min expiry)
  ↓
Returns BankID OAuth authorize URL
  ↓
Frontend redirects to BankID
  ↓
User authenticates with BankID (mobile app)
  ↓
BankID redirects to /api/auth/bankid/callback?code=XXX&state=YYY
```

#### BankID Callback (api/auth/bankid/callback/route.ts)

**Endpoint:** `GET /api/auth/bankid/callback`

**Required Implementation:**
```typescript
// 1. Verify state (CSRF protection)
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const storedState = cookies().get("bankid_state")?.value;

if (!state || !storedState || state !== storedState) {
  return jsonError("invalid_state", "CSRF validation failed", 400);
}

// 2. Exchange code for tokens
const tokenUrl = process.env.BANKID_TOKEN_URL;
const tokenResponse = await fetch(tokenUrl, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: code!,
    redirect_uri: process.env.BANKID_CALLBACK_URL!,
    client_id: process.env.BANKID_CLIENT_ID!,
    client_secret: process.env.BANKID_CLIENT_SECRET!,
  }),
});

const tokens = await tokenResponse.json();
const { id_token, access_token } = tokens;

// 3. Decode ID token (JWT with user info)
const payload = await jwtVerify(id_token, publicKey);
const { sub, name, birthdate, nin } = payload; // nin = fødselsnummer (11 digits)

// 4. Extract DOB from fødselsnummer
// Format: DDMMYYXXXXX (first 6 digits encode date)
const day = nin.slice(0, 2);
const month = nin.slice(2, 4);
const year = nin.slice(4, 6);
const fullYear = parseInt(year) < 40 ? `20${year}` : `19${year}`;
const dobFromNin = `${fullYear}-${month}-${day}`;

// 5. Verify age >= 18
const dob = new Date(dobFromNin);
const age = calculateAge(dob);
if (age < 18) {
  return jsonError("underage", "Du må være minst 18 år for å bruke Drop", 403);
}

// 6. Find or create user
let user = await getOne("SELECT id FROM users WHERE national_id_hash = ?", [hashNin(nin)]);

if (!user) {
  // Create user from BankID data
  const userId = randomId("usr");
  await run(
    `INSERT INTO users (
      id, email, first_name, last_name, date_of_birth,
      national_id_hash, bankid_verified, phone_verified,
      onboarding_completed, kyc_status
    ) VALUES (?, ?, ?, ?, ?, ?, 1, 1, 0, 'pending')`,
    [userId, null, name.split(" ")[0], name.split(" ")[1], dobFromNin, hashNin(nin)]
  );
  user = { id: userId };
}

// 7. Update existing user with BankID verification
await run(
  `UPDATE users SET
    bankid_verified = 1,
    bankid_verified_at = datetime('now'),
    national_id_hash = ?
   WHERE id = ?`,
  [hashNin(nin), user.id]
);

// 8. Initiate KYC verification
const kycResult = await initiateKyc(user.id, email || `${user.id}@drop.placeholder`);
await run("UPDATE users SET kyc_status = ? WHERE id = ?", [kycResult.status, user.id]);

// 9. Set auth cookie
await setAuthCookie({ userId: user.id, role: "user" });

// 10. Audit log
logAudit({
  userId: user.id,
  action: "bankid.verified",
  resourceType: "user",
  resourceId: user.id,
  details: { nin_last_4: nin.slice(-4) },
});

// 11. Redirect to dashboard or KYC widget
if (kycResult.redirectUrl) {
  return NextResponse.redirect(kycResult.redirectUrl);
} else {
  return NextResponse.redirect("/dashboard");
}
```

**Database Schema Update:**
```sql
ALTER TABLE users ADD COLUMN national_id_hash TEXT UNIQUE;
ALTER TABLE users ADD COLUMN bankid_verified INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN bankid_verified_at TEXT;
```

**Security Considerations:**
- Fødselsnummer stored as SHA-256 hash, never plaintext
- State token validates CSRF (prevents replay attacks)
- State cookie expires after 5 minutes
- ID token verified with BankID public key
- Age verification double-checked from fødselsnummer

**Error Cases:**
| Error | HTTP | Reason |
|-------|------|--------|
| `invalid_state` | 400 | CSRF validation failed (state mismatch) |
| `underage` | 403 | User is < 18 years old (extracted from fødselsnummer) |
| `bankid_unavailable` | 400 | Demo mode (BankID not configured) |
| `server_error` | 500 | Token exchange failed, invalid ID token |

**Gap Analysis:**
- ❌ Callback route missing implementation
- ❌ No fødselsnummer parsing logic
- ❌ No age verification from fødselsnummer
- ❌ No database schema for `national_id_hash`, `bankid_verified`
- ❌ Dashboard doesn't block unverified users

---

### Step 6: BankID → KYC Check

**Route:** `/api/auth/bankid/callback` → KYC service → `/dashboard`
**Trigger:** BankID verification success
**Service:** Sumsub (KYC provider)

#### KYC Service (lib/services/kyc.ts)

**Current Implementation:**

**Demo Mode:**
```typescript
if (isDemoMode()) {
  return { status: "approved" };
}
```

**Production Mode:**
```typescript
// 1. Create Sumsub applicant
const applicantResponse = await fetch(`${apiUrl}/resources/applicants`, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-App-Token": appToken,
    "X-App-Access-Sig": secretKey, // HMAC signature in production
  },
  body: JSON.stringify({
    externalUserId: userId,
    email: email,
    levelName: "basic-kyc-level",
  }),
});

const applicantData = await applicantResponse.json();
const applicantId = applicantData.id;

// 2. Generate SDK access token
const tokenResponse = await fetch(`${apiUrl}/resources/accessTokens`, {
  method: "POST",
  body: JSON.stringify({
    userId: userId,
    ttlInSecs: 3600, // 1 hour validity
  }),
});

const tokenData = await tokenResponse.json();
const widgetUrl = `https://cockpit.sumsub.com/embed/#/verification?accessToken=${tokenData.token}`;

return {
  status: "pending",
  redirectUrl: widgetUrl,
  externalId: applicantId,
};
```

**KYC Check Flow:**
```typescript
// Called from BankID callback after user verification
const kycResult = await initiateKyc(userId, email);

if (kycResult.status === "approved") {
  // Demo mode: immediate approval
  await run("UPDATE users SET kyc_status = 'approved' WHERE id = ?", [userId]);
  return NextResponse.redirect("/dashboard");
} else if (kycResult.redirectUrl) {
  // Production: redirect to Sumsub widget
  return NextResponse.redirect(kycResult.redirectUrl);
} else if (kycResult.status === "pending") {
  // KYC in progress, redirect to dashboard with pending status
  await run("UPDATE users SET kyc_status = 'pending' WHERE id = ?", [userId]);
  return NextResponse.redirect("/dashboard");
} else {
  // KYC rejected
  await run("UPDATE users SET kyc_status = 'rejected' WHERE id = ?", [userId]);
  return NextResponse.redirect("/dashboard?kyc=rejected");
}
```

**Sumsub Webhook (api/webhooks/sumsub/route.ts):**

**Required Implementation:**
```typescript
// Webhook receives KYC status updates from Sumsub
export async function POST(request: NextRequest) {
  const body = await request.json();
  const { type, applicantId, reviewStatus, reviewResult } = body;

  // Verify webhook signature (HMAC)
  const signature = request.headers.get("x-payload-digest");
  const expectedSignature = crypto
    .createHmac("sha256", process.env.SUMSUB_SECRET_KEY!)
    .update(JSON.stringify(body))
    .digest("hex");

  if (signature !== expectedSignature) {
    return jsonError("invalid_signature", "Webhook verification failed", 401);
  }

  // Find user by applicant ID
  const user = await getOne(
    "SELECT id FROM users WHERE kyc_external_id = ?",
    [applicantId]
  );

  if (!user) {
    return jsonError("not_found", "User not found", 404);
  }

  // Map Sumsub status to our status
  let status: "approved" | "pending" | "rejected" = "pending";
  if (reviewStatus === "completed" && reviewResult?.reviewAnswer === "GREEN") {
    status = "approved";
  } else if (reviewStatus === "completed" && reviewResult?.reviewAnswer === "RED") {
    status = "rejected";
  }

  // Update user KYC status
  await run(
    "UPDATE users SET kyc_status = ?, kyc_verified_at = datetime('now') WHERE id = ?",
    [status, user.id]
  );

  // Audit log
  logAudit({
    userId: user.id,
    action: `kyc.${status}`,
    resourceType: "user",
    resourceId: user.id,
    details: { applicantId, reviewStatus },
  });

  // Send notification
  if (status === "approved") {
    await sendNotification(user.id, {
      type: "kyc_approved",
      title: "Kontoen din er godkjent!",
      body: "Du kan nå bruke alle Drop-funksjoner.",
    });
  } else if (status === "rejected") {
    await sendNotification(user.id, {
      type: "kyc_rejected",
      title: "Verifisering feilet",
      body: "Kontakt kundeservice for hjelp.",
    });
  }

  return NextResponse.json({ success: true });
}
```

**Database Schema Update:**
```sql
ALTER TABLE users ADD COLUMN kyc_external_id TEXT;
ALTER TABLE users ADD COLUMN kyc_verified_at TEXT;
```

**KYC Status UI:**

**Dashboard Pending State:**
```tsx
if (user.kyc_status === "pending") {
  return (
    <div className="bg-[#FEF3C7] border border-[#FCD34D] rounded-2xl p-4 mb-6">
      <div className="flex items-start gap-3">
        <div className="w-10 h-10 bg-[#F59E0B] rounded-full flex items-center justify-center">
          <Clock className="w-5 h-5 text-white" />
        </div>
        <div>
          <h3 className="font-bold text-[#92400E]">Verifisering pågår</h3>
          <p className="text-sm text-[#92400E]/80">
            Vi gjennomgår dokumentene dine. Dette tar vanligvis 1-2 timer.
          </p>
        </div>
      </div>
    </div>
  );
}
```

**Dashboard Rejected State:**
```tsx
if (user.kyc_status === "rejected") {
  return (
    <div className="bg-[#FEE2E2] border border-[#FCA5A5] rounded-2xl p-4 mb-6">
      <div className="flex items-start gap-3">
        <div className="w-10 h-10 bg-[#EF4444] rounded-full flex items-center justify-center">
          <X className="w-5 h-5 text-white" />
        </div>
        <div>
          <h3 className="font-bold text-[#991B1B]">Verifisering feilet</h3>
          <p className="text-sm text-[#991B1B]/80 mb-3">
            Vi kunne ikke verifisere identiteten din. Kontakt kundeservice for hjelp.
          </p>
          <button className="text-sm font-medium text-[#EF4444] underline">
            Kontakt support
          </button>
        </div>
      </div>
    </div>
  );
}
```

**Transaction Blocking:**
```typescript
// All transaction endpoints must check KYC status
if (user.kyc_status !== "approved") {
  return jsonError(
    "kyc_required",
    "Du må fullføre identitetsverifisering før du kan sende penger",
    403
  );
}
```

**Gap Analysis:**
- ✅ KYC service implemented (demo + production modes)
- ❌ Webhook handler missing
- ❌ No UI for pending/rejected KYC states
- ❌ No transaction blocking based on KYC status
- ❌ No notification system for KYC status changes

---

### Step 7: KYC Approved → Full Dashboard Access

**Route:** `/dashboard` (fully unlocked)
**Trigger:** KYC status = 'approved'
**UI Reference:** `mockups/figma-make-export/src/components/Dashboard.tsx`

#### Dashboard Features (Unlocked After KYC)

**Available Actions:**
1. **Send Money** → `/send`
   - Remittance to 30+ countries
   - PISP initiates payment from user's bank account
   - Shows exchange rates, fees, recipient details

2. **Scan QR** → `/scan`
   - QR code scanner for merchant payments
   - PISP initiates payment from user's bank account
   - Shows merchant name, amount, confirm screen

3. **Bank Accounts** → `/accounts`
   - View linked bank account balances (AISP cached reads)
   - Connect new bank accounts
   - Set primary account

4. **Transaction History** → `/transactions`
   - Full transaction list with filters (date, type, status)
   - Export to PDF/CSV
   - Search by recipient, amount, reference

5. **Notifications** → `/notifications`
   - Push notifications and transaction alerts
   - Mark as read, delete

6. **Profile/Settings** → `/profile`
   - Change PIN, password, email
   - Language preference (NO/EN)
   - Push notification settings
   - Delete account

**Dashboard UI:**
```tsx
<div className="p-6">
  {/* Welcome banner */}
  <div className="mb-6">
    <h1 className="text-2xl font-bold text-[#0F172A]">
      Hei, {user.firstName}!
    </h1>
    <p className="text-[#64748B]">
      Her er en oversikt over dine kontoer
    </p>
  </div>

  {/* Balance card */}
  <div className="bg-gradient-to-br from-[#0B6E35] to-[#095a2b] rounded-2xl p-6 text-white mb-6">
    <p className="text-sm opacity-90">Total saldo</p>
    <p className="text-4xl font-bold mb-4">
      {formatCurrency(totalBalance)} NOK
    </p>
    <div className="flex gap-3">
      <button className="flex-1 bg-white/20 py-2 rounded-xl font-medium">
        Send penger
      </button>
      <button className="flex-1 bg-white/20 py-2 rounded-xl font-medium">
        Skann QR
      </button>
    </div>
  </div>

  {/* Recent transactions */}
  <div>
    <h2 className="text-lg font-bold text-[#1E293B] mb-4">
      Siste transaksjoner
    </h2>
    {transactions.slice(0, 5).map(tx => (
      <TransactionRow key={tx.id} transaction={tx} />
    ))}
    <Link href="/transactions" className="text-[#0B6E35] font-medium">
      Se alle →
    </Link>
  </div>
</div>
```

**Access Control:**
```typescript
// Middleware enforces KYC check on transaction routes
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Protected routes requiring KYC approval
  const transactionRoutes = ["/send", "/scan", "/api/transactions"];
  const requiresKyc = transactionRoutes.some(route => pathname.startsWith(route));

  if (requiresKyc) {
    const { user } = await getAuthUser(request);
    if (user.kyc_status !== "approved") {
      return NextResponse.redirect("/dashboard?kyc=pending");
    }
  }

  return NextResponse.next();
}
```

---

## 4. Database Schema

### 4.1 New Fields for `users` Table

```sql
ALTER TABLE users ADD COLUMN phone_verified INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN bankid_verified INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN bankid_verified_at TEXT;
ALTER TABLE users ADD COLUMN onboarding_completed INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT;
ALTER TABLE users ADD COLUMN national_id_hash TEXT UNIQUE;
ALTER TABLE users ADD COLUMN kyc_external_id TEXT;
ALTER TABLE users ADD COLUMN kyc_verified_at TEXT;
ALTER TABLE users ADD COLUMN pin_hash TEXT;
ALTER TABLE users ADD COLUMN pin_set_at TEXT;
```

### 4.2 `onboarding_progress` Table

**Purpose:** Track user onboarding state and drop-off points for analytics.

```sql
CREATE TABLE IF NOT EXISTS onboarding_progress (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  step TEXT NOT NULL, -- 'register', 'phone_otp', 'pin_setup', 'onboarding_tour', 'bankid', 'kyc'
  status TEXT NOT NULL, -- 'started', 'completed', 'skipped', 'failed'
  started_at TEXT NOT NULL,
  completed_at TEXT,
  drop_reason TEXT, -- For analytics: 'timeout', 'error', 'user_exit'
  metadata TEXT, -- JSON with step-specific data
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_onboarding_user ON onboarding_progress(user_id);
CREATE INDEX idx_onboarding_step ON onboarding_progress(step);
CREATE INDEX idx_onboarding_status ON onboarding_progress(status);
```

**Usage:**
```typescript
// Track step start
await run(
  `INSERT INTO onboarding_progress (id, user_id, step, status, started_at)
   VALUES (?, ?, ?, 'started', datetime('now'))`,
  [randomId("prog"), userId, "phone_otp"]
);

// Track step completion
await run(
  `UPDATE onboarding_progress
   SET status = 'completed', completed_at = datetime('now')
   WHERE user_id = ? AND step = ?`,
  [userId, "phone_otp"]
);

// Track drop-off
await run(
  `UPDATE onboarding_progress
   SET status = 'failed', drop_reason = 'otp_expired'
   WHERE user_id = ? AND step = ?`,
  [userId, "phone_otp"]
);
```

---

## 5. API Endpoints Summary

### 5.1 Existing Endpoints

| Endpoint | Method | Purpose | Status |
|----------|--------|---------|--------|
| `/api/auth/register` | POST | Create user account | ✅ Implemented |
| `/api/auth/login` | POST | Email/password login | ✅ Implemented |
| `/api/auth/verify-otp` | POST | Verify phone OTP | ✅ Implemented |
| `/api/auth/bankid` | GET | Initiate BankID OAuth | ✅ Partial (no callback) |
| `/api/auth/me` | GET | Get current user | ✅ Implemented |

### 5.2 New Endpoints Required

| Endpoint | Method | Purpose | Priority |
|----------|--------|---------|----------|
| `/api/auth/set-pin` | POST | Set 4-digit PIN | HIGH |
| `/api/auth/bankid/callback` | GET | BankID OAuth callback | HIGH |
| `/api/onboarding/complete` | POST | Mark onboarding complete | MEDIUM |
| `/api/webhooks/sumsub` | POST | KYC status updates | HIGH |
| `/api/auth/resend-otp` | POST | Resend phone OTP | MEDIUM |
| `/api/notifications` | GET | List user notifications | LOW |
| `/api/notifications/:id/read` | PATCH | Mark notification as read | LOW |

---

## 6. Edge Cases & Error Handling

### 6.1 Age Verification Failure

**Scenario:** User provides DOB indicating age < 18

**Frontend:**
```tsx
if (age < 18) {
  setError("Du må være minst 18 år for å bruke Drop");
  return;
}
```

**Backend:**
```typescript
if (age < 18) {
  return jsonError("underage", "Du må være minst 18 år for å bruke Drop", 403);
}
```

**UI Treatment:**
- Error message displayed in form
- Red background (#EF4444/10)
- No account creation
- No "try again" option (legal requirement)

**Legal Compliance:**
- Vilkår.html section 3: "Du må være minst 18 år"
- PSD2 compliance: Strong Customer Authentication requires adult age
- AML regulation: No accounts for minors

### 6.2 BankID Verification Failure

**Scenario 1: BankID returns fødselsnummer indicating age < 18**

**Handling:**
```typescript
const age = calculateAgeFromNin(nin);
if (age < 18) {
  await run("DELETE FROM users WHERE id = ?", [userId]); // Remove account
  logAudit({ userId, action: "bankid.underage_rejection" });
  return NextResponse.redirect("/register?error=underage");
}
```

**UI:**
```tsx
// /register?error=underage
<div className="bg-[#FEE2E2] border border-[#FCA5A5] rounded-2xl p-6">
  <h2 className="font-bold text-[#991B1B] mb-2">Verifisering feilet</h2>
  <p className="text-sm text-[#991B1B]/80">
    BankID viser at du er under 18 år. Drop er kun tilgjengelig for voksne.
  </p>
</div>
```

**Scenario 2: BankID OAuth fails (timeout, user cancels, invalid state)**

**Handling:**
```typescript
// Callback error handling
if (!code || !state) {
  return NextResponse.redirect("/register?error=bankid_cancelled");
}

if (state !== storedState) {
  logAudit({ action: "bankid.csrf_attempt", details: { ip } });
  return jsonError("invalid_state", "CSRF validation failed", 400);
}
```

**UI:**
```tsx
// /register?error=bankid_cancelled
<div className="bg-[#FEF3C7] border border-[#FCD34D] rounded-2xl p-6">
  <h2 className="font-bold text-[#92400E] mb-2">BankID-innlogging avbrutt</h2>
  <p className="text-sm text-[#92400E]/80 mb-3">
    Du avbrøt BankID-prosessen. Prøv igjen for å fullføre registreringen.
  </p>
  <button className="text-sm font-medium text-[#F59E0B] underline">
    Prøv igjen
  </button>
</div>
```

**Scenario 3: BankID network timeout**

**Handling:**
```typescript
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);

const tokenResponse = await fetch(tokenUrl, {
  signal: controller.signal,
  // ...
});

clearTimeout(timeoutId);
```

**Retry Strategy:**
- 30-second timeout per OAuth step
- Max 3 retry attempts
- Exponential backoff: 5s → 10s → 20s
- User-facing error: "BankID er ikke tilgjengelig. Prøv igjen senere."

### 6.3 KYC Rejection

**Scenario:** Sumsub rejects user identity verification

**Handling:**
```typescript
// Webhook handler
if (reviewStatus === "completed" && reviewResult?.reviewAnswer === "RED") {
  await run("UPDATE users SET kyc_status = 'rejected' WHERE id = ?", [userId]);
  await sendNotification(userId, {
    type: "kyc_rejected",
    title: "Verifisering feilet",
    body: "Kontakt kundeservice for hjelp.",
  });
  logAudit({ userId, action: "kyc.rejected", details: { applicantId } });
}
```

**Dashboard UI:**
```tsx
if (user.kyc_status === "rejected") {
  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
      <div className="bg-white rounded-2xl p-6 max-w-md mx-4">
        <div className="w-16 h-16 bg-[#EF4444]/10 rounded-full flex items-center justify-center mx-auto mb-4">
          <X className="w-8 h-8 text-[#EF4444]" />
        </div>
        <h2 className="text-2xl font-bold text-center mb-2">Verifisering feilet</h2>
        <p className="text-[#64748B] text-center mb-6">
          Vi kunne ikke verifisere identiteten din. Dette kan skyldes uklare dokumenter eller manglende informasjon.
        </p>
        <p className="text-sm text-[#64748B] text-center mb-6">
          Kontakt kundeservice på <a href="mailto:support@getdrop.no" className="text-[#0B6E35] underline">support@getdrop.no</a> for hjelp.
        </p>
        <button
          onClick={() => router.push("/profile")}
          className="w-full bg-[#E2E8F0] text-[#1E293B] py-3 rounded-xl font-medium"
        >
          Gå til profil
        </button>
      </div>
    </div>
  );
}
```

**Account State:**
- User can log in but cannot transact
- Profile accessible (change password, email)
- Support ticket creation enabled
- No remittance or QR payment access
- Transaction history remains visible

**Support Workflow:**
1. User contacts support@getdrop.no
2. Support agent reviews KYC rejection reason in Sumsub dashboard
3. Agent requests additional documents via email
4. User uploads documents to support ticket
5. Agent manually submits documents to Sumsub
6. Sumsub re-reviews → status updated via webhook
7. If approved: user notified, account unlocked

### 6.4 Phone OTP Timeout

**Scenario:** User doesn't verify OTP within 5 minutes

**Handling:**
```typescript
// OTP expiry check (verify-otp/route.ts line 67)
const now = new Date().toISOString();
const otpRecord = await getOne(
  `SELECT id, code, expires_at FROM otp_codes
   WHERE user_id = ? AND used = 0 AND expires_at > ?
   ORDER BY created_at DESC LIMIT 1`,
  [userId, now]
);

if (!otpRecord) {
  return jsonError("invalid_otp", "Invalid or expired code", 400);
}
```

**UI:**
```tsx
// Show expired state after 5 minutes
const [otpExpired, setOtpExpired] = useState(false);

useEffect(() => {
  const timer = setTimeout(() => setOtpExpired(true), 5 * 60 * 1000);
  return () => clearTimeout(timer);
}, []);

if (otpExpired) {
  return (
    <div className="bg-[#FEF3C7] border border-[#FCD34D] rounded-2xl p-4 mb-4">
      <p className="text-sm text-[#92400E]">
        Koden har utløpt. <button className="underline font-medium">Send ny kode</button>
      </p>
    </div>
  );
}
```

**Resend OTP Endpoint:**

**Required Implementation:**

**Endpoint:** `POST /api/auth/resend-otp`

**Request Body:**
```json
{
  "userId": "usr_abc123",
  "phone": "+4712345678"
}
```

**Backend Logic:**
```typescript
// Rate limit: Max 3 OTP sends per hour per user
const recentOtps = await getOne(
  `SELECT COUNT(*) as count FROM otp_codes
   WHERE user_id = ? AND created_at > datetime('now', '-1 hour')`,
  [userId]
);

if (recentOtps.count >= 3) {
  return jsonError("rate_limited", "For mange forsøk. Prøv igjen om 1 time.", 429);
}

// Mark old OTPs as used (prevent replay)
await run("UPDATE otp_codes SET used = 1 WHERE user_id = ?", [userId]);

// Generate new OTP
const otpCode = String(crypto.randomInt(100000, 1000000));
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();

await run(
  "INSERT INTO otp_codes (id, user_id, code, expires_at) VALUES (?, ?, ?, ?)",
  [randomId("otp"), userId, otpCode, expiresAt]
);

// TODO: Send via SMS provider
logger.info("OTP resent", { userId, phone });

return NextResponse.json({ data: { sent: true } });
```

**Error Cases:**
| Error | HTTP | Reason |
|-------|------|--------|
| `rate_limited` | 429 | More than 3 OTP sends in 1 hour |
| `bad_request` | 400 | Invalid user ID or phone |

### 6.5 User Abandons Onboarding

**Scenario:** User registers but doesn't complete onboarding

**Analytics Tracking:**
```typescript
// Track drop-off points
await run(
  `INSERT INTO onboarding_progress (id, user_id, step, status, started_at, drop_reason)
   VALUES (?, ?, ?, 'failed', datetime('now'), ?)`,
  [randomId("prog"), userId, "onboarding_tour", "user_exit"]
);
```

**Re-engagement Strategy:**

**Email Reminder (24h after registration):**
```
Subject: Fullfør registreringen din på Drop

Hei [FirstName],

Vi la merke til at du startet registrering på Drop men ikke fullførte prosessen.

Det tar bare 2 minutter å knytte BankID og få tilgang til:
✅ Lave gebyrer på remittance (0.5%)
✅ QR-betaling i butikk
✅ Direkte fra din bankkonto

[Fullfør registrering] (CTA button)

Mvh,
Drop-teamet
```

**Dashboard Banner (returning user without BankID):**
```tsx
if (user.bankid_verified === 0) {
  return (
    <div className="bg-[#0B6E35]/10 border border-[#0B6E35]/20 rounded-2xl p-4 mb-6">
      <div className="flex items-start gap-3">
        <Shield className="w-5 h-5 text-[#0B6E35] mt-1" />
        <div>
          <h3 className="font-bold text-[#0B6E35]">Fullfør registreringen</h3>
          <p className="text-sm text-[#0B6E35]/80 mb-3">
            Koble BankID for å få tilgang til alle funksjoner.
          </p>
          <button className="text-sm font-medium text-[#0B6E35] underline">
            Koble BankID nå
          </button>
        </div>
      </div>
    </div>
  );
}
```

**Analytics Metrics:**
```sql
-- Onboarding funnel conversion rates
SELECT
  step,
  COUNT(*) as started,
  SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
  ROUND(100.0 * SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) / COUNT(*), 2) as conversion_rate
FROM onboarding_progress
GROUP BY step
ORDER BY
  CASE step
    WHEN 'register' THEN 1
    WHEN 'phone_otp' THEN 2
    WHEN 'pin_setup' THEN 3
    WHEN 'onboarding_tour' THEN 4
    WHEN 'bankid' THEN 5
    WHEN 'kyc' THEN 6
  END;
```

**Expected Conversion Rates:**
| Step | Expected Conversion | Drop-off Reason |
|------|---------------------|-----------------|
| Register → Phone OTP | 90% | OTP not received, user exits |
| Phone OTP → PIN Setup | 95% | OTP timeout, wrong code |
| PIN Setup → Onboarding Tour | 98% | Accidental exit |
| Onboarding Tour → BankID | 70% | User skips, BankID unavailable |
| BankID → KYC | 95% | BankID fails, user cancels |
| KYC → Approved | 85% | Document issues, age < 18 |

**Overall Conversion:** ~50% (from registration to fully verified)

### 6.6 Network Errors

**Scenario:** API call fails due to network issues

**Frontend Retry Strategy:**
```tsx
async function fetchWithRetry(url: string, options: RequestInit, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      const res = await fetch(url, options);
      if (res.ok) return res;
      if (res.status >= 500 && i < retries - 1) {
        await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
        continue;
      }
      return res;
    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
    }
  }
}
```

**User-Facing Error:**
```tsx
<div className="bg-[#FEE2E2] border border-[#FCA5A5] rounded-2xl p-4">
  <h3 className="font-bold text-[#991B1B] mb-1">Noe gikk galt</h3>
  <p className="text-sm text-[#991B1B]/80 mb-3">
    Vi kunne ikke koble til serveren. Sjekk internettforbindelsen din.
  </p>
  <button className="text-sm font-medium text-[#EF4444] underline">
    Prøv igjen
  </button>
</div>
```

---

## 7. Analytics & Drop-off Tracking

### 7.1 Key Metrics

| Metric | Definition | Target |
|--------|------------|--------|
| Registration Start Rate | Visitors → Registration page | 25% |
| Registration Completion | Registration page → OTP sent | 90% |
| OTP Verification Rate | OTP sent → OTP verified | 85% |
| Onboarding Completion | OTP verified → Tour complete | 70% |
| BankID Conversion | Tour complete → BankID verified | 80% |
| KYC Approval Rate | BankID verified → KYC approved | 90% |
| Overall Conversion | Visitors → Fully verified | 12% |
| Time to Verify | Registration → KYC approved | < 2 hours |

### 7.2 Drop-off Points

**Funnel Visualization:**
```
100 visitors
  ↓ 25% (Registration Start Rate)
25 start registration
  ↓ 90% (Registration Completion)
23 send OTP
  ↓ 85% (OTP Verification Rate)
20 verify OTP
  ↓ 70% (Onboarding Completion)
14 complete tour
  ↓ 80% (BankID Conversion)
11 verify BankID
  ↓ 90% (KYC Approval Rate)
10 fully verified
```

**Drop-off Reasons:**

| Step | Drop-off % | Top Reasons |
|------|-----------|-------------|
| Registration Form | 10% | Form too long, unclear requirements |
| Phone OTP | 15% | OTP not received, timeout |
| PIN Setup | 2% | Accidental exit |
| Onboarding Tour | 30% | User skips (friction point) |
| BankID | 20% | BankID unavailable, user doesn't have it |
| KYC | 10% | Document issues, age verification fails |

**Optimization Priorities:**
1. **HIGH:** Reduce onboarding tour drop-off (30% → 10%)
   - Make skippable without blocking BankID
   - Shorten from 4 screens to 2 screens
   - Add progress indicator showing "2 min to finish"

2. **MEDIUM:** Improve BankID conversion (80% → 90%)
   - Clearer explanation of why BankID is required
   - Add fallback: "Don't have BankID? Use Vipps instead"
   - Show trust signals (bank logos, security icons)

3. **LOW:** Reduce OTP drop-off (15% → 10%)
   - Add "Resend OTP" button immediately visible
   - Show estimated delivery time: "SMS arrives in 10-30 seconds"
   - Add troubleshooting tips: "Check spam folder"

### 7.3 Analytics Implementation

**Event Tracking:**
```typescript
// Track page views
analytics.track("onboarding_step_viewed", {
  userId,
  step: "register",
  timestamp: Date.now(),
});

// Track form interactions
analytics.track("registration_form_submitted", {
  userId,
  fields: ["email", "phone", "dob"],
  timestamp: Date.now(),
});

// Track errors
analytics.track("otp_verification_failed", {
  userId,
  reason: "invalid_code",
  attempts: 3,
  timestamp: Date.now(),
});

// Track completion
analytics.track("onboarding_completed", {
  userId,
  duration: Date.now() - startTime,
  timestamp: Date.now(),
});
```

**Drop-off Report (Weekly):**
```sql
-- Generate weekly onboarding funnel report
WITH funnel AS (
  SELECT
    'Register' as step, 1 as step_order,
    COUNT(DISTINCT user_id) as users
  FROM onboarding_progress
  WHERE step = 'register' AND started_at > date('now', '-7 days')

  UNION ALL

  SELECT
    'Phone OTP' as step, 2 as step_order,
    COUNT(DISTINCT user_id) as users
  FROM onboarding_progress
  WHERE step = 'phone_otp' AND status = 'completed' AND completed_at > date('now', '-7 days')

  -- ... repeat for each step
)
SELECT
  step,
  users,
  LAG(users) OVER (ORDER BY step_order) as previous_step_users,
  ROUND(100.0 * users / LAG(users) OVER (ORDER BY step_order), 2) as conversion_rate
FROM funnel
ORDER BY step_order;
```

---

## 8. Re-engagement Strategy

### 8.1 Email Triggers

**Trigger 1: OTP Not Verified (1 hour after registration)**
```
Subject: Bekreft telefonnummeret ditt

Hei [FirstName],

Du er nesten ferdig med registreringen!

Vi sendte en 6-sifret kode til +47 [Phone]. Hvis du ikke mottok koden, kan du be om en ny.

[Fullfør registrering]

Koden utløper om 5 minutter.
```

**Trigger 2: BankID Not Linked (24 hours after OTP verification)**
```
Subject: Koble BankID for å bruke Drop

Hei [FirstName],

For å bruke Drop må du koble BankID. Dette tar bare 1 minutt og sikrer at pengene dine er trygge.

[Koble BankID nå]

Hvorfor BankID?
✅ Kun du har tilgang til kontoen din
✅ Vi kan aldri flytte penger uten ditt samtykke
✅ All data er kryptert

Mvh,
Drop-teamet
```

**Trigger 3: KYC Pending (48 hours after BankID verification)**
```
Subject: Verifisering pågår

Hei [FirstName],

Vi gjennomgår dokumentene dine. Dette tar vanligvis 1-2 timer, men kan ta opptil 48 timer.

Du får en varsling når kontoen din er godkjent.

Har du spørsmål? Svar på denne e-posten.

Mvh,
Drop-teamet
```

### 8.2 Push Notifications

**Notification 1: OTP Resend Available**
```json
{
  "type": "otp_resend",
  "title": "Ikke mottatt kode?",
  "body": "Trykk her for å sende en ny verifiseringskode",
  "action": "OPEN_APP",
  "data": { "screen": "register", "step": "verify" }
}
```

**Notification 2: KYC Approved**
```json
{
  "type": "kyc_approved",
  "title": "Kontoen din er godkjent! 🎉",
  "body": "Du kan nå sende penger og betale med QR",
  "action": "OPEN_APP",
  "data": { "screen": "dashboard" }
}
```

**Notification 3: KYC Rejected**
```json
{
  "type": "kyc_rejected",
  "title": "Verifisering feilet",
  "body": "Kontakt kundeservice for hjelp",
  "action": "OPEN_SUPPORT",
  "data": { "screen": "profile", "tab": "support" }
}
```

### 8.3 In-App Prompts

**Dashboard Banner (BankID not linked):**
```tsx
<div className="bg-gradient-to-r from-[#0B6E35] to-[#095a2b] rounded-2xl p-6 text-white mb-6">
  <h3 className="font-bold mb-2">Koble BankID for å låse opp alle funksjoner</h3>
  <p className="text-sm text-white/90 mb-4">
    Send penger til utlandet og betal i butikk med QR
  </p>
  <button className="bg-white text-[#0B6E35] py-2 px-4 rounded-xl font-medium">
    Koble BankID nå
  </button>
</div>
```

**Transaction Attempt Without BankID:**
```tsx
// User clicks "Send Money" without BankID
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  <div className="bg-white rounded-2xl p-6 max-w-md mx-4">
    <div className="w-16 h-16 bg-[#0B6E35]/10 rounded-full flex items-center justify-center mx-auto mb-4">
      <Lock className="w-8 h-8 text-[#0B6E35]" />
    </div>
    <h2 className="text-2xl font-bold text-center mb-2">BankID påkrevd</h2>
    <p className="text-[#64748B] text-center mb-6">
      For å sende penger må du først koble BankID. Dette sikrer at pengene dine er trygge.
    </p>
    <button className="w-full bg-[#0B6E35] text-white py-3 rounded-xl font-medium">
      Koble BankID
    </button>
    <button className="w-full text-[#64748B] py-3">
      Avbryt
    </button>
  </div>
</div>
```

---

## 9. Legal Consents

### 9.1 Required Consents

**Terms of Service:**
- Checkbox at registration: "Jeg godtar [vilkårene for bruk](https://getdrop.no/vilkar)"
- Must be checked to proceed
- Links to `landing/pages/vilkar.html`

**Privacy Policy:**
- Checkbox at registration: "Jeg godtar [personvernerklæringen](https://getdrop.no/privacy)"
- Must be checked to proceed
- Links to `landing/pages/privacy.html`

**PSD2 AISP/PISP Consent:**
- Modal at BankID connection:
  ```
  Tilgang til bankkontoen din

  Ved å koble BankID gir du Drop tillatelse til å:
  ✅ Lese saldo på din bankkonto (AISP)
  ✅ Initiere betalinger fra din bankkonto (PISP)

  Du kan trekke tilbake samtykket når som helst.

  [Godta og fortsett] [Avbryt]
  ```

**Marketing Consent (Optional):**
- Checkbox at registration: "Jeg ønsker å motta tips og tilbud fra Drop (valgfritt)"
- Default: unchecked
- Can be changed later in settings

### 9.2 Consent Storage

**Database Schema:**
```sql
-- Table already exists (architecture-document.md line 157)
CREATE TABLE IF NOT EXISTS consents (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL,
  consent_type TEXT NOT NULL, -- 'terms', 'privacy', 'psd2_aisp', 'psd2_pisp', 'marketing'
  granted INTEGER NOT NULL, -- 0 or 1
  granted_at TEXT,
  withdrawn_at TEXT,
  ip_address TEXT,
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_consents_user ON consents(user_id);
CREATE INDEX idx_consents_type ON consents(consent_type);
```

**Recording Consents:**
```typescript
// At registration (terms + privacy)
await run(
  `INSERT INTO consents (id, user_id, consent_type, granted, granted_at, ip_address)
   VALUES (?, ?, ?, 1, datetime('now'), ?)`,
  [randomId("con"), userId, "terms", ip]
);

await run(
  `INSERT INTO consents (id, user_id, consent_type, granted, granted_at, ip_address)
   VALUES (?, ?, ?, 1, datetime('now'), ?)`,
  [randomId("con"), userId, "privacy", ip]
);

// Optional marketing
if (marketingConsent) {
  await run(
    `INSERT INTO consents (id, user_id, consent_type, granted, granted_at, ip_address)
     VALUES (?, ?, ?, 1, datetime('now'), ?)`,
    [randomId("con"), userId, "marketing", ip]
  );
}

// At BankID connection (PSD2 AISP + PISP)
await run(
  `INSERT INTO consents (id, user_id, consent_type, granted, granted_at, ip_address)
   VALUES (?, ?, ?, 1, datetime('now'), ?), (?, ?, ?, 1, datetime('now'), ?)`,
  [randomId("con"), userId, "psd2_aisp", ip, randomId("con"), userId, "psd2_pisp", ip]
);
```

**Withdrawing Consent:**
```typescript
// User withdraws PSD2 consent (disconnect bank account)
await run(
  `UPDATE consents
   SET granted = 0, withdrawn_at = datetime('now')
   WHERE user_id = ? AND consent_type IN ('psd2_aisp', 'psd2_pisp')`,
  [userId]
);

// Unlink bank account
await run("DELETE FROM bank_accounts WHERE user_id = ?", [userId]);

// Audit log
logAudit({
  userId,
  action: "consent.withdrawn",
  resourceType: "consent",
  details: { types: ["psd2_aisp", "psd2_pisp"] },
});
```

### 9.3 GDPR Compliance

**Right to Access:**
```typescript
// GET /api/gdpr/data-export
// Returns JSON with all user data
{
  "user": { /* user record */ },
  "bank_accounts": [ /* accounts */ ],
  "transactions": [ /* transactions */ ],
  "consents": [ /* consents */ ],
  "audit_log": [ /* audit entries */ ]
}
```

**Right to Erasure:**
```typescript
// DELETE /api/gdpr/delete-account
// Soft delete: sets deleted_at, anonymizes PII
await run(
  `UPDATE users SET
    email = 'deleted_' || id || '@drop.deleted',
    first_name = 'Deleted',
    last_name = 'User',
    phone = NULL,
    national_id_hash = NULL,
    deleted_at = datetime('now')
   WHERE id = ?`,
  [userId]
);

// Anonymize audit logs (keep records for compliance, remove PII)
await run(
  `UPDATE audit_log SET
    details = json_set(details, '$.email', 'REDACTED')
   WHERE user_id = ?`,
  [userId]
);
```

**Data Retention:**
- Active users: indefinite
- Deleted users: 90 days (then full purge)
- Transaction records: 5 years (AML compliance)
- Audit logs: 7 years (regulatory requirement)

---

## 10. Acceptance Criteria

### 10.1 Functional Requirements

**Registration:**
- [ ] User can register with email, password, name, phone, DOB
- [ ] Age validation rejects users < 18 years
- [ ] Password complexity enforced (8+ chars, upper, lower, digit, special)
- [ ] Norwegian phone number required (+47)
- [ ] Duplicate email detection
- [ ] OTP generated and logged (SMS not sent in demo mode)

**Phone OTP Verification:**
- [ ] User receives 6-digit OTP (logged to console in demo mode)
- [ ] OTP expires after 5 minutes
- [ ] OTP marked as used after successful verification
- [ ] Rate limiting: 5 OTP attempts per minute per IP
- [ ] Generic error messages (no user enumeration)
- [ ] Resend OTP functionality

**PIN Setup:**
- [ ] User sets 4-digit PIN
- [ ] Weak PIN patterns rejected (0000, 1234, 1111, sequential)
- [ ] PIN stored as bcrypt hash
- [ ] Auto-advance to onboarding on 4th digit
- [ ] Backend endpoint `/api/auth/set-pin` implemented

**Onboarding Tour:**
- [ ] 4-screen carousel (Welcome, Benefits, BankID, Ready)
- [ ] Progress indicator shows current step
- [ ] Skip button available (except last screen)
- [ ] Back button visible (except first screen)
- [ ] Tour completion tracked in database
- [ ] Backend endpoint `/api/onboarding/complete` implemented

**BankID Verification:**
- [ ] User redirected to BankID OAuth flow
- [ ] CSRF protection via state token
- [ ] Callback extracts fødselsnummer from ID token
- [ ] Age verification from fødselsnummer (reject if < 18)
- [ ] Fødselsnummer stored as SHA-256 hash
- [ ] `bankid_verified` flag set to 1
- [ ] KYC initiation triggered after BankID success
- [ ] Dashboard blocks unverified users with modal

**KYC Check:**
- [ ] Demo mode: auto-approve KYC
- [ ] Production mode: redirect to Sumsub widget
- [ ] Webhook handler updates KYC status
- [ ] Pending state shows yellow banner on dashboard
- [ ] Rejected state shows red banner with support link
- [ ] Transaction routes blocked until KYC approved
- [ ] Push notification sent on KYC approval/rejection

### 10.2 Non-Functional Requirements

**Performance:**
- [ ] Registration API responds < 500ms (p95)
- [ ] OTP verification API responds < 300ms (p95)
- [ ] BankID OAuth redirect < 1s
- [ ] Onboarding UI loads < 1.5s (FCP)

**Security:**
- [ ] Password hashed with bcrypt (rounds: 12)
- [ ] PIN hashed with bcrypt (rounds: 12)
- [ ] JWT in httpOnly cookie (no localStorage)
- [ ] CSRF protection on BankID OAuth
- [ ] Rate limiting on all auth endpoints
- [ ] Audit trail for all user actions
- [ ] No PII in logs (phone numbers hashed)

**Accessibility:**
- [ ] WCAG 2.1 AA compliance
- [ ] Screen reader support
- [ ] Keyboard navigation
- [ ] Focus indicators on all interactive elements
- [ ] Error messages accessible (aria-live regions)

**Usability:**
- [ ] Mobile-first responsive design
- [ ] Touch targets >= 44px
- [ ] Clear error messages (Norwegian)
- [ ] Loading states for all async operations
- [ ] Success/error feedback for all actions

**Legal Compliance:**
- [ ] Terms of Service consent required
- [ ] Privacy Policy consent required
- [ ] PSD2 AISP/PISP consent modal at BankID
- [ ] Consent storage in database
- [ ] Age requirement enforced (18+)
- [ ] Norwegian residency requirement enforced (+47 phone, BankID)

### 10.3 Edge Case Coverage

- [ ] Age < 18 rejected (frontend + backend + BankID)
- [ ] BankID timeout handled gracefully
- [ ] BankID user cancellation handled
- [ ] KYC rejection flow implemented
- [ ] OTP expiry handled with resend
- [ ] Network errors retry with backoff
- [ ] User abandonment tracked in analytics
- [ ] Re-engagement emails triggered

---

## 11. Implementation Order

### Phase 1: Backend Foundations (Priority: HIGH)
**Duration:** 2 days
**Owner:** Backend agent

1. ✅ Database schema updates
   - Add new fields to `users` table (pin_hash, bankid_verified, onboarding_completed, national_id_hash)
   - Create `onboarding_progress` table
   - Create indexes

2. ✅ Missing API endpoints
   - `POST /api/auth/set-pin`
   - `GET /api/auth/bankid/callback`
   - `POST /api/onboarding/complete`
   - `POST /api/auth/resend-otp`
   - `POST /api/webhooks/sumsub`

3. ✅ Age verification logic
   - Extract DOB from fødselsnummer
   - Validate age >= 18 in BankID callback

4. ✅ Consent storage
   - Record consents at registration and BankID

### Phase 2: Frontend Fixes (Priority: HIGH)
**Duration:** 1 day
**Owner:** Frontend agent

1. ✅ PIN setup backend integration
   - Call `/api/auth/set-pin` after PIN entered
   - Validate weak PIN patterns
   - Handle errors

2. ✅ Onboarding completion tracking
   - Call `/api/onboarding/complete` on last screen

3. ✅ Dashboard BankID prompt
   - Non-dismissable modal for unverified users
   - "Koble BankID" button
   - Clear explanation

4. ✅ KYC status UI
   - Pending banner (yellow)
   - Rejected banner (red) with support link

### Phase 3: Edge Case Handling (Priority: MEDIUM)
**Duration:** 1 day
**Owner:** Backend + Frontend agents

1. ✅ OTP resend flow
   - Backend: Rate limiting (3 per hour)
   - Frontend: "Send ny kode" button

2. ✅ BankID error handling
   - Timeout retry
   - User cancellation
   - CSRF validation

3. ✅ KYC rejection flow
   - Webhook handler
   - Dashboard blocking UI
   - Support ticket creation

### Phase 4: Analytics & Re-engagement (Priority: LOW)
**Duration:** 1 day
**Owner:** Backend + Marketing

1. ✅ Drop-off tracking
   - Event logging in `onboarding_progress`
   - Funnel report SQL query

2. ✅ Email triggers
   - OTP not verified (1h)
   - BankID not linked (24h)
   - KYC pending (48h)

3. ✅ Push notifications
   - OTP resend available
   - KYC approved
   - KYC rejected

### Phase 5: Testing & Deployment (Priority: HIGH)
**Duration:** 2 days
**Owner:** QA agent + DevOps

1. ✅ Unit tests
   - Age validation (frontend + backend)
   - OTP verification
   - PIN validation
   - BankID callback

2. ✅ Integration tests
   - Full onboarding flow (register → KYC approved)
   - BankID OAuth flow
   - KYC webhook

3. ✅ E2E tests (Playwright)
   - Happy path: Register → Verify → BankID → Dashboard
   - Error path: Age < 18 → Rejected
   - Error path: BankID timeout → Retry

4. ✅ Deployment
   - Staging environment
   - Smoke tests
   - Production rollout

---

## 12. Success Metrics (3 Months Post-Launch)

| Metric | Target | Measurement |
|--------|--------|-------------|
| Registration Completion | 85% | OTP sent / Registration started |
| OTP Verification | 80% | OTP verified / OTP sent |
| Onboarding Completion | 70% | Tour complete / OTP verified |
| BankID Connection | 75% | BankID verified / Tour complete |
| KYC Approval | 90% | KYC approved / BankID verified |
| Overall Conversion | 40% | Fully verified / Registration started |
| Time to Verify | < 2 hours | Median time from registration to KYC approval |
| Drop-off Rate (Tour) | < 15% | Users who skip tour |
| Re-engagement Open Rate | 25% | Email open rate for re-engagement campaigns |
| Support Tickets (KYC) | < 5% | Tickets per verified user |

---

## 13. Rollout Plan

### 13.1 Soft Launch (Week 1)
- **Audience:** Internal team (10 users)
- **Goal:** Validate full flow, catch bugs
- **KYC:** Demo mode (auto-approve)
- **Monitoring:** Manual testing, bug reports in Slack

### 13.2 Beta Launch (Week 2-3)
- **Audience:** Friends & family (50 users)
- **Goal:** Gather UX feedback, measure conversion rates
- **KYC:** Demo mode (auto-approve)
- **Monitoring:** Analytics dashboard, user feedback survey

### 13.3 Limited Public Launch (Week 4-6)
- **Audience:** Invite-only (500 users)
- **Goal:** Test production BankID + KYC integration
- **KYC:** Production mode (Sumsub)
- **Monitoring:** Drop-off tracking, support ticket volume

### 13.4 Full Public Launch (Week 7+)
- **Audience:** Open to all (Norway)
- **Goal:** Scale to 10,000+ users
- **KYC:** Production mode (Sumsub)
- **Monitoring:** Weekly funnel reports, monthly conversion review

---

## 14. Appendix

### 14.1 Glossary

| Term | Definition |
|------|------------|
| **AISP** | Account Information Service Provider (PSD2) — reads bank account balance |
| **PISP** | Payment Initiation Service Provider (PSD2) — initiates payments from bank account |
| **BankID** | Norwegian eID system (OAuth 2.0 OIDC) for identity verification |
| **Fødselsnummer** | 11-digit Norwegian national ID (encodes DOB + unique ID) |
| **KYC** | Know Your Customer — identity verification for AML compliance |
| **Sumsub** | Third-party KYC provider (document verification) |
| **OTP** | One-Time Password (6-digit SMS code) |
| **SCA** | Strong Customer Authentication (PSD2 requirement) |
| **Pass-through model** | Drop never holds customer money; all funds stay in user's bank |

### 14.2 References

| Document | Location |
|----------|----------|
| Architecture Document | `project/architecture/architecture-document.md` |
| Terms of Service | `landing/pages/vilkar.html` |
| Privacy Policy | `landing/pages/privacy.html` |
| Figma Make Export (UI Source of Truth) | `mockups/figma-make-export/src/components/` |
| Current Onboarding Flow | `src/drop-app/src/app/onboarding/page.tsx` |
| Current Register Flow | `src/drop-app/src/app/register/page.tsx` |
| BankID OAuth | `src/drop-app/src/app/api/auth/bankid/route.ts` |
| KYC Service | `src/drop-app/src/lib/services/kyc.ts` |

### 14.3 Related Tasks

| Task | MC # | Description | Status |
|------|------|-------------|--------|
| Implement user registration | #947 | Email/password registration | ✅ Done |
| Implement BankID OAuth | #948 | BankID integration | 🚧 Partial |
| Implement KYC verification | #949 | Sumsub integration | 🚧 Partial |
| Build onboarding tour | #950 | 4-screen carousel | ✅ Done |
| Add consent tracking | #951 | GDPR compliance | ❌ Not started |
| Analytics integration | #952 | Posthog/Mixpanel | ❌ Not started |

---

**End of Specification**

**Next Steps:**
1. Review this spec with Alem for approval
2. Create implementation tasks in Mission Control
3. Assign tasks to builder agents
4. Begin Phase 1 (Backend Foundations)

**Questions for Alem:**
1. Preferred analytics platform (Posthog, Mixpanel, custom)?
2. SMS provider for OTP (Twilio, MessageBird)?
3. Email provider for re-engagement (SendGrid, Mailgun)?
4. KYC provider credentials (Sumsub account setup)?
5. BankID production credentials (when to request)?

# drop-push-notifications-spec

# Drop Push Notification Delivery Service — Implementation Spec

**Project:** Drop (Fintech Payment App)
**Task:** MC #1196
**Date:** 2026-02-17
**Author:** John (AI Director)

---

## 1. Executive Summary

Drop currently has notification stubs (database table, UI, demo-mode service) but no actual push delivery infrastructure. This spec defines a production-ready push notification system covering:
- Multi-platform delivery: Web Push (PWA primary), Firebase Cloud Messaging (Android future), APNs (iOS future)
- Notification taxonomy with security and compliance categories
- Device token management and lifecycle
- User preference management per Norwegian marketing laws
- Database schema for device tokens, notification queue, delivery logs
- API endpoints for device registration and preference management
- Integration with existing transaction and auth systems

**MVP Recommendation:** Web Push (PWA) — Drop currently has web app only, mobile apps planned for future. Start with Web Push API + service workers, add FCM/APNs when React Native mobile apps ship.

---

## 2. Architecture Overview

### 2.1 Unified Push Service Layer

**File:** `src/lib/push.ts` (NEW — replaces stub at `src/lib/services/notifications.ts`)

Provider-agnostic push notification abstraction. All push sending goes through this module.

**Interface:**
```typescript
// src/lib/push.ts

export interface DeviceToken {
  id: string;
  userId: string;
  platform: 'web' | 'android' | 'ios';
  token: string;           // For FCM/APNs, this is the device token
  endpoint?: string;       // For Web Push, this is the subscription endpoint
  keys?: {                 // For Web Push only
    p256dh: string;
    auth: string;
  };
  createdAt: string;
  lastUsedAt: string;
}

export interface NotificationPayload {
  userId: string;
  type: NotificationType;
  title: string;
  body: string;
  data?: Record<string, unknown>;
  priority?: 'high' | 'normal' | 'low';
  category?: string;       // For iOS notification categories
  badge?: number;          // Badge count (iOS/Android)
  sound?: string;          // Sound file name
}

export interface DeliveryResult {
  success: boolean;
  messageId?: string;
  platform: 'web' | 'android' | 'ios';
  error?: string;
}

// Core send function
export async function sendPushNotification(
  payload: NotificationPayload
): Promise<DeliveryResult[]>;

// Device token management
export async function registerDeviceToken(
  userId: string,
  platform: 'web' | 'android' | 'ios',
  token: string,
  keys?: { p256dh: string; auth: string; endpoint: string }
): Promise<{ success: boolean; error?: string }>;

export async function unregisterDeviceToken(
  userId: string,
  deviceId: string
): Promise<{ success: boolean }>;

export async function refreshDeviceToken(
  oldToken: string,
  newToken: string
): Promise<{ success: boolean }>;

// Preference check
export async function canSendNotification(
  userId: string,
  type: NotificationType
): Promise<boolean>;
```

---

### 2.2 Notification Type Taxonomy

**Security Principle:** Transactional notifications = mandatory (no opt-out). Promotional = explicit consent required (Norwegian markedsføringsloven).

```typescript
export type NotificationType =
  // TRANSACTIONAL (mandatory, no opt-out)
  | 'transfer_sent'           // Money sent from user's account
  | 'transfer_received'       // Money received into user's account
  | 'transfer_failed'         // Transfer failed (bank rejected)
  | 'login_alert'             // New device/location login
  | 'otp_code'                // OTP code for 2FA (future)
  | 'password_changed'        // Password changed (security alert)
  | 'bankid_linked'           // BankID linked to account
  | 'bankid_unlinked'         // BankID unlinked (security alert)
  | 'account_locked'          // Account locked due to suspicious activity
  | 'kyc_approved'            // KYC verification approved
  | 'kyc_rejected'            // KYC verification rejected

  // ACCOUNT (opt-in, enabled by default)
  | 'transaction_summary'     // Daily/weekly transaction summary
  | 'low_balance'             // Bank account balance below threshold
  | 'rate_update'             // Exchange rate update for pending transfer

  // PROMOTIONAL (opt-in, disabled by default, GDPR consent required)
  | 'referral'                // Referral program
  | 'new_feature'             // New feature announcement
  | 'special_offer';          // Special offers/promotions

export const NOTIFICATION_CATEGORIES = {
  transactional: [
    'transfer_sent',
    'transfer_received',
    'transfer_failed',
    'login_alert',
    'otp_code',
    'password_changed',
    'bankid_linked',
    'bankid_unlinked',
    'account_locked',
    'kyc_approved',
    'kyc_rejected',
  ],
  account: [
    'transaction_summary',
    'low_balance',
    'rate_update',
  ],
  promotional: [
    'referral',
    'new_feature',
    'special_offer',
  ],
} as const;

export const NOTIFICATION_PRIORITY = {
  transfer_sent: 'high',
  transfer_received: 'high',
  transfer_failed: 'high',
  login_alert: 'high',
  otp_code: 'high',
  password_changed: 'high',
  bankid_unlinked: 'high',
  account_locked: 'high',
  kyc_approved: 'normal',
  kyc_rejected: 'normal',
  bankid_linked: 'normal',
  transaction_summary: 'normal',
  low_balance: 'normal',
  rate_update: 'low',
  referral: 'low',
  new_feature: 'low',
  special_offer: 'low',
} as const;
```

**Norwegian Marketing Law Compliance:**
- **Markedsføringsloven §15:** Promotional notifications require EXPLICIT prior consent (opt-in)
- **GDPR Art. 6(1)(a):** Consent must be freely given, specific, informed, unambiguous
- **Implementation:** Promotional notifications OFF by default, require explicit user action to enable
- **Audit trail:** Log consent timestamp and context in `notification_preferences` table

---

### 2.3 Platform-Specific Implementations

#### A. Web Push API (PRIMARY — MVP)

**Tech Stack:**
- Service worker (`public/sw.js`) handles push events
- Web Push API (browser native, no SDK required)
- `web-push` npm library (server-side, VAPID signing)

**VAPID (Voluntary Application Server Identification):**
- Generate keys: `npx web-push generate-vapid-keys`
- Store in env: `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` (mailto:support@getdrop.no)

**Client-Side Flow:**
1. User visits Drop PWA
2. Service worker registers (`/sw.js`)
3. User grants notification permission (browser prompt)
4. Client subscribes to push: `registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY })`
5. Client sends subscription object to server: `POST /api/notifications/register-device`
6. Server stores subscription in `device_tokens` table

**Server-Side Flow:**
1. Transaction completes → create notification in `notifications` table
2. Fetch user's Web Push subscriptions from `device_tokens` WHERE platform='web'
3. For each subscription:
   - Use `web-push` library to send notification
   - `webpush.sendNotification(subscription, JSON.stringify(payload))`
4. Log delivery result in `notification_log`

**Service Worker (`public/sw.js`):**
```javascript
// Listen for push events
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};
  const { title, body, icon, badge, data: customData } = data;

  const options = {
    body: body,
    icon: icon || '/icon-192.png',
    badge: badge || '/badge-72.png',
    data: customData,
    vibrate: [200, 100, 200],
    tag: data.type || 'default',
    requireInteraction: data.priority === 'high',
  };

  event.waitUntil(
    self.registration.showNotification(title, options)
  );
});

// Handle notification click
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  const urlToOpen = event.notification.data?.url || '/dashboard';

  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
      // If Drop is already open, focus it
      for (const client of clientList) {
        if (client.url.includes(urlToOpen) && 'focus' in client) {
          return client.focus();
        }
      }
      // Otherwise, open new window
      if (clients.openWindow) {
        return clients.openWindow(urlToOpen);
      }
    })
  );
});
```

**Dependencies:**
```json
{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}
```

**Env Vars:**
```bash
# Web Push (VAPID keys)
VAPID_PUBLIC_KEY=BN...  # Public key (also exposed to client via /api/vapid-public-key)
VAPID_PRIVATE_KEY=...   # Private key (server-side only)
VAPID_SUBJECT=mailto:support@getdrop.no
```

---

#### B. Firebase Cloud Messaging (FUTURE — Android)

**When:** When React Native Android app ships.

**Tech Stack:**
- Firebase Cloud Messaging (FCM) HTTP v1 API
- `firebase-admin` SDK (server-side)
- `@react-native-firebase/messaging` (client-side)

**Setup:**
1. Create Firebase project at console.firebase.google.com
2. Add Android app to Firebase project (package name: `no.getdrop.app`)
3. Download `google-services.json`, place in React Native Android project
4. Download service account key JSON for server
5. Set env var: `FIREBASE_SERVICE_ACCOUNT_KEY` (base64-encoded JSON)

**Client-Side Flow (React Native):**
```typescript
// React Native app startup
import messaging from '@react-native-firebase/messaging';

async function registerForPushNotifications() {
  const authStatus = await messaging().requestPermission();
  if (authStatus === messaging.AuthorizationStatus.AUTHORIZED) {
    const token = await messaging().getToken();
    // Send to server: POST /api/notifications/register-device
    await fetch('/api/notifications/register-device', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ platform: 'android', token }),
    });
  }
}

// Handle foreground messages
messaging().onMessage(async (remoteMessage) => {
  // Show in-app notification UI
});

// Handle background messages (background handler in index.js)
messaging().setBackgroundMessageHandler(async (remoteMessage) => {
  console.log('Background message:', remoteMessage);
});

// Handle token refresh
messaging().onTokenRefresh((token) => {
  // Update server with new token
});
```

**Server-Side Flow:**
```typescript
import admin from 'firebase-admin';

// Initialize Firebase Admin (once on startup)
const serviceAccountKey = JSON.parse(
  Buffer.from(process.env.FIREBASE_SERVICE_ACCOUNT_KEY!, 'base64').toString('utf-8')
);

admin.initializeApp({
  credential: admin.credential.cert(serviceAccountKey),
});

// Send notification
async function sendFCMNotification(token: string, payload: NotificationPayload) {
  const message = {
    token: token,
    notification: {
      title: payload.title,
      body: payload.body,
    },
    data: payload.data || {},
    android: {
      priority: payload.priority === 'high' ? 'high' : 'normal',
      notification: {
        sound: payload.sound || 'default',
        badge: payload.badge,
      },
    },
  };

  const response = await admin.messaging().send(message);
  return response; // message ID
}
```

**Dependencies:**
```json
{
  "dependencies": {
    "firebase-admin": "^12.0.0"
  }
}
```

**Env Vars:**
```bash
FIREBASE_SERVICE_ACCOUNT_KEY=base64-encoded-json
```

**Cost:** FREE — FCM has no quota limits or pricing.

---

#### C. Apple Push Notification service (FUTURE — iOS)

**When:** When React Native iOS app ships.

**Tech Stack:**
- APNs HTTP/2 API
- `apn` npm library (server-side)
- `@react-native-firebase/messaging` (works for both FCM and APNs)

**Setup:**
1. Create iOS app in Apple Developer account
2. Create Push Notification certificate or Auth Key (.p8 file)
3. Download .p8 file, note Key ID and Team ID
4. Set env vars: `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_KEY_PATH` (or `APNS_KEY_CONTENT` as base64)

**Server-Side Flow:**
```typescript
import apn from 'apn';

// Initialize APNs provider
const apnProvider = new apn.Provider({
  token: {
    key: process.env.APNS_KEY_CONTENT!, // .p8 file content
    keyId: process.env.APNS_KEY_ID!,
    teamId: process.env.APNS_TEAM_ID!,
  },
  production: process.env.NODE_ENV === 'production',
});

// Send notification
async function sendAPNsNotification(deviceToken: string, payload: NotificationPayload) {
  const notification = new apn.Notification();
  notification.alert = {
    title: payload.title,
    body: payload.body,
  };
  notification.badge = payload.badge;
  notification.sound = payload.sound || 'default';
  notification.category = payload.category;
  notification.priority = payload.priority === 'high' ? 10 : 5;
  notification.payload = payload.data || {};
  notification.topic = 'no.getdrop.app'; // iOS bundle ID

  const result = await apnProvider.send(notification, deviceToken);
  return result; // Array of { device, status }
}
```

**Dependencies:**
```json
{
  "dependencies": {
    "apn": "^2.2.0"
  }
}
```

**Env Vars:**
```bash
APNS_KEY_ID=ABC123XYZ
APNS_TEAM_ID=DEF456UVW
APNS_KEY_CONTENT=base64-encoded-p8-file
```

**Cost:** FREE — APNs has no quota or pricing.

---

## 3. Database Schema

### 3.1 New Tables

#### `device_tokens`
Stores push notification device tokens/subscriptions.

**SQLite:**
```sql
CREATE TABLE IF NOT EXISTS device_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  token TEXT,                    -- FCM/APNs device token (NULL for Web Push)
  endpoint TEXT,                 -- Web Push endpoint URL (NULL for FCM/APNs)
  p256dh_key TEXT,               -- Web Push p256dh key (NULL for FCM/APNs)
  auth_key TEXT,                 -- Web Push auth key (NULL for FCM/APNs)
  user_agent TEXT,               -- Browser/device info
  created_at TEXT DEFAULT (datetime('now')),
  last_used_at TEXT DEFAULT (datetime('now')),
  active INTEGER DEFAULT 1       -- 0 = deactivated (stale/unregistered)
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_token ON device_tokens(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_endpoint ON device_tokens(endpoint) WHERE endpoint IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_device_tokens_platform ON device_tokens(platform);
CREATE INDEX IF NOT EXISTS idx_device_tokens_active ON device_tokens(active);
```

**PostgreSQL:**
```sql
CREATE TABLE IF NOT EXISTS device_tokens (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  token TEXT,
  endpoint TEXT,
  p256dh_key TEXT,
  auth_key TEXT,
  user_agent TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  active INTEGER DEFAULT 1
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_token ON device_tokens(token) WHERE token IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_device_tokens_endpoint ON device_tokens(endpoint) WHERE endpoint IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_device_tokens_user ON device_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_device_tokens_platform ON device_tokens(platform);
CREATE INDEX IF NOT EXISTS idx_device_tokens_active ON device_tokens(active);
```

**Deduplication:** Same device registering multiple times → UPSERT on `token` or `endpoint` (updates `last_used_at`, reactivates if inactive).

**Stale Token Cleanup:** Cron job (daily) marks tokens as inactive if `last_used_at` > 90 days OR if delivery fails with "token invalid" error.

---

#### `notification_queue`
Queue for deferred/retry delivery (future — MVP sends immediately).

**SQLite:**
```sql
CREATE TABLE IF NOT EXISTS notification_queue (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  data TEXT,                     -- JSON string
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','sent','failed','cancelled')),
  attempts INTEGER DEFAULT 0,
  max_attempts INTEGER DEFAULT 3,
  next_retry_at TEXT,            -- ISO timestamp
  created_at TEXT DEFAULT (datetime('now')),
  sent_at TEXT,
  error TEXT
);

CREATE INDEX IF NOT EXISTS idx_queue_user ON notification_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_queue_status ON notification_queue(status);
CREATE INDEX IF NOT EXISTS idx_queue_next_retry ON notification_queue(next_retry_at) WHERE status = 'pending';
```

**PostgreSQL:**
```sql
CREATE TABLE IF NOT EXISTS notification_queue (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  data TEXT,
  priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low')),
  status TEXT DEFAULT 'pending' CHECK(status IN ('pending','sent','failed','cancelled')),
  attempts INTEGER DEFAULT 0,
  max_attempts INTEGER DEFAULT 3,
  next_retry_at TIMESTAMP,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  sent_at TIMESTAMP,
  error TEXT
);

CREATE INDEX IF NOT EXISTS idx_queue_user ON notification_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_queue_status ON notification_queue(status);
CREATE INDEX IF NOT EXISTS idx_queue_next_retry ON notification_queue(next_retry_at) WHERE status = 'pending';
```

**MVP Note:** Queue table created but not used initially. MVP sends notifications synchronously. Post-MVP: add background worker that processes queue with retry logic (exponential backoff).

---

#### `notification_log`
Audit log of all push notification deliveries.

**SQLite:**
```sql
CREATE TABLE IF NOT EXISTS notification_log (
  id TEXT PRIMARY KEY,
  notification_id TEXT REFERENCES notifications(id),  -- NULL for push-only (no in-app)
  user_id TEXT NOT NULL REFERENCES users(id),
  device_token_id TEXT REFERENCES device_tokens(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed','skipped')),
  message_id TEXT,               -- Provider message ID (FCM/APNs/Web Push)
  error TEXT,
  sent_at TEXT DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_notif_log_user ON notification_log(user_id);
CREATE INDEX IF NOT EXISTS idx_notif_log_status ON notification_log(status);
CREATE INDEX IF NOT EXISTS idx_notif_log_sent_at ON notification_log(sent_at);
CREATE INDEX IF NOT EXISTS idx_notif_log_platform ON notification_log(platform);
```

**PostgreSQL:**
```sql
CREATE TABLE IF NOT EXISTS notification_log (
  id TEXT PRIMARY KEY,
  notification_id TEXT REFERENCES notifications(id),
  user_id TEXT NOT NULL REFERENCES users(id),
  device_token_id TEXT REFERENCES device_tokens(id),
  platform TEXT NOT NULL CHECK(platform IN ('web','android','ios')),
  type TEXT NOT NULL,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  status TEXT NOT NULL CHECK(status IN ('sent','failed','skipped')),
  message_id TEXT,
  error TEXT,
  sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_notif_log_user ON notification_log(user_id);
CREATE INDEX IF NOT EXISTS idx_notif_log_status ON notification_log(status);
CREATE INDEX IF NOT EXISTS idx_notif_log_sent_at ON notification_log(sent_at);
CREATE INDEX IF NOT EXISTS idx_notif_log_platform ON notification_log(platform);
```

**Retention:** Keep indefinitely for audit trail (fintech compliance). Monitoring queries:
```sql
-- Delivery rate last 24h
SELECT
  platform,
  COUNT(*) as total,
  SUM(CASE WHEN status='sent' THEN 1 ELSE 0 END) as sent,
  SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) as failed
FROM notification_log
WHERE sent_at > datetime('now', '-1 day')
GROUP BY platform;

-- Failure reasons
SELECT error, COUNT(*) as count
FROM notification_log
WHERE status='failed' AND sent_at > datetime('now', '-7 days')
GROUP BY error
ORDER BY count DESC
LIMIT 10;
```

---

#### `notification_preferences`
User preferences for notification types (opt-in/opt-out, quiet hours).

**SQLite:**
```sql
CREATE TABLE IF NOT EXISTS notification_preferences (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL CHECK(category IN ('transactional','account','promotional')),
  enabled INTEGER DEFAULT 1,
  quiet_hours_start TEXT,        -- HH:MM format (e.g., "22:00")
  quiet_hours_end TEXT,          -- HH:MM format (e.g., "08:00")
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_pref_user_cat ON notification_preferences(user_id, category);
```

**PostgreSQL:**
```sql
CREATE TABLE IF NOT EXISTS notification_preferences (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  category TEXT NOT NULL CHECK(category IN ('transactional','account','promotional')),
  enabled INTEGER DEFAULT 1,
  quiet_hours_start TEXT,
  quiet_hours_end TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_notif_pref_user_cat ON notification_preferences(user_id, category);
```

**Default behavior:**
- Transactional: ALWAYS enabled (row doesn't exist = enabled, quiet hours ignored)
- Account: Enabled by default (created on first app launch)
- Promotional: Disabled by default (created on first app launch)

**Quiet hours logic:**
- If notification type = transactional → send immediately (ignore quiet hours)
- Otherwise, check user's local time (use timezone from user profile or default to Europe/Oslo)
- If current time is between `quiet_hours_start` and `quiet_hours_end` → queue for later (send at `quiet_hours_end`)

---

### 3.2 Schema Changes to Existing Tables

#### `notifications` (existing table — ADD columns)

**Additions:**
```sql
-- SQLite migration
ALTER TABLE notifications ADD COLUMN notification_type TEXT;
ALTER TABLE notifications ADD COLUMN priority TEXT DEFAULT 'normal' CHECK(priority IN ('high','normal','low'));
ALTER TABLE notifications ADD COLUMN data TEXT;  -- JSON string with extra context
```

**PostgreSQL migration:**
```sql
ALTER TABLE notifications ADD COLUMN notification_type TEXT;
ALTER TABLE notifications ADD COLUMN priority TEXT DEFAULT 'normal';
ALTER TABLE notifications ADD COLUMN data TEXT;

ALTER TABLE notifications ADD CONSTRAINT notifications_priority_check
  CHECK (priority IN ('high','normal','low'));
```

**Purpose:** Existing `notifications` table stores in-app notifications. New columns allow linking in-app notifications to push notifications (same notification shows in both Notifications screen AND push).

---

## 4. API Endpoints

### 4.1 POST `/api/notifications/register-device`

**Purpose:** Register device token for push notifications.

**Auth:** Required (bearer token).

**Request:**
```json
{
  "platform": "web",
  "token": "fcm-token-or-apns-token",  // For FCM/APNs
  "subscription": {                     // For Web Push only
    "endpoint": "https://fcm.googleapis.com/...",
    "keys": {
      "p256dh": "base64-encoded-p256dh",
      "auth": "base64-encoded-auth"
    }
  },
  "userAgent": "Mozilla/5.0 ..."
}
```

**Response (200):**
```json
{
  "data": {
    "deviceId": "dtk_abc123",
    "registered": true
  }
}
```

**Errors:**
- 400: Missing platform or token/subscription
- 401: Unauthorized
- 409: Device already registered (returns existing deviceId)

**Logic:**
1. Validate platform ('web', 'android', 'ios')
2. For Web Push: validate subscription object (endpoint, keys.p256dh, keys.auth)
3. For FCM/APNs: validate token format
4. Check if token/endpoint already exists:
   - If exists → update `last_used_at`, set `active=1`, return existing ID
   - If not exists → insert new row
5. Return deviceId

**File:** `src/app/api/notifications/register-device/route.ts` (NEW)

---

### 4.2 DELETE `/api/notifications/devices/:deviceId`

**Purpose:** Unregister device token (user logs out or revokes permission).

**Auth:** Required.

**Response (200):**
```json
{
  "data": { "success": true }
}
```

**Logic:**
1. Verify device belongs to authenticated user
2. Set `active=0` (soft delete — keep for audit trail)

**File:** `src/app/api/notifications/devices/[deviceId]/route.ts` (NEW)

---

### 4.3 GET `/api/notifications/preferences`

**Purpose:** Get user's notification preferences.

**Auth:** Required.

**Response (200):**
```json
{
  "data": {
    "transactional": {
      "enabled": true,
      "canDisable": false
    },
    "account": {
      "enabled": true,
      "canDisable": true
    },
    "promotional": {
      "enabled": false,
      "canDisable": true
    },
    "quietHours": {
      "enabled": false,
      "start": null,
      "end": null
    }
  }
}
```

**Logic:**
1. Fetch rows from `notification_preferences` WHERE user_id = ?
2. If no rows exist → create defaults (transactional=1, account=1, promotional=0)
3. Return preferences

**File:** `src/app/api/notifications/preferences/route.ts` (NEW, GET handler)

---

### 4.4 PUT `/api/notifications/preferences`

**Purpose:** Update user's notification preferences.

**Auth:** Required.

**Request:**
```json
{
  "account": { "enabled": true },
  "promotional": { "enabled": false },
  "quietHours": {
    "enabled": true,
    "start": "22:00",
    "end": "08:00"
  }
}
```

**Response (200):**
```json
{
  "data": { "updated": true }
}
```

**Errors:**
- 400: Invalid category or quiet hours format
- 403: Attempt to disable transactional notifications

**Logic:**
1. Validate categories (cannot disable transactional)
2. Validate quiet hours format (HH:MM, 00:00-23:59)
3. UPSERT preferences into `notification_preferences` table
4. Update `updated_at = now()`

**File:** `src/app/api/notifications/preferences/route.ts` (NEW, PUT handler)

---

### 4.5 GET `/api/notifications/history`

**Purpose:** Get user's push notification delivery history (debugging/audit).

**Auth:** Required.

**Query params:**
- `limit` (default: 50, max: 100)
- `offset` (default: 0)

**Response (200):**
```json
{
  "data": [
    {
      "id": "nlog_abc123",
      "type": "transfer_received",
      "title": "Du mottok penger",
      "platform": "web",
      "status": "sent",
      "sentAt": "2026-02-17T14:30:00Z"
    }
  ],
  "pagination": {
    "limit": 50,
    "offset": 0,
    "total": 123
  }
}
```

**Logic:**
1. Query `notification_log` WHERE user_id = ? ORDER BY sent_at DESC LIMIT ? OFFSET ?
2. Count total rows for pagination
3. Return list

**File:** `src/app/api/notifications/history/route.ts` (NEW)

---

### 4.6 GET `/api/vapid-public-key`

**Purpose:** Expose VAPID public key to client for Web Push subscription.

**Auth:** Not required (public endpoint).

**Response (200):**
```json
{
  "publicKey": "BN4GvZtEZiZuqaasbD-..."
}
```

**File:** `src/app/api/vapid-public-key/route.ts` (NEW)

---

## 5. Integration Points

### 5.1 Transaction Complete (Remittance + QR Payment)

**Files to modify:**
- `src/app/api/transactions/remittance/route.ts`
- `src/app/api/transactions/qr-payment/route.ts`

**After transaction status = 'completed':**
```typescript
import { sendPushNotification } from '@/lib/push';
import { randomId } from '@/lib/utils';

// Create in-app notification (existing table)
const notificationId = randomId('ntf');
await run(
  `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
   VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
  [
    notificationId,
    userId,
    'transaction_complete',
    'Transaksjon fullført',
    `${amount} ${currency} sendt til ${recipientName}`,
    'transfer_sent',
    'high',
    JSON.stringify({ transactionId: txId, amount, currency }),
  ]
);

// Send push notification
await sendPushNotification({
  userId: userId,
  type: 'transfer_sent',
  title: 'Transaksjon fullført',
  body: `${amount} ${currency} sendt til ${recipientName}`,
  priority: 'high',
  data: {
    transactionId: txId,
    amount,
    currency,
    url: `/dashboard/transactions/${txId}`,
  },
});

// If recipient is a Drop user, notify them
if (recipientUserId) {
  const recipientNotifId = randomId('ntf');
  await run(
    `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      recipientNotifId,
      recipientUserId,
      'transaction_complete',
      'Du mottok penger',
      `${amount} ${currency} fra ${senderName}`,
      'transfer_received',
      'high',
      JSON.stringify({ transactionId: txId, amount, currency }),
    ]
  );

  await sendPushNotification({
    userId: recipientUserId,
    type: 'transfer_received',
    title: 'Du mottok penger',
    body: `${amount} ${currency} fra ${senderName}`,
    priority: 'high',
    data: {
      transactionId: txId,
      amount,
      currency,
      url: `/dashboard/transactions/${txId}`,
    },
  });
}
```

---

### 5.2 Login Alert (New Device)

**File to modify:** `src/app/api/auth/login/route.ts`

**After successful login:**
```typescript
import crypto from 'crypto';
import { sendPushNotification } from '@/lib/push';

const ip = getClientIp(request);
const userAgent = request.headers.get('user-agent') || 'Unknown';

// Generate device fingerprint
const deviceFingerprint = crypto.createHash('sha256')
  .update(`${ip}:${userAgent}`)
  .digest('hex');

// Check if device is new
const existingDevice = await getOne<{ id: string }>(
  "SELECT id FROM sessions WHERE user_id = ? AND device_fingerprint = ?",
  [userId, deviceFingerprint]
);

if (!existingDevice) {
  // New device → send login alert
  const notificationId = randomId('ntf');
  await run(
    `INSERT INTO notifications (id, user_id, type, title, body, notification_type, priority, data)
     VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      notificationId,
      userId,
      'security',
      'Ny pålogging oppdaget',
      `Pålogging fra ${userAgent.slice(0, 50)}`,
      'login_alert',
      'high',
      JSON.stringify({ ip, userAgent }),
    ]
  );

  await sendPushNotification({
    userId: userId,
    type: 'login_alert',
    title: 'Ny pålogging oppdaget',
    body: `Pålogging fra ${userAgent.slice(0, 50)}`,
    priority: 'high',
    data: {
      ip,
      userAgent,
      url: '/profile/security',
    },
  });
}

// Add device_fingerprint to session insert (schema change needed)
await run(
  `INSERT INTO sessions (id, user_id, token_hash, expires_at, device_fingerprint)
   VALUES (?, ?, ?, ?, ?)`,
  [sessionId, userId, tokenHash, expiresAt, deviceFingerprint]
);
```

**Schema change:**
```sql
-- Add to sessions table
ALTER TABLE sessions ADD COLUMN device_fingerprint TEXT;
CREATE INDEX IF NOT EXISTS idx_sessions_device ON sessions(device_fingerprint);
```

---

### 5.3 Account Events (KYC, BankID, Password Change)

**Files to modify:**
- `src/app/api/auth/change-password/route.ts` (if exists, else create)
- `src/app/api/kyc/verify/route.ts` (if exists)
- `src/app/api/bankid/link/route.ts` (if exists)

**Pattern (same for all):**
```typescript
// After event (e.g., password changed)
await sendPushNotification({
  userId: userId,
  type: 'password_changed',
  title: 'Passord endret',
  body: 'Passordet ditt ble nettopp endret. Hvis dette ikke var deg, kontakt support umiddelbart.',
  priority: 'high',
  data: {
    timestamp: new Date().toISOString(),
    url: '/profile/security',
  },
});
```

---

## 6. User Preference Management UI

### 6.1 Notification Settings Screen

**File to create:** `src/app/profile/notifications/page.tsx` (MODIFY existing if exists)

**UI Components:**
1. **Permission Status** — Shows if browser/OS notifications enabled
   - If not enabled → show "Enable Notifications" button → triggers browser permission prompt
2. **Category Toggles:**
   - Transactional: Always ON (disabled toggle, grayed out, "Required for security")
   - Account: Toggle (ON by default)
   - Promotional: Toggle (OFF by default, show consent text)
3. **Quiet Hours:**
   - Toggle "Enable Quiet Hours"
   - Time pickers: Start time (default 22:00), End time (default 08:00)
   - Note: "Transactional notifications (security alerts, payments) will still be sent"
4. **Device List:**
   - Shows registered devices (platform, last used)
   - "Remove" button per device

**Client-Side Logic:**
```typescript
// Check if push notifications supported
if ('serviceWorker' in navigator && 'PushManager' in window) {
  // Check current permission
  const permission = Notification.permission;
  if (permission === 'default') {
    // Show "Enable Notifications" button
  } else if (permission === 'granted') {
    // Subscribe to push
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
    });

    // Send to server
    await fetch('/api/notifications/register-device', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        platform: 'web',
        subscription: subscription.toJSON(),
        userAgent: navigator.userAgent,
      }),
    });
  } else {
    // Permission denied → show "Notifications blocked" message
  }
}
```

**Promotional Consent UI:**
```tsx
<div>
  <Toggle
    checked={promotionalEnabled}
    onChange={async (enabled) => {
      if (enabled) {
        // Show consent dialog
        const confirmed = await showConsentDialog(
          "Markedsføringsvarsler",
          "Jeg samtykker til å motta tilbud, anbefalinger og produktoppdateringer fra Drop. " +
          "Jeg kan trekke tilbake samtykket når som helst i innstillinger."
        );
        if (confirmed) {
          await updatePreferences({ promotional: { enabled: true } });
        }
      } else {
        await updatePreferences({ promotional: { enabled: false } });
      }
    }}
  />
  <label>Tilbud og kampanjer</label>
  <p className="text-xs text-gray-500">
    Motta tilbud, anbefalinger og produktoppdateringer (krever samtykke)
  </p>
</div>
```

---

## 7. Norwegian Marketing Law Compliance

### 7.1 Markedsføringsloven §15 (Marketing Consent)

**Law:** Promotional electronic messages (email, SMS, push) require EXPLICIT prior consent from recipient.

**Drop Implementation:**
- Promotional notifications OFF by default
- User must actively enable in settings
- Consent dialog shows clear purpose ("tilbud, anbefalinger, produktoppdateringer")
- Consent timestamp logged in `notification_preferences.updated_at`
- User can withdraw consent at any time (toggle OFF)

**Audit trail:**
```sql
-- Query: Show consent history for user
SELECT
  user_id,
  category,
  enabled,
  created_at,
  updated_at
FROM notification_preferences
WHERE user_id = ? AND category = 'promotional';
```

---

### 7.2 GDPR Compliance

**Art. 6(1)(a) — Consent as legal basis:**
- Consent must be freely given, specific, informed, unambiguous
- Drop: ✅ Toggle + consent dialog = unambiguous affirmative action
- Drop: ✅ Separate toggle for promotional (not bundled with account/transactional)

**Art. 7 — Conditions for consent:**
- Burden of proof: controller must demonstrate consent was given
- Drop: ✅ `notification_preferences` table logs timestamp and enabled status

**Art. 17 — Right to erasure:**
- User can delete account → all notification preferences deleted (CASCADE on user_id FK)

---

## 8. Security Considerations

### 8.1 No Sensitive Data in Push Payload

**Risk:** Push notifications travel through third-party servers (FCM, APNs, Web Push service). Payload can be logged/intercepted.

**Mitigation:**
- NEVER include: passwords, tokens, full card numbers, bank account numbers
- DO include: generic message ("Du mottok penger") + deep link to app
- Sensitive details fetched AFTER user opens app (authenticated API call)

**Example (GOOD):**
```json
{
  "title": "Ny transaksjon",
  "body": "Du mottok kr 2 500,00",
  "data": {
    "transactionId": "tx_abc123",
    "url": "/dashboard/transactions/tx_abc123"
  }
}
```

**Example (BAD):**
```json
{
  "title": "Ny transaksjon",
  "body": "Du mottok kr 2 500,00 fra John Doe (john@example.com) til konto 1234.56.78901",
  "data": { ... }
}
```

---

### 8.2 Token Encryption at Rest

**Risk:** Device tokens stored in plain text in DB → if DB compromised, attacker can send spam push notifications to all users.

**Mitigation (Post-MVP):**
- Encrypt `token`, `endpoint`, `p256dh_key`, `auth_key` columns at rest
- Use AES-256-GCM with key stored in env var (`DEVICE_TOKEN_ENCRYPTION_KEY`)
- Decrypt on read, encrypt on write

**MVP:** Store in plain text (same risk level as session tokens — if DB is compromised, session tokens are also exposed). Add encryption in Phase 2.

---

### 8.3 Rate Limiting

**Risk:** Attacker with stolen session token spams push notifications to user or all users.

**Mitigation:**
- Per-user rate limit: max 100 push notifications per hour
- Global rate limit: max 10,000 push notifications per hour (protect against abuse)
- Rate limit key: `push:{userId}:{hour}`

**Implementation:**
```typescript
import { checkRateLimit } from '@/lib/rate-limit';

async function sendPushNotification(payload: NotificationPayload) {
  const hour = new Date().toISOString().slice(0, 13); // "2026-02-17T14"
  const rateLimitKey = `push:${payload.userId}:${hour}`;

  const allowed = await checkRateLimit(rateLimitKey, 100, 3600); // 100 per hour
  if (!allowed) {
    console.warn(`[Push] Rate limit exceeded for user ${payload.userId}`);
    return [{ success: false, error: 'Rate limit exceeded', platform: 'web' }];
  }

  // Send notification...
}
```

---

## 9. Monitoring & Alerting

### 9.1 Delivery Metrics

**Dashboard queries (daily cron or on-demand):**

```sql
-- Delivery rate by platform (last 24h)
SELECT
  platform,
  COUNT(*) as total,
  ROUND(AVG(CASE WHEN status='sent' THEN 1.0 ELSE 0.0 END) * 100, 2) as success_rate
FROM notification_log
WHERE sent_at > datetime('now', '-1 day')
GROUP BY platform;

-- Opt-out rate by category
SELECT
  category,
  COUNT(*) as total_users,
  SUM(CASE WHEN enabled=0 THEN 1 ELSE 0 END) as opted_out,
  ROUND(AVG(CASE WHEN enabled=0 THEN 1.0 ELSE 0.0 END) * 100, 2) as opt_out_rate
FROM notification_preferences
GROUP BY category;

-- Top failure reasons
SELECT
  error,
  COUNT(*) as count,
  platform
FROM notification_log
WHERE status='failed' AND sent_at > datetime('now', '-7 days')
GROUP BY error, platform
ORDER BY count DESC
LIMIT 10;
```

---

### 9.2 Alerts (Slack/Email)

**Trigger conditions:**
- Delivery rate drops below 90% (hourly check)
- More than 100 failures in 1 hour
- Web Push VAPID keys missing on startup
- FCM/APNs credentials invalid (auth error)

**Implementation (future):**
```typescript
// In src/lib/alerts.ts (from drop-supporting-systems-plan.md)
await sendSlackAlert({
  severity: 'high',
  message: `Push notification delivery rate dropped to ${rate}% (platform: ${platform})`,
  link: 'http://localhost:3030/monitoring/push',
});
```

---

## 10. Cost Analysis

| Platform | Setup Cost | Operating Cost | Free Tier | Paid Tier |
|----------|-----------|----------------|-----------|-----------|
| **Web Push** | 0 kr | 0 kr | Unlimited (browser-managed) | N/A |
| **FCM (Android)** | 0 kr | 0 kr | Unlimited | N/A |
| **APNs (iOS)** | 794 kr/year (Apple Developer) | 0 kr | Unlimited | N/A |

**Total Annual Cost:** 794 kr (Apple Developer membership only).

**Infrastructure cost:** Minimal — push notification sending is lightweight (HTTP requests), no message queue or worker needed for MVP.

**Post-MVP (if >100k push/day):** Consider message queue (BullMQ + Redis, ~200 kr/month on Railway/Fly.io).

---

## 11. Implementation Plan

### Phase 1: Web Push (MVP) — 3 days

**Day 1: Backend Infrastructure (6h)**
- Create `device_tokens`, `notification_queue`, `notification_log`, `notification_preferences` tables
- Implement `src/lib/push.ts` with Web Push support
- Generate VAPID keys, add to env vars
- Create API endpoints:
  - POST `/api/notifications/register-device`
  - GET `/api/notifications/preferences`
  - PUT `/api/notifications/preferences`
  - GET `/api/vapid-public-key`

**Day 2: Frontend Integration (6h)**
- Create service worker `public/sw.js` (push event listener)
- Register service worker in `src/app/layout.tsx`
- Build notification settings UI (`src/app/profile/notifications/page.tsx`)
- Implement permission request flow
- Test Web Push subscription registration

**Day 3: Integration + Testing (6h)**
- Integrate push notifications into transaction routes (remittance, QR payment)
- Integrate login alert into auth/login route
- Add `device_fingerprint` column to `sessions` table
- Test end-to-end flows:
  - Register device → send test notification → receive on device
  - Complete transaction → receive push notification
  - Login from new device → receive security alert
  - Update preferences → verify opt-out works
- Deploy to staging

**Total: 18 hours (3 days @ 6h/day)**

---

### Phase 2: FCM (Android) — 1 day (FUTURE)

**When:** React Native Android app ready for testing.

**Tasks:**
- Set up Firebase project, download service account key
- Implement FCM support in `src/lib/push.ts`
- Add `firebase-admin` dependency
- Test with React Native app

---

### Phase 3: APNs (iOS) — 1 day (FUTURE)

**When:** React Native iOS app ready for testing.

**Tasks:**
- Generate APNs Auth Key (.p8 file) from Apple Developer account
- Implement APNs support in `src/lib/push.ts`
- Add `apn` dependency
- Test with React Native app

---

### Phase 4: Advanced Features — 2 days (FUTURE)

**Features:**
- Background queue + retry logic (BullMQ + Redis)
- Quiet hours enforcement (defer notifications to later)
- Rich notifications (images, actions, reply)
- Device token encryption at rest
- Admin dashboard for monitoring delivery rates

---

## 12. Testing Strategy

### 12.1 Unit Tests

**Test files to create:**
- `tests/unit/push.test.ts` — Test `sendPushNotification()`, `registerDeviceToken()`, preference checks
- `tests/unit/notification-preferences.test.ts` — Test default preferences, opt-in/opt-out logic

**Coverage:**
- ✅ Transactional notifications always sent (ignore preferences)
- ✅ Account notifications respect opt-out
- ✅ Promotional notifications require opt-in
- ✅ Quiet hours respected (except transactional)
- ✅ Rate limiting enforced
- ✅ Device token deduplication (same token = update, not insert)

---

### 12.2 Integration Tests

**Test files to create:**
- `tests/integration/push-flow.test.ts` — End-to-end push notification flow

**Scenarios:**
1. **Register device → send notification → verify log entry**
   - POST `/api/notifications/register-device` with Web Push subscription
   - Trigger transaction → verify `notification_log` entry created with status='sent'
2. **Opt-out → verify notification skipped**
   - Update preferences: account notifications OFF
   - Trigger transaction → verify notification NOT sent (status='skipped' in log)
3. **Login alert → new device**
   - Login from new User-Agent → verify login alert notification sent
4. **Promotional consent → verify GDPR compliance**
   - Enable promotional notifications → verify `notification_preferences.updated_at` updated

---

### 12.3 Manual Testing Checklist

- [ ] Web Push permission prompt appears on first visit
- [ ] Notification received after granting permission
- [ ] Notification click opens correct URL in app
- [ ] Notification settings UI shows correct state (enabled/disabled per category)
- [ ] Quiet hours toggle works (notifications deferred)
- [ ] Device removal works (device marked inactive)
- [ ] Promotional consent dialog shows before enabling
- [ ] Rate limit enforced (send 101 notifications → last one fails)

---

## 13. Success Metrics

### Week 1 (Post-Deployment)
- [ ] 0 push notification failures (delivery rate 100%)
- [ ] <5% users block notifications after permission prompt
- [ ] 0 GDPR complaints about unsolicited promotional notifications

### Month 1
- [ ] >70% users with push notifications enabled
- [ ] >50% notification open rate (click-through from push to app)
- [ ] <10% opt-out rate for account notifications
- [ ] 0 support tickets about missing notifications

### Quarter 1
- [ ] Push notifications enabled for all transaction types
- [ ] Login alerts sent for 100% of new device logins
- [ ] Promotional notifications available (consent flow tested)
- [ ] FCM/APNs integration ready for mobile app launch

---

## 14. Rollout Strategy

### Staging (1 week)
- Deploy Web Push to staging environment
- Test with internal users (Alem, team)
- Verify DNS/HTTPS setup (Web Push requires HTTPS)
- Check spam score (should be N/A for push, unlike email)

### Production (Gradual)
- **Week 1:** Enable Web Push for NEW users only (flag in `users` table: `push_enabled`)
- **Week 2:** Monitor delivery rate, opt-out rate, open rate
- **Week 3:** Enable for ALL users (remove flag, make default)
- **Week 4:** Enable transactional notifications (transaction receipts, login alerts)
- **Month 2:** Enable account notifications (summaries, low balance)
- **Month 3:** Enable promotional notifications (with consent flow)

---

## 15. Acceptance Criteria

**Push Service Layer:**
- [ ] `sendPushNotification()` sends via Web Push in production
- [ ] `sendPushNotification()` logs to console in demo mode
- [ ] `registerDeviceToken()` stores device token in DB
- [ ] Device token deduplication works (UPSERT on endpoint/token)
- [ ] Stale token cleanup marks inactive tokens (>90 days)

**Database:**
- [ ] All tables created on `initDb()`
- [ ] Indexes exist for performance queries
- [ ] Default preferences created on first app launch

**API Endpoints:**
- [ ] POST `/api/notifications/register-device` registers Web Push subscription
- [ ] GET `/api/notifications/preferences` returns user preferences
- [ ] PUT `/api/notifications/preferences` updates preferences
- [ ] GET `/api/vapid-public-key` exposes public key

**Integration:**
- [ ] Transaction completion sends push notification to sender
- [ ] Transfer received sends push notification to recipient (if Drop user)
- [ ] Login from new device sends security alert
- [ ] Promotional notifications require consent dialog

**Compliance:**
- [ ] Transactional notifications always sent (no opt-out)
- [ ] Promotional notifications OFF by default
- [ ] Consent timestamp logged in DB
- [ ] No sensitive data in push payload

**UI:**
- [ ] Notification settings screen shows category toggles
- [ ] Quiet hours UI functional
- [ ] Device list shows registered devices
- [ ] Permission prompt appears on first visit

---

## 16. Dependencies

**Add to `package.json`:**
```json
{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}
```

**Future (when mobile apps ship):**
```json
{
  "dependencies": {
    "firebase-admin": "^12.0.0",
    "apn": "^2.2.0"
  }
}
```

**Install:**
```bash
cd ~/ALAI/products/Drop/src/drop-app
npm install web-push
```

---

## 17. Env Vars

**Add to `.env.example`:**
```bash
# --- Push Notifications ---

# Web Push (VAPID keys)
# Generate: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BN...
VAPID_PRIVATE_KEY=...
VAPID_SUBJECT=mailto:support@getdrop.no

# Firebase Cloud Messaging (future - Android)
# FIREBASE_SERVICE_ACCOUNT_KEY=base64-encoded-json

# Apple Push Notification service (future - iOS)
# APNS_KEY_ID=ABC123XYZ
# APNS_TEAM_ID=DEF456UVW
# APNS_KEY_CONTENT=base64-encoded-p8-file
```

**Generate VAPID keys:**
```bash
npx web-push generate-vapid-keys
# Outputs:
# Public Key: BN4GvZtEZiZuqaasbD-...
# Private Key: ...
```

---

## 18. File List

**Files to CREATE:**
```
src/lib/push.ts                                     # Push notification service layer
src/app/api/notifications/register-device/route.ts  # Device registration endpoint
src/app/api/notifications/devices/[deviceId]/route.ts # Device unregister endpoint
src/app/api/notifications/preferences/route.ts      # Preferences GET/PUT endpoints
src/app/api/notifications/history/route.ts          # Notification history endpoint
src/app/api/vapid-public-key/route.ts               # VAPID public key endpoint
public/sw.js                                        # Service worker (push event listener)
tests/unit/push.test.ts                             # Unit tests for push service
tests/integration/push-flow.test.ts                 # Integration tests
```

**Files to MODIFY:**
```
src/lib/db.ts                                       # Add device_tokens, notification_queue, notification_log, notification_preferences tables
src/lib/db.ts                                       # Add notification_type, priority, data columns to notifications table
src/lib/db.ts                                       # Add device_fingerprint column to sessions table
src/app/api/transactions/remittance/route.ts        # Add push notification on completion
src/app/api/transactions/qr-payment/route.ts        # Add push notification on completion
src/app/api/auth/login/route.ts                     # Add login alert for new devices
src/app/profile/notifications/page.tsx              # Modify to add preference toggles
src/app/layout.tsx                                  # Register service worker
.env.example                                        # Add VAPID_* env vars
package.json                                        # Add web-push dependency
src/lib/services/notifications.ts                   # DELETE (replaced by src/lib/push.ts)
```

**Total:** 9 new files, 11 modified files.

---

**END OF SPEC**

# drop-rebrand-plan

# Plan: Drop Full Rebrand

## Objective
Rebrand all Drop screens (12 app pages + 13 landing pages) to match Alem's approved Stitch design. Plus logo, branding assets, and email templates.

## Source of Truth
- Stitch reference: `/Users/makinja/ALAI/products/Drop/design/stitch-login-reference.png`
- Design system: `/Users/makinja/ALAI/products/Drop/design/design-system-reference.md`
- Login page (already rebranded): `src/drop-app/src/app/login/page.tsx`

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | logo-builder | Export/create logo, generate all asset sizes | builder |
| V1 | logo-validator | Verify all logo assets exist and are correct | validator |
| B2 | app-auth-builder | Rebrand Login + Onboarding pages | builder |
| B3 | app-core-builder | Rebrand Dashboard + Home + Send + Scan pages | builder |
| B4 | app-util-builder | Rebrand Cards + Accounts + Profile + History + Merchant pages | builder |
| V2 | app-validator | Verify all 12 app pages match design system | validator |
| B5 | landing-main-builder | Rebrand landing index + product pages | builder |
| B6 | landing-info-builder | Rebrand company + legal landing pages | builder |
| V3 | landing-validator | Verify all 13 landing pages match design system | validator |
| B7 | branding-builder | Email templates + OG images + favicon | builder |
| V4 | branding-validator | Verify email templates and brand assets | validator |

### Step-by-Step Tasks

#### Phase 1: Logo & Brand Foundation
**Task 1:** Create/export Drop logo and all brand assets
- Owner: B1
- BlockedBy: none
- Files: `src/drop-app/public/drop-icon.png`, `src/drop-app/public/favicon.svg`, `brand/logo-icon.svg`, `brand/app-icon.svg`, `brand/favicon.svg`, `src/drop-app/src/components/drop-logo.tsx`
- Instructions:
  1. Read the Stitch reference image at `design/stitch-login-reference.png`
  2. The logo is: green rounded square (#0B6E35) with white $ symbol and circular transfer arrows, plus gold (#D4A017) accent dot
  3. Create a clean SVG version of this logo
  4. Generate PNG versions: drop-icon.png (128x128, 256x256), icon-200.png, icon-48.png
  5. Create favicon.svg using Drop green (#0B6E35) not ALAI green
  6. Update drop-logo.tsx: DropAppIcon should render the new logo SVG inline
  7. Update brand/ directory SVG files
- Acceptance:
  - [ ] drop-icon.png exists in public/ at 128x128 minimum
  - [ ] favicon.svg uses #0B6E35
  - [ ] drop-logo.tsx DropAppIcon renders new logo
  - [ ] All brand/ SVGs updated

**Task 2:** Validate logo and brand assets
- Owner: V1
- BlockedBy: 1
- Acceptance: All logo files exist, correct colors, no broken references

#### Phase 2: App Pages (parallel)
**Task 3:** Rebrand Login + Onboarding
- Owner: B2
- BlockedBy: none
- Files: `src/drop-app/src/app/login/page.tsx`, `src/drop-app/src/app/onboarding/page.tsx`
- Instructions:
  1. Read design-system-reference.md
  2. Login is already close to target — refine if needed
  3. Onboarding: apply same design language (gray bg, white cards, green buttons, Fraunces headings)
  4. Keep all existing logic (form validation, API calls, state management)
- Acceptance:
  - [ ] Login matches Stitch reference
  - [ ] Onboarding uses same design language
  - [ ] All form logic preserved

**Task 4:** Rebrand Dashboard + Home + Send + Scan
- Owner: B3
- BlockedBy: none
- Files: `src/drop-app/src/app/page.tsx`, `src/drop-app/src/app/dashboard/page.tsx`, `src/drop-app/src/app/send/page.tsx`, `src/drop-app/src/app/scan/page.tsx`
- Instructions:
  1. Read design-system-reference.md AND current login/page.tsx as reference
  2. These pages use BottomNav — use the "app pages WITH bottom nav" layout pattern
  3. Dashboard: main screen after login — balance display, quick actions, recent transactions
  4. Home: app entry point, likely redirect or welcome
  5. Send: 4-step money transfer flow — preserve all step logic
  6. Scan: QR scanner — preserve camera/scan logic
  7. Keep ALL business logic, only change visual styling
- Acceptance:
  - [ ] All 4 pages use consistent design system
  - [ ] BottomNav present on all pages
  - [ ] All existing functionality preserved

**Task 5:** Rebrand Cards + Accounts + Profile + History + Merchant
- Owner: B4
- BlockedBy: none
- Files: `src/drop-app/src/app/cards/page.tsx`, `src/drop-app/src/app/accounts/page.tsx`, `src/drop-app/src/app/profile/page.tsx`, `src/drop-app/src/app/history/page.tsx`, `src/drop-app/src/app/merchant/page.tsx`
- Instructions:
  1. Read design-system-reference.md AND current login/page.tsx as reference
  2. All pages use BottomNav layout pattern
  3. Cards: virtual/physical card management
  4. Accounts: linked bank accounts list
  5. Profile: user settings and preferences
  6. History: transaction list with filters
  7. Merchant: merchant onboarding flow
  8. logo-preview/page.tsx can be deleted or simplified
  9. Keep ALL business logic, only change visual styling
- Acceptance:
  - [ ] All 5 pages use consistent design system
  - [ ] BottomNav on all pages
  - [ ] All existing functionality preserved

**Task 6:** Validate all app pages
- Owner: V2
- BlockedBy: 3, 4, 5
- Acceptance:
  - [ ] All 12 pages load without errors
  - [ ] Consistent color palette (#0B6E35, #D4A017, #EEEEEE, #1A1A1A)
  - [ ] Consistent typography (Fraunces headings, DM Sans body)
  - [ ] BottomNav on all pages except login/onboarding
  - [ ] No hardcoded wrong colors or fonts

#### Phase 3: Landing Pages (parallel)
**Task 7:** Rebrand landing index + product pages
- Owner: B5
- BlockedBy: none
- Files: `landing/index.html`, `landing/pages/send-penger.html`, `landing/pages/qr-betaling.html`, `landing/pages/priser.html`, `landing/pages/sikkerhet.html`
- Instructions:
  1. Read design-system-reference.md (Landing Page section)
  2. These are static HTML — use CSS variables + Google Fonts CDN
  3. Main index: hero section, features, CTA, footer
  4. Product pages: detailed feature descriptions
  5. Consistent navbar and footer across all pages
  6. Colors: same as app (#0B6E35, #D4A017, #EEEEEE, white)
  7. Desktop-responsive (max-width 1200px, mobile-friendly)
  8. Use favicon.svg with Drop green
- Acceptance:
  - [ ] All 5 pages share consistent navbar/footer
  - [ ] Same color palette as app
  - [ ] Responsive (375px to 1440px)
  - [ ] No broken links between pages

**Task 8:** Rebrand landing company + legal pages
- Owner: B6
- BlockedBy: none
- Files: `landing/pages/om-drop.html`, `landing/pages/karriere.html`, `landing/pages/presse.html`, `landing/pages/kontakt.html`, `landing/pages/personvern.html`, `landing/pages/vilkar.html`, `landing/pages/lisenser.html`, `landing/pages/cookies.html`
- Instructions:
  1. Read design-system-reference.md (Landing Page section)
  2. Read landing/index.html for navbar/footer pattern (use same)
  3. Company pages (om-drop, karriere, presse, kontakt): content + design
  4. Legal pages (personvern, vilkar, lisenser, cookies): clean readable text layout
  5. Consistent with the main landing page style
- Acceptance:
  - [ ] All 8 pages share same navbar/footer as index
  - [ ] Consistent colors and typography
  - [ ] Legal pages readable and well-structured
  - [ ] All internal links work

**Task 9:** Validate all landing pages
- Owner: V3
- BlockedBy: 7, 8
- Acceptance:
  - [ ] All 13 pages render correctly
  - [ ] Consistent design across all pages
  - [ ] All links work
  - [ ] Mobile responsive

#### Phase 4: Email & Remaining Branding
**Task 10:** Create email templates + OG images
- Owner: B7
- BlockedBy: 1
- Files: new `src/drop-app/src/email-templates/` directory, `brand/og-image.html`
- Instructions:
  1. Create HTML email templates (inline CSS, 600px max-width):
     - welcome.html — Welcome to Drop
     - transaction-receipt.html — Payment confirmation
     - password-reset.html — Reset password link
  2. Use Drop brand colors, logo, "Send money. Simply." tagline
  3. Update og-image.html in brand/ to match new design
  4. All emails must work in Gmail, Outlook, Apple Mail
- Acceptance:
  - [ ] 3 email templates created
  - [ ] Valid HTML email (inline CSS, table layout)
  - [ ] Drop branding consistent
  - [ ] OG image updated

**Task 11:** Validate email templates and branding
- Owner: V4
- BlockedBy: 10
- Acceptance:
  - [ ] Email templates have inline CSS
  - [ ] No external CSS/JS dependencies
  - [ ] Drop logo and colors present
  - [ ] OG image renders correctly

## Validation Commands
```bash
# App — dev server
cd /Users/makinja/ALAI/products/Drop/src/drop-app && npm run build

# Landing — open in browser
open /Users/makinja/ALAI/products/Drop/landing/index.html

# Check all files exist
ls -la /Users/makinja/ALAI/products/Drop/src/drop-app/public/drop-icon.png
ls -la /Users/makinja/ALAI/products/Drop/src/drop-app/public/favicon.svg
ls -la /Users/makinja/ALAI/products/Drop/brand/logo-icon.svg
```

# drop-sms-otp-spec

# Drop SMS/OTP 2FA Implementation Specification

**Version:** 1.0
**Date:** 2026-02-17
**Author:** John (AI Director)
**Status:** DRAFT — AWAITING APPROVAL
**MC Task:** #1189

---

## Executive Summary

This specification defines the implementation of SMS-based Two-Factor Authentication (2FA) for Drop's high-value financial transactions. Drop already uses BankID for primary authentication — this adds SMS OTP as a second factor for critical operations (remittance, QR payments, account changes).

**Key Points:**
- **Scope:** Transaction verification ONLY (not login replacement)
- **Target:** Norwegian users (+47 validation)
- **Provider:** SMS gateway integration (Twilio recommended, see comparison)
- **Storage:** SQLite `otp_tokens` table with 5-min expiry
- **Rate limiting:** Anti-abuse protection per user/IP
- **Fallback:** BankID re-auth if SMS fails

---

## 1. Requirements Analysis

### 1.1 Functional Requirements

| ID | Requirement | Priority | Rationale |
|----|-------------|----------|-----------|
| FR-1 | Send 6-digit OTP via SMS on remittance initiation | HIGH | Primary security control |
| FR-2 | Verify OTP before processing transaction | HIGH | Prevents unauthorized transfers |
| FR-3 | Rate limit OTP requests (3/hour per user) | HIGH | Anti-abuse, anti-spam |
| FR-4 | Expire OTP after 5 minutes | HIGH | Security best practice |
| FR-5 | Support Norwegian phone numbers (+47) | HIGH | Target market |
| FR-6 | Block reused/expired OTPs | HIGH | Prevent replay attacks |
| FR-7 | Fallback to BankID re-auth if SMS fails | MEDIUM | Accessibility, reliability |
| FR-8 | Audit log all OTP operations | MEDIUM | Compliance, debugging |
| FR-9 | Allow user to resend OTP (1x per transaction) | MEDIUM | UX improvement |
| FR-10 | Support OTP for QR payments | MEDIUM | Future feature parity |

### 1.2 Non-Functional Requirements

| ID | Requirement | Target | Measurement |
|----|-------------|--------|-------------|
| NFR-1 | SMS delivery time | < 30 seconds | Provider SLA |
| NFR-2 | OTP generation time | < 100ms | Server-side perf |
| NFR-3 | Verification latency | < 200ms | Database lookup + validation |
| NFR-4 | Availability | 99.9% | Provider uptime |
| NFR-5 | SMS delivery rate | > 95% | Provider metrics |
| NFR-6 | Cost per OTP | < 0.10 NOK | Budget constraint |

### 1.3 User Stories

**US-1: Remittance with OTP**
AS a Drop user
WHEN I initiate a remittance transfer
THEN I receive an SMS with a 6-digit code
AND I enter the code within 5 minutes
AND the transaction proceeds only if the code is correct

**US-2: Failed SMS fallback**
AS a Drop user
WHEN I don't receive the OTP SMS within 30 seconds
THEN I can request a resend (1x) OR re-authenticate with BankID
AND the transaction completes via the fallback method

**US-3: Rate limiting protection**
AS a Drop user
WHEN I request more than 3 OTPs in 1 hour
THEN I receive an error message
AND must wait or use BankID fallback

---

## 2. SMS Provider Comparison

### 2.1 Provider Research

| Provider | Pricing (Norway) | Delivery Time | Reliability | Integration Complexity | Notes |
|----------|------------------|---------------|-------------|------------------------|-------|
| **Twilio** | ~$0.065 (~0.70 NOK) | < 10s | 99.95% | Low (REST API) | Industry standard, good docs, auto-scaling |
| **MessageBird (Bird)** | ~$0.008-0.065 | < 15s | 99.9% | Low (REST API) | 90% cheaper than Twilio on bulk, Norway-specific pricing unclear |
| **Vonage** | €0.0057-0.0642 (~0.06-0.70 NOK) | < 20s | 99.5% | Medium (complex API) | Voice fallback available |
| **BudgetSMS** | €0.048 (~0.52 NOK) | < 30s | 95% | Low (HTTP/XML) | Lower cost, lower reliability |
| **PSWinCom (DASH)** | Not public | Unknown | Unknown | Unknown | Norwegian provider, requires quote |
| **Intelecom** | Not public | Unknown | Unknown | Unknown | Norwegian provider, requires quote |

**Recommendation: Twilio**

**Rationale:**
1. **Proven reliability** — 99.95% uptime, < 10s delivery globally
2. **Developer-friendly** — Node.js SDK, webhook support, excellent docs
3. **Scalability** — Auto-volume pricing, no manual negotiation
4. **Norway coverage** — Tier 1 country, consistent delivery
5. **Cost acceptable** — ~0.70 NOK per SMS, budget allows < 0.10 NOK per OTP (TBD: verify with Alem)
6. **Fallback options** — Voice OTP available if SMS fails

**Alternative: MessageBird (if cost is critical)**
- 90% cheaper on bulk volume
- Need to verify Norway-specific pricing
- Slightly longer delivery time (< 15s vs < 10s)

**Decision:** Use Twilio for MVP. Re-evaluate cost after 1000 transactions.

### 2.2 Cost Analysis

**Assumptions:**
- 500 remittance transactions/month (MVP target)
- 1.2 OTP per transaction (20% resend rate)
- Twilio pricing: $0.065/SMS (~0.70 NOK)

**Monthly Cost:**
- 500 tx × 1.2 OTP = 600 SMS
- 600 × 0.70 NOK = **420 NOK/month** (~€35)

**Annual Cost:** ~5,000 NOK (~€420)

**Cost per transaction:** 0.84 NOK (acceptable for fintech)

---

## 3. Architecture Design

### 3.1 System Flow

```
┌─────────────┐
│   User      │
│  (Browser)  │
└─────┬───────┘
      │ 1. POST /api/transactions/remittance
      ▼
┌─────────────────────────────────────┐
│  Next.js API Route                  │
│  /api/transactions/remittance       │
│                                     │
│  1. Validate transaction data       │
│  2. Check user KYC status           │
│  3. Generate 6-digit OTP            │
│  4. Store OTP in DB (expires 5min)  │
│  5. Call Twilio API → send SMS      │
│  6. Return transaction_pending      │
└─────┬───────────────────────────────┘
      │ 2. SMS sent
      ▼
┌─────────────┐
│   Twilio    │
│  SMS API    │
└─────┬───────┘
      │ 3. Deliver SMS to +47 number
      ▼
┌─────────────┐
│   User      │
│  (Phone)    │
└─────┬───────┘
      │ 4. User enters OTP code
      ▼
┌─────────────────────────────────────┐
│  Next.js API Route                  │
│  /api/otp/verify                    │
│                                     │
│  1. Lookup OTP in DB                │
│  2. Validate: not expired, correct  │
│  3. Mark OTP as used                │
│  4. Process transaction via PISP    │
│  5. Return transaction_completed    │
└─────────────────────────────────────┘
```

### 3.2 Database Schema

**New Table: `otp_tokens`**

```sql
CREATE TABLE IF NOT EXISTS otp_tokens (
  id TEXT PRIMARY KEY,              -- otp_xxxxx
  user_id TEXT NOT NULL,            -- FK to users.id
  phone_number TEXT NOT NULL,       -- E.164 format (+47...)
  code TEXT NOT NULL,               -- 6-digit numeric code (plain, NOT hashed — short-lived)
  purpose TEXT NOT NULL,            -- 'remittance', 'qr_payment', 'account_change'
  transaction_id TEXT,              -- FK to transactions.id (nullable)
  status TEXT NOT NULL,             -- 'pending', 'verified', 'expired', 'failed'
  attempts INTEGER DEFAULT 0,       -- Verification attempts (max 3)
  resend_count INTEGER DEFAULT 0,   -- Resend attempts (max 1)
  created_at TEXT DEFAULT (datetime('now')),
  expires_at TEXT NOT NULL,         -- 5 minutes from created_at
  verified_at TEXT,                 -- Timestamp when verified
  ip_address TEXT,                  -- Request IP for audit
  user_agent TEXT,                  -- Request user agent

  FOREIGN KEY (user_id) REFERENCES users(id),
  FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);

CREATE INDEX idx_otp_user_status ON otp_tokens(user_id, status);
CREATE INDEX idx_otp_expires ON otp_tokens(expires_at);
CREATE INDEX idx_otp_transaction ON otp_tokens(transaction_id);
```

**Why NOT hash the OTP code?**
- **Short-lived:** 5-min expiry makes brute-force impractical
- **Single-use:** Deleted/marked used after verification
- **Performance:** Plain lookup faster than bcrypt compare (< 200ms target)
- **Industry standard:** Most OTP providers store plain (Twilio, Auth0, Firebase)

**Security mitigations:**
- Rate limiting (3 OTP requests/hour per user)
- Max 3 verification attempts per OTP
- SQLite file encryption at rest (OS-level)
- Audit logging all OTP operations

---

## 4. API Endpoints

### 4.1 POST /api/otp/send

**Purpose:** Generate and send OTP via SMS

**Request:**
```json
{
  "purpose": "remittance",
  "transactionId": "tx_rem_xyz123",
  "phoneNumber": "+4798765432"
}
```

**Response (200):**
```json
{
  "success": true,
  "data": {
    "otpId": "otp_abc123",
    "expiresAt": "2026-02-17T15:05:00Z",
    "sentTo": "+47 XXX XX 432",
    "canResend": true,
    "resendAfter": "2026-02-17T15:01:00Z"
  }
}
```

**Validation:**
- User must be authenticated (JWT)
- User must have KYC approved
- Purpose must be: `remittance`, `qr_payment`, `account_change`
- Phone number must be +47 (Norwegian)
- Rate limit: 3 requests/hour per user

### 4.2 POST /api/otp/verify

**Request:**
```json
{
  "otpId": "otp_abc123",
  "code": "123456",
  "transactionId": "tx_rem_xyz123"
}
```

**Response (200):**
```json
{
  "success": true,
  "data": {
    "verified": true,
    "transactionId": "tx_rem_xyz123"
  }
}
```

### 4.3 POST /api/otp/resend

**Request:**
```json
{
  "otpId": "otp_abc123"
}
```

**Response (200):**
```json
{
  "success": true,
  "data": {
    "otpId": "otp_abc123",
    "sentTo": "+47 XXX XX 432",
    "expiresAt": "2026-02-17T15:05:00Z"
  }
}
```

---

## 5. Transaction Flow Integration

**New Flow (with OTP 2FA):**
```
1. POST /api/transactions/remittance
     → Create transaction (status: pending_2fa)
     → Generate OTP
     → Send SMS
     → Return 202 { otpId, transactionId }

2. User receives SMS, enters code

3. POST /api/otp/verify
     → Verify code
     → Update transaction (status: processing)
     → Initiate PISP payment
     → Return 200 { verified: true }
```

---

## 6. Rate Limiting Strategy

| Scope | Limit | Window | Action |
|-------|-------|--------|--------|
| **Per User** | 3 OTP requests | 1 hour | Block, suggest BankID |
| **Per IP** | 10 OTP requests | 1 hour | Block, abuse detection |
| **Per Phone** | 5 OTP requests | 1 hour | Block, anti-spam |
| **Verification** | 3 tries | Per OTP | Mark failed, require new |
| **Resend** | 1 resend | Per OTP | Block, suggest BankID |

---

## 7. Security Considerations

| Threat | Severity | Mitigation |
|--------|----------|------------|
| **SMS Interception** | HIGH | Short expiry (5 min), single-use, audit log |
| **Brute Force** | MEDIUM | Max 3 attempts, rate limiting |
| **SMS Bombing** | MEDIUM | Rate limiting (3/hour per user) |
| **SIM Swap** | HIGH | BankID fallback, anomaly detection (future) |
| **Replay Attack** | LOW | Single-use, status tracking |

### Phone Number Validation

```typescript
function validateNorwegianPhone(phone: string): boolean {
  const cleaned = phone.replace(/[\s-]/g, '');
  const regex = /^\+47\d{8}$/;
  return regex.test(cleaned);
}
```

---

## 8. Service Implementation

### 8.1 File Structure

```
src/drop-app/src/lib/services/
├── otp.ts              # NEW — OTP service
├── twilio.ts           # NEW — Twilio client
├── index.ts            # Export services
└── __tests__/
    ├── otp.test.ts
    └── twilio.test.ts
```

### 8.2 OTP Service Interface

```typescript
export interface OtpService {
  generate(userId: string, phone: string, purpose: string): Promise<{ otpId: string; expiresAt: string }>;
  send(otpId: string): Promise<void>;
  verify(otpId: string, code: string, userId: string): Promise<boolean>;
  resend(otpId: string): Promise<void>;
  cleanup(): Promise<void>; // Cron job
}
```

### 8.3 Twilio Service

**Environment Variables:**
```bash
TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+47XXXXXXXX
```

---

## 9. Frontend Integration

### 9.1 UI Components

**`OtpInput.tsx`** — 6-digit code input
- Auto-advance on digit entry
- Paste support
- Mobile numeric keyboard
- Accessibility (ARIA labels)

**`OtpDialog.tsx`** — Modal for OTP entry
- Countdown timer (5 min)
- Resend button (appears after 30s)
- BankID fallback link
- Error messages

---

## 10. Testing Strategy

**Unit Tests:**
- ✅ Generate OTP creates 6-digit code
- ✅ Verify OTP accepts correct code
- ✅ Verify OTP rejects wrong code
- ✅ Verify OTP rejects expired code
- ✅ Resend OTP blocks after 1 resend

**Integration Tests:**
- ✅ /api/otp/send requires auth + KYC
- ✅ /api/otp/verify validates code
- ✅ Rate limiting enforced

**E2E Tests (Playwright):**
- ✅ Complete remittance with OTP
- ✅ Resend OTP flow
- ✅ BankID fallback

---

## 11. Deployment Plan

**Phase 1: Backend (Week 1)**
1. Database migration: `otp_tokens` table
2. OTP service + Twilio service
3. API routes: send, verify, resend
4. Unit + integration tests

**Phase 2: Frontend (Week 2)**
5. OtpInput + OtpDialog components
6. Modify remittance flow
7. E2E tests

**Phase 3: Deployment (Week 3)**
8. Twilio production setup
9. Staging deployment + manual test
10. Production deploy (feature flag)
11. Monitor metrics
12. Enable for all users

### Feature Flag

```typescript
const OTP_ENABLED = process.env.FEATURE_OTP_2FA === 'true';
```

Rollout: 10% → monitor → 100%

---

## 12. Acceptance Criteria

**Functional:**
- [ ] User receives SMS OTP within 30s
- [ ] User can verify OTP and complete transaction
- [ ] User can resend OTP (1x)
- [ ] Rate limiting blocks after 3/hour
- [ ] OTP expires after 5 minutes
- [ ] Audit log captures all operations

**Non-Functional:**
- [ ] SMS delivery < 30s (95th percentile)
- [ ] Verification latency < 200ms
- [ ] Cost per OTP < 0.10 NOK
- [ ] Availability > 99.9%

**Security:**
- [ ] Norwegian phone validation (+47)
- [ ] Max 3 verification attempts
- [ ] Single-use OTP
- [ ] Audit log includes IP, user agent
- [ ] HTTPS enforced

---

## 13. Cost & Timeline

**Cost:**
- SMS: 420 NOK/month (500 tx/month)
- Annual: ~5,000 NOK
- Cost per transaction: 0.84 NOK

**Timeline:**
- Week 1: Backend
- Week 2: Frontend
- Week 3: Deployment
- **Total: 3 weeks**

---

## 14. Risks & Mitigations

| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| SMS delivery failures | HIGH | LOW | BankID fallback, 99.95% SLA |
| Cost overrun | MEDIUM | LOW | Monitor, cap at 1000/month |
| SIM swap attacks | HIGH | LOW | BankID re-auth |
| UX friction | MEDIUM | MEDIUM | Clear errors, fallback |
| Twilio outage | HIGH | VERY LOW | BankID fallback |

---

## 15. Future Enhancements

- Voice OTP fallback
- Authenticator app (TOTP)
- Anomaly detection
- Trusted devices (skip OTP)
- International numbers
- Multi-language SMS
- Biometric verification

---

## 16. References

**Research:**
- [Twilio SMS Pricing Norway](https://www.twilio.com/en-us/sms/pricing/no)
- [Norway SMS Pricing 2025: Compare 11 Providers](https://www.sent.dm/resources/norway-sms-pricing)
- [MessageBird SMS Pricing](https://bird.com/en-us/pricing/sms)
- [Top 8 SMS OTP Providers in 2026](https://www.engagelab.com/blog/otp-service-provider)
- [Top 7 OTP Service Providers](https://www.smscountry.com/blog/top-otp-service-providers/)
- [Best SMS Gateway Providers](https://prelude.so/blog/best-sms-gateway-providers)

**Internal Docs:**
- Drop Architecture: `~/ALAI/products/Drop/project/architecture/architecture-document.md`
- Drop Auth: `~/ALAI/products/Drop/src/drop-app/src/lib/auth.ts`
- Drop Middleware: `~/ALAI/products/Drop/src/drop-app/src/lib/middleware.ts`

---

## 17. Approvals

| Role | Name | Date | Status |
|------|------|------|--------|
| **Spec Author** | John | 2026-02-17 | ✅ COMPLETE |
| **Tech Review** | TBD | TBD | ⏳ PENDING |
| **Security Review** | TBD | TBD | ⏳ PENDING |
| **CEO Approval** | Alem | TBD | ⏳ PENDING |

---

## Appendix A: SMS Templates

**Norwegian:**
```
Drop: Din bekreftelseskode er {CODE}. Koden utløper om 5 minutter.
```

**English:**
```
Drop: Your verification code is {CODE}. Expires in 5 minutes.
```

**Character Count:** < 160 chars (1 SMS segment)

---

## Appendix B: Error Messages

| Code | User Message | Developer Note |
|------|--------------|----------------|
| `otp_expired` | "Code expired. Request new code." | OTP > 5 min |
| `otp_invalid` | "Incorrect code. Try again." | Wrong code |
| `otp_failed` | "Too many attempts. Request new code." | 3+ attempts |
| `rate_limited` | "Too many requests. Wait {X} minutes." | 3+ in 1 hour |
| `sms_failed` | "SMS failed. Try resend or BankID." | Twilio error |
| `phone_invalid` | "Invalid phone. Use +47 number." | Not Norwegian |

---

**END OF SPECIFICATION**

**MC Task #1189:** Spec complete. Ready for review.
**Next Action:** Submit to Alem for GO/NO-GO decision.
**Estimated Implementation:** 3 weeks (backend + frontend + deployment).

# drop-supporting-systems-plan

# Plan: Drop Supporting Systems — Monitoring, Logging, Alerts, Backups

## Research Summary

### What Exists
- Health check endpoint (`GET /api/health`) — DB ping, latency, uptime, version
- Container health checks (Docker Compose 30s, Fly.io 30s)
- Auto-restart on failure (`restart: unless-stopped`)
- CI/CD pipeline (5 GitHub Actions jobs: lint, test, build, e2e, docker)
- Security basics (JWT httpOnly, bcrypt, CSRF, rate limiting, parameterized SQL)
- Manual SQLite backup/restore documented in DEPLOYMENT.md

### What's Missing (from docs/infrastructure/MONITORING.md)
- External uptime monitoring
- Error tracking (Sentry)
- Structured logging (JSON + request IDs)
- Log aggregation
- Alerting (Slack/email)
- Audit logging (compliance requirement — PSD2, AML, GDPR)
- Database performance monitoring
- Automated backups
- Security scanning in CI

### Tech Stack Context
- Next.js 16 + React 19, API Routes
- SQLite (better-sqlite3) for demo, PostgreSQL for prod
- Docker multi-stage builds
- Fly.io staging config ready (not deployed)
- Vitest + Playwright tests

## Objective

Implement the missing supporting systems that make Drop operationally ready: structured logging, error tracking, audit logging, automated backups, alerting, and CI security scanning.

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | logging-builder | Build structured logging + audit log system | builder |
| V1 | logging-validator | Validate logging implementation | validator |
| B2 | monitoring-builder | Build error tracking (Sentry) + uptime + alerting | builder |
| V2 | monitoring-validator | Validate monitoring setup | validator |
| B3 | backup-builder | Build automated backup system + CI security scanning | builder |
| V3 | backup-validator | Validate backup + CI security | validator |

### Step-by-Step Tasks

---

#### Phase 1: Structured Logging + Audit Log (Foundation)

**Task 1:** Implement structured logging library
- Owner: B1
- BlockedBy: none
- Files: `src/drop-app/src/lib/logger.ts` (new)
- Acceptance:
  - [ ] JSON-formatted log output with timestamp, level, requestId, message, metadata
  - [ ] Request ID generation middleware (UUID per request, passed through all handlers)
  - [ ] Log levels: debug, info, warn, error
  - [ ] Writes to stdout (Docker-friendly, no file writes)
  - [ ] All existing API routes use logger instead of console.log
  - [ ] No new dependencies if possible (use built-in, or pino if needed for perf)

**Task 2:** Implement audit log table + middleware
- Owner: B1
- BlockedBy: 1
- Files: `src/drop-app/src/lib/db.ts` (schema), `src/drop-app/src/lib/audit.ts` (new)
- Acceptance:
  - [ ] `audit_log` table: id, timestamp, user_id, action, resource, details (JSON), ip_address, user_agent, request_id
  - [ ] Actions logged: login_success, login_failure, logout, register, password_change, transfer_initiated, transfer_completed, qr_payment, kyc_submitted, session_created, session_revoked
  - [ ] Audit entries created in same transaction as domain action where possible
  - [ ] Retention: no auto-delete (5-year compliance requirement noted in comments)
  - [ ] GET /api/admin/audit endpoint (admin-only, paginated) for future use

**Task 3:** Validate logging + audit log
- Owner: V1
- BlockedBy: 2
- Acceptance:
  - [ ] All API routes produce structured JSON logs on request
  - [ ] Request IDs are consistent across a single request's log entries
  - [ ] Login success/failure both produce audit log entries
  - [ ] Transfer/payment actions produce audit log entries
  - [ ] Audit table schema matches spec
  - [ ] Build passes, all existing tests still pass
  - [ ] No console.log left in API routes (replaced with logger)

---

#### Phase 2: Error Tracking + Monitoring + Alerting

**Task 4:** Integrate Sentry error tracking
- Owner: B2
- BlockedBy: 1 (needs logger for context)
- Files: `src/drop-app/src/lib/sentry.ts` (new), `src/drop-app/src/app/layout.tsx` (init), `src/drop-app/next.config.ts`
- Acceptance:
  - [ ] @sentry/nextjs installed and initialized (client + server)
  - [ ] DSN configurable via `SENTRY_DSN` env var
  - [ ] Sentry disabled when env var not set (no crash in dev)
  - [ ] Unhandled errors + rejected promises captured
  - [ ] API route errors captured with request context (user_id, requestId)
  - [ ] Source maps uploaded in Docker build (or skipped if no SENTRY_AUTH_TOKEN)
  - [ ] Environment tag: production/staging/development
  - [ ] Performance monitoring (traces sample rate configurable via env)

**Task 5:** Add health check monitoring + Slack alerting hook
- Owner: B2
- BlockedBy: 4
- Files: `src/drop-app/src/lib/alerts.ts` (new), env vars
- Acceptance:
  - [ ] Slack webhook integration via `SLACK_WEBHOOK_URL` env var
  - [ ] Alert on: application startup, graceful shutdown, unhandled error spike (>5 in 1 min)
  - [ ] Alert format: emoji + severity + message + timestamp + link to Sentry
  - [ ] Cooldown: max 1 alert per type per 10 minutes (no spam)
  - [ ] No-op when SLACK_WEBHOOK_URL not configured
  - [ ] UptimeRobot setup documented in MONITORING.md (external, free tier, checks /api/health)

**Task 6:** Validate monitoring + alerting
- Owner: V2
- BlockedBy: 5
- Acceptance:
  - [ ] Sentry captures thrown errors in API routes (test with intentional throw)
  - [ ] Sentry DSN not hardcoded (env var only)
  - [ ] Sentry disabled gracefully in dev (no SENTRY_DSN = no crash)
  - [ ] Slack alert function works with mock webhook (unit test)
  - [ ] No sensitive data in Sentry events (no passwords, tokens, card numbers)
  - [ ] MONITORING.md updated with full stack docs
  - [ ] Build passes, all existing tests still pass

---

#### Phase 3: Automated Backups + CI Security

**Task 7:** Create automated backup script + CI security scanning
- Owner: B3
- BlockedBy: none (independent)
- Files: `src/drop-app/scripts/backup.sh` (new), `.github/workflows/ci.yml` (edit)
- Acceptance:
  - [ ] backup.sh: SQLite `.backup` command (safe, atomic), timestamped output
  - [ ] backup.sh: Configurable retention (default 30 days, delete older)
  - [ ] backup.sh: Exit code for cron/monitoring integration
  - [ ] backup.sh: Works inside Docker container (volume mount)
  - [ ] Docker Compose: backup service or documented cron setup
  - [ ] CI: `npm audit --audit-level=high` step added to GitHub Actions
  - [ ] CI: Fails build on HIGH/CRITICAL vulnerabilities
  - [ ] .github/dependabot.yml created (weekly npm updates)
  - [ ] DEPLOYMENT.md updated with backup schedule + restore procedure

**Task 8:** Validate backups + CI security
- Owner: V3
- BlockedBy: 7
- Acceptance:
  - [ ] backup.sh creates valid SQLite backup (can be opened, tables exist)
  - [ ] backup.sh handles missing DB gracefully (exit 1, clear message)
  - [ ] Old backups cleaned up after retention period
  - [ ] npm audit step present in CI workflow
  - [ ] dependabot.yml valid YAML, correct config
  - [ ] DEPLOYMENT.md backup section accurate
  - [ ] Build passes, all existing tests still pass

---

## Validation Commands

```bash
# Phase 1: Logging + Audit
cd ~/ALAI/products/Drop/src/drop-app
npm run build                           # Build passes
npm test                                # All tests pass
# Start app, hit /api/auth/login → check stdout for JSON log
# Check /api/health → verify request ID in logs
# SELECT * FROM audit_log → verify entries after login

# Phase 2: Monitoring
# Set SENTRY_DSN → start app → trigger error → check Sentry dashboard
# Set SLACK_WEBHOOK_URL → trigger alert → check Slack
# npm run build with SENTRY_AUTH_TOKEN → verify sourcemaps

# Phase 3: Backups + CI
bash scripts/backup.sh                  # Creates timestamped backup
sqlite3 backups/drop-*.db ".tables"     # Verify backup integrity
# Push to GitHub → CI runs → npm audit step visible
# Check dependabot.yml in .github/
```

## Summary

| Phase | What | Effort |
|-------|------|--------|
| 1 | Structured logging + audit log | ~1 day |
| 2 | Sentry + Slack alerts + uptime docs | ~1 day |
| 3 | Automated backups + CI security scanning | ~0.5 day |

**Total: ~2.5 days with 3 builder/validator pairs running in parallel.**

All 3 phases can run in parallel (Phase 1 and 3 are independent, Phase 2 depends on Phase 1 Task 1 for logger context).

# drop-transaction-failure-spec

# Drop Transaction Failure Handling & Recovery

**Task:** MC #1191
**Created:** 2026-02-17
**Author:** John (Software Architect Agent)
**Status:** DRAFT — Awaiting Alem approval

---

## Executive Summary

This specification defines comprehensive transaction failure handling for Drop's fintech payment system. Drop operates as a PSD2 PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money. This creates unique challenges:

- **External dependency:** Every transaction depends on user's bank and Open Banking provider
- **Asynchronous flow:** PISP initiation → bank processing → status callback (can take seconds to days)
- **Failure modes:** Network timeouts, bank declines, partial processing, provider outages
- **Customer impact:** Real money, real trust — failures must be handled gracefully

**Core principles:**
1. **Clear state machine** — No ambiguous states
2. **Idempotency** — Network retries never cause double-charges
3. **Automatic retry** — Transient failures self-heal
4. **User communication** — Always tell user what's happening
5. **Admin tooling** — Manual intervention when automation can't resolve

---

## 1. Current State Analysis

### 1.1 What We Have (Good)

**Idempotency keys:**
- Both `/api/transactions/remittance/route.ts` and `/api/transactions/qr-payment/route.ts` accept `idempotencyKey`
- Check for existing transaction: `SELECT ... WHERE idempotency_key = ? AND user_id = ?`
- Returns cached response for duplicate requests (prevents double-charge)
- **Status:** ✅ **Production-ready**

**Basic error handling:**
- `insufficient_balance` error caught and returned as 402
- Rate limiting: IP (10/min) + user (3/min)
- Transaction wrapped in DB transaction (atomic balance check + insert)
- **Status:** ✅ **Good foundation**

**30-second timeout:**
- PISP API calls have `AbortController` with 30s timeout
- Returns specific timeout error: "Payment request timeout"
- **Status:** ✅ **Implemented**

### 1.2 What's Missing (Critical Gaps)

❌ **State machine enforcement:**
- `transactions.status` has CHECK constraint: `'processing','completed','failed'`
- But no state transition validation (can jump from processing → completed without rules)
- No transition audit (who/when/why status changed)

❌ **Retry logic:**
- Timeout errors return failure immediately — no retry
- No exponential backoff
- No max retry counter
- No dead letter queue for permanently failed transactions

❌ **Background reconciliation:**
- Transactions stuck in `processing` status stay there forever
- No periodic job to check PISP provider for status updates
- No admin alert when transactions are stuck

❌ **Partial failure handling:**
- FX conversion success + transfer failure → no rollback/refund flow
- No compensation logic for partial state

❌ **User communication:**
- No transaction status page showing real-time progress
- No push notification on status change
- No email on final completion/failure
- Error messages are generic (not user-friendly)

❌ **Admin tools:**
- No `/api/admin/transactions/stuck` endpoint to list limbo transactions
- No manual retry mechanism
- No manual resolution workflow

---

## 2. Transaction State Machine

### 2.1 States

```
┌─────────────┐
│  initiated  │ ──────┐
└─────────────┘       │
      │               │
      ▼               │
┌─────────────┐       │
│ processing  │       │ (timeout after 30s)
└─────────────┘       │
      │               │
      ├───────────────┴────────────┐
      │                            │
      ▼                            ▼
┌─────────────┐            ┌─────────────┐
│  completed  │            │   timeout   │
└─────────────┘            └─────────────┘
                                  │
                                  ▼
                           ┌─────────────┐
                           │   failed    │
                           └─────────────┘

┌─────────────────────────────────────────────────────┐
│             partially_completed                      │ (future — FX success, transfer fail)
└─────────────────────────────────────────────────────┘
```

### 2.2 State Definitions

| State | Meaning | Terminal? | User-Facing Message |
|-------|---------|-----------|---------------------|
| `initiated` | API request received, validation passed, DB record created | No | "Initiating payment..." |
| `processing` | PISP provider accepted request, waiting for bank confirmation | No | "Your payment is being processed" |
| `timeout` | PISP provider didn't respond within 30s, will check status later | No | "Processing your payment — we'll notify you when complete" |
| `completed` | Bank confirmed payment successful | Yes | "Payment completed" |
| `failed` | Bank declined, or PISP returned permanent error | Yes | "Payment failed: [reason]" |
| `partially_completed` | FX conversion succeeded but transfer failed (future) | No | "Processing refund..." |

**Terminal states:** `completed`, `failed` — no further transitions allowed

### 2.3 Valid Transitions

```typescript
const VALID_TRANSITIONS = {
  initiated: ["processing", "failed"],
  processing: ["completed", "timeout", "failed"],
  timeout: ["completed", "failed", "processing"], // retry
  partially_completed: ["completed", "failed"], // after refund
  completed: [], // terminal
  failed: [], // terminal
};
```

**Enforcement:** Database CHECK constraint + application-level validation

### 2.4 Transition Audit

Every status change logged in `audit_log`:

```sql
INSERT INTO audit_log (
  id, user_id, action, resource_type, resource_id,
  details, ip_address, user_agent, request_id
) VALUES (
  'aud_xyz', 'usr_abc', 'TRANSACTION_STATUS_CHANGE',
  'transaction', 'tx_rem_123',
  '{"from": "processing", "to": "completed", "reason": "PISP callback", "external_id": "ext_456"}',
  '10.0.1.5', 'Drop-iOS/1.0', 'req_789'
);
```

**Compliance:** PSD2 requires 5-year audit trail of all payment operations

---

## 3. Idempotency

### 3.1 Current Implementation (Keep It)

✅ **Already production-ready:**

```typescript
// Check for existing transaction with this idempotency key (scoped to user)
const existing = await getOne<ExistingTx>(
  "SELECT id, type, status, amount, currency, fee, ...
   FROM transactions
   WHERE idempotency_key = ? AND user_id = ?",
  [idempotencyKey, u.id]
);

if (existing) {
  // Return cached response (same payload as successful creation)
  return NextResponse.json({ data: existing }, { status: 200 });
}
```

**Key features:**
- Scoped to user (prevents IDOR)
- Returns exact same response (status 200, not 201)
- No expiry — idempotency keys valid forever
- Client must generate UUID or similar unique key

### 3.2 Best Practices

**Client implementation:**
```typescript
// Generate idempotency key client-side
const idempotencyKey = `${userId}_${Date.now()}_${crypto.randomUUID()}`;

// Send with every payment request
await fetch('/api/transactions/remittance', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    recipientId: 'rec_123',
    amount: 500,
    idempotencyKey, // ← REQUIRED
  })
});
```

**No changes needed** — current implementation is correct

---

## 4. Retry Logic

### 4.1 Classification of Errors

| Error | Type | Retry? | Example |
|-------|------|--------|---------|
| Network timeout | Transient | ✅ Yes | `AbortError`, socket timeout |
| PISP 5xx | Transient | ✅ Yes | 500 Internal Server Error, 503 Service Unavailable |
| PISP 4xx client error | Permanent | ❌ No | 400 Bad Request, 401 Unauthorized |
| Bank decline | Permanent | ❌ No | Insufficient funds (from bank), invalid IBAN |
| Validation error | Permanent | ❌ No | Amount < minimum, KYC not approved |

**Rule:** Only retry errors that are transient (temporary network/server issues)

### 4.2 Exponential Backoff Strategy

**Max retries:** 3 attempts
**Delays:** 2s → 8s → 32s (exponential)
**Jitter:** ±20% to avoid thundering herd

```typescript
const RETRY_CONFIG = {
  maxRetries: 3,
  baseDelayMs: 2000, // 2 seconds
  maxDelayMs: 60000, // 1 minute cap
  jitterPercent: 0.2, // ±20%
};

function calculateDelay(attempt: number): number {
  const exponentialDelay = RETRY_CONFIG.baseDelayMs * Math.pow(4, attempt - 1);
  const cappedDelay = Math.min(exponentialDelay, RETRY_CONFIG.maxDelayMs);
  const jitter = cappedDelay * RETRY_CONFIG.jitterPercent * (Math.random() * 2 - 1);
  return Math.floor(cappedDelay + jitter);
}

// Attempt 1: 2s ± 400ms  = 1.6-2.4s
// Attempt 2: 8s ± 1.6s   = 6.4-9.6s
// Attempt 3: 32s ± 6.4s  = 25.6-38.4s
```

### 4.3 Retry Implementation

**Two approaches:**

#### Option A: In-Process Retry (Simpler, Recommended for MVP)

Retry within the same API request (synchronous):

```typescript
async function callPispWithRetry(
  fn: () => Promise<PaymentResult>,
  txId: string
): Promise<PaymentResult> {
  let lastError: Error | null = null;

  for (let attempt = 1; attempt <= RETRY_CONFIG.maxRetries; attempt++) {
    try {
      const result = await fn();

      // Success — return immediately
      if (result.success) return result;

      // Permanent error (4xx, bank decline) — don't retry
      if (isPermanentError(result.error)) {
        await logAudit({
          userId: txId,
          action: "PISP_PERMANENT_ERROR",
          resourceType: "transaction",
          resourceId: txId,
          details: { attempt, error: result.error },
        });
        return result;
      }

      // Transient error — prepare to retry
      lastError = new Error(result.error || "Unknown error");

    } catch (error) {
      lastError = error as Error;

      // Non-retryable (validation error, etc.)
      if (!isTransientError(error)) throw error;
    }

    // If not last attempt, wait before retry
    if (attempt < RETRY_CONFIG.maxRetries) {
      const delay = calculateDelay(attempt);
      await logAudit({
        userId: txId,
        action: "PISP_RETRY_SCHEDULED",
        resourceType: "transaction",
        resourceId: txId,
        details: { attempt, nextAttempt: attempt + 1, delayMs: delay },
      });
      await sleep(delay);
    }
  }

  // All retries exhausted
  await logAudit({
    userId: txId,
    action: "PISP_ALL_RETRIES_FAILED",
    resourceType: "transaction",
    resourceId: txId,
    details: { maxRetries: RETRY_CONFIG.maxRetries, lastError: lastError?.message },
  });

  return {
    success: false,
    status: "failed",
    error: `Payment failed after ${RETRY_CONFIG.maxRetries} attempts`
  };
}
```

**Pros:**
- Simple — no queue infrastructure needed
- User waits for final result (good UX for fast retries)
- Automatic cleanup (no orphan jobs)

**Cons:**
- Request can take up to ~40s (blocks thread)
- If server crashes mid-retry, transaction stuck
- No visibility into retry progress

#### Option B: Background Job Queue (Production-Grade)

Move retries to background worker using job queue:

**Tech stack:**
- **Job queue:** BullMQ (Redis-backed) or pg-boss (PostgreSQL-backed, no extra infra)
- **Worker:** Separate process polls queue every 5s

**Flow:**
1. API route creates transaction with status `initiated`
2. Enqueue job: `{ type: "pisp_call", txId: "tx_rem_123", attempt: 1 }`
3. Return to user: `{ status: "processing", txId: "tx_rem_123" }`
4. Worker picks job → calls PISP → updates transaction status
5. On transient failure → re-enqueue with delay + increment attempt
6. On success/permanent failure → mark transaction terminal

**Pros:**
- Non-blocking (API responds instantly)
- Survives server restarts (jobs persisted in DB)
- Can inspect queue (show pending retries in admin dashboard)

**Cons:**
- More complex (requires job queue setup)
- More infrastructure (Redis or pg-boss tables)
- User must poll `/api/transactions/[id]` for status updates

**Recommendation:** Start with Option A (in-process) for MVP. Migrate to Option B when transaction volume increases.

### 4.4 Dead Letter Queue

After max retries exhausted:

1. **Mark transaction as `failed`** with reason: `"PISP provider unreachable after 3 attempts"`
2. **Create admin alert** in separate table:

```sql
CREATE TABLE admin_alerts (
  id TEXT PRIMARY KEY,
  alert_type TEXT NOT NULL, -- 'transaction_stuck', 'pisp_failure', etc.
  severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
  resource_type TEXT,
  resource_id TEXT,
  title TEXT NOT NULL,
  description TEXT,
  status TEXT DEFAULT 'open' CHECK(status IN ('open','investigating','resolved','dismissed')),
  created_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT,
  resolved_by TEXT
);

INSERT INTO admin_alerts (
  id, alert_type, severity, resource_type, resource_id,
  title, description
) VALUES (
  'alert_xyz', 'transaction_stuck', 'high', 'transaction', 'tx_rem_123',
  'Transaction failed after 3 retries',
  'Transaction tx_rem_123 (user: usr_abc, amount: 500 NOK) failed to process after 3 attempts. PISP provider returned: "Service Unavailable". Manual investigation required.'
);
```

3. **Send Slack/email to ops team** (via webhook or existing notification system)

4. **Admin dashboard shows alert** at `/admin/alerts` with:
   - Transaction details
   - Retry history (from audit_log)
   - Manual actions: "Retry Now", "Refund User", "Mark Resolved"

---

## 5. Timeout Recovery

### 5.1 Scenario

User initiates payment → PISP accepts request → network drops → no response after 30s → transaction stuck in `processing`

**Current behavior:** API returns error, transaction never completes

**New behavior:** Mark as `timeout`, schedule background reconciliation

### 5.2 Implementation

**Step 1:** On timeout, transition to `timeout` status

```typescript
// In payments.ts
if (error instanceof Error && error.name === "AbortError") {
  // Don't fail immediately — schedule status check
  await updateTransactionStatus(txId, "timeout", "PISP request timeout - will check status later");

  // Enqueue background reconciliation job (runs after 2 min)
  await scheduleStatusCheck(txId, 120000); // 2 minutes

  return {
    success: true, // ← YES! Tell API route we handled it
    status: "timeout",
    message: "Payment is processing — we'll notify you when complete"
  };
}
```

**Step 2:** Background worker checks status

```typescript
// reconciliation-worker.ts
async function checkTransactionStatus(txId: string) {
  const tx = await getOne("SELECT * FROM transactions WHERE id = ?", [txId]);
  if (!tx) return;

  // Call PISP provider's GET /payments/{id} endpoint
  const status = await pispProvider.getPaymentStatus(tx.external_id);

  if (status.completed) {
    await updateTransactionStatus(txId, "completed", "Confirmed via reconciliation");
    await notifyUser(tx.user_id, "payment_completed", { txId });
  } else if (status.failed) {
    await updateTransactionStatus(txId, "failed", status.reason);
    await notifyUser(tx.user_id, "payment_failed", { txId, reason: status.reason });
  } else {
    // Still processing — check again in 5 min
    await scheduleStatusCheck(txId, 300000); // 5 minutes
  }
}
```

**Step 3:** Periodic sweep (every 10 minutes)

Find all transactions stuck in `timeout` or `processing` for > 10 minutes:

```sql
SELECT id FROM transactions
WHERE status IN ('timeout', 'processing')
  AND created_at < datetime('now', '-10 minutes')
LIMIT 100;
```

For each: call `checkTransactionStatus(txId)`

### 5.3 User Experience

**User sees:**
1. **Immediate response:** "Processing your payment — we'll send you a notification when it's complete" (status 202)
2. **Push notification (1-2 min later):** "Your 500 NOK payment to Mama Jasmina is complete"
3. **Transaction list updates:** Polling `/api/transactions` or WebSocket push

**What if it never completes?**
- After 24 hours stuck in `timeout` → mark as `failed` + admin alert
- User can contact support via `/support` page
- Admin manually investigates + refunds if needed

---

## 6. Partial Failure Handling

### 6.1 Scenario (Future — FX Conversion)

Remittance flow with FX conversion:
1. User sends 500 NOK → 5,085 RSD
2. FX conversion succeeds (NOK debited from user's bank)
3. International transfer fails (recipient bank rejects)
4. **Problem:** User's money is gone, recipient didn't receive it

**Current code:** No FX conversion step (demo uses hardcoded exchange rates)

**Future risk:** When FX provider is added, must handle partial success

### 6.2 Classification

| Scenario | Recoverable? | Action |
|----------|-------------|--------|
| FX success + transfer success | N/A | ✅ Complete |
| FX success + transfer fail | ✅ Yes | Refund converted amount back to NOK |
| FX fail + transfer not attempted | ✅ Yes | Transaction never started, return error |
| FX timeout + transfer unknown | ⚠️ Maybe | Check FX provider status, then refund or complete |

### 6.3 Compensation Flow (When FX Added)

**Database changes:**

Add `compensation_status` field:

```sql
ALTER TABLE transactions ADD COLUMN compensation_status TEXT CHECK(
  compensation_status IN ('none', 'pending', 'completed', 'failed')
) DEFAULT 'none';
```

**Flow:**

```typescript
// 1. Attempt FX conversion
const fxResult = await fxProvider.convert({ from: "NOK", to: "RSD", amount: 500 });

if (!fxResult.success) {
  await updateTransactionStatus(txId, "failed", "FX conversion failed");
  return { success: false, status: "failed", error: fxResult.error };
}

// 2. Mark FX complete
await run("UPDATE transactions SET fx_completed_at = datetime('now'), fx_external_id = ? WHERE id = ?",
  [fxResult.externalId, txId]);

// 3. Attempt international transfer
const transferResult = await pispProvider.transferInternational({ ... });

if (!transferResult.success) {
  // Transfer failed — need to refund FX
  await updateTransactionStatus(txId, "partially_completed", "Transfer failed, initiating refund");
  await run("UPDATE transactions SET compensation_status = 'pending' WHERE id = ?", [txId]);

  // 4. Initiate refund (convert RSD back to NOK + credit user's bank account)
  const refundResult = await fxProvider.refund({
    originalConversionId: fxResult.externalId,
    recipientBankAccountId: tx.from_bank_account_id
  });

  if (refundResult.success) {
    await updateTransactionStatus(txId, "failed", "Transfer failed, refund completed");
    await run("UPDATE transactions SET compensation_status = 'completed' WHERE id = ?", [txId]);
  } else {
    // Refund also failed — escalate to manual review
    await updateTransactionStatus(txId, "failed", "Transfer and refund failed - manual review required");
    await run("UPDATE transactions SET compensation_status = 'failed' WHERE id = ?", [txId]);
    await createAdminAlert({
      type: "compensation_failed",
      severity: "critical",
      resourceId: txId,
      title: "Refund failed after partial payment",
      description: `Transaction ${txId}: FX conversion succeeded (${fxResult.externalId}) but transfer and refund both failed. User's 500 NOK is stuck in limbo. URGENT MANUAL INTERVENTION REQUIRED.`
    });
  }
}
```

**SLA:** Refund must complete within 24 hours (PSD2 requirement)

### 6.4 Edge Cases

**Q: What if refund takes 48 hours?**
A: Status remains `partially_completed` until refund clears. User sees: "Processing refund — this may take up to 2 business days"

**Q: What if user's bank account is closed?**
A: Refund fails → admin alert → manual investigation → refund via alternative method (e.g., bank transfer to new account)

**Q: What if FX provider goes down during refund?**
A: Retry with exponential backoff (same logic as Step 4). After max retries → admin alert.

---

## 7. User Communication

### 7.1 Transaction Status Page

**Route:** `/transactions/[id]`

**Content:**

```tsx
// src/app/transactions/[id]/page.tsx

export default function TransactionDetailPage({ params }: { params: { id: string } }) {
  const { data: tx } = useSWR(`/api/transactions/${params.id}`, fetcher, {
    refreshInterval: tx?.status === "processing" || tx?.status === "timeout" ? 2000 : 0
  });

  if (!tx) return <div>Loading...</div>;

  return (
    <div className="p-6">
      <StatusBadge status={tx.status} />
      <h1 className="text-2xl font-semibold mt-4">{tx.type === "remittance" ? "Money Transfer" : "QR Payment"}</h1>

      {/* Real-time status */}
      <div className="mt-6">
        {tx.status === "initiated" && <StatusMessage icon="⏳" message="Initiating payment..." />}
        {tx.status === "processing" && <StatusMessage icon="🔄" message="Your payment is being processed" />}
        {tx.status === "timeout" && <StatusMessage icon="⏰" message="Processing your payment — we'll notify you when complete" />}
        {tx.status === "completed" && <StatusMessage icon="✅" message="Payment completed" />}
        {tx.status === "failed" && <StatusMessage icon="❌" message={`Payment failed: ${tx.failure_reason || "Unknown error"}`} />}
      </div>

      {/* Timeline */}
      <div className="mt-8">
        <h2 className="font-medium mb-4">Timeline</h2>
        <Timeline events={tx.timeline} />
      </div>

      {/* Details */}
      <div className="mt-8 grid grid-cols-2 gap-4">
        <DetailRow label="Amount" value={`${tx.amount} ${tx.currency}`} />
        <DetailRow label="Fee" value={`${tx.fee} ${tx.currency}`} />
        {tx.type === "remittance" && (
          <>
            <DetailRow label="Recipient" value={tx.recipient_name} />
            <DetailRow label="Exchange Rate" value={tx.exchange_rate} />
            <DetailRow label="Recipient Gets" value={`${tx.receive_amount} ${tx.receive_currency}`} />
            <DetailRow label="ETA" value={tx.eta || "1-2 business days"} />
          </>
        )}
        <DetailRow label="Transaction ID" value={tx.id} />
        <DetailRow label="Created" value={new Date(tx.created_at).toLocaleString("nb-NO")} />
      </div>

      {/* Actions */}
      {tx.status === "failed" && (
        <button className="mt-6 btn-primary" onClick={() => retryTransaction(tx.id)}>
          Try Again
        </button>
      )}
    </div>
  );
}
```

**Timeline data:**

API response includes `timeline` array:

```json
{
  "id": "tx_rem_123",
  "status": "completed",
  "timeline": [
    { "timestamp": "2026-02-17T10:00:00Z", "event": "created", "message": "Payment initiated" },
    { "timestamp": "2026-02-17T10:00:02Z", "event": "processing", "message": "Sent to bank" },
    { "timestamp": "2026-02-17T10:00:45Z", "event": "completed", "message": "Payment confirmed by bank" }
  ]
}
```

Fetched from `audit_log` table where `resource_id = tx.id` and `action LIKE 'TRANSACTION_%'`

### 7.2 Push Notifications

**When to send:**

| Status Change | Title | Body |
|--------------|-------|------|
| `processing` → `completed` | "Payment Complete" | "Your 500 NOK payment to Mama Jasmina is complete" |
| `processing` → `failed` | "Payment Failed" | "Your 500 NOK payment failed. Tap to view details" |
| `timeout` → `completed` | "Payment Complete" | "Your payment has been confirmed by the bank" |
| `partially_completed` → `failed` | "Refund Processed" | "Your 500 NOK has been refunded to your account" |

**Implementation:**

```typescript
// lib/services/notifications.ts
export async function sendPushNotification(params: {
  userId: string;
  title: string;
  body: string;
  data: Record<string, string>;
}) {
  // Check user settings
  const settings = await getOne("SELECT push_enabled FROM settings WHERE user_id = ?", [params.userId]);
  if (!settings?.push_enabled) return;

  // Get user's push tokens (stored in separate table)
  const tokens = await query<{ token: string }>(
    "SELECT token FROM push_tokens WHERE user_id = ? AND active = 1",
    [params.userId]
  );

  // Send via Firebase Cloud Messaging (FCM) or Apple Push Notification Service (APNS)
  for (const { token } of tokens) {
    await fcm.send({
      token,
      notification: { title: params.title, body: params.body },
      data: params.data,
    });
  }

  // Log notification
  await run(
    "INSERT INTO notifications (id, user_id, type, title, body) VALUES (?, ?, ?, ?, ?)",
    [randomId("ntf"), params.userId, "push", params.title, params.body]
  );
}
```

**Call from status update:**

```typescript
async function updateTransactionStatus(
  txId: string,
  newStatus: string,
  reason?: string
) {
  const tx = await getOne("SELECT * FROM transactions WHERE id = ?", [txId]);
  if (!tx) throw new Error("Transaction not found");

  // Update status
  await run("UPDATE transactions SET status = ?, updated_at = datetime('now') WHERE id = ?",
    [newStatus, txId]);

  // Log audit
  await logAudit({ ... });

  // Send push notification
  if (newStatus === "completed" || newStatus === "failed") {
    await notifications.sendPushNotification({
      userId: tx.user_id,
      title: newStatus === "completed" ? "Payment Complete" : "Payment Failed",
      body: newStatus === "completed"
        ? `Your ${tx.amount} NOK payment is complete`
        : `Your ${tx.amount} NOK payment failed${reason ? `: ${reason}` : ""}`,
      data: { txId, status: newStatus },
    });
  }
}
```

### 7.3 Email Notifications

**When to send:** Only for terminal states (`completed`, `failed`)

**Template:**

```html
<!-- email-templates/transaction-completed.html -->
<html>
<body style="font-family: Inter, sans-serif;">
  <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
    <h1>Payment Complete</h1>
    <p>Your payment of <strong>{{amount}} {{currency}}</strong> to <strong>{{recipientName}}</strong> has been completed.</p>
    <p><strong>Transaction ID:</strong> {{txId}}</p>
    <p><strong>Date:</strong> {{completedAt}}</p>
    <a href="https://getdrop.no/transactions/{{txId}}" style="display: inline-block; padding: 12px 24px; background: #00E5A0; color: #000; text-decoration: none; border-radius: 8px; margin-top: 20px;">
      View Transaction
    </a>
  </div>
</body>
</html>
```

**Send via existing email service:**

```typescript
// lib/services/email.ts
import { email } from "@/lib/services";

await email.send({
  to: user.email,
  subject: "Payment Complete",
  template: "transaction-completed",
  data: {
    amount: tx.amount,
    currency: tx.currency,
    recipientName: tx.recipient_name,
    txId: tx.id,
    completedAt: new Date(tx.completed_at).toLocaleString("nb-NO"),
  },
});
```

### 7.4 Error Messages (User-Friendly)

**Current:** Generic errors like "PISP API error: 500"

**New:** Human-readable messages

| Error Code | User-Facing Message (Norwegian) | English |
|------------|--------------------------------|---------|
| `insufficient_balance` | "Ikke nok dekning på bankkontoen" | "Insufficient funds in your bank account" |
| `bank_declined` | "Banken din avslo betalingen. Kontakt banken for detaljer." | "Your bank declined the payment. Contact your bank for details." |
| `invalid_iban` | "Ugyldig kontonummer. Sjekk mottakerens kontoopplysninger." | "Invalid account number. Check recipient's account details." |
| `pisp_timeout` | "Betalingen tar lengre tid enn vanlig. Vi varsler deg når den er fullført." | "Payment is taking longer than usual. We'll notify you when complete." |
| `pisp_unavailable` | "Vår betalingsleverandør er midlertidig utilgjengelig. Prøv igjen om noen minutter." | "Our payment provider is temporarily unavailable. Try again in a few minutes." |
| `max_retries_exceeded` | "Betalingen feilet etter flere forsøk. Kontakt kundestøtte." | "Payment failed after multiple attempts. Contact support." |

**Implementation:**

```typescript
// lib/error-messages.ts
const ERROR_MESSAGES: Record<string, { no: string; en: string }> = {
  insufficient_balance: {
    no: "Ikke nok dekning på bankkontoen",
    en: "Insufficient funds in your bank account"
  },
  // ... all errors above
};

export function getUserFacingError(errorCode: string, language: "no" | "en" = "no"): string {
  return ERROR_MESSAGES[errorCode]?.[language] || ERROR_MESSAGES.default[language];
}
```

---

## 8. Admin Tools

### 8.1 Stuck Transactions Endpoint

**Route:** `GET /api/admin/transactions/stuck`

**Access:** Requires admin role (check JWT: `user.role === 'admin'`)

**Query:**

```sql
SELECT
  t.id,
  t.user_id,
  t.type,
  t.status,
  t.amount,
  t.currency,
  t.created_at,
  t.updated_at,
  u.email AS user_email,
  u.first_name || ' ' || u.last_name AS user_name,
  (julianday('now') - julianday(t.created_at)) * 24 AS hours_stuck
FROM transactions t
JOIN users u ON t.user_id = u.id
WHERE t.status IN ('processing', 'timeout', 'partially_completed')
  AND t.created_at < datetime('now', '-10 minutes')
ORDER BY t.created_at ASC
LIMIT 100;
```

**Response:**

```json
{
  "data": [
    {
      "id": "tx_rem_456",
      "userId": "usr_abc",
      "userName": "Amir Hadžić",
      "userEmail": "amir@example.com",
      "type": "remittance",
      "status": "timeout",
      "amount": 500,
      "currency": "NOK",
      "createdAt": "2026-02-17T08:00:00Z",
      "hoursStuck": 2.5
    }
  ],
  "total": 1
}
```

### 8.2 Manual Retry Endpoint

**Route:** `POST /api/admin/transactions/[id]/retry`

**Access:** Admin only

**Action:**
1. Validate transaction is in retryable state (`timeout`, `failed` with transient error)
2. Reset retry counter
3. Call PISP provider again (with retry logic from Section 4)
4. Log admin action in audit_log

**Implementation:**

```typescript
// src/app/api/admin/transactions/[id]/retry/route.ts
export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const { user, error } = await requireAuth(request);
  if (error) return error;

  if (user.role !== "admin") {
    return jsonError("forbidden", "Admin access required", 403);
  }

  const txId = params.id;
  const tx = await getOne("SELECT * FROM transactions WHERE id = ?", [txId]);

  if (!tx) {
    return jsonError("not_found", "Transaction not found", 404);
  }

  if (!["timeout", "failed"].includes(tx.status)) {
    return jsonError("invalid_state", "Transaction is not retryable", 400);
  }

  // Log admin action
  await logAudit({
    userId: user.id,
    action: "ADMIN_TRANSACTION_RETRY",
    resourceType: "transaction",
    resourceId: txId,
    details: { previousStatus: tx.status },
    ipAddress: getClientIp(request),
    requestId: getRequestId(request.headers),
  });

  // Reset transaction to initiated
  await run("UPDATE transactions SET status = 'initiated', retry_count = 0 WHERE id = ?", [txId]);

  // Re-call PISP with retry logic
  const result = tx.type === "remittance"
    ? await payments.initiateRemittance({ ... })
    : await payments.initiateQrPayment({ ... });

  if (result.success) {
    return NextResponse.json({ message: "Retry initiated", status: result.status });
  } else {
    return jsonError("retry_failed", result.error || "Retry failed", 500);
  }
}
```

### 8.3 Manual Resolution Endpoint

**Route:** `POST /api/admin/transactions/[id]/resolve`

**Body:**

```json
{
  "action": "mark_completed" | "mark_failed" | "initiate_refund",
  "reason": "Admin manually verified with bank",
  "externalReference": "bank_ref_12345" // optional
}
```

**Actions:**

| Action | Effect |
|--------|--------|
| `mark_completed` | Set status to `completed`, add admin note to audit_log |
| `mark_failed` | Set status to `failed`, add reason, notify user |
| `initiate_refund` | Trigger refund flow (for partially_completed), set compensation_status to `pending` |

**Implementation:**

```typescript
export async function POST(request: NextRequest, { params }: { params: { id: string } }) {
  const { user, error } = await requireAuth(request);
  if (error) return error;
  if (user.role !== "admin") return jsonError("forbidden", "Admin access required", 403);

  const body = await request.json();
  const { action, reason, externalReference } = body;

  const txId = params.id;
  const tx = await getOne("SELECT * FROM transactions WHERE id = ?", [txId]);
  if (!tx) return jsonError("not_found", "Transaction not found", 404);

  switch (action) {
    case "mark_completed":
      await run("UPDATE transactions SET status = 'completed', completed_at = datetime('now') WHERE id = ?", [txId]);
      await logAudit({ userId: user.id, action: "ADMIN_MARK_COMPLETED", resourceId: txId, details: { reason, externalReference } });
      await notifications.sendPushNotification({ userId: tx.user_id, title: "Payment Complete", body: "Your payment has been confirmed" });
      return NextResponse.json({ message: "Transaction marked as completed" });

    case "mark_failed":
      await run("UPDATE transactions SET status = 'failed', failure_reason = ? WHERE id = ?", [reason, txId]);
      await logAudit({ userId: user.id, action: "ADMIN_MARK_FAILED", resourceId: txId, details: { reason } });
      await notifications.sendPushNotification({ userId: tx.user_id, title: "Payment Failed", body: reason });
      return NextResponse.json({ message: "Transaction marked as failed" });

    case "initiate_refund":
      // TODO: Call refund provider
      await run("UPDATE transactions SET compensation_status = 'pending' WHERE id = ?", [txId]);
      await logAudit({ userId: user.id, action: "ADMIN_INITIATE_REFUND", resourceId: txId, details: { reason } });
      return NextResponse.json({ message: "Refund initiated" });

    default:
      return jsonError("invalid_action", "Invalid action", 400);
  }
}
```

### 8.4 Admin Dashboard

**Route:** `/admin/transactions`

**Features:**

1. **Overview Cards:**
   - Stuck transactions (count)
   - Failed last 24h (count)
   - Average resolution time

2. **Stuck Transactions Table:**
   - Columns: TX ID, User, Amount, Status, Hours Stuck, Actions
   - Actions: "Retry", "Resolve", "View Audit Log"

3. **Filters:**
   - Status (processing, timeout, partially_completed)
   - Stuck > X hours
   - User search (email, ID)

**Screenshot mockup:**

```
┌────────────────────────────────────────────────────┐
│  Admin: Stuck Transactions                         │
├────────────────────────────────────────────────────┤
│  [ Stuck: 3 ]  [ Failed 24h: 12 ]  [ Avg: 1.2h ]  │
├────────────────────────────────────────────────────┤
│  Filters: [Status: All ▼] [Stuck > 1h ▼]          │
├────────────────────────────────────────────────────┤
│  TX ID       │ User          │ Amount │ Status   │ Hours │ Actions        │
│  tx_rem_456  │ amir@ex.com  │ 500 NOK│ timeout  │ 2.5   │ [Retry][Resolve]│
│  tx_qr_789   │ sara@ex.com  │ 129 NOK│ processing│ 0.8   │ [Retry][Resolve]│
└────────────────────────────────────────────────────┘
```

---

## 9. Database Schema Changes

### 9.1 New Columns on `transactions` Table

```sql
-- Retry tracking
ALTER TABLE transactions ADD COLUMN retry_count INTEGER DEFAULT 0;
ALTER TABLE transactions ADD COLUMN last_retry_at TEXT;

-- External references
ALTER TABLE transactions ADD COLUMN external_id TEXT; -- PISP provider's transaction ID
ALTER TABLE transactions ADD COLUMN external_status TEXT; -- Raw status from provider

-- Failure details
ALTER TABLE transactions ADD COLUMN failure_reason TEXT;
ALTER TABLE transactions ADD COLUMN failure_code TEXT; -- Machine-readable error code

-- Compensation (for partial failures)
ALTER TABLE transactions ADD COLUMN compensation_status TEXT CHECK(
  compensation_status IN ('none', 'pending', 'completed', 'failed')
) DEFAULT 'none';
ALTER TABLE transactions ADD COLUMN compensation_completed_at TEXT;

-- Timeline
ALTER TABLE transactions ADD COLUMN updated_at TEXT DEFAULT (datetime('now'));

-- FX tracking (future)
ALTER TABLE transactions ADD COLUMN fx_completed_at TEXT;
ALTER TABLE transactions ADD COLUMN fx_external_id TEXT;
```

### 9.2 New State: `timeout`

Update CHECK constraint:

```sql
-- Before:
status TEXT DEFAULT 'processing' CHECK(status IN ('processing','completed','failed'))

-- After:
status TEXT DEFAULT 'initiated' CHECK(status IN ('initiated','processing','timeout','completed','failed','partially_completed'))
```

**Migration (SQLite):**

SQLite doesn't support `ALTER TABLE ... MODIFY CONSTRAINT`, so recreate table:

```sql
-- Create new table with updated constraint
CREATE TABLE transactions_new (
  id TEXT PRIMARY KEY,
  user_id TEXT NOT NULL REFERENCES users(id),
  type TEXT NOT NULL CHECK(type IN ('remittance','qr_payment')),
  status TEXT DEFAULT 'initiated' CHECK(status IN ('initiated','processing','timeout','completed','failed','partially_completed')),
  -- ... all other columns
);

-- Copy data
INSERT INTO transactions_new SELECT * FROM transactions;

-- Drop old, rename new
DROP TABLE transactions;
ALTER TABLE transactions_new RENAME TO transactions;

-- Recreate indexes
CREATE UNIQUE INDEX idx_tx_idempotency ON transactions(idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE INDEX idx_transactions_user ON transactions(user_id);
CREATE INDEX idx_transactions_merchant ON transactions(merchant_id);
```

### 9.3 New Table: `admin_alerts`

```sql
CREATE TABLE admin_alerts (
  id TEXT PRIMARY KEY,
  alert_type TEXT NOT NULL, -- 'transaction_stuck', 'pisp_failure', 'compensation_failed', etc.
  severity TEXT NOT NULL CHECK(severity IN ('low','medium','high','critical')),
  resource_type TEXT, -- 'transaction', 'user', 'merchant', etc.
  resource_id TEXT,
  title TEXT NOT NULL,
  description TEXT,
  status TEXT DEFAULT 'open' CHECK(status IN ('open','investigating','resolved','dismissed')),
  created_at TEXT DEFAULT (datetime('now')),
  resolved_at TEXT,
  resolved_by TEXT, -- user_id of admin who resolved
  resolution_notes TEXT
);

CREATE INDEX idx_admin_alerts_status ON admin_alerts(status);
CREATE INDEX idx_admin_alerts_type ON admin_alerts(alert_type);
CREATE INDEX idx_admin_alerts_created ON admin_alerts(created_at);
```

### 9.4 New Table: `retry_history`

Optional (if want detailed retry logs separate from audit_log):

```sql
CREATE TABLE retry_history (
  id TEXT PRIMARY KEY,
  transaction_id TEXT NOT NULL REFERENCES transactions(id),
  attempt INTEGER NOT NULL, -- 1, 2, 3
  started_at TEXT DEFAULT (datetime('now')),
  completed_at TEXT,
  success INTEGER DEFAULT 0, -- 0 = failed, 1 = succeeded
  error_code TEXT,
  error_message TEXT,
  pisp_response TEXT -- Full JSON response from PISP provider
);

CREATE INDEX idx_retry_history_tx ON retry_history(transaction_id);
```

**Alternative:** Use `audit_log` table (already exists, sufficient for MVP)

---

## 10. File Structure & Implementation Checklist

### 10.1 Files to Create

```
src/
├── app/
│   ├── api/
│   │   ├── transactions/
│   │   │   ├── [id]/
│   │   │   │   ├── route.ts          # GET transaction by ID (add timeline)
│   │   │   │   └── retry/route.ts     # NEW: POST retry transaction (user-facing, for failed txs)
│   │   ├── admin/
│   │   │   ├── transactions/
│   │   │   │   ├── stuck/route.ts     # NEW: GET stuck transactions
│   │   │   │   └── [id]/
│   │   │   │       ├── retry/route.ts  # NEW: POST admin retry
│   │   │   │       └── resolve/route.ts # NEW: POST admin manual resolution
│   │   │   └── alerts/
│   │   │       ├── route.ts            # NEW: GET admin alerts (list)
│   │   │       └── [id]/route.ts       # NEW: PATCH resolve alert
│   ├── transactions/
│   │   └── [id]/
│   │       └── page.tsx                # NEW: Transaction detail page
│   └── admin/
│       ├── transactions/
│       │   └── page.tsx                # NEW: Admin stuck transactions dashboard
│       └── alerts/
│           └── page.tsx                # NEW: Admin alerts dashboard
├── lib/
│   ├── services/
│   │   ├── payments.ts                 # MODIFY: Add retry logic + timeout handling
│   │   ├── reconciliation.ts           # NEW: Background status checks
│   │   └── notifications.ts            # MODIFY: Add transaction notifications
│   ├── db-migrations/
│   │   └── 004-transaction-recovery.sql # NEW: Schema changes
│   ├── retry.ts                        # NEW: Retry logic (exponential backoff)
│   ├── state-machine.ts                # NEW: Transaction state transitions
│   ├── error-messages.ts               # NEW: User-friendly error messages
│   └── admin-alerts.ts                 # NEW: Admin alert creation/management
└── workers/
    └── reconciliation-worker.ts        # NEW: Background job to check stuck txs
```

### 10.2 Implementation Phases

#### Phase 1: State Machine & Audit (Week 1)

- [ ] Update `transactions` table schema (new columns + `timeout` state)
- [ ] Implement state transition validation in `lib/state-machine.ts`
- [ ] Add transition audit logging (every status change → audit_log)
- [ ] Update API routes to use state machine validation
- [ ] **Deliverable:** Status changes are validated + audited

#### Phase 2: Retry Logic (Week 2)

- [ ] Implement exponential backoff in `lib/retry.ts`
- [ ] Add error classification (transient vs permanent)
- [ ] Update `payments.ts` to use retry wrapper
- [ ] Add retry counter tracking in DB
- [ ] **Deliverable:** Transient errors auto-retry up to 3 times

#### Phase 3: Timeout Recovery (Week 2-3)

- [ ] Change timeout behavior: return `timeout` status instead of failure
- [ ] Create `reconciliation-worker.ts` background job
- [ ] Implement PISP status polling (every 10 min for stuck txs)
- [ ] Add timeout → completed/failed transitions
- [ ] **Deliverable:** Timeouts self-resolve via background reconciliation

#### Phase 4: User Communication (Week 3)

- [ ] Create transaction detail page (`/transactions/[id]`)
- [ ] Add real-time status polling (SWR with 2s refresh)
- [ ] Implement push notifications for status changes
- [ ] Add email notifications for terminal states
- [ ] Implement user-friendly error messages
- [ ] **Deliverable:** Users always know transaction status

#### Phase 5: Admin Tools (Week 4)

- [ ] Create `admin_alerts` table
- [ ] Implement stuck transaction detection (every 10 min sweep)
- [ ] Build admin dashboard (`/admin/transactions`)
- [ ] Add manual retry endpoint (`POST /api/admin/transactions/[id]/retry`)
- [ ] Add manual resolution endpoint (`POST /api/admin/transactions/[id]/resolve`)
- [ ] **Deliverable:** Admins can intervene on stuck transactions

#### Phase 6: Partial Failure Handling (Future — After FX Provider Integration)

- [ ] Add `compensation_status` field
- [ ] Implement refund flow for partial failures
- [ ] Add FX provider status checks
- [ ] Test compensation scenarios
- [ ] **Deliverable:** Partial failures trigger automatic refunds

---

## 11. Testing Strategy

### 11.1 Unit Tests

**Retry logic:**
```typescript
describe("Retry with exponential backoff", () => {
  test("succeeds on first attempt", async () => {
    const result = await callPispWithRetry(() => Promise.resolve({ success: true }));
    expect(result.success).toBe(true);
  });

  test("retries on transient error", async () => {
    let attempts = 0;
    const result = await callPispWithRetry(async () => {
      attempts++;
      if (attempts < 3) throw new Error("Network timeout");
      return { success: true };
    });
    expect(attempts).toBe(3);
  });

  test("stops on permanent error", async () => {
    let attempts = 0;
    const result = await callPispWithRetry(async () => {
      attempts++;
      return { success: false, error: "invalid_iban" }; // permanent
    });
    expect(attempts).toBe(1);
  });
});
```

**State machine:**
```typescript
describe("Transaction state machine", () => {
  test("allows initiated → processing", () => {
    expect(canTransition("initiated", "processing")).toBe(true);
  });

  test("blocks processing → initiated", () => {
    expect(canTransition("processing", "initiated")).toBe(false);
  });

  test("blocks completed → anything", () => {
    expect(canTransition("completed", "failed")).toBe(false);
  });
});
```

### 11.2 Integration Tests

**Scenario: Timeout recovery**
1. Mock PISP to timeout on first call
2. Initiate transaction → verify status = `timeout`
3. Run reconciliation worker
4. Mock PISP to return `completed`
5. Verify transaction status = `completed`
6. Verify push notification sent

**Scenario: Retry exhaustion**
1. Mock PISP to return 503 three times
2. Initiate transaction
3. Verify transaction status = `failed`
4. Verify admin alert created
5. Verify user notified

### 11.3 End-to-End Tests

**User journey:**
1. User initiates remittance (500 NOK → RSD)
2. PISP times out after 30s
3. User sees "Processing — we'll notify you"
4. 2 minutes later: background worker checks status
5. PISP returns `completed`
6. User receives push notification
7. User opens transaction detail page → sees "Completed"
8. User receives email confirmation

**Admin journey:**
1. Transaction stuck in `timeout` for 2 hours
2. Admin opens `/admin/transactions` dashboard
3. Sees transaction in "Stuck" list
4. Clicks "Retry" → transaction re-attempted
5. PISP succeeds → status = `completed`
6. Admin marks alert as "Resolved"

---

## 12. Acceptance Criteria

### 12.1 State Machine

- [x] All status transitions validated against whitelist
- [x] Invalid transitions blocked at DB + app level
- [x] Every status change logged in `audit_log` with timestamp + reason
- [x] Terminal states (`completed`, `failed`) cannot transition

### 12.2 Idempotency

- [x] Duplicate requests with same `idempotencyKey` return cached response (already implemented)
- [x] Idempotency keys scoped to user (prevents IDOR) (already implemented)
- [x] Response includes identical payload + status 200 (already implemented)

### 12.3 Retry Logic

- [x] Transient errors (5xx, timeout) trigger automatic retry
- [x] Exponential backoff: 2s → 8s → 32s (with jitter)
- [x] Max 3 retry attempts
- [x] Permanent errors (4xx, bank decline) fail immediately (no retry)
- [x] After max retries: mark as `failed` + create admin alert

### 12.4 Timeout Recovery

- [x] Timeout returns `timeout` status (not `failed`)
- [x] Background worker checks PISP status every 10 min
- [x] Stuck transactions (> 10 min) swept periodically
- [x] Timeout → completed/failed based on PISP response
- [x] User notified when status resolves

### 12.5 Partial Failure

- [x] `compensation_status` field added (for future FX refunds)
- [x] Refund flow triggers on transfer failure (when FX provider added)
- [x] Compensation failures escalate to admin alert
- [x] User sees "Processing refund" status

### 12.6 User Communication

- [x] Transaction detail page (`/transactions/[id]`) shows:
  - Real-time status
  - Timeline of events
  - User-friendly error messages
- [x] Push notifications sent on status change
- [x] Email sent on terminal status (`completed`, `failed`)
- [x] Error messages in Norwegian (primary) + English

### 12.7 Admin Tools

- [x] `/api/admin/transactions/stuck` returns all stuck transactions
- [x] `/api/admin/transactions/[id]/retry` manually retries transaction
- [x] `/api/admin/transactions/[id]/resolve` manually marks completed/failed
- [x] Admin dashboard shows stuck transactions with action buttons
- [x] Admin alerts created for:
  - Max retries exhausted
  - Compensation failure
  - Transaction stuck > 24 hours

---

## 13. Monitoring & Alerting

### 13.1 Metrics to Track

| Metric | Threshold | Alert If |
|--------|-----------|----------|
| Stuck transactions (count) | 5 | > 10 |
| Average resolution time (hours) | 1 | > 4 |
| Failed transactions (last 24h) | 50 | > 100 |
| PISP timeout rate (%) | 5% | > 15% |
| Retry success rate (%) | 80% | < 60% |
| Compensation failures (count) | 0 | > 0 |

### 13.2 Dashboard Queries

**Stuck transactions:**
```sql
SELECT COUNT(*) FROM transactions
WHERE status IN ('processing', 'timeout')
  AND created_at < datetime('now', '-10 minutes');
```

**Average resolution time:**
```sql
SELECT AVG(julianday(completed_at) - julianday(created_at)) * 24 AS hours
FROM transactions
WHERE status = 'completed'
  AND completed_at > datetime('now', '-24 hours');
```

**PISP timeout rate:**
```sql
SELECT
  SUM(CASE WHEN failure_code = 'pisp_timeout' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) AS timeout_pct
FROM transactions
WHERE created_at > datetime('now', '-24 hours');
```

### 13.3 Log Events

**Every transaction state change:**
```json
{
  "level": "info",
  "msg": "Transaction status changed",
  "txId": "tx_rem_123",
  "userId": "usr_abc",
  "from": "processing",
  "to": "completed",
  "reason": "PISP callback received",
  "externalId": "ext_456",
  "timestamp": "2026-02-17T10:00:45Z"
}
```

**PISP API call failures:**
```json
{
  "level": "error",
  "msg": "PISP API call failed",
  "txId": "tx_rem_123",
  "attempt": 2,
  "errorCode": "pisp_timeout",
  "errorMessage": "Request timeout after 30s",
  "willRetry": true,
  "nextRetryIn": "8000ms",
  "timestamp": "2026-02-17T10:00:30Z"
}
```

**Retry exhaustion:**
```json
{
  "level": "error",
  "msg": "All retries exhausted",
  "txId": "tx_rem_123",
  "maxRetries": 3,
  "lastError": "PISP provider unavailable",
  "adminAlertCreated": "alert_xyz",
  "timestamp": "2026-02-17T10:01:10Z"
}
```

---

## 14. Security Considerations

### 14.1 Admin Endpoints

**Access control:**
- All admin endpoints require `user.role === 'admin'` (checked via JWT)
- Audit every admin action (`ADMIN_TRANSACTION_RETRY`, `ADMIN_MARK_COMPLETED`, etc.)
- Log IP address + user agent for all admin operations

**Rate limiting:**
- Admin endpoints: 60 requests/min (higher than user endpoints)
- Admin dashboard: no rate limit (internal tool)

### 14.2 Idempotency Key Security

**No vulnerability:** Idempotency keys scoped to user → can't replay another user's transaction

**Best practice:** Client generates key = `${userId}_${timestamp}_${random}` (prevents guessing)

### 14.3 Transaction Status Leaks

**Risk:** User A checks `/api/transactions/tx_rem_123` → sees User B's transaction

**Mitigation (already implemented):**
```typescript
const tx = await getOne(
  "SELECT * FROM transactions WHERE id = ? AND user_id = ?",
  [txId, user.id] // ← Scoped to logged-in user
);
```

**Admin endpoints:** Bypass user_id check (admin sees all transactions)

---

## 15. Cost Analysis

### 15.1 Infrastructure

| Component | Cost | Notes |
|-----------|------|-------|
| Background worker | $0 | Same server process (cron or `setInterval`) |
| Job queue (pg-boss) | $0 | Uses existing PostgreSQL (when migrated from SQLite) |
| Job queue (BullMQ) | ~$20/mo | Redis hosting (if chosen over pg-boss) |
| Push notifications (FCM) | Free | Up to unlimited (Firebase Cloud Messaging) |
| Email (SendGrid) | $15/mo | 50k emails/month (transactional tier) |

**Total:** $15-35/mo (depending on job queue choice)

### 15.2 PISP API Costs

**Retry costs:**
- 3 retries per failed transaction
- If 5% of transactions fail → 5% * 3 = 15% extra PISP API calls
- Assuming 10,000 txs/month, 5% fail = 500 failed → 1,500 retry calls
- Cost: depends on PISP provider (typically $0.01-0.05 per API call)
- **Estimated:** $15-75/mo in extra API fees

**Reconciliation costs:**
- Background worker checks status every 10 min for stuck txs
- If 1% stuck (100 txs/month) → 100 * 6 status checks/hour * 24h = 14,400 API calls
- **Estimated:** $144-720/mo

**Optimization:** Only check status for transactions stuck > 10 min (reduces unnecessary calls)

---

## 16. Open Questions (For Alem)

### 16.1 Retry Strategy

**Q1:** Should we do in-process retry (Option A) or background job queue (Option B)?

**Recommendation:** Start with Option A (simpler, no extra infra). Migrate to Option B when transaction volume > 10k/month.

### 16.2 Notification Channels

**Q2:** Email + push notifications both? Or only one?

**Recommendation:** Both. Email is fallback (if user disabled push). Send email only for terminal states.

### 16.3 Admin Alert Delivery

**Q3:** How should admin alerts be delivered?
- Option A: Dashboard only (admin must check `/admin/alerts`)
- Option B: Email to ops team
- Option C: Slack webhook
- Option D: SMS for critical alerts

**Recommendation:** Option C (Slack) for high/critical alerts. Dashboard for all.

### 16.4 Stuck Transaction Threshold

**Q4:** When should we mark a transaction as "stuck"?
- Current spec: 10 minutes
- Alternative: 1 hour (less aggressive)

**Recommendation:** 10 min for reconciliation sweep, 24h for admin alert (gives time to self-resolve)

### 16.5 Partial Failure Compensation SLA

**Q5:** What's acceptable refund time for partial failures?
- PSD2 requires 24h for refunds
- Faster = better UX

**Recommendation:** Initiate refund immediately, complete within 24h (meet regulatory minimum)

---

## 17. Next Steps

1. **Review this spec** with Alem
2. **Approve/reject each section** (or request changes)
3. **Prioritize phases** (which to implement first?)
4. **Assign to builder agent** (one phase at a time)
5. **Validation after each phase** (validator agent checks implementation)

**Estimated timeline:** 4 weeks for Phases 1-5, Phase 6 (partial failure) deferred until FX provider integrated

---

## Appendix A: State Diagram (ASCII)

```
┌─────────────┐
│  initiated  │──────────────┐
└──────┬──────┘              │
       │                     │ (immediate fail: validation error)
       ▼                     ▼
┌─────────────┐        ┌─────────────┐
│ processing  │        │   failed    │ (terminal)
└──────┬──────┘        └─────────────┘
       │
       ├──────────────────┬──────────────────┐
       │                  │                  │
       │ (success)        │ (timeout)        │ (permanent error)
       ▼                  ▼                  ▼
┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  completed  │    │   timeout   │    │   failed    │
│ (terminal)  │    └──────┬──────┘    └─────────────┘
└─────────────┘           │
                          │ (reconciliation)
                          ├───────────┬───────────┐
                          │           │           │
                          ▼           ▼           │
                    ┌─────────────┐ ┌─────────────┐│ (retry)
                    │  completed  │ │   failed    ││
                    └─────────────┘ └─────────────┘▼
                                            ┌─────────────┐
                                            │ processing  │
                                            └─────────────┘

Future:
┌─────────────────────────────┐
│    partially_completed      │
└──────────┬──────────────────┘
           │ (refund)
           ├───────────┬───────────┐
           ▼           ▼           ▼
    ┌─────────────┐ ┌─────────────┐
    │  completed  │ │   failed    │
    └─────────────┘ └─────────────┘
```

---

## Appendix B: Error Code Reference

| Code | Type | Retry? | User Message (NO) | User Message (EN) |
|------|------|--------|-------------------|-------------------|
| `insufficient_balance` | Permanent | No | "Ikke nok dekning på bankkontoen" | "Insufficient funds" |
| `bank_declined` | Permanent | No | "Banken din avslo betalingen" | "Your bank declined the payment" |
| `invalid_iban` | Permanent | No | "Ugyldig kontonummer" | "Invalid account number" |
| `kyc_required` | Permanent | No | "Identitetsverifisering kreves" | "Identity verification required" |
| `pisp_timeout` | Transient | Yes | "Betalingen tar lengre tid enn vanlig" | "Payment taking longer than usual" |
| `pisp_unavailable` | Transient | Yes | "Betalingsleverandør midlertidig utilgjengelig" | "Payment provider temporarily unavailable" |
| `network_error` | Transient | Yes | "Nettverksfeil — prøver igjen automatisk" | "Network error — retrying automatically" |
| `pisp_5xx` | Transient | Yes | "Betalingsleverandør har tekniske problemer" | "Payment provider experiencing technical issues" |
| `max_retries_exceeded` | Permanent | No | "Betalingen feilet etter flere forsøk" | "Payment failed after multiple attempts" |
| `validation_error` | Permanent | No | "Ugyldig forespørsel" | "Invalid request" |

---

**End of Specification**

# drop-validation-hardening-plan

# Plan: Drop Validation Hardening

## Research Summary

### What the documentation says (spec vs reality)

**Sources reviewed:**
1. `project/architecture/api-specification.md` — API contract, field examples, error codes
2. `project/architecture/architecture-document.md` — User requirements from vilkår.html (legally binding)
3. `project/docs/security-qa-audit.md` — Issue #14 (email), #19 (password), #9 (amounts)
4. `project/docs/drop-qa-rapport.md` — QA findings C-1 through L-10
5. `src/lib/middleware/validation.ts` — Existing validators (UNUSED by any route)
6. `src/app/api/auth/register/route.ts` — Current registration validation
7. `src/app/onboarding/page.tsx` — Current frontend validation

### Gap Analysis

| Field | Spec / Audit Requirement | Current Implementation | Gap |
|-------|--------------------------|----------------------|-----|
| **Email** | Regex `/^[^\s@]+@[^\s@]+\.[^\s@]+$/` (audit #14) | `email.includes("@")` | YES — accepts `@`, `a@`, `@ @` |
| **Password** | 12+ chars, uppercase, lowercase, digit (audit #19) | `length >= 8`, no complexity | YES — "12345678" passes |
| **First/Last Name** | `validateName()` exists in validation.ts — 1-100 chars, no script tags, sanitized | `typeof === "string"` only | YES — "123", XSS, 100K chars pass |
| **Phone** | `+47` Norwegian format (architecture doc 1.4) | No validation at all | YES — any string passes |
| **Age** | 18+ from vilkår.html (architecture doc 1.4) | Not implemented | YES — but deferred (needs BankID) |
| **Amount (remittance)** | 100-50,000 NOK, 2 decimal places, finite (audit #9) | 100-50,000 check, isFinite | PARTIAL — no decimal precision |
| **Amount (QR)** | 1-100,000 NOK, 2 decimals | 1-100,000 check, isFinite | PARTIAL — no decimal precision |
| **Bank account** | IBAN validation (validation.ts has `validateIBAN`) | No validation | YES — but separate scope |
| **Currency** | NOK, RSD, BAM, PLN, PKR, TRY, EUR | Missing RSD, TRY, PKR, NOK | YES — but unused validator |

### Existing assets (ready to wire up)

`src/lib/middleware/validation.ts` already has:
- `validateEmail()` — proper regex
- `validatePhone()` — international `+` format, 8-15 digits
- `validateAmount()` — positive, max 2 decimals
- `validateIBAN()` — format + mod-97 checksum
- `validatePIN()` — exactly 4 digits
- `validateName()` — 1-100 chars, no script tags
- `sanitizeText()` — strips HTML, control chars, max length
- `validateCurrency()` — needs RSD, TRY, PKR, NOK added

**Key insight:** The validators EXIST but are NEVER IMPORTED. The middleware/ directory is entirely dead code (QA rapport C-1). The fix is to wire existing validators into the actual routes.

## Objective

Wire existing validation utilities into all API routes and frontend forms. Close the gap between documented requirements and actual implementation. Do NOT create new validators — use what exists in `validation.ts`, extend where needed.

## Scope

**In scope:**
1. Registration API — email, password, name, phone validation
2. Registration frontend — matching client-side checks
3. Login frontend — email format check
4. Amount validation — add decimal precision check to remittance + QR payment routes
5. Update existing tests to match new validation rules
6. Currency validator — add missing currencies

**Out of scope (separate tasks):**
- Age verification (needs BankID integration)
- IBAN validation for recipients (separate feature)
- CSRF protection (separate security task)
- Audit logging (separate task)
- Password complexity upgrade to 12+ (BREAKING CHANGE — needs migration plan, keep 8 for now but add complexity)

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | validation-builder | Implement validation wiring | builder |
| V1 | validation-validator | Verify all validation works | validator |

### Step-by-Step Tasks

#### Phase 1: Backend Validation (API Routes)

**Task 1:** Wire validators into registration API
- Owner: B1
- BlockedBy: none
- Files: `src/app/api/auth/register/route.ts`, `src/lib/middleware/validation.ts`
- Changes:
  1. Import `validateEmail`, `validateName`, `sanitizeText`, `validatePhone` from `@/lib/middleware/validation`
  2. Replace `!email.includes("@")` with `!validateEmail(email)`
  3. Replace `!firstName || typeof firstName !== "string"` with `!validateName(firstName)`
  4. Replace `!lastName || typeof lastName !== "string"` with `!validateName(lastName)`
  5. Add phone validation: `if (phone && !validatePhone(phone))` error
  6. Sanitize names before storing: `sanitizeText(firstName, 100)`, `sanitizeText(lastName, 100)`
  7. Add password complexity: min 8 chars + at least 1 letter + at least 1 digit (soft upgrade, not full 12-char yet)
  8. Add `validateCurrency` update: add missing currencies (RSD, TRY, PKR, NOK)
- Acceptance:
  - [ ] `user@` rejected (no domain)
  - [ ] `@domain.com` rejected (no local part)
  - [ ] `user space@domain.com` rejected (spaces)
  - [ ] `valid@email.com` accepted
  - [ ] `123` as firstName rejected (contains only digits? No — validateName allows digits, just checks length + no script tags. This is acceptable.)
  - [ ] `<script>alert(1)</script>` as name rejected
  - [ ] Empty firstName rejected
  - [ ] 200+ char firstName truncated to 100
  - [ ] Phone without `+` prefix rejected (if provided)
  - [ ] Phone `+4712345678` accepted
  - [ ] Password `12345678` rejected (no letter)
  - [ ] Password `abcdefgh` rejected (no digit)
  - [ ] Password `abc12345` accepted (letter + digit + 8 chars)

**Task 2:** Wire validators into amount routes
- Owner: B1
- BlockedBy: none
- Files: `src/app/api/transactions/remittance/route.ts`, `src/app/api/transactions/qr-payment/route.ts`
- Changes:
  1. Import `validateAmount` from `@/lib/middleware/validation`
  2. Add decimal precision check before range check: `if (Math.round(amount * 100) !== amount * 100)` → 400 error
  3. Existing range checks stay (100-50K for remittance, 1-100K for QR)
- Acceptance:
  - [ ] `100.999` rejected (3 decimals)
  - [ ] `100.99` accepted (2 decimals)
  - [ ] `100` accepted (0 decimals)
  - [ ] Existing range tests still pass

**Task 3:** Validate Task 1 + Task 2
- Owner: V1
- BlockedBy: 1, 2
- Acceptance:
  - [ ] Read all modified files, verify imports are correct
  - [ ] Verify no breaking changes to existing passing tests
  - [ ] Run `npm test` — all 120 Vitest tests pass
  - [ ] Run `npx playwright test` — all 75 e2e tests pass (may need test updates)

#### Phase 2: Frontend Validation (Client-side)

**Task 4:** Add proper validation to registration form
- Owner: B1
- BlockedBy: 3 (backend must be validated first)
- Files: `src/app/onboarding/page.tsx`
- Changes:
  1. Replace `!email.includes("@")` with proper regex check matching backend
  2. Add inline error for email format: "Ugyldig e-postformat"
  3. Add password complexity check: show "Passord må inneholde bokstaver og tall"
  4. Add name format hint if script tags detected: "Ugyldig tegn i navn"
  5. Add phone format hint: "Norsk telefonnummer påkrevd (+47...)"
  6. Keep button disabled logic, but add per-field inline errors
- Acceptance:
  - [ ] `user@` shows email format error immediately on blur
  - [ ] `12345678` password shows complexity error
  - [ ] Valid form submits normally
  - [ ] Error messages in Norwegian

**Task 5:** Add email validation to login form
- Owner: B1
- BlockedBy: 3
- Files: `src/app/login/page.tsx`
- Changes:
  1. Add email format check matching backend regex
  2. Show "Ugyldig e-postformat" if email doesn't match on submit
- Acceptance:
  - [ ] Invalid email format shows Norwegian error
  - [ ] Valid email + wrong password shows credential error (not format error)

**Task 6:** Validate Task 4 + Task 5
- Owner: V1
- BlockedBy: 4, 5
- Acceptance:
  - [ ] Read all modified frontend files
  - [ ] Verify error messages are in Norwegian
  - [ ] Run `npx playwright test` — all 75 e2e tests pass
  - [ ] Verify no regressions in user-flows or input-chaos tests

#### Phase 3: Test Updates

**Task 7:** Update e2e tests for new validation rules
- Owner: B1
- BlockedBy: 6
- Files: `tests/e2e/input-chaos.spec.ts`, `tests/e2e/user-flows.spec.ts`
- Changes:
  1. Update password boundary tests — "12345678" now fails (no letter), use "pass1234" instead
  2. Update any test assertions that depend on old weak validation
  3. Add new positive test: valid registration with proper fields
  4. Verify all 75 tests pass with the new validation
- Acceptance:
  - [ ] `npx playwright test` — 75/75 pass
  - [ ] `npm test` — 120/120 pass
  - [ ] No test uses weak input that now gets rejected without updating the assertion

**Task 8:** Final validation — full test suite
- Owner: V1
- BlockedBy: 7
- Acceptance:
  - [ ] `npm test` — 120/120 pass (5 consecutive runs)
  - [ ] `npx playwright test` — 75/75 pass
  - [ ] Manual check: registration form rejects `<script>` in name
  - [ ] Manual check: registration form rejects `user@` email
  - [ ] Manual check: registration accepts valid Norwegian data

## Files Modified

### Backend (API)
- `src/app/api/auth/register/route.ts` — wire validators
- `src/app/api/transactions/remittance/route.ts` — decimal precision
- `src/app/api/transactions/qr-payment/route.ts` — decimal precision
- `src/lib/middleware/validation.ts` — add missing currencies

### Frontend
- `src/app/onboarding/page.tsx` — proper client-side validation
- `src/app/login/page.tsx` — email format check

### Tests
- `tests/e2e/input-chaos.spec.ts` — update for new rules
- `tests/e2e/user-flows.spec.ts` — update if needed

## Validation Commands

```bash
# Unit + integration tests
npm test

# E2E tests (both suites)
npx playwright test

# Quick API validation
curl -s http://localhost:3000/api/auth/register \
  -X POST -H "Content-Type: application/json" \
  -d '{"email":"user@","password":"12345678","firstName":"<script>","lastName":"Test"}' | jq .
# Expected: 422 with validation errors
```

## Risk Assessment

- **Low risk:** Wiring existing validators — they're already written and tested in isolation
- **Medium risk:** Frontend changes may break e2e test assertions that depend on old behavior
- **Mitigation:** Phase 3 explicitly updates tests after backend + frontend are done
- **NOT breaking existing users:** Password min stays at 8 (just adds letter+digit requirement). Demo user "demo1234" has both letters and digits — still works.

# drop-observability-plan

# Drop — System Support & Observability Plan

**Client:** Drop (Digital Banking)
**Executing Company:** FlowForge (DevOps & Infrastructure)
**Support Company:** HelixSupport (Production Support & SLA)
**Status:** DRAFT — čeka CEO approval
**Created:** 2026-02-20

---

## Current State (AS-IS)

Drop već ima:
- ✅ Structured JSON logging (custom logger, request ID tracking)
- ✅ Health check endpoint (`/api/health` sa DB verification)
- ✅ Slack alerting (error spike detection, lifecycle events)
- ✅ Container health checks (Docker + AWS App Runner)
- ✅ Automated SQLite backup (WAL-safe, 30-day retention)
- ✅ CI/CD sa security scanning (Trivy, npm audit)
- ✅ Terraform IaC (AWS + Cloudflare)

Drop nema:
- ❌ External uptime monitoring
- ❌ Log aggregation (logs samo u `docker logs`)
- ❌ Error tracking (Sentry skinut)
- ❌ DB performance monitoring
- ❌ Business metrics dashboard
- ❌ APM / distributed tracing
- ❌ Alerting escalation (samo Slack)

---

## Target State (TO-BE)

### Tier 1: Essential (Week 1)
**Cost: ~$0 — free tiers**

| Component | Tool | Why |
|-----------|------|-----|
| Uptime monitoring | BetterStack (free: 10 monitors) | Independent od AWS — znaš kad padne PRIJE korisnika |
| Error tracking | Sentry (free: 5K events/mo) | Stack traces, user context, release tracking |
| Log shipping | AWS CloudWatch Logs (App Runner native) | Searchable logs, retention, metric filters |

### Tier 2: Visibility (Week 2)
**Cost: ~$0-20/mo**

| Component | Tool | Why |
|-----------|------|-----|
| DB monitoring | RDS Performance Insights (free tier) | Slow queries, connection pool, wait events |
| CDN analytics | Cloudflare Analytics (free) | Traffic patterns, threats, cache hit rate |
| Alerting escalation | BetterStack On-call (free: 1 team) | Slack → Email → SMS escalation chain |

### Tier 3: Intelligence (Week 3-4)
**Cost: ~$0-50/mo**

| Component | Tool | Why |
|-----------|------|-----|
| Business metrics | Custom endpoint + Grafana Cloud (free: 10K metrics) | Tx/hour, success rate, revenue |
| Application metrics | Prometheus client in app | Request latency, error rate, saturation |
| Dashboards | Grafana Cloud (free tier) | SLO tracking, operational dashboards |

### Tier 4: Advanced (Future)
**Kad bude potreba (production scale)**

| Component | Tool | Why |
|-----------|------|-----|
| Distributed tracing | OpenTelemetry → Grafana Tempo | Cross-service request flow |
| Log aggregation | Grafana Loki | Centralized searchable logs |
| Chaos engineering | Manual game days | Resilience validation |

---

## FlowForge Execution Plan

### Phase 1: Plan (ovaj dokument)
- [x] AS-IS analiza Drop infrastrukture
- [x] Tool selection (free-first approach)
- [x] Tiered rollout plan
- [ ] CEO approval

**Gate:** Alem kaže GO

### Phase 2: Provision (Week 1)

**FlowForge SRE → implementacija:**

#### 2a. BetterStack Uptime (30 min)
- Kreiraj BetterStack account (free tier)
- Monitors: health endpoint (60s), landing page (5min), API (60s)
- Alerting: Slack #drop-ops → email → SMS
- Status page: public URL za klijente

#### 2b. Sentry Re-integracija (1-2h)
- `npm install @sentry/nextjs` u drop-app
- DSN u AWS Secrets Manager
- Source maps upload u CI/CD
- Error boundary u React components
- Server-side error capturing u API routes
- Alert rules: new issue → Slack, spike → email

#### 2c. CloudWatch Logs (1h)
- App Runner → CloudWatch Logs (native, samo enable)
- Log group: `/drop/production`
- Retention: 30 dana
- Metric filters: ERROR count, latency p99, 5xx count
- CloudWatch Alarm: 5xx > 5/min → SNS → Slack

### Phase 3: Deploy (Week 2)

#### 3a. RDS Performance Insights (15 min)
- Enable u Terraform (performance_insights_enabled = true)
- Terraform apply

#### 3b. Cloudflare Analytics (15 min)
- Već aktivan — samo verifikuj da radi
- Webhook za DDoS alerts → Slack

#### 3c. Alerting Escalation (30 min)
- BetterStack On-call team setup
- Escalation: Slack (0min) → Email (5min) → SMS (15min)
- On-call schedule: John (primary)

### Phase 4: Monitor (Week 3-4)

#### 4a. Business Metrics Endpoint (2-3h)
- Novi API route: `GET /api/metrics/business`
- Metrics: transactions/hour, success rate, active users, avg amount
- Prometheus format output
- Grafana Cloud dashboard

#### 4b. Application Metrics (2-3h)
- `prom-client` npm package
- Metrics: http_request_duration, http_requests_total, db_query_duration
- `GET /metrics` endpoint (Prometheus scrape)
- Grafana Cloud → Prometheus remote write

#### 4c. SLO Dashboard (1-2h)
- Grafana dashboard sa:
  - Uptime SLO (target: 99.9%)
  - Latency SLO (p99 < 500ms)
  - Error rate SLO (< 0.1%)
  - Business KPIs

### Phase 5: Optimize (Ongoing)

**HelixSupport preuzima:**
- Weekly metrics review
- Incident response po SLA (P1: 15min, P2: 1h)
- Post-mortem za svaki P1/P2
- Monthly SLA report za Alema
- Monitoring tuning (alert thresholds, noise reduction)

---

## Cost Summary

| Item | Monthly Cost |
|------|-------------|
| BetterStack Uptime (free tier) | $0 |
| Sentry (free tier, 5K events) | $0 |
| CloudWatch Logs (App Runner) | ~$5 |
| RDS Performance Insights (free) | $0 |
| Grafana Cloud (free tier) | $0 |
| **Total** | **~$5/mo** |

Kad Drop skalira → upgrade na paid tiers (~$50-100/mo).

---

## Success Metrics

| Metric | Target |
|--------|--------|
| MTTD (Mean Time to Detect) | < 5 min |
| MTTR (Mean Time to Recover) | < 1h (P1) |
| Uptime SLO | 99.9% |
| Undetected outages | 0 |
| Alert noise (false positives) | < 10% |

---

## Dependencies

- AWS credentials (Terraform apply access)
- BetterStack account creation
- Sentry project creation
- Grafana Cloud account creation

---

## Approval

**CEO Decision Required:**
- [ ] Approve plan (GO / NO-GO / MODIFY)
- [ ] Approve tool selection (BetterStack, Sentry, Grafana)
- [ ] Approve timeline (4 weeks)
- [ ] Budget confirmation (~$5/mo)

# drop-observability-azure-research

# Drop — Observability Stack on Azure (Research)

**Client:** Drop (Digital Banking)
**Type:** Research — Azure equivalent of AWS observability plan
**Reference:** ~/system/specs/drop-observability-plan.md (AWS version)
**Created:** 2026-02-20

---

## AWS → Azure Mapping

| Komponenta | AWS (Drop danas) | Azure ekvivalent |
|-----------|-------------------|-------------------|
| Log aggregation | CloudWatch Logs | **Azure Monitor Logs** (Log Analytics Workspace) |
| Alarmi | CloudWatch Alarms | **Azure Monitor Alerts** (Metric Alerts + Log Alerts) |
| Notifikacije | SNS Topics | **Action Groups** (email, SMS, Slack webhook, Teams) |
| DB monitoring | RDS Performance Insights | **Query Performance Insight** (Azure Database for PostgreSQL) |
| App observability | App Runner Observability | **Application Insights** (auto-instrument, zero-code za .NET/Node/Python) |
| Container logs | App Runner → CloudWatch | **Container Apps Logs** → Log Analytics (automatic) |
| Uptime monitoring | BetterStack (external) | **BetterStack** (isto — cloud-agnostic) ILI Azure Availability Tests |
| Error tracking | Sentry (external) | **Sentry** (isto — cloud-agnostic) ILI Application Insights Exceptions |
| Metrics/Dashboards | Grafana Cloud (external) | **Grafana Cloud** (isto) ILI Azure Dashboards + Workbooks |

---

## Azure-Specific Prednosti

1. **Application Insights** — Ubija 3 alata odjednom (APM + error tracking + metrics). Auto-instrument za Node.js. Zamjenjuje Sentry + Prometheus + dio Grafane.
2. **Log Analytics Workspace** — KQL query language (moćniji od CloudWatch Insights). Centralni log store.
3. **Action Groups** — Jedna konfiguracija za SVE alerte (email + SMS + Slack webhook + Teams + Azure Function).
4. **Azure Workbooks** — Besplatni interaktivni dashboardi (zamjena za Grafana ako ne treba multi-cloud).

---

## Terraform (AzureRM)

```hcl
# 1. Log Analytics Workspace
resource "azurerm_log_analytics_workspace" "main" {
  name                = "drop-logs"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  sku                 = "PerGB2018"    # Pay-as-you-go, 5GB/mo free
  retention_in_days   = 30
}

# 2. Application Insights
resource "azurerm_application_insights" "main" {
  name                = "drop-appinsights"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  workspace_id        = azurerm_log_analytics_workspace.main.id
  application_type    = "Node.JS"
}

# 3. Action Group (notifikacije)
resource "azurerm_monitor_action_group" "ops" {
  name                = "drop-ops-alerts"
  resource_group_name = azurerm_resource_group.main.name
  short_name          = "dropops"

  email_receiver {
    name          = "ceo"
    email_address = "alem@alai.no"
  }
  # Slack: webhook_receiver { ... service_uri = "https://hooks.slack.com/..." }
}

# 4. Alert Rules (primjeri)
resource "azurerm_monitor_metric_alert" "high_error_rate" {
  name                = "drop-high-error-rate"
  resource_group_name = azurerm_resource_group.main.name
  scopes              = [azurerm_application_insights.main.id]
  severity            = 1
  frequency           = "PT1M"
  window_size         = "PT5M"

  criteria {
    metric_namespace = "microsoft.insights/components"
    metric_name      = "requests/failed"
    aggregation      = "Count"
    operator         = "GreaterThan"
    threshold        = 5
  }
  action { action_group_id = azurerm_monitor_action_group.ops.id }
}

# 5. Availability Test (uptime monitoring)
resource "azurerm_application_insights_standard_web_test" "health" {
  name                    = "drop-health-check"
  resource_group_name     = azurerm_resource_group.main.name
  location                = azurerm_resource_group.main.location
  application_insights_id = azurerm_application_insights.main.id
  frequency               = 300  # 5 min
  enabled                 = true

  request { url = "https://drop.example.com/api/health" }
}
```

---

## Koraci za implementaciju

1. [ ] Kreiraj Azure Resource Group za monitoring resurse
2. [ ] Deploy Log Analytics Workspace (Terraform)
3. [ ] Deploy Application Insights connected to Workspace (Terraform)
4. [ ] Dodaj `APPLICATIONINSIGHTS_CONNECTION_STRING` u app environment
5. [ ] `npm install applicationinsights` u app + `appinsights.setup().start()` na boot
6. [ ] Kreiraj Action Group sa email + Slack webhook (Terraform)
7. [ ] Dodaj Metric Alerts: error rate, response time p99, availability (Terraform)
8. [ ] Dodaj Availability Test za health endpoint (Terraform)
9. [ ] Opciono: BetterStack kao external monitor (isti setup kao AWS varijanta)
10. [ ] Verifikuj: App Insights prima telemetriju, alarmi rade, logi vidljivi u Log Analytics

---

## Cost (Azure)

| Stavka | Cijena |
|--------|--------|
| Application Insights | 5GB/mo free, zatim ~$2.30/GB |
| Log Analytics | 5GB/mo free ingestion, 31 dana free retention |
| Alert Rules | 1st metric alert free, zatim ~$0.10/rule/mo |
| Availability Tests | 1st 10 free |
| **Total (mali promet)** | **~$0-5/mo** |

---

## Reference

- AWS plan: ~/system/specs/drop-observability-plan.md
- Azure Monitor docs: https://learn.microsoft.com/en-us/azure/azure-monitor/
- Application Insights Node.js: https://learn.microsoft.com/en-us/azure/azure-monitor/app/nodejs
- Terraform AzureRM Monitor: https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/monitor_metric_alert

# drop-status-2026-02-20

# Drop — Project Status Report

**Date:** 2026-02-20
**Phase:** 0.5 MVP Hardening (ACTIVE)
**Demo Ready:** YES (lokalno / Vercel preview)

---

## UI/Frontend

| Dio | Status | Detalji |
|-----|--------|---------|
| Landing (getdrop.no) | LIVE | 12 stranica, Vercel, responsive |
| Web App | Funkcionalan | Next.js 16, 20 ruta, 53 komponente |
| Login/Register/KYC | Radi | JWT auth, demo user (amir@example.com / demo1234) |
| Dashboard + Send | Radi | Balance, remittance flow, fee breakdown |
| QR Payment + Merchant | Radi | Scanner, merchant dashboard, QR generacija |
| Transactions/Profile | Radi | History, filters, settings |
| Mobile App | Ne radi | Expo skeleton postoji, nije buildano |

## AWS Infrastruktura

| Komponenta | Status | Napomena |
|-----------|--------|----------|
| Terraform IaC | Spreman | VPC, SG, ECR, RDS, Secrets Manager |
| RDS PostgreSQL 16 | Provisioniran | Multi-AZ, 30-day backup, Performance Insights enabled |
| ECR Registry | Postoji | Docker image repo |
| CloudWatch Alarms | Postoje | 5 alarma wired na SNS |
| SNS Alerting | Novo | Email → alem@alai.no (čeka terraform apply) |
| App Runner | Pripremljen | Terraform config postoji, nije deployan |
| Cloudflare DNS | Konfigurisan | getdrop.no pointing |
| Docker | Radi | 3-stage build, CI/CD (lint→test→build→Trivy) |

App NIJE deployan na AWS. Terraform state postoji, RDS provisioniran, ali App Runner još nije pokrenut.

## Demo Readiness

| Flow | Demo? | Kako |
|------|-------|------|
| Landing page | DA | getdrop.no — live |
| Login → Dashboard → Send | DA | localhost:3000 ili Vercel preview |
| QR merchant payment | DA | Desktop browser sa kamerom |
| Real bank transfer | NE | Mock — čeka banking partnera (SB1) |
| BankID login | NE | Mock — čeka developer account |
| Mobile app | NE | Nije buildano |
| KYC verifikacija | NE | Auto-approved u dev mode |

## Blokersi

1. Banking partner — SB1 pitch poslan 16.02, čekamo odgovor
2. BankID — zahtijeva partnera + developer account
3. Apple Developer — enrollment pending (MC #1312)
4. Finanstilsynet licenca — draft pripremljen, nije apliciran

## Compliance Readiness

| Area | % |
|------|---|
| Licensing | 0% |
| PSD2/SCA | 10% |
| AML/KYC | 5% |
| GDPR | 15% |
| ICT Security | 25% |
| Overall | 8/100 |

## Security

Audit 2026-02-12: 0 CRITICAL, 0 HIGH, 2 MEDIUM (acknowledged), 4 LOW.
Hardening completed 2026-02-13.

## Specs & Plans

- Observability (AWS): ~/system/specs/drop-observability-plan.md
- Observability (Azure): ~/system/specs/drop-observability-azure-research.md
- Security audit: ~/ALAI/products/Drop/security/drop-security-rapport.md
- Roadmap: ~/ALAI/products/Drop/ROADMAP.md

# Page Specifications

Per-page UI and feature specifications

# Page: Accounts

# Page Spec: Bank Accounts

## Route
`/accounts`

## Architecture Status
**Core**

## Figma Reference
`accounts.png`

## Visual Description from Figma

The bank accounts page displays all AISP-linked bank accounts via Open Banking:

- **Top bar:** Left side has a back arrow (ArrowLeft, NOT chevron) icon. "Mine kontoer" heading in bold **sans-serif** (NOT serif). No right-side icons.
- **Info card:** Light green/mint-colored rounded card with green shield icon (left side) and Norwegian text:
  - **"Open Banking (PSD2)"** — bold heading
  - "Drop leser saldo fra banken din via BankID-samtykke. Vi lagrer aldri pengene dine — alt går direkte fra din bankkonto." — body text explaining PSD2 pass-through model
- **Bank account cards:** Two white rounded cards, vertically stacked:
  1. **DNB account**
     - Green circular icon with bank/building symbol (left side)
     - **"DNB"** — bold heading
     - **"Primær"** — small green badge/pill with green text and light green background, positioned next to bank name
     - "1234.56.78901" — account number in muted gray text below bank name
     - **"35 200,00 NOK"** — large bold balance amount (right side, top-aligned)
  2. **Nordea account**
     - Green circular icon with bank/building symbol (left side)
     - **"Nordea"** — bold heading (no badge)
     - "9876.54.32100" — account number in muted gray text below bank name
     - **"12 320,50 NOK"** — large bold balance amount (right side, top-aligned)
- **Total balance card:** White rounded card with:
  - "Samlet saldo" — muted gray text on left
  - **"47 520,50 NOK"** — large bold green text on right
- **Add account button:** White rounded card/button with:
  - **"+ Koble til ny bankkonto"** — center-aligned, dark text, plus icon prefix
- **Bottom navigation:** 5 tabs — Hjem (house outline), Send (paper plane outline), Skann (QR outline), Kort (card outline, active/green filled), Profil (person outline). "Kort" tab is active (green).

**NOTE:** Figma shows "Kort" tab active, but this is likely a design artifact. The accounts page should activate the "Profil" tab or no tab (if accounts is accessed from dashboard link).

Background is light gray. Spacing between cards is consistent.

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Back arrow (chevron left)
│   └── Center: "Mine kontoer" heading
├── Info Card (light green bg, green shield icon)
│   ├── "Open Banking (PSD2)" heading
│   └── Explanation text (PSD2 pass-through)
├── Bank Account Cards (white rounded)
│   └── For each account:
│       ├── Bank icon (green circle) + Bank name + Badge (if primary) + Account number
│       └── Balance amount (right-aligned)
├── Total Balance Card (white rounded)
│   ├── "Samlet saldo" label (left)
│   └── Total amount in green (right)
├── Add Account Button (white rounded)
│   └── "+ Koble til ny bankkonto"
└── Bottom Nav (Profil active or none)
```

## Components

### Top Bar
```tsx
<div className="flex items-center px-6 pt-6">
  <button onClick={router.back} className="rounded-lg p-2 hover:bg-white/80">
    <ChevronLeft className="h-5 w-5 text-[#1A1A1A]" />
  </button>
  <h1 className="flex-1 text-center font-[family-name:var(--font-fraunces)] text-xl font-bold text-[#1A1A1A] -ml-12">
    Mine kontoer
  </h1>
</div>
```

### Info Card
```tsx
<div className="rounded-2xl bg-[#0B6E35]/10 p-4 border border-[#0B6E35]/20">
  <div className="flex gap-3">
    <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-[#0B6E35]/20">
      <Shield className="h-5 w-5 text-[#0B6E35]" />
    </div>
    <div>
      <p className="font-semibold text-[#1A1A1A]">Open Banking (PSD2)</p>
      <p className="mt-1 text-sm text-[#6B7280]">
        Drop leser saldo fra banken din via BankID-samtykke. Vi lagrer aldri pengene dine — alt går direkte fra din bankkonto.
      </p>
    </div>
  </div>
</div>
```

### Bank Account Card
```tsx
<div className="flex items-center justify-between rounded-2xl bg-white p-4 shadow-sm">
  <div className="flex items-center gap-3">
    <div className="flex h-12 w-12 items-center justify-center rounded-full bg-[#0B6E35]/10">
      <Landmark className="h-6 w-6 text-[#0B6E35]" />
    </div>
    <div>
      <div className="flex items-center gap-2">
        <p className="text-base font-bold text-[#1A1A1A]">{bankName}</p>
        {isPrimary && (
          <span className="rounded-full bg-[#0B6E35]/10 px-2 py-0.5 text-xs font-medium text-[#0B6E35]">
            Primær
          </span>
        )}
      </div>
      <p className="text-sm text-[#6B7280]">{accountNumber}</p>
    </div>
  </div>
  <p className="text-lg font-bold text-[#1A1A1A]">{formattedBalance} NOK</p>
</div>
```

### Total Balance Card
```tsx
<div className="flex items-center justify-between rounded-2xl bg-white p-4 shadow-sm">
  <p className="text-sm text-[#6B7280]">Samlet saldo</p>
  <p className="text-xl font-bold text-[#0B6E35]">{formattedTotal} NOK</p>
</div>
```

### Add Account Button
```tsx
<button className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-white text-base font-medium text-[#1A1A1A] shadow-sm transition-colors hover:bg-[#F9FAFB]">
  <Plus className="h-5 w-5" />
  Koble til ny bankkonto
</button>
```

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| List of linked bank accounts | AISP (Open Banking) | GET `/api/accounts` |
| Bank name | AISP account metadata | — |
| Account number | AISP account metadata | — |
| Account balance | AISP real-time balance read | — |
| Primary account flag | User preference stored in DB | — |
| Total balance | Calculated sum of all accounts | Client-side calculation |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back to previous page (likely `/dashboard`) |
| Bank account card | Click | Navigate to account detail page (optional) |
| "+ Koble til ny bankkonto" button | Click | Initiate BankID flow to link new account via Open Banking |
| Bottom nav tabs | Click | Navigate to respective page |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page heading | Mine kontoer |
| Info card heading | Open Banking (PSD2) |
| Info card text | Drop leser saldo fra banken din via BankID-samtykke. Vi lagrer aldri pengene dine — alt går direkte fra din bankkonto. |
| Primary badge | Primær |
| Total balance label | Samlet saldo |
| Add account button | Koble til ny bankkonto |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Info card bg | `#0B6E35` at 10% opacity (`bg-[#0B6E35]/10`) |
| Info card border | `#0B6E35` at 20% opacity (`border-[#0B6E35]/20`) |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Button radius | `rounded-2xl` |
| Icon circle radius | `rounded-full` |
| Bank icon size | `h-12 w-12` |
| Shield icon size | `h-10 w-10` |

## Bottom Navigation
**Yes** — "Kort" tab shown as active in Figma (green, filled card icon), but this is likely a design artifact. In production, either "Profil" tab should be active (if accounts is part of profile section) or no tab should be highlighted (if accessed from dashboard link).

# Page: Dashboard

# Page Spec: Dashboard

## Route
`/dashboard`

## Architecture Status
**Core**

## Figma Reference
`dashboard.png`

## Visual Description from Figma

The dashboard is the main home screen after login:

- **Top bar:** Left side has a small Drop icon (green, simplified) + "drop" wordmark in bold serif. Right side has three icons: bell (notifications), a logout/share icon (arrow pointing out of box), and user avatar circle (gray circle with "A" initial).
- **Balance card:** White rounded card with:
  - "Hei, Amir!" greeting in muted text
  - **"kr 47 520,50"** — large bold balance amount
  - "DNB . 1234.56.78901" — bank name and partial account number
  - "Saldo lest fra din bankkonto via Open Banking" — small muted text explaining PSD2
  - Green building/bank icon + "2 kontoer tilkoblet" green link text
- **Action buttons row:** Two side-by-side buttons:
  - **"Send penger"** — dark green bg, white text, paper plane icon (Send/navigation style). Takes about 45% width.
  - **"Skann QR"** — white bg with border, dark text, QR code icon. Takes about 55% width.
- **Recent transactions section:**
  - "Siste transaksjoner" bold heading with "Se alle" green link on the right
  - Three transaction cards, each in its own white rounded card:
    1. **Kenan Basic** — "Overforing" subtitle, green paper plane icon in green circle, "-2 500 NOK" amount, green "fullfort" badge
    2. **Rema 1000** — "QR-betaling" subtitle, yellow QR icon in yellow circle, "-189 NOK" amount, green "fullfort" badge
    3. **Mirza Hadzic** — "Overforing" subtitle, green paper plane icon, "-5 000 NOK" amount, yellow/orange "behandler" badge
- **Bottom navigation:** 5 tabs — Hjem (active, green, filled house icon), Send (paper plane outline), Skann (QR outline), Kort (card outline), Profil (person outline). All inactive tabs are gray.

Background is light gray. Large empty space between transactions and bottom nav.

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Drop icon (small) + "drop" wordmark
│   └── Right: Bell icon, Logout icon, User avatar (initials)
├── Balance Card (white rounded)
│   ├── "Hei, {name}!" greeting
│   ├── Balance amount (large bold)
│   ├── Bank name + account number
│   ├── PSD2 explanation text
│   └── "{n} kontoer tilkoblet" link
├── Action Buttons Row
│   ├── "Send penger" (green, paper plane icon)
│   └── "Skann QR" (outlined, QR icon)
├── Recent Transactions Section
│   ├── "Siste transaksjoner" heading + "Se alle" link
│   └── Transaction cards (up to 3)
│       ├── Icon (type-based) + Name + Type subtitle
│       └── Amount + Status badge
└── Bottom Nav (Hjem active)
```

## Components

### Top Bar
```tsx
<div className="flex items-center justify-between px-6 pt-6">
  <div className="flex items-center gap-2">
    <Image src="/drop-icon.png" alt="Drop" width={28} height={28} />
    <span className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-[#1A1A1A]">drop</span>
  </div>
  <div className="flex items-center gap-3">
    <button className="rounded-lg p-2 hover:bg-white/80">
      <Bell className="h-5 w-5 text-[#6B7280]" />
    </button>
    <button className="rounded-lg p-2 hover:bg-white/80">
      <LogOut className="h-5 w-5 text-[#6B7280]" />
    </button>
    <div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#E5E7EB] text-sm font-semibold text-[#6B7280]">
      {initials}
    </div>
  </div>
</div>
```

### Balance Card
```tsx
<div className="rounded-2xl bg-white p-6 shadow-sm">
  <p className="text-sm text-[#6B7280]">Hei, {firstName}!</p>
  <p className="mt-1 text-2xl font-bold text-[#1A1A1A]">kr {formattedBalance}</p>
  <p className="mt-1 text-xs text-[#6B7280]">{bankName} . {accountNumber}</p>
  <p className="text-xs text-[#9CA3AF]">Saldo lest fra din bankkonto via Open Banking</p>
  <div className="mt-3 flex items-center gap-1.5">
    <Landmark className="h-4 w-4 text-[#0B6E35]" />
    <Link href="/accounts" className="text-sm font-medium text-[#0B6E35] hover:underline">{accountCount} kontoer tilkoblet</Link>
  </div>
</div>
```

### Action Buttons Row
```tsx
<div className="flex gap-3">
  <Link href="/send" className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl bg-[#0B6E35] text-sm font-semibold text-white hover:bg-[#095C2C] transition-colors">
    <Send className="h-4 w-4" />
    Send penger
  </Link>
  <Link href="/scan" className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl border border-[#E5E7EB] bg-white text-sm font-medium text-[#1A1A1A] transition-colors hover:bg-[#F9FAFB]">
    <QrCode className="h-4 w-4" />
    Skann QR
  </Link>
</div>
```

### Recent Transactions Section
```tsx
<div>
  <div className="flex items-center justify-between">
    <h2 className="text-lg font-bold text-[#1A1A1A]">Siste transaksjoner</h2>
    <Link href="/transactions" className="text-sm font-medium text-[#0B6E35] hover:underline">Se alle</Link>
  </div>
  <div className="mt-3 space-y-3">
    {/* transaction cards */}
  </div>
</div>
```

### Transaction Card
```tsx
<div className="flex items-center justify-between rounded-2xl bg-white p-4 shadow-sm">
  <div className="flex items-center gap-3">
    <div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#0B6E35]/10">
      {type === 'transfer' ? <Send className="h-5 w-5 text-[#0B6E35]" /> : <QrCode className="h-5 w-5 text-[#D4A017]" />}
    </div>
    <div>
      <p className="text-sm font-semibold text-[#1A1A1A]">{counterparty}</p>
      <p className="text-xs text-[#6B7280]">{typeLabel}</p>
    </div>
  </div>
  <div className="text-right">
    <p className="text-sm font-semibold text-[#1A1A1A]">{formattedAmount} NOK</p>
    <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClasses}`}>{statusLabel}</span>
  </div>
</div>
```

### Status Badges
| Status | Label | Classes |
|--------|-------|---------|
| completed | fullfort | `bg-[#16A34A]/10 text-[#16A34A]` |
| pending | behandler | `bg-[#D97706]/10 text-[#D97706]` |
| failed | feilet | `bg-[#EF4444]/10 text-[#EF4444]` |

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| User name | JWT / user profile | GET `/api/account` |
| Balance | AISP (primary bank account) | GET `/api/account` |
| Bank name + account number | AISP linked account | GET `/api/account` |
| Connected accounts count | AISP | GET `/api/account` |
| Last 3 transactions | Transaction history | GET `/api/transactions?limit=3` |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Bell icon | Click | Navigate to `/notifications` |
| Logout icon | Click | Clear JWT cookie, redirect to `/login` |
| User avatar | Click | Navigate to `/profile` |
| "2 kontoer tilkoblet" link | Click | Navigate to `/accounts` |
| "Send penger" button | Click | Navigate to `/send` |
| "Skann QR" button | Click | Navigate to `/scan` |
| "Se alle" link | Click | Navigate to `/transactions` |
| Transaction card | Click | Navigate to transaction detail (optional) |
| Bottom nav tabs | Click | Navigate to respective page |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Greeting | Hei, {name}! |
| PSD2 note | Saldo lest fra din bankkonto via Open Banking |
| Accounts link | {n} kontoer tilkoblet |
| Send button | Send penger |
| Scan button | Skann QR |
| Transactions heading | Siste transaksjoner |
| See all link | Se alle |
| Transfer type | Overforing |
| QR payment type | QR-betaling |
| Status: completed | fullfort |
| Status: pending | behandler |
| Status: failed | feilet |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Accent/Gold | `#D4A017` |
| Text primary | `#1A1A1A` |
| Text body | `#374151` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Success | `#16A34A` |
| Warning | `#D97706` |
| Error | `#EF4444` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Button radius | `rounded-xl` |
| Icon circle radius | `rounded-full` |
| Avatar size | `h-9 w-9` |

## Bottom Navigation
**Yes** — "Hjem" tab active (green, filled house icon). All other tabs inactive (gray, outline icons).

# Page: Landing

# Page Spec: Landing

## Route
`/`

## Architecture Status
**Core**

## Figma Reference
**NO FIGMA — design needed.** Designed from architecture document + design system reference.

## Visual Description (Design Direction)
No Figma screenshot exists. This is a public marketing/landing page for unauthenticated users. It should follow the design system's visual language (green primary, white cards, DM Sans body font, Fraunces for brand text) and present Drop's value proposition: simple remittance and QR payments for everyone in Norway/Scandinavia. Desktop max-width 1200px, responsive down to 375px mobile.

## Page Layout

```
Full-width page (no bottom nav, public page)
├── Hero Section
│   ├── Drop logo (green square + $ icon + gold dot)
│   ├── "drop" wordmark (Fraunces serif bold)
│   ├── Tagline: "Enklere betalinger. Lavere gebyrer."
│   ├── Subtitle text explaining value proposition
│   ├── CTA: "Kom i gang" → /register
│   └── Secondary CTA: "Logg inn" → /login
├── Hero Section (with ambient glow background)
│   ├── Drop logo (green square + $ icon + gold dot)
│   ├── "drop" wordmark (Fraunces serif bold)
│   ├── Headline: "Enklere betalinger. Lavere gebyrer." (split, second line green)
│   ├── Subtitle text explaining value proposition
│   ├── Primary CTA: "Opprett konto — gratis" → /register
│   ├── Secondary CTA: "Logg inn" → /login
│   └── Stats card (3 columns): "0,5%" gebyr, "<2t" leveringstid, "30+" land
├── Features Section
│   ├── Heading: "Alt du trenger i en app"
│   ├── Feature card 1: Send penger (remittance icon + description)
│   ├── Feature card 2: Betal med QR (QR icon + description)
│   └── Feature card 3: Virtuelt kort (card icon + description) **NOT "Bankkontoer"**
├── Trust Section
│   ├── BankID-verifisert badge (shield icon)
│   ├── Rask overføring badge (fast transfer icon)
│   └── 30+ land badge (corridors icon)
├── Merchant CTA Section
│   ├── "Er du butikkeier?" heading
│   ├── Description: "Ta imot betalinger via QR. 1% gebyr..."
│   └── "Kom i gang" button
└── Footer
    └── "Drop — et produkt av ALAI Holding AS" copyright
```

## Components

### Hero Section
```
<div className="flex min-h-screen flex-col items-center justify-center bg-[#EEEEEE] px-6">
  <div className="w-full max-w-sm space-y-6 text-center">
    <Image src="/drop-icon.png" alt="Drop" width={80} height={80} />
    <h1 className="font-[family-name:var(--font-fraunces)] text-3xl font-bold text-[#1A1A1A]">drop</h1>
    <p className="text-sm italic text-[#0B6E35]">Enklere betalinger. Lavere gebyrer.</p>
    <!-- CTA buttons -->
  </div>
</div>
```

### Feature Cards
```
<div className="rounded-2xl bg-white p-6 shadow-sm">
  <Icon className="h-8 w-8 text-[#0B6E35] mb-3" />
  <h3 className="text-lg font-bold text-[#1A1A1A]">Feature Title</h3>
  <p className="text-sm text-[#374151]">Description</p>
</div>
```

### Hero Headline
```
<h1 className="font-[family-name:var(--font-fraunces)] text-5xl font-extrabold tracking-tight mb-4 leading-[1.08] text-[#1A1A1A]">
  Enklere betalinger.
  <br />
  <span className="text-[#0B6E35]">Lavere gebyrer.</span>
</h1>
```

### Primary CTA Button
```
<Link href="/register" className="group flex h-14 items-center justify-center gap-2 rounded-xl bg-[#0B6E35] text-white font-semibold text-base hover:bg-[#095C2C]">
  Opprett konto — gratis
  <ArrowRight className="h-4 w-4 group-hover:translate-x-0.5" />
</Link>
```

### Secondary CTA
```
<Link href="/login" className="flex h-14 items-center justify-center rounded-xl border border-[#E5E7EB] bg-white text-[#1A1A1A] font-semibold text-base hover:bg-[#F9FAFB]">
  Logg inn
</Link>
```

### Stats Card
```
<div className="grid grid-cols-3 gap-4 rounded-2xl bg-white border border-[#E5E7EB] shadow-sm p-5">
  <div className="text-center">
    <p className="text-2xl font-bold text-[#0B6E35]">0,5%</p>
    <p className="text-xs text-[#6B7280] mt-1">Gebyr</p>
  </div>
  <!-- repeat for other stats -->
</div>
```

## Data Displayed
- Static marketing content only
- No API calls
- No authentication required

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| "Opprett konto — gratis" button | Click | Navigate to `/register` |
| "Logg inn" button | Click | Navigate to `/login` |
| Merchant "Kom i gang" button | Click | **NOT IMPLEMENTED** (placeholder, href="#") |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Brand wordmark | drop |
| Hero headline line 1 | Enklere betalinger. |
| Hero headline line 2 | Lavere gebyrer. |
| Hero subtitle | Send penger, betal med QR, administrer kort — alt på ett sted. Laget for alle i Skandinavia. |
| Primary CTA | Opprett konto — gratis |
| Secondary CTA | Logg inn |
| Stats label 1 | Gebyr |
| Stats label 2 | Leveringstid |
| Stats label 3 | Land |
| Features heading | Alt du trenger i en app |
| Feature 1 title | Send penger |
| Feature 1 desc | Overfør penger internasjonalt. 0,5% gebyr — billigere enn banken. |
| Feature 2 title | Betal med QR |
| Feature 2 desc | Skann og betal i butikken. Raskere og billigere enn kort. |
| Feature 3 title | Virtuelt kort |
| Feature 3 desc | Få et virtuelt kort for netthandel. Bestill fysisk kort hjem. |
| Trust badge 1 | BankID-verifisert |
| Trust badge 2 | Rask overføring |
| Trust badge 3 | 30+ land |
| Merchant CTA heading | Er du butikkeier? |
| Merchant CTA desc | Ta imot betalinger via QR. 1% gebyr — billigere enn kortterminalen. |
| Merchant CTA button | Kom i gang |
| Footer copyright | Drop — et produkt av ALAI Holding AS |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Text primary | `#1A1A1A` |
| Text body | `#374151` |
| Text muted | `#6B7280` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Body font | DM Sans (default) |
| Card radius | `rounded-2xl` |
| Button radius | `rounded-xl` |
| Button height | `h-12` |
| Shadow | `shadow-sm` |

## Bottom Navigation
**No** — this is a public page, no bottom nav.

# Page: Login

# Page Spec: Login

## Route
`/login`

## Architecture Status
**Core**

## Figma Reference
`login.png`

## Visual Description from Figma

The login page shows:

- **Top section:** Drop logo — green rounded square with white dollar-sign icon (with circular arrows around it) and a small gold/orange dot on the top-right corner of the icon. Below the icon: "drop" in bold serif font (Fraunces). Below that: "Enklere betalinger. Lavere gebyrer." in green italic text.
- **Form card:** White rounded card with subtle shadow containing:
  1. **E-postadresse** — label above, input field with mail/envelope icon on the left. Placeholder: "navn@eksempel.no". Field has light gray background with rounded corners and subtle border.
  2. **Passord** — label above, input field with lock icon on the left and an eye/visibility toggle icon on the right. Shows dots (password masked). Same styling as email field.
  3. **"Glemt passord?"** — right-aligned green link text below the password field.
  4. **"Logg inn"** button — full width, dark green background (`#0B6E35`), white text, rounded corners. Has a right arrow icon next to the text.
  5. **Divider:** **NOT IMPLEMENTED** (claimed in Figma spec but missing from code)
  6. **Social login buttons:** **NOT IMPLEMENTED** (BankID + Vipps claimed in Figma spec but missing from code)
- **Bottom text:** "Har du ikke konto? Opprett konto" — "Opprett konto" is a green bold link.

Background is light gray. The entire form is centered vertically and horizontally on the page.

## Page Layout

```
Full-page centered (no bottom nav — auth page)
├── Logo Section (centered)
│   ├── Drop icon (64x64)
│   ├── "drop" wordmark (Fraunces serif bold)
│   └── Tagline italic green
├── Form Card (white rounded)
│   ├── E-postadresse input (mail icon)
│   ├── Passord input (lock icon + eye toggle)
│   ├── "Glemt passord?" link (right-aligned) **NOT IMPLEMENTED** (href="#")
│   └── "Logg inn" button (green, full width, arrow icon)
└── "Har du ikke konto? Opprett konto" link
```

## Components

### Logo Block
```tsx
<div className="flex flex-col items-center space-y-2">
  <Image src="/drop-icon.png" alt="Drop" width={64} height={64} />
  <h1 className="font-[family-name:var(--font-fraunces)] text-3xl font-bold text-[#1A1A1A]">drop</h1>
  <p className="text-sm italic text-[#0B6E35]">Enklere betalinger. Lavere gebyrer.</p>
</div>
```

### Form Card
```tsx
<div className="rounded-2xl bg-white p-6 shadow-sm space-y-4">
  {/* form fields + buttons */}
</div>
```

### Email Input
```tsx
<div>
  <label className="mb-1.5 block text-sm font-medium text-[#1A1A1A]">E-postadresse</label>
  <div className="relative">
    <Mail className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
    <input type="email" className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-3 text-sm outline-none transition-colors focus:border-[#0B6E35] focus:ring-1 focus:ring-[#0B6E35]" placeholder="navn@eksempel.no" />
  </div>
</div>
```

### Password Input
```tsx
<div>
  <label className="mb-1.5 block text-sm font-medium text-[#1A1A1A]">Passord</label>
  <div className="relative">
    <Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
    <input type={showPassword ? "text" : "password"} className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-10 text-sm outline-none transition-colors focus:border-[#0B6E35] focus:ring-1 focus:ring-[#0B6E35]" placeholder="........" />
    <button className="absolute right-3 top-1/2 -translate-y-1/2">
      <Eye className="h-4 w-4 text-[#9CA3AF]" />
    </button>
  </div>
  <div className="mt-1 text-right">
    <Link href="/forgot-password" className="text-xs font-medium text-[#0B6E35] hover:underline">Glemt passord?</Link>
  </div>
</div>
```

### Login Button
```tsx
<Button className="h-12 w-full rounded-xl bg-[#0B6E35] text-white text-sm font-semibold hover:bg-[#095C2C] transition-colors flex items-center justify-center gap-2">
  Logg inn
  <ArrowRight className="h-4 w-4" />
</Button>
```

### Divider
```tsx
<div className="flex items-center gap-3">
  <div className="h-px flex-1 bg-[#D1D5DB]" />
  <span className="text-xs font-medium tracking-wide text-[#9CA3AF]">ELLER LOGG INN MED</span>
  <div className="h-px flex-1 bg-[#D1D5DB]" />
</div>
```

### Social Login Buttons
```tsx
<div className="flex gap-3">
  <button className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl border border-[#E5E7EB] bg-white text-sm font-medium text-[#1A1A1A] transition-colors hover:bg-[#F9FAFB]">
    <Image src="/bankid-icon.png" alt="BankID" width={20} height={20} />
    BankID
  </button>
  <button className="flex h-12 flex-1 items-center justify-center gap-2 rounded-xl border border-[#E5E7EB] bg-white text-sm font-medium text-[#1A1A1A] transition-colors hover:bg-[#F9FAFB]">
    <Image src="/vipps-icon.png" alt="Vipps" width={20} height={20} />
    Vipps
  </button>
</div>
```

### Register Link
```tsx
<p className="text-center text-sm text-[#6B7280]">
  Har du ikke konto? <Link href="/register" className="font-semibold text-[#0B6E35] hover:underline">Opprett konto</Link>
</p>
```

### Error Message
```tsx
<p className="text-sm text-[#EF4444] bg-[#EF4444]/10 rounded-md p-2">{error}</p>
```

## Data Displayed
- Static form (no pre-loaded data)
- On submit: POST `/api/auth/login` with email + password
- On success: JWT set in httpOnly cookie, redirect to `/dashboard`

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| E-postadresse input | Type | Captures email |
| Passord input | Type | Captures password |
| Eye icon | Click | Toggle password visibility |
| "Glemt passord?" link | Click | Navigate to forgot password flow |
| "Logg inn" button | Click | POST /api/auth/login, on success redirect to /dashboard |
| BankID button | Click | Initiate BankID auth flow (research ongoing) |
| Vipps button | Click | Initiate Vipps auth flow (research ongoing) |
| "Opprett konto" link | Click | Navigate to /register |

### Validation Rules
| Field | Rule | Error Message |
|-------|------|---------------|
| E-postadresse | Required, valid email | Ugyldig e-postadresse |
| Passord | Required, min 1 char | Passord er paakrevd |
| Auth failure | Invalid credentials | Feil e-postadresse eller passord |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Email label | E-postadresse |
| Email placeholder | navn@eksempel.no |
| Password label | Passord |
| Forgot password link | Glemt passord? |
| Login button | Logg inn |
| Divider text | ELLER LOGG INN MED |
| BankID button | BankID |
| Vipps button | Vipps |
| Register prompt | Har du ikke konto? |
| Register link | Opprett konto |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Input bg | `#F9FAFB` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Border | `#E5E7EB` |
| Divider | `#D1D5DB` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Error | `#EF4444` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Input radius | `rounded-lg` |
| Button radius | `rounded-xl` |
| Input height | `h-11` |
| Button height | `h-12` |

## Bottom Navigation
**No** — this is an auth page, no bottom nav.

# Page: Notifications

# Page Spec: Notifications

## Route
`/notifications`

## Architecture Status
**Core**

## Figma Reference
**No Figma — designed from architecture**

## Visual Description

The notifications page displays transaction alerts, system messages, and payment confirmations:

- **Top bar:** Left side has a back arrow (ArrowLeft, NOT chevron). "Varsler" heading in bold **sans-serif** (NOT serif). No right-side icons.
- **Notification list:** Vertically stacked notification cards, each in its own white rounded card:
  - **Unread notifications:** Bold title text with a green dot indicator (left edge)
  - **Read notifications:** Regular title text without indicator
  - Each card contains:
    - **Icon:** Type-based icon in colored circle (green for transfers, yellow for QR payments, blue for system messages)
    - **Title:** Bold notification heading
    - **Description:** Muted text with details
    - **Timestamp:** Muted text showing relative time ("2 timer siden", "I går 14:30")
- **Empty state:** When no notifications exist:
  - Large bell icon with slash (gray, centered)
  - "Ingen varsler" heading
  - "Du vil motta varsler om transaksjoner og viktige meldinger her" description text
- **Bottom navigation:** 5 tabs — Hjem (house outline, gray), Send (paper plane outline, gray), Skann (QR outline, gray), Kort (card outline, gray), Profil (person outline, gray). No tab highlighted as active.

Background is light gray (#EEEEEE). All cards are white with rounded corners (#FFFFFF, rounded-2xl). Unread notifications appear at the top.

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Back arrow (chevron left)
│   └── Center: "Varsler" heading
├── Notification List (or Empty State)
│   └── Notification cards (sorted by date, unread first)
│       ├── Unread indicator (green dot)
│       ├── Icon (type-based color)
│       ├── Title (bold if unread)
│       ├── Description (muted text)
│       └── Timestamp (relative time)
└── Bottom Nav (no active tab)
```

## Components

### Top Bar
```tsx
<div className="flex items-center gap-3 px-6 pt-6">
  <Link href="/dashboard">
    <button className="rounded-lg p-2 hover:bg-white/80">
      <ArrowLeft className="h-5 w-5 text-[#6B7280]" />
    </button>
  </Link>
  <h1 className="text-xl font-bold text-[#1A1A1A]">
    Varsler
  </h1>
</div>
```

### Notification Card (Unread)
```tsx
<div
  onClick={() => markAsRead(notification.id)}
  className="relative flex items-start gap-3 rounded-2xl bg-white p-4 shadow-sm cursor-pointer hover:bg-[#F9FAFB] transition-colors"
>
  {/* Unread indicator dot */}
  <div className="absolute left-0 top-1/2 -translate-y-1/2 h-2 w-2 rounded-full bg-[#0B6E35]" />

  {/* Icon */}
  <div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconBgColor}`}>
    {getNotificationIcon(notification.type)}
  </div>

  {/* Content */}
  <div className="flex-1">
    <p className="text-sm font-bold text-[#1A1A1A]">{notification.title}</p>
    <p className="text-xs text-[#6B7280] mt-0.5">{notification.description}</p>
    <p className="text-xs text-[#9CA3AF] mt-1">{formatTimestamp(notification.created_at)}</p>
  </div>
</div>
```

### Notification Card (Read)
```tsx
<div
  className="flex items-start gap-3 rounded-2xl bg-white p-4 shadow-sm"
>
  {/* Icon */}
  <div className={`flex h-10 w-10 items-center justify-center rounded-full ${iconBgColor}`}>
    {getNotificationIcon(notification.type)}
  </div>

  {/* Content */}
  <div className="flex-1">
    <p className="text-sm font-semibold text-[#1A1A1A]">{notification.title}</p>
    <p className="text-xs text-[#6B7280] mt-0.5">{notification.description}</p>
    <p className="text-xs text-[#9CA3AF] mt-1">{formatTimestamp(notification.created_at)}</p>
  </div>
</div>
```

### Empty State
```tsx
<div className="flex flex-col items-center justify-center px-6 py-16">
  <div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#E5E7EB]">
    <BellOff className="h-8 w-8 text-[#9CA3AF]" />
  </div>
  <p className="mt-4 text-lg font-bold text-[#1A1A1A]">Ingen varsler</p>
  <p className="mt-2 text-center text-sm text-[#6B7280] max-w-xs">
    Du vil motta varsler om transaksjoner og viktige meldinger her
  </p>
</div>
```

### Notification Icon Logic
```tsx
function getNotificationIcon(type: string) {
  switch (type) {
    case 'transaction_sent':
    case 'transaction_received':
      return <Send className="h-5 w-5 text-[#0B6E35]" />;
    case 'qr_payment':
      return <QrCode className="h-5 w-5 text-[#D4A017]" />;
    case 'system_message':
    case 'account_linked':
    case 'security_alert':
      return <AlertCircle className="h-5 w-5 text-[#3B82F6]" />;
    default:
      return <Bell className="h-5 w-5 text-[#6B7280]" />;
  }
}

function getIconBgColor(type: string) {
  switch (type) {
    case 'transaction_sent':
    case 'transaction_received':
      return 'bg-[#0B6E35]/10';
    case 'qr_payment':
      return 'bg-[#D4A017]/10';
    case 'system_message':
    case 'account_linked':
    case 'security_alert':
      return 'bg-[#3B82F6]/10';
    default:
      return 'bg-[#E5E7EB]';
  }
}
```

### Timestamp Formatter
```tsx
function formatTimestamp(timestamp: string): string {
  const now = new Date();
  const then = new Date(timestamp);
  const diffMs = now.getTime() - then.getTime();
  const diffMins = Math.floor(diffMs / 60000);
  const diffHours = Math.floor(diffMs / 3600000);
  const diffDays = Math.floor(diffMs / 86400000);

  if (diffMins < 1) return 'Akkurat nå';
  if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? 'minutt' : 'minutter'} siden`;
  if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? 'time' : 'timer'} siden`;
  if (diffDays === 1) return `I går ${then.toLocaleTimeString('nb-NO', { hour: '2-digit', minute: '2-digit' })}`;
  if (diffDays < 7) return `${diffDays} dager siden`;

  return then.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
```

## Data Displayed

| Data | Source | API |
|------|--------|-----|
| Notification list | Notifications table | GET `/api/notifications` |
| Notification title | Notification record | From notifications table |
| Notification description | Notification record | From notifications table |
| Notification type | Notification record | transaction_sent / transaction_received / qr_payment / system_message / account_linked / security_alert |
| Notification read status | Notification record | is_read boolean |
| Notification timestamp | Notification record | created_at |

## User Interactions

| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back to previous page (dashboard) |
| Unread notification card | Click | Mark notification as read, update UI to show read state |
| Read notification card | Click | Optional — navigate to related resource (e.g., transaction detail) |
| Bottom nav tabs | Click | Navigate to respective page |

## Norwegian Labels

| Element | Norwegian Text |
|---------|---------------|
| Page heading | Varsler |
| Empty state heading | Ingen varsler |
| Empty state description | Du vil motta varsler om transaksjoner og viktige meldinger her |
| Timestamp: Now | Akkurat nå |
| Timestamp: Minutes | {n} minutt siden / {n} minutter siden |
| Timestamp: Hours | {n} time siden / {n} timer siden |
| Timestamp: Yesterday | I går {time} |
| Timestamp: Days | {n} dager siden |
| Notification: Transaction sent | Overføring sendt |
| Notification: Transaction received | Penger mottatt |
| Notification: QR Payment | QR-betaling fullført |
| Notification: System message | Systemmelding |
| Notification: Account linked | Bankkonto tilkoblet |
| Notification: Security alert | Sikkerhetsvarsel |

## Design Tokens

| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Accent/Gold | `#D4A017` |
| Info blue | `#3B82F6` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Unread indicator | `#0B6E35` (green dot) |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Icon circle radius | `rounded-full` |
| Hover bg | `#F9FAFB` |

## Bottom Navigation

**Yes** — No tab active (all tabs gray, outline icons).

# Page: Profile

# Page Spec: Profile (Settings)

## Route
`/profile`

## Architecture Status
**Core**

## Figma Reference
`profile.png`

## Visual Description from Figma

The profile/settings page is the user account management screen:

- **Top bar:** Left side has a back arrow (ArrowLeft, NOT chevron) in muted gray. "Profil" heading in bold **sans-serif** font (NOT serif). Right side is empty. Background is light gray (#EEEEEE).
- **User card:** White rounded card at top with:
  - Circular avatar with light green background (#0B6E35/10 = 10% opacity green)
  - Initials "AH" in dark green (#0B6E35) centered in the avatar circle
  - User name "Amir Hadžić" in bold black **LEFT-ALIGNED** (NOT centered) next to avatar
  - Email "amir@eksempel.no" in muted gray below name, **LEFT-ALIGNED** (NOT centered)
  - **LAYOUT:** Avatar and text are in horizontal flexbox (NOT centered vertical stack)
- **Menu card:** White rounded card containing 5 menu items:
  1. **"Mine kontoer"** — building/bank icon (outlined, dark gray) on left, chevron right on right side
  2. **"Varsler"** — bell icon (outlined, dark gray) on left, chevron right on right side
  3. **"Innstillinger"** — gear/cog icon (outlined, dark gray) on left, chevron right on right side
  4. **"Sikkerhet"** — shield icon (outlined, dark gray) on left, chevron right on right side
  5. **"Hjelp og støtte"** — question mark in circle icon (outlined, dark gray) on left, chevron right on right side
  - Each menu item has padding, separated by subtle divider lines
  - Icons are consistent size (approximately 20-24px)
  - Text is medium weight, dark black
  - Chevron right icons are muted gray
- **Logout button:** Separate white rounded button with:
  - "Logg ut" text in red (#EF4444)
  - Logout/exit icon (LogOut component) **inline with text** (NOT on left with text to right)
  - **2px solid red border** (border-2 border-[#EF4444])
  - White background, hover changes to light red (#FEF2F2)
- **Footer text:** **NOT IMPLEMENTED** (claimed in spec but missing from code)
- **Bottom navigation:** 5 tabs — Hjem (house outline), Send (paper plane outline), Skann (QR outline), Kort (card outline), Profil (active, green, filled person icon). All inactive tabs are gray.

Background is light gray (#EEEEEE). Spacing between cards is consistent.

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Back arrow
│   ├── Center: "Profil" heading
│   └── Right: Empty
├── User Card (white rounded, centered content)
│   ├── Avatar circle (initials, green on light green bg)
│   ├── User full name (bold)
│   └── Email address (muted)
├── Menu Card (white rounded)
│   ├── "Mine kontoer" (bank icon + chevron)
│   ├── "Varsler" (bell icon + chevron)
│   ├── "Innstillinger" (gear icon + chevron)
│   ├── "Sikkerhet" (shield icon + chevron)
│   └── "Hjelp og støtte" (question icon + chevron)
├── Logout Button Card (white rounded, red text)
│   └── "Logg ut" (logout icon)
├── Footer Text
│   └── "Drop v0.1.0 · ALAI Holding AS"
└── Bottom Nav (Profil active)
```

## Components

### Top Bar
```tsx
<div className="flex items-center gap-3 px-6 pt-6">
  <Link href="/dashboard">
    <button className="rounded-lg p-2 hover:bg-white/80">
      <ArrowLeft className="h-5 w-5 text-[#6B7280]" />
    </button>
  </Link>
  <h1 className="text-xl font-bold text-[#1A1A1A]">Profil</h1>
</div>
```

### User Card
```tsx
<div className="rounded-2xl bg-white p-6 shadow-sm">
  <div className="flex items-center gap-4">
    <div className="h-14 w-14 rounded-full bg-[#0B6E35]/10 flex items-center justify-center text-xl font-bold text-[#0B6E35]">
      {user.firstName.charAt(0)}{user.lastName.charAt(0)}
    </div>
    <div>
      <p className="font-semibold text-lg text-[#1A1A1A]">{user.firstName} {user.lastName}</p>
      <p className="text-sm text-[#6B7280]">{user.email}</p>
    </div>
  </div>
</div>
```

### Menu Card
```tsx
<div className="rounded-2xl bg-white shadow-sm overflow-hidden">
  <MenuItem
    icon={<Building2 className="h-5 w-5" />}
    label="Mine kontoer"
    href="/accounts"
  />
  <Divider />
  <MenuItem
    icon={<Bell className="h-5 w-5" />}
    label="Varsler"
    href="/notifications"
  />
  <Divider />
  <MenuItem
    icon={<Settings className="h-5 w-5" />}
    label="Innstillinger"
    href="/settings"
  />
  <Divider />
  <MenuItem
    icon={<Shield className="h-5 w-5" />}
    label="Sikkerhet"
    href="/security"
  />
  <Divider />
  <MenuItem
    icon={<HelpCircle className="h-5 w-5" />}
    label="Hjelp og støtte"
    href="/support"
  />
</div>
```

### Menu Item Component
```tsx
<Link href={href} className="flex items-center justify-between px-5 py-4 hover:bg-[#F9FAFB] transition-colors">
  <div className="flex items-center gap-3">
    <div className="text-[#6B7280]">{icon}</div>
    <span className="text-[15px] font-medium text-[#1A1A1A]">{label}</span>
  </div>
  <ChevronRight className="h-5 w-5 text-[#9CA3AF]" />
</Link>
```

### Divider
```tsx
<div className="h-px bg-[#E5E7EB] mx-5" />
```

### Logout Button
```tsx
<button
  onClick={handleLogout}
  className="w-full rounded-2xl bg-white border border-[#E5E7EB] p-4 shadow-sm hover:bg-[#FEF2F2] transition-colors"
>
  <div className="flex items-center justify-center gap-2">
    <LogOut className="h-5 w-5 text-[#EF4444]" />
    <span className="text-[15px] font-semibold text-[#EF4444]">Logg ut</span>
  </div>
</button>
```

### Footer Text
```tsx
<p className="text-center text-xs text-[#9CA3AF]">
  Drop v0.1.0 · ALAI Holding AS
</p>
```

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| User full name | JWT / user profile | GET `/api/account` |
| User email | User profile | GET `/api/account` |
| User initials | Derived from full name | Client-side |
| App version | Build metadata | Static |
| Company name | Static | Static |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back to previous page |
| Avatar | Click | Optional: navigate to profile edit page |
| "Mine kontoer" | Click | Navigate to `/accounts` |
| "Varsler" | Click | Navigate to `/notifications` |
| "Innstillinger" | Click | Navigate to `/settings` (app preferences) |
| "Sikkerhet" | Click | Navigate to `/security` (PIN, biometrics, 2FA) |
| "Hjelp og støtte" | Click | Navigate to `/support` (FAQs, contact) |
| "Logg ut" button | Click | Clear JWT cookie, redirect to `/login` |
| Bottom nav tabs | Click | Navigate to respective page |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page title | Profil |
| Bank accounts menu | Mine kontoer |
| Notifications menu | Varsler |
| Settings menu | Innstillinger |
| Security menu | Sikkerhet |
| Help menu | Hjelp og støtte |
| Logout button | Logg ut |
| Footer | Drop v0.1.0 · ALAI Holding AS |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Avatar bg | `#D1E9DC` (light green/mint) |
| Avatar text | `#0B6E35` |
| Primary | `#0B6E35` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Danger | `#EF4444` |
| Danger hover bg | `#FEF2F2` |
| Hover bg | `#F9FAFB` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Avatar radius | `rounded-full` |
| Avatar size | `h-20 w-20` |
| Menu item padding | `px-5 py-4` |
| Icon size | `h-5 w-5` |

## Bottom Navigation
**Yes** — "Profil" tab active (green, filled person icon). All other tabs inactive (gray, outline icons).

# Page: Register

# Page Spec: Register

## Route
`/register`

## Architecture Status
**Core**

## Figma Reference
`onboarding.png`

## Implementation Status
**SPEC DOCUMENTS STEP 1 ONLY.** The actual implementation has 4 steps:
1. **Personal Info** (documented below) — name, email, phone, DOB, password
2. **Phone Verification** (NOT DOCUMENTED) — OTP input
3. **PIN Setup** (NOT DOCUMENTED) — 4-digit PIN with number pad
4. **Success Screen** (NOT DOCUMENTED) — welcome message + navigate to dashboard

This spec covers ONLY step 1. Steps 2-4 need separate specs or this spec needs expansion.

## Visual Description from Figma

The register page (called "Opprett konto" in Figma) has step 1 of 3:

- **Top section:** Drop logo (green rounded square with white $ icon and gold dot top-right), "drop" wordmark in bold serif font, tagline "Enklere betalinger. Lavere gebyrer." in green italic text.
- **Progress indicator:** Three-segment horizontal bar below the tagline. First segment is filled dark green (active), other two segments are light gray. This indicates "step 1 of 3" in a multi-step registration.
- **Form card:** White rounded card containing the form with heading "Opprett konto" in bold.
- **Form fields (6 total):**
  1. **Fornavn** and **Etternavn** — side by side in a 2-column layout. Each has a user icon (person outline) on the left. Placeholders: "Amir" / "Hadzic".
  2. **E-postadresse** — full width, mail/envelope icon left. Placeholder: "amir@eksempel.no".
  3. **Telefonnummer** — full width, phone icon left. Placeholder shows "+47" prefix.
  4. **Fodselsdato** — full width, calendar icon left. Placeholder: "mm/dd/yyyy". Has a calendar picker icon on the right.
  5. **Passord** — full width, lock icon left. Placeholder: "Minst 8 tegn". Eye/visibility toggle icon on the right.
- **"Fortsett" button:** Full-width green button below the card (appears slightly muted green, not as saturated as the login button).
- **Bottom text:** "Har du allerede konto? Logg inn" — "Logg inn" is a green link.

Background is light gray (`#EEEEEE`). All input fields have light gray backgrounds with subtle borders.

## Page Layout

```
Full-page centered (no bottom nav — auth page)
├── Logo Section (centered)
│   ├── Drop icon (64x64)
│   ├── "drop" wordmark (Fraunces serif bold)
│   └── Tagline italic green
├── Progress Bar (3 steps)
│   ├── Step 1: filled green (active)
│   ├── Step 2: gray
│   └── Step 3: gray
├── Form Card (white rounded)
│   ├── "Opprett konto" heading
│   ├── Row: Fornavn | Etternavn (2-col)
│   ├── E-postadresse input
│   ├── Telefonnummer input (+47)
│   ├── Fodselsdato input (date picker)
│   └── Passord input (toggle visibility)
├── "Fortsett" button (full width, outside card)
└── "Har du allerede konto? Logg inn" link
```

## Components

### Logo Block
```tsx
<div className="flex flex-col items-center space-y-2">
  <Image src="/drop-icon.png" alt="Drop" width={64} height={64} />
  <h1 className="font-[family-name:var(--font-fraunces)] text-3xl font-bold text-[#1A1A1A]">drop</h1>
  <p className="text-sm italic text-[#0B6E35]">Enklere betalinger. Lavere gebyrer.</p>
</div>
```

### Progress Bar (3-step)
```tsx
<div className="flex gap-2">
  <div className="h-1 flex-1 rounded-full bg-[#0B6E35]" />  {/* active */}
  <div className="h-1 flex-1 rounded-full bg-[#E5E7EB]" />  {/* inactive */}
  <div className="h-1 flex-1 rounded-full bg-[#E5E7EB]" />  {/* inactive */}
</div>
```

### Form Card
```tsx
<div className="rounded-2xl bg-white p-6 shadow-sm">
  <h2 className="mb-4 text-xl font-bold text-[#1A1A1A]">Opprett konto</h2>
  {/* form fields */}
</div>
```

### Name Fields (2-column)
```tsx
<div className="grid grid-cols-2 gap-3">
  <div>
    <label className="mb-1.5 block text-sm font-medium text-[#1A1A1A]">Fornavn</label>
    <div className="relative">
      <User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
      <input className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-3 text-sm" placeholder="Amir" />
    </div>
  </div>
  <div>
    <label className="mb-1.5 block text-sm font-medium text-[#1A1A1A]">Etternavn</label>
    <div className="relative">
      <User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
      <input className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-3 text-sm" placeholder="Hadzic" />
    </div>
  </div>
</div>
```

### Standard Input (email, phone, DOB, password)
```tsx
<div>
  <label className="mb-1.5 block text-sm font-medium text-[#1A1A1A]">{label}</label>
  <div className="relative">
    <Icon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
    <input className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-3 text-sm outline-none transition-colors focus:border-[#0B6E35] focus:ring-1 focus:ring-[#0B6E35]" />
  </div>
</div>
```

### Password Input (with eye toggle)
```tsx
<div className="relative">
  <Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#6B7280]" />
  <input type={showPassword ? "text" : "password"} className="h-11 w-full rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] pl-10 pr-10 text-sm" placeholder="Minst 8 tegn" />
  <button className="absolute right-3 top-1/2 -translate-y-1/2">
    <Eye className="h-4 w-4 text-[#9CA3AF]" />
  </button>
</div>
```

### Submit Button
```tsx
<Button className="h-12 w-full rounded-xl bg-[#0B6E35] text-white text-sm font-semibold hover:bg-[#095C2C] transition-colors">
  Fortsett
</Button>
```

### Error Message
```tsx
<p className="text-sm text-[#EF4444] bg-[#EF4444]/10 rounded-md p-2">{error}</p>
```

## Data Displayed
- Static form (no pre-loaded data)
- On submit: POST `/api/auth/register` with form data

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Fornavn input | Type | Captures first name |
| Etternavn input | Type | Captures last name |
| E-postadresse input | Type | Captures email |
| Telefonnummer input | Type | Captures phone (+47 prefix) |
| Fodselsdato input | Click/type | Opens date picker, captures DOB |
| Passord input | Type | Captures password (min 8 chars) |
| Eye icon | Click | Toggle password visibility |
| "Fortsett" button | Click | Validate all fields, POST /api/auth/register, on success navigate to /login |
| "Logg inn" link | Click | Navigate to /login |

### Validation Rules
| Field | Rule | Error Message |
|-------|------|---------------|
| Fornavn | Required, min 2 chars | Fornavn er paakrevd |
| Etternavn | Required, min 2 chars | Etternavn er paakrevd |
| E-postadresse | Required, valid email format | Ugyldig e-postadresse |
| Telefonnummer | Required, starts with +47, 8 digits | Ugyldig telefonnummer |
| Fodselsdato | Required, age >= 18 | Du ma vaere minst 18 ar |
| Passord | Required, min 8 chars | Passordet ma vaere minst 8 tegn |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page heading | Opprett konto |
| First name label | Fornavn |
| Last name label | Etternavn |
| Email label | E-postadresse |
| Email placeholder | amir@eksempel.no |
| Phone label | Telefonnummer |
| Phone placeholder | +47 |
| DOB label | Fodselsdato |
| DOB placeholder | mm/dd/yyyy |
| Password label | Passord |
| Password placeholder | Minst 8 tegn |
| Submit button | Fortsett |
| Login prompt | Har du allerede konto? |
| Login link | Logg inn |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Input bg | `#F9FAFB` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Border | `#E5E7EB` |
| Progress active | `#0B6E35` |
| Progress inactive | `#E5E7EB` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Error | `#EF4444` |
| Card radius | `rounded-2xl` |
| Input radius | `rounded-lg` |
| Button radius | `rounded-xl` |
| Input height | `h-11` |
| Button height | `h-12` |

## Bottom Navigation
**No** — this is an auth page, no bottom nav.

# Page: Scan

# Page Spec: Scan QR

## Route
`/scan`

## Architecture Status
**Core**

## Figma Reference
`scan.png`

## Visual Description from Figma

The scan page is a full-screen dark camera-style view for QR code scanning:

- **Top bar:** Left side has a white back arrow (`←`). Center has "Scan QR" in white bold text. No right-side elements.
- **Camera viewport:** Full-screen dark background (`#1A1A1A` or near-black) simulating a camera feed.
- **Scanner viewfinder:** Large centered square/rectangle with:
  - Dashed gray border (`#6B7280` approx) forming the full perimeter
  - Four green corner brackets (thick, `#0B6E35`) at each corner — top-left, top-right, bottom-left, bottom-right
  - A horizontal green scanning line across the middle of the viewfinder (animated or static)
- **Instruction text:** Near the bottom, white/light text: "Pek kameraet mot butikkens QR-kode" — muted, centered.
- **Simulate button:** Full-width green button at the very bottom: "Simuler skanning" — dark green background (`#0B6E35`), white text, rounded corners. This is a demo-only button for simulating a QR scan without a real camera.
- **No bottom navigation** — this is a full-screen overlay/modal page.

## Page Layout

```
Full-screen dark (no bottom nav — camera overlay)
├── Top Bar (over dark bg)
│   ├── Left: Back arrow (white)
│   └── Center: "Scan QR" title (white, bold)
├── Camera Viewport (dark bg, full screen)
│   └── Scanner Viewfinder (centered)
│       ├── Dashed border (gray)
│       ├── Green corner brackets (4 corners)
│       └── Green scan line (horizontal, centered)
├── Instruction Text
│   └── "Pek kameraet mot butikkens QR-kode"
└── Simulate Button (demo only)
    └── "Simuler skanning" (green, full width)
```

## Components

### Top Bar
```tsx
<div className="flex items-center justify-between px-6 pt-6">
  <button onClick={() => router.back()} className="rounded-lg p-2 hover:bg-white/10">
    <ArrowLeft className="h-5 w-5 text-white" />
  </button>
  <h1 className="text-lg font-bold text-white">Scan QR</h1>
  <div className="w-9" /> {/* spacer for centering */}
</div>
```

### Scanner Viewfinder
```tsx
<div className="relative mx-auto aspect-square w-[75%] max-w-[320px]">
  {/* Dashed border */}
  <div className="absolute inset-0 rounded-2xl border-2 border-dashed border-[#6B7280]" />

  {/* Green corner brackets — top-left */}
  <div className="absolute left-0 top-0 h-10 w-10 rounded-tl-2xl border-l-4 border-t-4 border-[#0B6E35]" />
  {/* Green corner brackets — top-right */}
  <div className="absolute right-0 top-0 h-10 w-10 rounded-tr-2xl border-r-4 border-t-4 border-[#0B6E35]" />
  {/* Green corner brackets — bottom-left */}
  <div className="absolute bottom-0 left-0 h-10 w-10 rounded-bl-2xl border-b-4 border-l-4 border-[#0B6E35]" />
  {/* Green corner brackets — bottom-right */}
  <div className="absolute bottom-0 right-0 h-10 w-10 rounded-br-2xl border-b-4 border-r-4 border-[#0B6E35]" />

  {/* Scan line (centered horizontal) */}
  <div className="absolute left-4 right-4 top-1/2 h-0.5 -translate-y-1/2 bg-[#0B6E35]" />
</div>
```

### Instruction Text
```tsx
<p className="text-center text-sm text-[#9CA3AF]">
  Pek kameraet mot butikkens QR-kode
</p>
```

### Simulate Button (Demo Only)
```tsx
<button
  onClick={handleSimulateScan}
  className="mx-6 mb-8 h-12 w-[calc(100%-48px)] rounded-xl bg-[#0B6E35] text-sm font-semibold text-white transition-colors hover:bg-[#095C2C]"
>
  Simuler skanning
</button>
```

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| None pre-loaded | — | — |
| On scan success | QR payload (merchant ID, amount) | POST `/api/transactions` (type: qr_payment) |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back (router.back()) |
| Camera viewfinder | Scan QR code | Parse QR payload → navigate to payment confirmation |
| "Simuler skanning" button | Click | Simulate QR scan with mock merchant data → navigate to payment confirmation |

### QR Scan Flow (Demo)
1. User taps "Simuler skanning"
2. App generates mock QR payload: `{ merchantId: "REMA1000-001", merchantName: "Rema 1000", amount: 189 }`
3. Navigate to confirmation screen (inline or modal) showing merchant name + amount
4. User confirms → POST `/api/transactions` with type `qr_payment`, PISP initiates payment from linked bank account
5. On success → redirect to `/dashboard` with success toast

### QR Scan Flow (Production — Future)
1. Camera activates, user points at merchant QR code
2. QR library decodes payload (merchant ID, amount, reference)
3. Same confirmation + PISP flow as above

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page title | Scan QR |
| Instruction | Pek kameraet mot butikkens QR-kode |
| Simulate button | Simuler skanning |
| Confirm prompt | Bekreft betaling |
| Merchant label | Butikk |
| Amount label | Belop |
| Pay button | Betal nå |
| Cancel button | Avbryt |
| Success toast | Betaling fullfort! |
| Error toast | Betaling feilet. Prov igjen. |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#1A1A1A` (dark/camera) |
| Viewfinder border | `border-dashed border-[#6B7280]` |
| Corner brackets | `border-[#0B6E35]` (4px) |
| Scan line | `bg-[#0B6E35]` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Text white | `#FFFFFF` |
| Text muted (on dark) | `#9CA3AF` |
| Button radius | `rounded-xl` |
| Viewfinder radius | `rounded-2xl` |
| Button height | `h-12` |

## Bottom Navigation
**No** — this is a full-screen camera overlay, no bottom nav.

# Page: Send

<!-- FIXES applied (architect-1, iteration 1):
1. Heading font: Fraunces (serif) → sans-serif (text-xl font-bold), Fraunces is logo-only
2. Step indicator position: was right-aligned on same row as heading → moved below heading, centered
3. Back arrow icon: ChevronLeft → ArrowLeft (thin left arrow matches Figma)
4. Search input: removed explicit border, uses bg contrast on gray page
5. "Ny mottaker" button: removed border, uses bg contrast only
6. Design Tokens: removed Brand font Fraunces reference
-->
# Page Spec: Send Money

## Route
`/send`

## Architecture Status
**Core**

## Implementation Status
**SPEC DOCUMENTS STEP 1 ONLY.** The actual implementation has a 4-step flow:
1. **Select Recipient** (documented below) — search recipients, add new
2. **Enter Amount** (NOT DOCUMENTED) — amount input + exchange rate display
3. **Confirm** (NOT DOCUMENTED) — review transfer details, fee breakdown
4. **Success** (NOT DOCUMENTED) — confirmation screen + receipt

This spec covers ONLY step 1. Steps 2-4 need separate specs or this spec needs expansion.

## Figma Reference
`send.png`

## Visual Description from Figma

The Send Money page is the first step in the money transfer flow:

- **Top bar:** Left side has a back arrow (thin left arrow) icon in muted gray. Center has "Send penger" heading in bold sans-serif font. Below the heading, centered, is "1/4" step indicator in muted text.
- **Background:** Light gray (`#EEEEEE`).
- **Search input:** Large white rounded input field with:
  - Magnifying glass icon on the left (gray)
  - Placeholder text "Søk mottakere..." in gray
  - Full width with rounded corners
- **Empty state message:** Centered text below search input:
  - "Ingen mottakere funnet" in muted gray text (`#6B7280`)
  - Indicates no recipients have been added yet
- **Add recipient button:** White rounded button at bottom of content area:
  - "+" icon on left in dark color
  - "Ny mottaker" text in dark color
  - Full width, white rounded, no visible border (contrast against gray bg)
  - Positioned well above bottom navigation
- **Bottom navigation:** Visible but all tabs inactive (no green highlight).

The page has a clean, minimal design with large touch targets and plenty of whitespace.

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Back arrow (chevron)
│   ├── Center: "Send penger" heading
│   └── Right: "1/4" step indicator
├── Content Area (light gray bg)
│   ├── Search Input
│   │   ├── Search icon (left)
│   │   └── "Søk mottakere..." placeholder
│   ├── Empty State
│   │   └── "Ingen mottakere funnet" message
│   └── Add Recipient Button
│       ├── "+" icon
│       └── "Ny mottaker" label
└── Bottom Nav (all tabs inactive)
```

## Components

### Top Bar
```tsx
<div className="flex items-center justify-between px-6 pt-6 pb-4">
  <button onClick={() => router.back()} className="rounded-lg p-2 hover:bg-white/80">
    <ChevronLeft className="h-6 w-6 text-[#1A1A1A]" />
  </button>
  <h1 className="font-[family-name:var(--font-fraunces)] text-xl font-bold text-[#1A1A1A]">Send penger</h1>
  <span className="text-sm text-[#6B7280]">1/4</span>
</div>
```

### Search Input
```tsx
<div className="relative">
  <Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-[#9CA3AF]" />
  <input
    type="text"
    placeholder="Søk mottakere..."
    className="h-14 w-full rounded-2xl border border-[#E5E7EB] bg-white pl-12 pr-4 text-[#1A1A1A] placeholder:text-[#9CA3AF] focus:border-[#0B6E35] focus:outline-none focus:ring-2 focus:ring-[#0B6E35]/20"
    value={searchQuery}
    onChange={(e) => setSearchQuery(e.target.value)}
  />
</div>
```

### Empty State
```tsx
<div className="py-12 text-center">
  <p className="text-sm text-[#6B7280]">Ingen mottakere funnet</p>
</div>
```

### Add Recipient Button
```tsx
<button
  onClick={() => router.push('/send/add-recipient')}
  className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl border border-[#E5E7EB] bg-white text-sm font-medium text-[#1A1A1A] transition-colors hover:bg-[#F9FAFB]"
>
  <Plus className="h-5 w-5" />
  Ny mottaker
</button>
```

### Recipient List (when populated)
```tsx
<div className="space-y-3">
  {recipients.map((recipient) => (
    <button
      key={recipient.id}
      onClick={() => handleSelectRecipient(recipient)}
      className="flex w-full items-center justify-between rounded-2xl bg-white p-4 text-left transition-colors hover:bg-[#F9FAFB]"
    >
      <div className="flex items-center gap-3">
        <div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#0B6E35]/10">
          <User className="h-5 w-5 text-[#0B6E35]" />
        </div>
        <div>
          <p className="text-sm font-semibold text-[#1A1A1A]">{recipient.name}</p>
          <p className="text-xs text-[#6B7280]">{recipient.country} • {recipient.accountNumber}</p>
        </div>
      </div>
      <ChevronRight className="h-5 w-5 text-[#9CA3AF]" />
    </button>
  ))}
</div>
```

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| Saved recipients | User's recipient list | GET `/api/recipients` |
| Search results | Filtered recipient list | Client-side filtering |
| Step indicator | Flow state | Static (1/4) |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back to previous page (dashboard) |
| Search input | Type | Filter recipient list in real-time |
| Add recipient button | Click | Navigate to `/send/add-recipient` (step 2) |
| Recipient card | Click | Select recipient, navigate to amount input (step 3) |
| Bottom nav tabs | Click | Navigate to respective page (exits send flow) |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page title | Send penger |
| Step indicator | 1/4 |
| Search placeholder | Søk mottakere... |
| Empty state | Ingen mottakere funnet |
| Add recipient button | Ny mottaker |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Input bg | `#FFFFFF` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Button radius | `rounded-2xl` |
| Input radius | `rounded-2xl` |
| Avatar/Icon circle | `rounded-full` |
| Icon circle bg | `#0B6E35]/10` (10% opacity green) |

## Bottom Navigation
**Yes** — All tabs inactive (gray, outline icons). User is in the send flow but not on a main tab page.

## Flow Context

This is **step 1 of 4** in the send money flow:
1. **Select Recipient** (this page) — choose from saved recipients or add new
2. **Add Recipient** — enter recipient details (name, country, account)
3. **Enter Amount** — specify amount and currency
4. **Review & Confirm** — review details, initiate PISP payment from user's bank account

**Pass-through model reminder:** Drop initiates payment via PISP from user's linked bank account. No money is held by Drop.

# Page: Transactions

# Page Spec: Transaction History

## Route
`/transactions`

## Architecture Status
**Core**

## Figma Reference
`history.png`

## Visual Description from Figma

The transaction history page shows a complete list of all transactions with filtering:

- **Top bar:** Left side has a back arrow (ArrowLeft, NOT chevron). Center has "Transaksjonshistorikk" heading in bold **sans-serif** (NOT serif). No right-side icons.
- **Filter tabs row:** Three pill-shaped tabs using Tabs/TabsList/TabsTrigger components:
  - **"Alle"** — filter="all", active tab with dark green background (#0B6E35), white text
  - **"Overføringer"** — filter="remittance" (NOT "Overforinger"), inactive gray
  - **"QR-betalinger"** — filter="qr_payment", inactive gray
- **Date-based grouping:** Transactions grouped by labels:
  - **"I dag"** — transactions from today
  - **"I går"** — transactions from yesterday
  - **"Denne uken"** — transactions within last 7 days (NOT "DENNE UKEN" in uppercase)
  - **"Eldre"** — older transactions (NOT in original Figma, but implemented)
- **Transaction cards:** Each card shows:
  - Icon (ArrowUpRight for remittance in green, QrCode for QR payment in gold #D4A017)
  - Recipient name or "Betaling"
  - Type label: "Overføring" or "QR-betaling"
  - Amount with Norwegian number formatting (space as thousand separator)
  - Status badge: "fullført" (green), "behandler" (orange), "feilet" (red)
- **Bottom navigation:** 5 tabs — **ALL INACTIVE** (no active tab shown on this page, contrary to Figma which shows Kort active).

Background is light gray (#EEEEEE). Large empty space below transactions. All cards are white with rounded corners (#FFFFFF, rounded-2xl).

## Page Layout

```
App page WITH bottom nav
├── Top Bar
│   ├── Left: Back arrow (chevron left)
│   └── Center: "Transaksjonshistorikk" heading
├── Filter Tabs Row (white rounded container)
│   ├── "Alle" (active, green bg)
│   ├── "Overforinger" (inactive, white bg)
│   └── "QR-betalinger" (inactive, white bg)
├── Transaction List (grouped by date)
│   ├── Date Separator: "I GAR" (uppercase, muted)
│   ├── Transaction cards (yesterday's transactions)
│   ├── Date Separator: "DENNE UKEN" (uppercase, muted)
│   └── Transaction cards (this week's transactions)
└── Bottom Nav (Kort tab active/highlighted)
```

## Components

### Top Bar
```tsx
<div className="flex items-center justify-center px-6 pt-6 relative">
  <button onClick={() => router.back()} className="absolute left-6 rounded-lg p-2 hover:bg-white/80">
    <ChevronLeft className="h-6 w-6 text-[#1A1A1A]" />
  </button>
  <h1 className="font-[family-name:var(--font-fraunces)] text-xl font-bold text-[#1A1A1A]">
    Transaksjonshistorikk
  </h1>
</div>
```

### Filter Tabs Row
```tsx
<div className="flex gap-2 rounded-2xl bg-white p-1.5 shadow-sm">
  <button
    onClick={() => setFilter('all')}
    className={`flex-1 rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
      filter === 'all'
        ? 'bg-[#0B6E35] text-white'
        : 'bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
    }`}
  >
    Alle
  </button>
  <button
    onClick={() => setFilter('transfers')}
    className={`flex-1 rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
      filter === 'transfers'
        ? 'bg-[#0B6E35] text-white'
        : 'bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
    }`}
  >
    Overforinger
  </button>
  <button
    onClick={() => setFilter('qr')}
    className={`flex-1 rounded-full px-4 py-2 text-sm font-semibold transition-colors ${
      filter === 'qr'
        ? 'bg-[#0B6E35] text-white'
        : 'bg-white text-[#6B7280] hover:bg-[#F9FAFB]'
    }`}
  >
    QR-betalinger
  </button>
</div>
```

### Date Separator
```tsx
<div className="px-6 pt-4 pb-2">
  <p className="text-xs font-semibold uppercase tracking-wider text-[#9CA3AF]">
    {dateLabel}
  </p>
</div>
```

### Transaction Card
```tsx
<div className="flex items-center justify-between rounded-2xl bg-white p-4 shadow-sm">
  <div className="flex items-center gap-3">
    <div className={`flex h-10 w-10 items-center justify-center rounded-full ${
      type === 'transfer' ? 'bg-[#0B6E35]/10' : 'bg-[#D4A017]/10'
    }`}>
      {type === 'transfer'
        ? <Send className="h-5 w-5 text-[#0B6E35]" />
        : <QrCode className="h-5 w-5 text-[#D4A017]" />
      }
    </div>
    <div>
      <p className="text-sm font-semibold text-[#1A1A1A]">{counterparty}</p>
      <p className="text-xs text-[#6B7280]">{typeLabel}</p>
    </div>
  </div>
  <div className="text-right">
    <p className="text-sm font-semibold text-[#1A1A1A]">{formattedAmount} NOK</p>
    <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${statusClasses}`}>
      {statusLabel}
    </span>
  </div>
</div>
```

### Status Badges
| Status | Label | Classes |
|--------|-------|---------|
| completed | fullfort | `bg-[#16A34A]/10 text-[#16A34A]` |
| pending | behandler | `bg-[#D97706]/10 text-[#D97706]` |
| failed | feilet | `bg-[#EF4444]/10 text-[#EF4444]` |

## Data Displayed
| Data | Source | API |
|------|--------|-----|
| All transactions | Transaction history, filtered by selected tab | GET `/api/transactions?filter={all\|transfers\|qr}` |
| Transaction counterparty | Transaction record | From transactions table |
| Transaction type | Transaction record | transfer / qr_payment |
| Transaction amount | Transaction record | Negative for outgoing |
| Transaction status | Transaction record | completed / pending / failed |
| Transaction date | Transaction record | For grouping by date separator |

## User Interactions
| Element | Action | Result |
|---------|--------|--------|
| Back arrow | Click | Navigate back to previous page (dashboard) |
| "Alle" tab | Click | Show all transactions (no filter) |
| "Overforinger" tab | Click | Filter to show only transfer transactions |
| "QR-betalinger" tab | Click | Filter to show only QR payment transactions |
| Transaction card | Click | Navigate to transaction detail page (optional) |
| Bottom nav tabs | Click | Navigate to respective page |

## Norwegian Labels
| Element | Norwegian Text |
|---------|---------------|
| Page heading | Transaksjonshistorikk |
| Filter: All | Alle |
| Filter: Transfers | Overforinger |
| Filter: QR Payments | QR-betalinger |
| Date separator: Yesterday | I GAR |
| Date separator: This week | DENNE UKEN |
| Date separator: This month | DENNE MANEDEN |
| Date separator: Earlier | TIDLIGERE |
| Transaction type: Transfer | Overforing |
| Transaction type: QR Payment | QR-betaling |
| Status: completed | fullfort |
| Status: pending | behandler |
| Status: failed | feilet |

## Design Tokens
| Token | Value |
|-------|-------|
| Page bg | `#EEEEEE` |
| Card bg | `#FFFFFF` |
| Primary | `#0B6E35` |
| Primary hover | `#095C2C` |
| Accent/Gold | `#D4A017` |
| Text primary | `#1A1A1A` |
| Text muted | `#6B7280` |
| Text light | `#9CA3AF` |
| Border | `#E5E7EB` |
| Success | `#16A34A` |
| Warning | `#D97706` |
| Error | `#EF4444` |
| Brand font | `font-[family-name:var(--font-fraunces)]` |
| Card radius | `rounded-2xl` |
| Button radius | `rounded-xl` |
| Pill radius | `rounded-full` |
| Icon circle radius | `rounded-full` |

## Bottom Navigation
**Yes** — "Kort" tab appears active/highlighted (based on Figma). All other tabs inactive (gray, outline icons).

# drop-sprint1-implementation-plan

# Plan: Drop Sprint 1 Implementation

> **Date:** 2026-02-26
> **Author:** John (AI Director)
> **MC Task:** #2110+
> **Status:** PENDING APPROVAL
> **Sprint Duration:** 5 weeks (UI prototype with mock integrations)

## Research Summary

### Existing Codebase (MUCH more than expected)
- **46 API routes** with complete business logic (auth, transactions, GDPR, admin, cards, merchants)
- **20 UI pages** all with real content (zero stubs)
- **936-line db.ts** with dual-driver (SQLite + PostgreSQL) — needs PostgreSQL-only refactor
- **13 database tables** via node-pg-migrate (raw SQL, NOT Drizzle) — need 12 more tables
- **BankID OIDC** fully implemented with PSD2 dynamic linking (298-line callback)
- **12 Figma Make export screens** (Vite+React) as UI source of truth
- **docker-compose.production.yml** already has PostgreSQL 16
- **2 test files** — needs major expansion
- **NO Drizzle ORM** — currently raw SQL via better-sqlite3 + pg
- **NO BullMQ/Redis** — not yet implemented
- **SHA-256 national_id_hash** — must change to AES-256-GCM
- **Email/password + PIN paths** still in code — must remove for BankID-only

### Key Insight
This is NOT a greenfield build. It's a **refactor to align with architecture decisions** + **add missing infrastructure** + **extend with new FRs**. The existing code is functional but pre-dates the architecture review.

---

## Objective

Refactor existing Drop codebase to align with all 16 ADRs, add missing database tables and infrastructure (Redis, BullMQ, Drizzle), implement BankID-only auth, and bring UI in line with Figma Make export designs. All external integrations (BankID, ZTL, FX, compliance) remain mocked.

---

## Team Orchestration

### Team Members

| ID | Name | Role | Agent Type | Model |
|----|------|------|------------|-------|
| B1 | db-builder | Database: PostgreSQL-only + Drizzle + 12 new tables | builder | sonnet |
| V1 | db-validator | Validate database migration + schema | validator | sonnet |
| B2 | auth-builder | BankID-only auth refactor + AES-256-GCM | builder | sonnet |
| V2 | auth-validator | Validate auth + security | validator | sonnet |
| B3 | infra-builder | Docker, Redis, BullMQ, graceful shutdown | builder | sonnet |
| V3 | infra-validator | Validate infrastructure | validator | sonnet |
| B4 | api-builder | New API routes for FR-073 through FR-077 | builder | sonnet |
| V4 | api-validator | Validate API routes | validator | sonnet |
| B5 | ui-builder | Align UI with Figma Make + admin portal | builder | sonnet |
| V5 | ui-validator | Validate UI against Figma Make | validator | sonnet |

---

## Step-by-Step Tasks

### Phase 1: Foundation (Week 1) — Database + Infrastructure

**Task 1: PostgreSQL-only db.ts refactor**
- Owner: B1
- BlockedBy: none
- Description: Replace 936-line dual-driver `src/lib/db.ts` with PostgreSQL-only via Drizzle ORM. Remove `better-sqlite3` dependency. Remove all SQLite code paths. Install `drizzle-orm` + `drizzle-kit` + `@neondatabase/serverless` (for edge compat) or `postgres` driver. Create Drizzle schema matching existing 13 tables.
- Files: `src/lib/db.ts`, `package.json`, `drizzle.config.ts`
- Acceptance:
  - [ ] `better-sqlite3` removed from package.json
  - [ ] `drizzle-orm` and `drizzle-kit` in dependencies
  - [ ] `src/lib/db.ts` uses Drizzle with `pg` driver only
  - [ ] All existing `getOne()`, `getAll()`, `run()` calls still work (adapter layer or full migration)
  - [ ] `docker-compose.yml` updated with PostgreSQL service for dev
  - [ ] `drop.db` SQLite file removed from project

**Task 2: Drizzle schema for all 25 tables**
- Owner: B1
- BlockedBy: 1
- Description: Create `src/lib/schema.ts` with Drizzle table definitions for all 25 tables per database-design.md. Remove node-pg-migrate, use Drizzle Kit for migrations. Include the 12 new tables: `aml_alerts`, `str_reports`, `screening_results`, `consents`, `data_access_requests`, `complaints`, `reconciliation_reports`, `reconciliation_discrepancies`, `circuit_breaker_state`, `webhook_events`, `webhook_dlq`, `disputes`.
- Files: `src/lib/schema.ts`, `drizzle.config.ts`, `migrations/` (Drizzle format)
- Acceptance:
  - [ ] 25 Drizzle table definitions matching database-design.md
  - [ ] All FKs, indexes, constraints defined
  - [ ] `national_id_hash` column renamed to `national_id_encrypted` + `national_id_hmac`
  - [ ] `password_hash` column still exists but default='BANKID_ONLY' (backwards compat)
  - [ ] `npx drizzle-kit generate` produces valid migration
  - [ ] `npx drizzle-kit push` applies to local PostgreSQL without errors

**Task 3: Validate database migration**
- Owner: V1
- BlockedBy: 2
- Acceptance:
  - [ ] All 25 tables created in PostgreSQL
  - [ ] Schema matches database-design.md exactly
  - [ ] Indexes from indexing-strategy.md present
  - [ ] No SQLite references remain in codebase (`grep -r "better-sqlite3\|sqlite" src/`)
  - [ ] Docker PostgreSQL starts and accepts connections

**Task 4: Redis + BullMQ infrastructure**
- Owner: B3
- BlockedBy: none (parallel with Task 1)
- Description: Add Redis and BullMQ per ADR-015. Create `src/lib/redis.ts` (connection), `src/lib/queues.ts` (5 queue definitions), `src/lib/workers/` (worker stubs for each queue). Add Redis to `docker-compose.yml`. Add `SIGTERM` handler per ADR-016.
- Files: `src/lib/redis.ts`, `src/lib/queues.ts`, `src/lib/workers/`, `docker-compose.yml`, `src/lib/shutdown-handlers.node.ts`
- Acceptance:
  - [ ] `bullmq` and `ioredis` in package.json
  - [ ] Redis service in docker-compose.yml (port 6379)
  - [ ] 5 queues defined: `payment-critical`, `settlement`, `compliance`, `reporting`, `notifications`
  - [ ] SIGTERM handler with 25s drain, `shutdown_interrupted` state
  - [ ] `docker compose up` starts both PostgreSQL and Redis
  - [ ] Health endpoint includes Redis connectivity check

**Task 5: Validate infrastructure**
- Owner: V3
- BlockedBy: 4
- Acceptance:
  - [ ] `docker compose up` starts app + PostgreSQL + Redis
  - [ ] Health endpoint reports all services healthy
  - [ ] BullMQ can enqueue and process a test job
  - [ ] SIGTERM handler tested (send SIGTERM, verify graceful shutdown)

---

### Phase 2: Auth + Security (Week 2)

**Task 6: BankID-only authentication**
- Owner: B2
- BlockedBy: 2 (needs Drizzle schema)
- Description: Remove all email/password and phone/PIN auth paths. Keep BankID OIDC as sole auth. Remove `src/app/api/auth/login/`, `src/app/api/auth/register/`, `src/app/api/auth/verify-otp/`. Update `src/app/api/auth/bankid/callback/route.ts` to use AES-256-GCM for fødselsnummer (not SHA-256). Update session policy: 30min sliding idle, 8h absolute, 5min payment SCA. Remove `bcryptjs` dependency (no passwords).
- Files: `src/app/api/auth/`, `src/lib/auth.ts`, `src/lib/services/auth-provider.ts`, `src/app/login/page.tsx`, `src/app/register/page.tsx`
- Acceptance:
  - [ ] `/api/auth/login` — REMOVED
  - [ ] `/api/auth/register` — REMOVED
  - [ ] `/api/auth/verify-otp` — REMOVED
  - [ ] `/api/auth/bankid` — sole auth entry point
  - [ ] BankID callback stores `national_id_encrypted` (AES-256-GCM) + `national_id_hmac` (HMAC-SHA256)
  - [ ] JWT has 30min expiry, refresh token server-side
  - [ ] Login page shows only "Logg inn med BankID" button
  - [ ] Registration page redirects to BankID flow
  - [ ] `bcryptjs` removed from package.json
  - [ ] Zero references to `password`, `pin`, `email+password` in auth flows

**Task 7: Validate auth + security**
- Owner: V2
- BlockedBy: 6
- Acceptance:
  - [ ] No email/password/PIN auth paths reachable
  - [ ] BankID mock flow works end-to-end
  - [ ] national_id stored as AES-256-GCM encrypted (verify with DB inspection)
  - [ ] Session expiry at 30min verified
  - [ ] `grep -r "SHA-256\|sha256\|national_id_hash" src/` returns zero results in auth code
  - [ ] `grep -r "bcrypt\|password_hash" src/` — only legacy column definition, no active auth use

---

### Phase 3: API Routes (Week 2-3)

**Task 8: Webhook handling API (FR-076)**
- Owner: B4
- BlockedBy: 2, 4 (needs Drizzle + BullMQ)
- Description: Create `POST /api/webhooks/banking-partner` per FR-076. HMAC-SHA256 validation, IP whitelist, idempotent processing, state machine (pending→processing→completed/failed), DLQ after 3 attempts.
- Files: `src/app/api/webhooks/banking-partner/route.ts`, `src/lib/services/webhook-processor.ts`
- Acceptance:
  - [ ] HMAC-SHA256 signature validation with 5-min timestamp check
  - [ ] webhook_events table populated on each webhook
  - [ ] Duplicate webhook_id returns existing result (idempotent)
  - [ ] Failed webhooks retry 3 times then move to webhook_dlq
  - [ ] Returns 200 within 5 seconds

**Task 9: Reconciliation API (FR-073)**
- Owner: B4
- BlockedBy: 2, 4
- Description: Create reconciliation job and API. BullMQ cron job at 06:00 Oslo time. Compares Drop transactions with mock banking partner data. Creates reconciliation_reports and reconciliation_discrepancies records. Admin endpoint `GET /api/admin/reconciliation` to view reports.
- Files: `src/lib/workers/reconciliation-worker.ts`, `src/app/api/admin/reconciliation/route.ts`
- Acceptance:
  - [ ] BullMQ cron job scheduled at 06:00 Europe/Oslo
  - [ ] Reconciliation report created with matched/discrepancy counts
  - [ ] Discrepancies categorized by type
  - [ ] Admin API returns reports with pagination

**Task 10: Circuit breaker service (FR-075)**
- Owner: B4
- BlockedBy: 2, 4
- Description: Create circuit breaker service per FR-075. Shared state in PostgreSQL `circuit_breaker_state` table. 5 instances (BankID, ZTL, FX, compliance, push). Fallback behaviors per dependency.
- Files: `src/lib/services/circuit-breaker.ts`, `src/lib/workers/circuit-breaker-monitor.ts`
- Acceptance:
  - [ ] 5 circuit breaker instances initialized on startup
  - [ ] State transitions: closed→open after 5 failures, open→half-open after 30s
  - [ ] Each external service call wrapped in circuit breaker
  - [ ] Fallback behavior per dependency (read-only mode, cached rates, etc.)

**Task 11: Dispute/refund API (FR-077)**
- Owner: B4
- BlockedBy: 2
- Description: Create dispute endpoints per FR-077. `POST /api/transactions/:id/dispute` for user submission. `GET /api/admin/disputes` for admin queue. State machine (submitted→acknowledged→investigating→decided→closed).
- Files: `src/app/api/transactions/[id]/dispute/route.ts`, `src/app/api/admin/disputes/route.ts`, `src/lib/services/dispute.ts`
- Acceptance:
  - [ ] User can submit dispute from transaction detail
  - [ ] Auto-acknowledgement (logged, notification created)
  - [ ] Admin can view/manage dispute queue
  - [ ] State machine enforces valid transitions only
  - [ ] 5-year retention with do_not_delete flag

**Task 12: Validate API routes**
- Owner: V4
- BlockedBy: 8, 9, 10, 11
- Acceptance:
  - [ ] All new endpoints respond correctly (mock data)
  - [ ] Webhook idempotency verified (send same webhook twice)
  - [ ] Circuit breaker state transitions verified
  - [ ] Dispute lifecycle flows correctly
  - [ ] All endpoints require auth (except webhook which uses HMAC)

---

### Phase 4: UI Alignment (Week 3-4)

**Task 13: Align existing screens with Figma Make export**
- Owner: B5
- BlockedBy: 6 (needs BankID-only auth)
- Description: Compare each of the 10 existing screens against Figma Make export (`mockups/figma-make-export/src/app/screens/`). Update layouts, colors, typography, spacing to match. Screens: Login, Onboarding, Dashboard, SendMoney, BankAccounts, TransactionHistory, ScanQR, Profile, Notifications, MerchantDashboard. Plus 2 merchant QR screens.
- Files: All page.tsx files in `src/app/`
- Acceptance:
  - [ ] Each screen visually matches Figma Make export
  - [ ] Login page: BankID-only (no email/password form)
  - [ ] Dashboard: balance card, recent transactions, quick actions
  - [ ] SendMoney: multi-step flow matching Make export
  - [ ] Brand colors match Drop brand guidelines (green gradient #0B6E35→#064E25)
  - [ ] Mobile-first responsive design

**Task 14: Admin portal UI (EP-09)**
- Owner: B5
- BlockedBy: 8, 9, 10, 11 (needs admin APIs)
- Description: Create admin portal pages per EP-09. MFA login (mock), AML alert dashboard, STR filing, KYC review, transaction search, settlement management, report generation, audit log viewer. Route: `/app/admin/`.
- Files: `src/app/admin/` (new directory), 8 page files
- Acceptance:
  - [ ] Admin login page with MFA (mocked)
  - [ ] AML alert queue with filter/sort/action buttons
  - [ ] STR filing form pre-filled from alert data
  - [ ] User KYC search and detail view
  - [ ] Transaction search with filters and CSV export
  - [ ] Settlement dashboard with batch status
  - [ ] Compliance report list with PDF/CSV download stubs
  - [ ] Audit log viewer with search

**Task 15: Validate UI**
- Owner: V5
- BlockedBy: 13, 14
- Acceptance:
  - [ ] All 10 consumer screens match Figma Make export (visual comparison)
  - [ ] Admin portal: all 8 pages render without errors
  - [ ] No broken routes (404s)
  - [ ] Mobile responsive (viewport 375px)
  - [ ] Brand colors consistent
  - [ ] No email/password UI anywhere

---

### Phase 5: Integration + Testing (Week 4-5)

**Task 16: Mock services for all external integrations**
- Owner: B4
- BlockedBy: 6, 10 (needs auth + circuit breaker)
- Description: Create/update mock services for: BankID (already exists), ZTL banking partner (PISP/AISP mock), FX rate provider (mock with realistic NOK→RSD/BAM/PLN/EUR/PKR/TRY rates), compliance provider (mock PEP/sanctions screening), push notifications (mock). All mocks should be realistic and toggleable via feature flags.
- Files: `src/lib/services/mock-*.ts` (update existing + create new)
- Acceptance:
  - [ ] BankID mock returns valid OIDC flow
  - [ ] PISP mock returns realistic payment confirmation after delay
  - [ ] AISP mock returns balance data for linked accounts
  - [ ] FX mock returns rates for all 6 corridors
  - [ ] Compliance mock returns PEP clear/match based on test data
  - [ ] All mocks activated via `MOCK_MODE=true` env var

**Task 17: End-to-end test suite**
- Owner: B4
- BlockedBy: 16
- Description: Expand test suite (currently 2 files). Add Playwright E2E tests for critical user flows: BankID login → dashboard → send money → transaction history. Add API integration tests for webhook, reconciliation, dispute. Add unit tests for circuit breaker, idempotency logic.
- Files: `tests/e2e/`, `tests/integration/`, `tests/unit/`
- Acceptance:
  - [ ] E2E: Login flow → Dashboard renders
  - [ ] E2E: Send money flow (mock) completes
  - [ ] E2E: Admin portal accessible
  - [ ] Integration: Webhook processing with HMAC
  - [ ] Integration: Reconciliation job produces report
  - [ ] Unit: Circuit breaker state transitions
  - [ ] Unit: Idempotency key generation deterministic
  - [ ] All tests pass in CI (docker-compose up + test)

**Task 18: Final validation**
- Owner: V1 + V2 + V3 + V4 + V5 (joint)
- BlockedBy: 17
- Description: Full system validation. Start docker-compose, run all tests, verify all acceptance criteria from all tasks, check for security issues.
- Acceptance:
  - [ ] `docker compose up` → app healthy within 30s
  - [ ] All 25 tables present in PostgreSQL
  - [ ] BankID-only auth (no other paths)
  - [ ] All new API routes functional
  - [ ] All UI screens render correctly
  - [ ] Zero SQLite references in codebase
  - [ ] Zero SHA-256 national_id references
  - [ ] QA-19 check passes (≥17/19 for H priority)

---

## Validation Commands

```bash
# Database
docker compose up -d
npx drizzle-kit push
psql -h localhost -U drop -d drop -c "\dt" | wc -l  # Should show 25 tables

# Auth
grep -r "better-sqlite3\|bcrypt\|password.*login\|pin.*login" src/ # Should be zero
grep -r "national_id_hash" src/ # Should be zero
curl http://localhost:3000/api/auth/login # Should 404

# Infrastructure
docker compose ps # PostgreSQL + Redis + app all healthy
curl http://localhost:3000/api/health # Should include redis: ok, db: ok

# Tests
npm test
npx playwright test

# QA Gate
node ~/system/tools/qa-19.js check <task-id>
```

---

## Risk Mitigation

| Risk | Mitigation |
|------|-----------|
| db.ts refactor breaks all 46 API routes | Task 1 creates adapter layer first, then migrates route-by-route |
| BankID mock breaks existing flow | Keep `BANKID_MOCK=true` env var, test before removing legacy paths |
| Drizzle migration incompatible with existing data | Fresh PostgreSQL for Sprint 1 (no production data yet) |
| UI alignment takes longer than expected | Prioritize 5 core screens (Login, Dashboard, Send, Scan, Transactions), defer rest |

---

## Summary

| Phase | Duration | Tasks | Builders | Validators |
|-------|----------|-------|----------|------------|
| 1: Foundation | Week 1 | 5 | B1, B3 | V1, V3 |
| 2: Auth + Security | Week 2 | 2 | B2 | V2 |
| 3: API Routes | Week 2-3 | 5 | B4 | V4 |
| 4: UI Alignment | Week 3-4 | 3 | B5 | V5 |
| 5: Integration + Testing | Week 4-5 | 3 | B4 | All |
| **Total** | **5 weeks** | **18 tasks** | **5 builders** | **5 validators** |

---

*Approve plan? Then run `/build-plan` to execute.*

# Forge the Anvil — PI Orchestrator Fix

# Plan: Forge the Anvil — PI Orchestrator Fix

## Research Summary

Analysed pi-orchestrator.js (1706 lines), ollama-tool-agent.js (548 lines), hivemind.js (994 lines), dag-scheduler.js (394 lines), plus MC stats, minions.db, events.db, and HiveMind data.

**Key findings:**
- Minion runs: 24 total, 5 completed (21%), 16 cancelled, 3 failed
- Event chain: 62 delegates → 11 completions (18%)
- Proof-of-work bug CONFIRMED: `reportCompletion()` line 1091 checks for GOTCHA/verify dir BEFORE line 1104 creates them
- Budget flat $2.00 for all Claude CLI tasks (line 782)
- ollama-tool-agent: MAX_TURNS=10, no JSON repair, no retry
- HiveMind: 6,843 entries, only 4 types have TTLs — briefing/alert/coordination. Everything else permanent
- dag-scheduler already has `fail()` — no work needed
- routing-outcomes.db exists but tracks routing, not task outcomes

## Objective

Fix the 3 highest-impact bugs in PI orchestrator, harden the ollama agent, and add basic outcome tracking. Target: minion completion >50% on next 20+ runs.

## Team Orchestration

### Team Members
| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | proof-of-work-builder | Fix reportCompletion ordering + budget scaling | builder |
| V1 | proof-of-work-validator | Validate proof-of-work fix + budget | validator |
| B2 | ollama-hardening-builder | Harden ollama-tool-agent | builder |
| V2 | ollama-hardening-validator | Validate ollama hardening | validator |
| B3 | hivemind-ttl-builder | Add TTLs + outcome tracking | builder |
| V3 | hivemind-ttl-validator | Validate HiveMind + tracking | validator |

### Step-by-Step Tasks

#### Phase 1: Fix Proof-of-Work Bug + Budget Scaling (HIGHEST IMPACT)

**Task 1:** Fix reportCompletion proof-of-work ordering
- Owner: B1
- BlockedBy: none
- File: `~/system/kernel/pi-orchestrator.js`
- What: Move the "AUTO QA PREP" block (lines 1104-1146) BEFORE the "VALIDATION GATE" check (lines 1079-1101). The auto-prep creates `/tmp/verify-{id}` and `/tmp/gotcha-task-{id}.md` that the gate checks for. Currently gate checks first → always fails for local models that can't write these files.
- Acceptance:
  - [ ] Auto-prep block executes BEFORE proof-of-work check
  - [ ] Proof-of-work check still blocks if auto-prep produced nothing AND agent produced nothing
  - [ ] `node -c ~/system/kernel/pi-orchestrator.js` passes (syntax valid)

**Task 2:** Scale Claude CLI budget by complexity
- Owner: B1
- BlockedBy: 1
- File: `~/system/kernel/pi-orchestrator.js` (pipelineClaudeCLI function, line 782)
- What: Replace hardcoded `'--max-budget-usd', '2.00'` with complexity-based budget. Read complexity from task or modelSelection. Map: complexity 1-2=$0.50, 3=$1.00, 4=$2.00, 5=$5.00.
- How: The `pipelineClaudeCLI` function already receives `task` parameter. Add budget lookup before spawn. Pass `task.budget` or compute from modelSelection.tier.
- Acceptance:
  - [ ] Budget varies by tier (check spawn args in code)
  - [ ] Tier 5 gets $5.00, Tier 4 gets $2.00, lower tiers get less
  - [ ] Syntax check passes

**Task 3:** Add human-active backoff
- Owner: B1
- BlockedBy: 2
- File: `~/system/kernel/pi-orchestrator.js` (runCycle function, around line 1234)
- What: At top of runCycle, check if `/tmp/mc-active-task` exists and is fresh (<5 min). If so, skip Claude CLI tasks (set claude concurrency to 0 for this cycle). Local models still run.
- Acceptance:
  - [ ] When human is active, cloud tasks are deferred
  - [ ] Local model tasks still execute
  - [ ] When marker is stale (>5 min), normal behavior resumes

**Task 4:** Validate Phase 1 fixes
- Owner: V1
- BlockedBy: 3
- What: Read pi-orchestrator.js and verify all 3 changes. Run syntax check. Verify auto-prep is before gate check. Verify budget is dynamic. Verify human backoff logic.
- Acceptance:
  - [ ] `node -c ~/system/kernel/pi-orchestrator.js` exits 0
  - [ ] Auto-prep block appears BEFORE proof-of-work check in reportCompletion
  - [ ] Budget in pipelineClaudeCLI is NOT hardcoded $2.00
  - [ ] runCycle has human-active check

#### Phase 2: Harden Ollama Tool Agent

**Task 5:** Increase MAX_TURNS and add JSON repair
- Owner: B2
- BlockedBy: none (parallel with Phase 1)
- File: `~/system/tools/ollama-tool-agent.js`
- What:
  1. Change `MAX_TURNS = 10` to `MAX_TURNS = 20` (line 44)
  2. In the JSON parse block (around line 320), add fallback: if `JSON.parse(jsonStr)` fails, try regex extraction `/{[\s\S]*}/` and retry parse
  3. In tool execution (around line 338-352), wrap in retry: if TOOLS[tool].exec throws, retry once with 2s delay
- Acceptance:
  - [ ] MAX_TURNS is 20
  - [ ] JSON parse has regex fallback before giving up
  - [ ] Tool execution retries once on failure
  - [ ] `node -c ~/system/tools/ollama-tool-agent.js` passes

**Task 6:** Validate ollama-tool-agent changes
- Owner: V2
- BlockedBy: 5
- What: Read ollama-tool-agent.js. Verify MAX_TURNS, JSON repair, and retry logic.
- Acceptance:
  - [ ] MAX_TURNS = 20
  - [ ] JSON fallback regex exists after parse failure
  - [ ] Tool exec has try/catch with retry
  - [ ] Syntax valid

#### Phase 3: HiveMind TTL + Outcome Tracking

**Task 7:** Add aggressive TTLs to HiveMind
- Owner: B3
- BlockedBy: none (parallel with Phase 1 and 2)
- File: `~/system/agents/hivemind/hivemind.js` (TTL_DAYS object, line 201)
- What: Expand TTL_DAYS to cover high-volume types currently permanent:
  ```
  briefing: 3       (was 7)
  alert: 14          (was 30)
  coordination_request: 7  (unchanged)
  coordination_response: 7 (unchanged)
  update: 7          (NEW — 306 entries)
  info: 7            (NEW — 378 entries)
  queue: 3           (NEW — 302 entries)
  error: 14          (NEW — 290 entries)
  report: 14         (NEW — 284 entries)
  task-update: 7     (NEW — 221 entries)
  task: 7            (NEW — 116 entries)
  spam: 1            (NEW — 99 entries)
  intake: 7          (NEW — 94 entries)
  ```
- Acceptance:
  - [ ] TTL_DAYS has 13+ entries (was 4)
  - [ ] briefing reduced to 3 days
  - [ ] alert reduced to 14 days
  - [ ] spam has 1 day TTL
  - [ ] Syntax valid

**Task 8:** Add task outcome tracking to pi-orchestrator
- Owner: B3
- BlockedBy: 7
- File: `~/system/kernel/pi-orchestrator.js`
- What: After verifyQuality in runCycle (around line 1293), record outcome to `~/system/databases/learning-loop.db`:
  1. Create SQLite DB + table at top of file (lazy init like other DBs):
     ```sql
     CREATE TABLE IF NOT EXISTS task_outcomes (
       id INTEGER PRIMARY KEY AUTOINCREMENT,
       mc_task_id INTEGER, task_domain TEXT, task_type TEXT,
       complexity INTEGER, tier INTEGER, model TEXT, pipeline TEXT,
       success BOOLEAN, quality_score REAL, response_length INTEGER,
       latency_ms INTEGER, failure_reason TEXT, priority TEXT,
       created_at TEXT DEFAULT (datetime('now'))
     );
     ```
  2. Insert row after quality gate check (both pass and fail paths)
  3. Also insert on pipeline failure (result.success=false path)
- Acceptance:
  - [ ] learning-loop.db gets created on first run
  - [ ] task_outcomes table has correct schema
  - [ ] Outcomes recorded for success AND failure paths
  - [ ] Syntax valid

**Task 9:** Selective HiveMind feedback
- Owner: B3
- BlockedBy: 8
- File: `~/system/kernel/pi-orchestrator.js` (feedbackToHiveMind function, line 1211)
- What: Currently stores ALL tier 2+ responses >200 chars. Change to:
  1. Only store tier 3+ responses (skip tier 2 — too much noise)
  2. Truncate to 1000 chars (was 5000)
  3. Skip if quality gate failed (no failed responses into HiveMind)
- Acceptance:
  - [ ] feedbackToHiveMind checks tier >= 3 (was >= 2)
  - [ ] Truncation is 1000 chars (was 5000)
  - [ ] Function is only called after quality gate passes

**Task 10:** Validate Phase 3
- Owner: V3
- BlockedBy: 9
- What: Read hivemind.js and pi-orchestrator.js. Verify TTLs, outcome tracking, and selective feedback.
- Acceptance:
  - [ ] HiveMind TTL_DAYS has 13+ entries
  - [ ] learning-loop.db schema is correct
  - [ ] feedbackToHiveMind only stores tier 3+, truncated to 1000 chars
  - [ ] Both files pass syntax check

## Validation Commands

```bash
# Syntax check all modified files
node -c ~/system/kernel/pi-orchestrator.js
node -c ~/system/tools/ollama-tool-agent.js
node -c ~/system/agents/hivemind/hivemind.js

# Restart daemon
launchctl kickstart -k gui/501/com.john.pi-orchestrator

# Monitor
tail -f ~/system/logs/pi-orchestrator.log

# Check HiveMind cleanup
node ~/system/agents/hivemind/hivemind.js cleanup

# Check learning loop DB
sqlite3 ~/system/databases/learning-loop.db "SELECT COUNT(*) FROM task_outcomes"
```

## What This Plan Does NOT Include (Intentionally)

- **Workflow engine** — premature. Fix the basics first.
- **Adaptive router (Thompson Sampling)** — only 24 data points. Need 100+ outcomes before routing optimization makes sense.
- **Project coordinator** — can't orchestrate projects if single tasks fail.
- **Daemon consolidation** — works fine, not broken.
- **Contract test suite** — nice to have but not the bottleneck.

These belong in Phase 2 AFTER Gate 1 metrics prove the foundation works.

## Gate 1 Success Criteria

After these fixes, run PI orchestrator for 48h and measure:
- Minion Success Rate > 50% on 20+ new tasks
- No false completions (tasks marked done with no output)
- HiveMind entry count drops after cleanup
- learning-loop.db has outcome data for every task attempt

# drop-backend-migration-plan

# Drop Backend Migration Plan
# Hono/TypeScript → Kotlin/Ktor

**Authored by:** Petter Graff (Software Architect Agent)
**Date:** 2026-03-29
**MC Task:** #5124
**Status:** READY FOR REVIEW — Requires CEO approval before any build action (ZAKON #2)

---

## Executive Summary

Five previous attempts failed because this migration was treated as a single monolithic task. It is not one task. It is eight tasks — each independently deliverable, each with its own acceptance criteria, each runnable on a separate agent thread.

The core architectural principle here is **strangler fig**: the new Ktor service grows alongside the existing Hono service. Traffic is routed per-endpoint via nginx (or AWS load balancer at the path level). At no point is the old service down while the new one is being built. Cutover is a DNS/nginx config change, not a big-bang deployment.

The database does not change. Same 24 PostgreSQL 16 tables, same schemas. Flyway replaces Drizzle for migrations going forward, but the existing tables are adopted as-is via a Flyway baseline migration. This is the key insight: decoupling the DB migration from the API migration means each phase can be independently verified against a live database.

---

## Why It Failed Before (Root Cause Analysis)

| Failure Pattern | Root Cause | Fix in This Plan |
|---|---|---|
| Agent ran out of context window | 22 routes + 24 tables = too much in one pass | Each phase has ≤4 route files |
| API contracts broken | No explicit contract test suite defined upfront | Phase 0 builds contract tests FIRST |
| PII encryption silently wrong | AES-256-GCM reimplemented from scratch each time | Phase 0 documents the exact algorithm; Phase 2 validates it against known vectors |
| BankID OIDC stub wrong | Complex OAuth2 PKCE flow with state validation | Phase 1 is ONLY auth — nothing else |
| Agent improvised table mappings | No Exposed ORM specification | Phase 0 produces Exposed entity specs for all 24 tables |
| No parallel running strategy | Hono taken down too early | Strangler fig: both services run until Phase 8 |

---

## System Architecture During Migration

```
                    ┌─────────────────────────────────────────────┐
                    │           nginx / AWS ALB                    │
                    │                                              │
                    │  Phase 0-1: ALL → Hono :3001               │
                    │  Phase 2+:  /v1/auth → Ktor :8080          │
                    │             /v1/users → Ktor :8080          │
                    │             /v1/* remaining → Hono :3001    │
                    │  Phase 8:  ALL → Ktor :8080, Hono offline   │
                    └─────────────────────────────────────────────┘
                            │                    │
                    ┌───────┴──────┐    ┌────────┴──────┐
                    │  Hono :3001  │    │  Ktor :8080   │
                    │  (existing)  │    │   (new)       │
                    └──────────────┘    └───────────────┘
                            │                    │
                            └────────┬───────────┘
                                     │
                          ┌──────────┴──────────┐
                          │  PostgreSQL 16       │
                          │  (unchanged schema)  │
                          └─────────────────────┘
                                     │
                          ┌──────────┴──────────┐
                          │  Redis 7             │
                          │  (sessions + limits) │
                          └─────────────────────┘
```

**Key constraint:** Both services share the same PostgreSQL instance and Redis instance. Session tokens issued by Hono must be validatable by Ktor (same JWT secret, same session table). This is addressed in Phase 1.

---

## Database: 24 Tables Mapped to Phases

| Table | Phase | Notes |
|---|---|---|
| `users` | 2 | Core entity |
| `sessions` | 1 | JWT validation — needed from day one |
| `refresh_tokens` | 1 | Token refresh |
| `otp_codes` | 1 | Phone OTP |
| `oidc_states` | 1 | BankID OIDC state/nonce |
| `settings` | 2 | User settings |
| `user_preferences` | 2 | User prefs |
| `recipients` | 3 | Money movement |
| `transactions` | 3 | Core money table |
| `exchange_rates` | 3 | Rates |
| `bank_accounts` | 4 | AISP |
| `ob_consents` | 4 | Open Banking consents (Berlin Group) |
| `cards` | 7 | Feature-flagged, low priority |
| `spending_limits` | 7 | Cards related |
| `merchants` | 7 | QR merchants |
| `notifications` | 6 | Push notifications |
| `push_tokens` | 6 | Expo push tokens |
| `audit_log` | 5 | Compliance |
| `aml_alerts` | 5 | AML |
| `str_reports` | 5 | Suspicious Transaction Reports |
| `screening_results` | 5 | PEP/sanctions |
| `consents` | 5 | GDPR consents |
| `data_access_requests` | 5 | GDPR Art. 20 |
| `complaints` | 5 | PSD2 complaints |
| `withdrawal_requests` | 5 | Angrerett |
| `webhook_events` | 6 | Webhook dedup |
| `webhook_dlq` | 6 | Dead letter queue |
| `settlement_batches` | 6 | Financial settlement |
| `settlement_items` | 6 | Settlement line items |
| `reconciliation_reports` | 6 | Reconciliation |
| `reconciliation_discrepancies` | 6 | Reconciliation details |
| `circuit_breaker_state` | 3 | PISP/AISP circuit breakers |
| `disputes` | 5 | Transaction disputes |

---

## Phases

---

### Phase 0: Foundation + Contract Baseline

**Goal:** Ktor skeleton project. All 24 Exposed entities. All API contracts documented as tests. PII encryption verified.

**Duration estimate:** L (4-6 days)

**Files to create:**
```
drop-api-ktor/
├── build.gradle.kts
├── settings.gradle.kts
├── gradle.properties
├── Dockerfile
├── src/main/kotlin/no/getdrop/api/
│   ├── Application.kt          ← Ktor entry point
│   ├── Routing.kt              ← Route registration (stubs)
│   ├── Database.kt             ← HikariCP + Exposed setup
│   ├── plugins/
│   │   ├── Serialization.kt
│   │   ├── Security.kt         ← JWT config (same secret as Hono)
│   │   └── Monitoring.kt       ← Sentry + OTel
│   └── db/
│       └── Tables.kt           ← Exposed Table objects (all 24)
├── src/main/resources/
│   ├── application.conf
│   └── db/migration/
│       └── V1__baseline.sql    ← Flyway baseline (existing tables, no-op)
└── src/test/kotlin/no/getdrop/api/
    ├── PiiEncryptionTest.kt    ← AES-256-GCM round-trip tests
    └── ContractBaselineTest.kt ← HTTP smoke test vs Hono (contract capture)
```

**Key technical decisions for this phase:**
1. Ktor version: 3.x (current stable as of 2026)
2. Exposed version: 0.54.x (current stable) — use DAO-style for complex entities, DSL-style for simple queries
3. Flyway baseline: `V1__baseline.sql` runs `SET search_path = public` + marks all existing tables as already migrated. No DDL changes.
4. PII encryption: `PiiEncryptionTest` must verify the Kotlin implementation produces the same ciphertext format `v1:<iv_hex>:<tag_hex>:<ciphertext_hex>` as the TypeScript implementation. Test vectors must be generated FROM the Hono system and validated against.
5. JWT: Both services use identical `JWT_SECRET` env var. `jose` library in Hono uses HS256 by default — Ktor must use the same algorithm and same claim structure.

**Acceptance criteria:**
- [ ] `./gradlew build` succeeds with zero errors
- [ ] `./gradlew test` passes `PiiEncryptionTest` with 5+ test vectors from Hono
- [ ] Flyway baseline runs cleanly against dev PostgreSQL without modifying any table
- [ ] Ktor starts on port 8080 and returns 200 on `GET /health`
- [ ] All 24 Exposed Table objects defined with correct column types
- [ ] Contract baseline test captures all Hono endpoint response shapes

**Dependencies:** None. Can start immediately.

**Risk:** LOW. No traffic routing change. Ktor not yet in production path.

---

### Phase 1: Health + Auth (JWT + BankID OIDC)

**Route files migrated:**
- `health.ts` → `HealthRoutes.kt`
- `auth.ts` → `AuthRoutes.kt`
- `admin-auth.ts` → `AdminAuthRoutes.kt`

**Endpoints:**
```
GET  /v1/health                    → HealthRoutes
GET  /v1/auth/bankid/initiate      → AuthRoutes (BankID OIDC initiation)
POST /v1/auth/bankid/callback      → AuthRoutes (BankID OIDC token exchange)
POST /v1/auth/send-otp             → AuthRoutes
POST /v1/auth/verify-otp           → AuthRoutes
POST /v1/auth/login                → AuthRoutes (demo/dev only)
POST /v1/auth/register             → AuthRoutes (demo/dev only)
POST /v1/auth/demo-login           → AuthRoutes
POST /v1/auth/demo-admin-login     → AuthRoutes
GET  /v1/auth/me                   → AuthRoutes
POST /v1/auth/logout               → AuthRoutes
POST /v1/auth/refresh              → AuthRoutes
POST /v1/auth/demo/login           → AuthRoutes
POST /v1/admin-auth/*              → AdminAuthRoutes
```

**Tables:** sessions, refresh_tokens, otp_codes, oidc_states (+ users read-only)

**Duration estimate:** L (5-7 days)

**BankID OIDC implementation notes:**
The TypeScript implementation uses `initiateOIDC`, `exchangeAndVerify`, `findOrCreateUser`, `validateAndConsumeState`. These must be reimplemented in Kotlin using the same PKCE flow:
1. Generate `state` (random, stored in `oidc_states` with nonce + expiry)
2. Build authorization URL with `response_type=code`, `scope=openid profile`, `code_challenge` (S256)
3. Callback receives `code` + `state`, validate state from DB, exchange code for tokens via HTTP
4. Verify ID token signature (JWK endpoint from BankID OIDC discovery)
5. Extract `sub` (fødselsnummer hash) and user claims
6. `findOrCreateUser`: lookup by `national_id_hash`, create if new

**Critical:** The `state` + `nonce` validation must be byte-for-byte identical in behavior to the TypeScript version. Any difference here breaks mobile login flow permanently.

**Traffic routing (nginx):** After Phase 1 validation passes contract tests:
```nginx
location /v1/health    { proxy_pass http://ktor:8080; }
location /v1/auth/     { proxy_pass http://ktor:8080; }
location /v1/          { proxy_pass http://hono:3001; }
```

**Acceptance criteria:**
- [ ] BankID OIDC full flow tested with staging BankID (not just stub)
- [ ] JWT tokens issued by Ktor are accepted by Hono's `authMiddleware` (shared secret)
- [ ] JWT tokens issued by Hono are accepted by Ktor's JWT plugin (shared secret)
- [ ] `POST /v1/auth/logout` revokes session in shared Redis AND `sessions` table
- [ ] OTP rate limiting: 5 OTP sends per hour per phone number
- [ ] Demo mode respected (`DROP_MODE=demo` env var bypasses BankID)
- [ ] Contract test: all Hono auth response shapes match Ktor auth response shapes exactly

**Dependencies:** Phase 0 complete.

**Risk:** HIGH. Auth is the most critical path. A JWT incompatibility breaks ALL authenticated routes. Mitigation: keep Hono auth route live alongside Ktor during test period. Only cut over to Ktor auth after 48h of parallel validation.

---

### Phase 2: Core Entities (Users, Settings, Sessions)

**Route files migrated:**
- `user.ts` → `UserRoutes.kt`
- `settings.ts` → `SettingsRoutes.kt`

**Endpoints:**
```
DELETE /v1/user/account            → UserRoutes (GDPR erasure request)
GET    /v1/user/export             → UserRoutes (GDPR Art. 20 data export)
PATCH  /v1/user/profile            → UserRoutes
GET    /v1/settings                → SettingsRoutes
PUT    /v1/settings                → SettingsRoutes
GET    /v1/settings/preferences    → SettingsRoutes
PUT    /v1/settings/preferences    → SettingsRoutes
```

**Tables:** users (write), settings, user_preferences, data_access_requests

**Duration estimate:** M (3-4 days)

**PII encryption note:** `DELETE /v1/user/account` is a soft-delete (sets `deleted_at`). The TypeScript version also creates a `data_access_requests` entry with `request_type=erasure`. This must be replicated exactly. Do not actually delete the row — AML 5-year retention requirement.

User data export (`GET /v1/user/export`) must decrypt `national_id_encrypted` using the PII key — this is the ONLY place in the system where decryption happens. Verify the Kotlin `PiiEncryption.decrypt()` function against Phase 0 test vectors.

**Acceptance criteria:**
- [ ] `PATCH /v1/user/profile` validates input (sanitization equivalent to TypeScript `sanitizeText`)
- [ ] `DELETE /v1/user/account` sets `deleted_at`, revokes all sessions, creates erasure record — verified in DB
- [ ] `national_id_encrypted` decryption produces correct plaintext (test vector from Phase 0)
- [ ] Settings CRUD operations match Hono response shapes exactly
- [ ] Rate limiting applied: user routes inherit global 100 req/min per IP

**Dependencies:** Phase 1 complete (needs JWT validation).

**Risk:** MEDIUM. PII handling is sensitive. Mitigation: Phase 0 test vectors validate decryption before this phase starts.

---

### Phase 3: Transactions + Recipients + Exchange Rates

**Route files migrated:**
- `transactions.ts` → `TransactionRoutes.kt`
- `recipients.ts` → `RecipientRoutes.kt`
- `rates.ts` → `RateRoutes.kt`

**Endpoints:**
```
GET  /v1/transactions              → TransactionRoutes (list, paginated)
GET  /v1/transactions/analytics   → TransactionRoutes
GET  /v1/transactions/summary     → TransactionRoutes
POST /v1/transactions/remittance  → TransactionRoutes (PISP initiation — HIGH RISK)
POST /v1/transactions/qr-payment  → TransactionRoutes (PISP QR — HIGH RISK)
POST /v1/transactions/disclosure  → TransactionRoutes
GET  /v1/transactions/:id         → TransactionRoutes
GET  /v1/transactions/:id/receipt → TransactionRoutes
GET  /v1/recipients               → RecipientRoutes
POST /v1/recipients               → RecipientRoutes
PUT  /v1/recipients/:id           → RecipientRoutes
DELETE /v1/recipients/:id        → RecipientRoutes
GET  /v1/rates                    → RateRoutes (public, no auth)
GET  /v1/rates/:currency          → RateRoutes
```

**Tables:** transactions, recipients, exchange_rates, circuit_breaker_state

**Duration estimate:** L (6-8 days)

**PISP implementation — critical notes:**
The remittance and QR payment routes are the highest-risk endpoints in the system. They initiate real money movement. The TypeScript implementation contains:

1. **Idempotency key validation** — `transactions.idempotency_key` + `uniqueIndex`. Kotlin must validate this BEFORE any external PISP call. HTTP 409 on duplicate.
2. **Rate lock** — `rate_lock_expires_at` column. If rate lock expired, re-fetch rate before proceeding.
3. **Circuit breaker** — `circuit_breaker_state` table + in-memory registry (`circuitBreakerRegistry`). Kotlin must implement the same state machine: CLOSED → OPEN (after N failures) → HALF_OPEN (after timeout) → CLOSED.
4. **PISP attempt tracking** — `pisp_attempts`, `pisp_timeout_count`, `pisp_last_attempt_at`.
5. **Refund tracking** — `refund_status`, `refund_amount_ore`, `refunded_at`.

All monetary amounts MUST remain in integer øre. No floating point arithmetic on money values.

**Acceptance criteria:**
- [ ] `POST /v1/transactions/remittance` returns 409 on duplicate idempotency key
- [ ] Circuit breaker transitions: CLOSED→OPEN after 5 failures, HALF_OPEN after 30s, CLOSED after 2 successes
- [ ] Amount calculations use integer arithmetic only (BigInteger/Long in Kotlin — no Double)
- [ ] `GET /v1/transactions` pagination matches Hono response shape (cursor-based or offset?)
- [ ] Exchange rates endpoint returns same structure as Hono (check `to_currency`, `rate`, `updated_at` field names)
- [ ] Contract tests pass for all 8 transaction endpoints

**Dependencies:** Phase 2 complete.

**Risk:** VERY HIGH. Money movement. Mitigation: PISP routes are NOT cut over to Ktor until 5 days of parallel logging (same request sent to both, responses compared, no actual PISP calls doubled).

---

### Phase 4: Banking (AISP, Open Banking Consents, Bank Accounts)

**Route files migrated:**
- `ob-consents.ts` → `ObConsentRoutes.kt`

**Endpoints:**
```
GET  /v1/ob-consents                       → ObConsentRoutes (list active consents)
POST /v1/ob-consents                       → ObConsentRoutes (create consent)
DELETE /v1/ob-consents/:id                → ObConsentRoutes (revoke consent)
GET  /v1/ob-consents/:id/accounts         → ObConsentRoutes (AISP: list bank accounts)
POST /v1/ob-consents/:id/refresh          → ObConsentRoutes (refresh AISP balance)
```

**Tables:** ob_consents, bank_accounts

**Duration estimate:** M (3-5 days)

**PSD2 / Berlin Group compliance notes:**
1. **90-day consent expiry** — enforced in `ob_consents.expires_at`. Ktor must reject AISP calls when `status != 'active'` OR `expires_at < now()`.
2. **4 AISP calls per day** — `access_count_today` counter + `last_access_date` for daily reset. This is a Berlin Group RTS requirement. The counter logic must be atomic (use PostgreSQL row-level lock or advisory lock to prevent race conditions).
3. **Consent revocation** — sets `status = 'revoked'`, `revoked_at = now()`. Also logs to `audit_log` with action `CONSENT_REVOKED`.

**Acceptance criteria:**
- [ ] AISP call rejected when consent expired — HTTP 403 with `consent_expired` error code
- [ ] AISP call rejected at count > 4/day — HTTP 429 with `daily_limit_exceeded`
- [ ] Daily counter resets at midnight (compared by `last_access_date` string, YYYY-MM-DD)
- [ ] Consent creation writes to `audit_log`
- [ ] Consent revocation writes to `audit_log`
- [ ] Contract tests pass for all 5 ob-consent endpoints

**Dependencies:** Phase 3 complete.

**Risk:** HIGH (PSD2 regulatory compliance). Mitigation: Parallel run against Hono for 72h before cutover.

---

### Phase 5: Compliance (AML, KYC, GDPR, Consents, Disputes)

**Route files migrated:**
- `consents.ts` → `ConsentRoutes.kt`
- `complaints.ts` → `ComplaintRoutes.kt`
- `disputes.ts` → `DisputeRoutes.kt`
- `reports.ts` → `ReportRoutes.kt` (admin only)

**Endpoints:**
```
GET  /v1/consents                  → ConsentRoutes (list user consents)
POST /v1/consents                  → ConsentRoutes (record consent grant)
DELETE /v1/consents/:id           → ConsentRoutes (withdraw consent)
POST /v1/complaints                → ComplaintRoutes (submit complaint)
GET  /v1/complaints                → ComplaintRoutes (list user complaints)
GET  /v1/complaints/:id            → ComplaintRoutes
POST /v1/disputes                  → DisputeRoutes (submit dispute)
GET  /v1/disputes                  → DisputeRoutes (list user disputes)
GET  /v1/disputes/:id              → DisputeRoutes
GET  /v1/admin/reports/transactions → ReportRoutes (admin — CSV/JSON export)
GET  /v1/admin/reports/aml         → ReportRoutes (admin — AML summary)
```

**Tables:** consents, data_access_requests, complaints, disputes, audit_log, aml_alerts, str_reports, screening_results, withdrawal_requests

**Duration estimate:** L (5-7 days)

**Compliance notes:**
1. **PSD2 complaint SLA** — `complaints` must be acknowledged within 15 business days (EU PSD2 Art. 101). Ktor does not need to enforce this programmatically, but the `status` field transitions must be correct.
2. **AML audit trail** — ALL writes to `aml_alerts` and `str_reports` must produce `audit_log` entries. This is a Finanstilsynet requirement.
3. **GDPR consent** — `consents.withdrawn_at` is nullable. Withdrawal sets this timestamp. Do NOT delete the consent record (audit trail requirement).
4. **Disputes** — `sla_deadline` is 15 business days from submission. Kotlin must calculate this correctly (exclude Norwegian public holidays if the calculation is done here — check if Hono does this or delegates to a cron job).
5. **Reports** — CSV export must use the same column names and encoding as Hono. Frontend may parse this directly.

**Acceptance criteria:**
- [ ] Consent withdrawal sets `withdrawn_at`, does not delete row
- [ ] Complaint submission produces `audit_log` entry
- [ ] AML alert creation produces `audit_log` entry with `severity` mapped correctly
- [ ] Dispute `sla_deadline` calculation matches Hono output (test with known input date)
- [ ] CSV export column names match Hono exactly
- [ ] All endpoints require appropriate auth (`authMiddleware` or `adminMiddleware`)
- [ ] Contract tests pass for all compliance endpoints

**Dependencies:** Phase 4 complete.

**Risk:** MEDIUM. These are compliance-critical but not on the hot transaction path. Errors here are serious but not immediately money-losing.

---

### Phase 6: Operations (Notifications, Cron, Webhooks, Metrics, Settlement)

**Route files migrated:**
- `notifications.ts` → `NotificationRoutes.kt`
- `cron.ts` → `CronRoutes.kt`
- `metrics.ts` → `MetricsRoutes.kt`
- `webhooks.ts` → `WebhookRoutes.kt`

**Endpoints:**
```
GET  /v1/notifications             → NotificationRoutes (list, paginated)
POST /v1/notifications/mark-read  → NotificationRoutes
DELETE /v1/notifications/:id      → NotificationRoutes
POST /v1/push-tokens              → NotificationRoutes (register Expo push token)
DELETE /v1/push-tokens/:token     → NotificationRoutes
GET  /v1/cron/retention           → CronRoutes (data retention — IP-restricted)
GET  /v1/cron/rates               → CronRoutes (FX rate refresh)
GET  /v1/cron/settlement          → CronRoutes (daily settlement)
GET  /v1/cron/reconciliation      → CronRoutes (daily reconciliation)
GET  /v1/metrics                  → MetricsRoutes (Prometheus format)
POST /v1/webhooks/payment         → WebhookRoutes (banking partner webhook)
POST /v1/webhooks/partner/*       → WebhookRoutes
```

**Tables:** notifications, push_tokens, webhook_events, webhook_dlq, settlement_batches, settlement_items, reconciliation_reports, reconciliation_discrepancies

**Duration estimate:** L (5-7 days)

**Webhook security — critical:**
The TypeScript implementation uses `timingSafeEqual` for HMAC signature validation. Kotlin MUST use a constant-time comparison for webhook secrets. Java's `MessageDigest.isEqual()` is NOT constant-time. Use `org.bouncycastle` or implement explicitly.

**Settlement notes:**
`runDailySettlement` and `runDailyReconciliation` are called from cron routes. The Kotlin implementation must use database transactions (Exposed `transaction {}` block) for settlement batch creation — partial writes must roll back.

**Cron route security:**
TypeScript version uses IP allowlist check (`getClientIp` + rate limit). Kotlin must replicate this — cron routes are NOT behind JWT auth, they are behind IP filtering. This is a security boundary.

**Metrics:**
Prometheus `/v1/metrics` endpoint — use Ktor + `micrometer-prometheus` (compatible with `prom-client` metric names if configured correctly). Verify Grafana dashboard still works after cutover.

**Acceptance criteria:**
- [ ] Webhook HMAC validation uses constant-time comparison
- [ ] Duplicate webhook (`webhook_events.webhook_id` unique constraint) returns 200, not 500
- [ ] Dead letter queue write happens on processing failure
- [ ] Settlement batch creation is transactional (rollback on partial failure)
- [ ] Cron routes return 403 for non-allowlisted IPs
- [ ] Prometheus metrics endpoint returns valid exposition format
- [ ] Push token registration is idempotent (same token, same user = upsert not duplicate)

**Dependencies:** Phase 5 complete.

**Risk:** MEDIUM. Operational, not user-facing critical path.

---

### Phase 7: Admin + Merchants + Cards + Remaining

**Route files migrated:**
- `admin.ts` → `AdminRoutes.kt`
- `merchants.ts` → `MerchantRoutes.kt`
- `cards.ts` → `CardRoutes.kt`
- `withdrawal.ts` → `WithdrawalRoutes.kt`
- `openapi.ts` → `OpenApiRoutes.kt`

**Endpoints:**
```
GET  /v1/admin/audit               → AdminRoutes (paginated audit log)
GET  /v1/admin/users               → AdminRoutes (user list)
PATCH /v1/admin/users/:id/kyc     → AdminRoutes (KYC status update)
GET  /v1/admin/aml                 → AdminRoutes (AML alert list)
PATCH /v1/admin/aml/:id           → AdminRoutes (AML alert update)
GET  /v1/admin/circuit-breakers   → AdminRoutes (circuit breaker status)
POST /v1/admin/circuit-breakers/reset → AdminRoutes
GET  /v1/merchants                 → MerchantRoutes
POST /v1/merchants                 → MerchantRoutes
GET  /v1/merchants/:id             → MerchantRoutes
PUT  /v1/merchants/:id             → MerchantRoutes
POST /v1/merchants/:id/qr         → MerchantRoutes (QR code generation)
GET  /v1/cards                     → CardRoutes (feature-flagged)
POST /v1/cards                     → CardRoutes (feature-flagged)
PUT  /v1/cards/:id/freeze         → CardRoutes (feature-flagged)
POST /v1/withdrawal                → WithdrawalRoutes (angrerett)
GET  /v1/openapi.json              → OpenApiRoutes
```

**Tables:** (all remaining) cards, spending_limits, merchants

**Duration estimate:** M (3-5 days)

**Cards note:**
Cards are feature-flagged off in production. Implement the routes but gate them behind `feature.cards.enabled` config flag. This mirrors the TypeScript feature flag behavior.

**QR HMAC note:**
`merchants.qr_hmac_key` is a 32-byte hex secret per merchant. QR code validation uses HMAC-SHA256 over `merchantId:amount:timestamp`. Kotlin must replicate this exactly — any difference breaks the mobile QR scanner.

**Admin portal middleware:**
`adminPortalMiddleware` in TypeScript uses a separate session table (`admin_sessions`) and MFA verification. Verify the admin session schema exists in the DB — it may be in a migration not yet in `schema.ts`.

**Acceptance criteria:**
- [ ] QR HMAC validation produces identical results to TypeScript for test vectors
- [ ] Cards routes return 404 when `feature.cards.enabled=false`
- [ ] Admin MFA check enforced (`mfa_required` error if MFA enabled but not verified)
- [ ] Audit log pagination is cursor-based (consistent with Hono behavior)
- [ ] OpenAPI spec served at `/v1/openapi.json`

**Dependencies:** Phase 6 complete.

**Risk:** LOW-MEDIUM. Admin and feature-flagged routes are not on critical user path.

---

### Phase 8: Cutover

**Goal:** Switch all traffic to Ktor. Decommission Hono.

**Duration estimate:** S (1-2 days + monitoring period)

**Cutover checklist:**

**Pre-cutover (must all pass):**
- [ ] All phases 1-7 have been running in parallel for minimum 48h each
- [ ] Zero contract test failures across all endpoints
- [ ] Prometheus metrics match between Hono and Ktor for equivalent traffic
- [ ] No errors in Sentry from Ktor (baseline noise only)
- [ ] Load test with k6 smoke script passes on Ktor
- [ ] PII decryption test passes on production PII_ENCRYPTION_KEY (test vector from a real user record — use a test account)
- [ ] BankID OIDC full flow tested on staging with real BankID

**Cutover procedure:**
1. Enable maintenance mode for 5 minutes (display "Vi jobber" in app)
2. Update nginx config: ALL traffic → Ktor :8080
3. Remove Hono from nginx upstream (keep container running, just off traffic)
4. Wait 15 minutes, monitor Sentry + Grafana
5. If zero critical errors: disable maintenance mode
6. If errors: roll nginx back to Hono (5 minute downtime max)
7. After 72h stable: decommission Hono container and TypeScript API codebase

**Rollback SLA:** Under 5 minutes (nginx config change only, no DB change needed).

**Acceptance criteria:**
- [ ] All k6 smoke tests pass on Ktor with 0% error rate
- [ ] p99 latency within 20% of Hono baseline
- [ ] Zero 5xx errors in first 30 minutes post-cutover
- [ ] BankID login works end-to-end on mobile
- [ ] At least one successful remittance transaction through Ktor

---

## Risk Register

| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| JWT incompatibility breaks all auth | MEDIUM | CRITICAL | Phase 0 and 1 validate cross-service token validation before any traffic cut |
| PII decryption produces wrong plaintext | LOW | CRITICAL | Phase 0 builds test vectors from Hono output; Phase 2 validates |
| PISP idempotency race condition | MEDIUM | HIGH | Use PostgreSQL advisory lock or `INSERT ... ON CONFLICT` with row-level lock |
| BankID OIDC state validation mismatch | MEDIUM | HIGH | Phase 1 tests against real staging BankID, not stub |
| Settlement batch partial write | LOW | HIGH | Exposed `transaction {}` block required; tested explicitly |
| Webhook HMAC timing attack | LOW | MEDIUM | Constant-time comparison enforced in Phase 6 acceptance criteria |
| Cards QR HMAC mismatch | LOW | MEDIUM | Phase 7 test vectors from TypeScript |
| Cron routes exposed without IP filter | MEDIUM | MEDIUM | Replicated in Phase 6; tested with IP outside allowlist |
| Berlin Group 4/day AISP race condition | MEDIUM | MEDIUM | Atomic counter update with PostgreSQL row lock in Phase 4 |
| Monetary float arithmetic | LOW | CRITICAL | All amounts in Long/BigInteger; enforced by code review in Phase 3 |

---

## Agent Assignment Model

Each phase runs as a single dedicated builder agent. DO NOT combine phases.

| Phase | Agent Type | Priority | Notes |
|---|---|---|---|
| 0 | Builder (Sonnet) | H | Unblocks all other phases |
| 1 | Builder (Sonnet) | H | Auth — pair with Validator immediately |
| 2 | Builder (Sonnet) | H | After Phase 1 validated |
| 3 | Builder (Sonnet) | H | Highest risk, separate Validator required |
| 4 | Builder (Sonnet) | H | PSD2 compliance — Validator required |
| 5 | Builder (Sonnet) | M | Can start after Phase 4 |
| 6 | Builder (Sonnet) | M | Can start after Phase 5 |
| 7 | Builder (Sonnet) | M | Can start after Phase 6 |
| 8 | John + Alem | H | Cutover is an operational decision, not a build task |

Each builder agent MUST:
1. Read this plan first (ZAKON #18)
2. Read the specific route file(s) for their phase
3. Read the relevant Exposed ORM documentation from Phase 0 output
4. Run contract tests after every endpoint implementation
5. NOT proceed to the next endpoint if contract test fails

---

## Parallel Running Strategy — nginx Configuration

**Stage 1 (after Phase 1 cutover):**
```nginx
upstream hono  { server localhost:3001; }
upstream ktor  { server localhost:8080; }

location /v1/health     { proxy_pass http://ktor; }
location /v1/auth/      { proxy_pass http://ktor; }
location /v1/           { proxy_pass http://hono; }
```

**Stage 3 (after Phase 3 cutover):**
```nginx
location /v1/health        { proxy_pass http://ktor; }
location /v1/auth/         { proxy_pass http://ktor; }
location /v1/user/         { proxy_pass http://ktor; }
location /v1/settings/     { proxy_pass http://ktor; }
location /v1/transactions/ { proxy_pass http://ktor; }
location /v1/recipients/   { proxy_pass http://ktor; }
location /v1/rates/        { proxy_pass http://ktor; }
location /v1/              { proxy_pass http://hono; }
```

Continue pattern through Phase 7. nginx location blocks are ordered most-specific first.

**AWS App Runner note:** If running on AWS App Runner (current infrastructure), the nginx layer may need to be added as an intermediate ECS task or ALB listener rule. The principle is the same — route by path prefix.

---

## Effort Summary

| Phase | Effort | Cumulative |
|---|---|---|
| 0 — Foundation | L (4-6 days) | 4-6d |
| 1 — Auth | L (5-7 days) | 9-13d |
| 2 — Core Entities | M (3-4 days) | 12-17d |
| 3 — Transactions | L (6-8 days) | 18-25d |
| 4 — Banking/AISP | M (3-5 days) | 21-30d |
| 5 — Compliance | L (5-7 days) | 26-37d |
| 6 — Operations | L (5-7 days) | 31-44d |
| 7 — Admin + Cards | M (3-5 days) | 34-49d |
| 8 — Cutover | S (1-2 days) | 35-51d |

**Total: 35-51 builder-days.** With parallel Sonnet agents running 2 phases simultaneously (where dependencies allow), wall-clock time is approximately 3-4 weeks.

S = 1-2 days | M = 3-5 days | L = 5-8 days

---

## What the Next Agent (Phase 0 Builder) Must Do First

1. Read `/Users/makinja/ALAI/products/Drop/src/shared/db/schema.ts` (full file)
2. Read `/Users/makinja/ALAI/products/Drop/src/shared/crypto/pii-encryption.ts`
3. Read `/Users/makinja/ALAI/products/Drop/src/drop-api/src/server.ts` (route registration order)
4. Create `/Users/makinja/ALAI/products/Drop/src/drop-api-ktor/` directory
5. Initialize Gradle project with Ktor 3.x + Exposed + Flyway + Kotest dependencies
6. Implement all 24 Exposed Table objects (matching column names exactly)
7. Implement `PiiEncryption.kt` — AES-256-GCM with same `v1:<iv>:<tag>:<ciphertext>` format
8. Write `PiiEncryptionTest.kt` with test vectors generated from the TypeScript implementation
9. Write Flyway `V1__baseline.sql` that does nothing (baseline only)
10. Verify `./gradlew test` passes before marking phase done

---

*Plan authored after reading: BUILD-BLUEPRINT.md, all 22 route files, schema.ts (full), middleware/auth.ts, and lumiscare-to-lobby-migration-plan.md for architectural patterns.*

# drop-full-delivery-plan

# Plan: Drop Full Delivery (#7187)

## Research Summary

Full codebase audit completed (2026-04-06). Drop is 65-70% feature complete, 40% production ready.

**What exists:**
- Landing page with waitlist form (PostgreSQL-backed) — 13 languages
- Next.js web app: 27 pages, 72 API routes, CSRF/CSP security
- Hono/TS backend: 27 route modules, 20 DB tables, demo mode
- React Native/Expo mobile: 18 screens, BankID WebView, biometrics
- BankID mock + Bank mock (PISP/AISP simulation) — fully implemented
- Docker Compose (postgres, redis, api, app)
- 11 GitHub Actions workflows (CI/CD)
- 561 test files (mixed quality)

**What's missing:**
- Production deploy to Azure (currently Vercel/Fly.io)
- Comprehensive E2E test suite (~100 scenarios, currently ~3)
- CI/CD targeting Azure (currently Fly.io)
- Browser verification that bugs are fixed post-deploy
- Native mobile builds tested on device

**CEO constraints:**
- Deploy to Azure (NOT Vercel)
- Everything that exists must WORK in demo
- ~100 E2E scenarios covering all user flows
- CI/CD triggers Playwright on every push to dev/main
- Native apps for iOS + Android

## Objective

Deploy Drop to Azure with a fully functional demo (BankID mock + Bank mock), 100+ E2E test scenarios with CI/CD integration, and verified native mobile builds.

## Team Orchestration

### Team Members

| ID | Name | Role | Agent Type | Model |
|----|------|------|------------|-------|
| TL | Petter Graff | Team Lead / Architect | persona (petter-graff) | sonnet |
| B1 | infra-builder | Azure deploy + Docker + CI/CD | builder (flowforge) | sonnet |
| B2 | e2e-builder | Playwright E2E test suite (~100 scenarios) | builder (codecraft) | sonnet |
| B3 | mobile-builder | Native mobile build verification + fixes | builder (paul-hudson) | sonnet |
| B4 | landing-builder | Landing page Azure deploy + form verification | builder (frontend-builder) | sonnet |
| V1 | drop-validator | Validate all phases | validator | sonnet |
| D1 | Jake Wharton | Android advisory (consulted by B3) | persona (jake-wharton) | sonnet |
| D2 | Thaer Sabri | Payments domain advisory (consulted by TL) | persona (thaer-sabri) | sonnet |

### Step-by-Step Tasks

---

#### Phase 1: Azure Infrastructure + Deploy (B1)

**Task 1.1:** Dockerize and deploy Drop full stack to Azure
- Owner: B1 (flowforge)
- BlockedBy: none
- Description:
  - Use existing Docker Compose as base (postgres:16, redis:7, drop-api, drop-app)
  - Deploy to Azure VM 4.223.110.181 or new Azure Container Apps
  - Configure DNS: app.getdrop.no → Azure (not Vercel)
  - Configure DNS: getdrop.no → Azure (static landing from landing/)
  - Set DROP_MODE=demo, SEED_DEMO=true
  - Ensure PostgreSQL persistent volume
  - Ensure Redis for rate limiting/sessions
  - Configure SSL/TLS (Let's Encrypt or Azure managed cert)
  - Set environment variables (DATABASE_URL, JWT_SECRET, PII_ENCRYPTION_KEY, etc.)
- Files owned: docker-compose.production.yml, infrastructure/, .github/workflows/deploy*.yml
- Acceptance:
  - [ ] app.getdrop.no loads on Azure (not white page)
  - [ ] getdrop.no landing loads with waitlist form
  - [ ] Waitlist form submits and stores in PostgreSQL
  - [ ] Demo login works at app.getdrop.no/login
  - [ ] Health endpoint returns 200: app.getdrop.no/api/health
  - [ ] SSL certificate valid

**Task 1.2:** Configure CI/CD for Azure deploy
- Owner: B1 (flowforge)
- BlockedBy: 1.1
- Description:
  - Update .github/workflows/ci.yml to run Playwright E2E on every push to dev/main
  - Create .github/workflows/deploy-azure.yml for production deploy to Azure
  - Trigger: push to main → build → test → deploy
  - Trigger: push to dev → build → test (no deploy)
  - Use GitHub Actions with SSH deploy or Azure CLI
- Files owned: .github/workflows/
- Acceptance:
  - [ ] Push to dev triggers lint + test + Playwright
  - [ ] Push to main triggers lint + test + Playwright + Azure deploy
  - [ ] Failed tests block deploy

---

#### Phase 2: E2E Test Suite (B2) — PARALLEL with Phase 1

**Task 2.1:** Create comprehensive Playwright E2E test suite (~100 scenarios)
- Owner: B2 (codecraft)
- BlockedBy: none (can write tests against local docker compose)
- Description:
  - Use existing playwright.config.ts as base
  - Create organized test structure in src/drop-app/tests/e2e/
  - All tests run against demo mode (DROP_MODE=demo)
  - Test categories and approximate scenario counts:

  **Authentication (12 scenarios):**
  - Demo login happy path
  - Demo login with different users (standard, merchant, admin)
  - Login form validation (empty, invalid email, wrong password)
  - Logout
  - Session expiry handling
  - CSRF protection verification
  - Rate limiting on login
  - BankID mock flow initiation
  - Register new account
  - Register validation (age <18, invalid phone)
  - Auth redirect (unauthenticated user → login)
  - Multiple device session handling

  **Onboarding (8 scenarios):**
  - Complete onboarding flow
  - Skip optional steps
  - KYC verification mock
  - Phone number validation (+47 only)
  - Age verification (must be 18+)
  - Terms acceptance required
  - Back navigation during onboarding
  - Resume incomplete onboarding

  **Dashboard (8 scenarios):**
  - Dashboard loads with balance
  - Recent transactions display
  - Quick action buttons work
  - Pull-to-refresh behavior
  - Empty state (new user)
  - Currency display (NOK)
  - Navigation to all sections
  - Notification badge count

  **Send Money / Remittance (15 scenarios):**
  - Send to existing recipient
  - Add new recipient + send
  - Amount validation (min, max, decimal)
  - Currency selection (30+ countries)
  - Fee calculation display (0.5%)
  - Exchange rate display
  - Confirmation screen
  - Transaction success
  - Transaction failure handling
  - Receipt generation
  - Send again from history
  - Rate lock (30-min expiry)
  - Insufficient balance handling
  - AML threshold warning
  - Cancel mid-transaction

  **QR Payments (8 scenarios):**
  - Generate QR code (merchant)
  - Scan QR code (customer)
  - QR payment confirmation
  - QR payment success
  - Invalid QR code handling
  - QR amount pre-filled
  - QR payment receipt
  - Merchant dashboard after QR payment

  **Bank Accounts (6 scenarios):**
  - View linked accounts
  - Link new bank account (AISP mock)
  - Refresh balance
  - Multiple accounts display
  - Unlink account
  - Consent renewal flow

  **Transaction History (10 scenarios):**
  - List all transactions
  - Filter by date range
  - Filter by type (sent/received)
  - Search transactions
  - Transaction detail view
  - Receipt download/view
  - CSV export
  - Pagination/infinite scroll
  - Empty state
  - Transaction status indicators

  **Merchant (8 scenarios):**
  - Register as merchant
  - Merchant dashboard loads
  - QR code generation
  - Sales overview
  - Transaction list (merchant view)
  - Settlement statement
  - Merchant profile edit
  - Merchant fee display

  **Profile & Settings (12 scenarios):**
  - View profile
  - Edit personal details
  - Change language (nb, en, etc.)
  - Notification preferences toggle
  - Security settings
  - Biometric lock toggle (face unlock)
  - Privacy policy accessible
  - Terms of service accessible
  - GDPR data export request
  - GDPR objection/rectification
  - Help/FAQ accessible
  - Complaint filing

  **Notifications (5 scenarios):**
  - Notification list loads
  - Mark as read
  - Mark all as read
  - Unread count badge
  - Empty notifications state

  **Edge Cases & Error Handling (8 scenarios):**
  - Network error handling
  - 500 server error display
  - 404 page
  - Session expired mid-action
  - Concurrent tab handling
  - Mobile viewport responsiveness
  - Accessibility (keyboard navigation)
  - Input sanitization (XSS prevention)

  **TOTAL: ~100 scenarios**

- Files owned: src/drop-app/tests/e2e/**
- Acceptance:
  - [ ] 100+ test scenarios written
  - [ ] All tests pass against local Docker compose (demo mode)
  - [ ] Tests organized by feature area
  - [ ] Each test has descriptive name
  - [ ] Tests are independent (no order dependency)
  - [ ] Test report generated (HTML reporter)

---

#### Phase 3: Landing Page Azure Deploy (B4) — PARALLEL with Phase 1 & 2

**Task 3.1:** Deploy landing page to Azure
- Owner: B4 (frontend-builder)
- BlockedBy: none
- Description:
  - Landing page is static HTML (landing/index.html + api/waitlist.js)
  - Deploy static files via Nginx on Azure VM or Azure Static Web Apps
  - Waitlist API needs Node.js runtime (serverless or Express wrapper)
  - Configure getdrop.no DNS → Azure
  - Ensure DATABASE_URL for waitlist PostgreSQL
  - Verify form submission works end-to-end
- Files owned: landing/, nginx configs
- Acceptance:
  - [ ] getdrop.no loads landing page
  - [ ] Waitlist form submits successfully
  - [ ] Email stored in PostgreSQL waitlist_signups table
  - [ ] All 13 language variants accessible
  - [ ] SSL certificate valid
  - [ ] Mobile responsive

---

#### Phase 4: Mobile Build Verification (B3) — PARALLEL

**Task 4.1:** Verify and fix React Native mobile builds
- Owner: B3 (paul-hudson persona for iOS guidance)
- BlockedBy: none
- Description:
  - Verify iOS build compiles (EAS Build)
  - Verify Android build compiles (EAS Build)
  - Test demo mode on iOS Simulator
  - Test demo mode on Android Emulator
  - Verify BankID WebView integration in demo mode
  - Verify biometric auth flow
  - Fix any build errors
  - Document build steps
  - Consult jake-wharton persona for Android-specific issues (Samsung Knox, biometric)
- Files owned: src/drop-mobile/
- Acceptance:
  - [ ] iOS build compiles without errors
  - [ ] Android build compiles without errors
  - [ ] Demo login works on iOS Simulator
  - [ ] Demo login works on Android Emulator
  - [ ] Navigation through all 18 screens works
  - [ ] Biometric auth prompt appears

---

#### Phase 5: Validation (V1) — AFTER Phases 1-4

**Task 5.1:** Validate entire Drop deployment
- Owner: V1 (validator)
- BlockedBy: 1.1, 1.2, 2.1, 3.1, 4.1
- Description:
  - Browser verification: app.getdrop.no loads and demo works
  - Browser verification: getdrop.no form submits
  - Run full Playwright E2E suite against Azure deployment
  - Verify CI/CD pipeline triggers correctly
  - Verify mobile builds
  - Check HiveMind updates from all builders
  - Run qa-19.js quality gate
- Acceptance:
  - [ ] app.getdrop.no renders (NOT white page) — Playwright screenshot
  - [ ] getdrop.no waitlist form submits — Playwright screenshot
  - [ ] E2E suite: 90%+ pass rate on Azure
  - [ ] CI/CD: test push to dev triggers workflow
  - [ ] Mobile: builds compile
  - [ ] qa-19.js score >= 17/19

---

## Validation Commands

```bash
# Health check
curl -s https://app.getdrop.no/api/health | jq .

# Landing page
curl -s -o /dev/null -w "%{http_code}" https://getdrop.no

# Waitlist form
curl -s -X POST https://getdrop.no/api/waitlist -H "Content-Type: application/json" -d '{"email":"test@test.com"}'

# Run E2E tests against Azure
cd ~/ALAI/products/Drop/src/drop-app && BASE_URL=https://app.getdrop.no npx playwright test

# CI/CD verification
gh workflow list -R ALAI/Drop
gh run list -R ALAI/Drop --limit 5

# QA gate
node ~/system/tools/qa-19.js check 7187

# Mobile builds
cd ~/ALAI/products/Drop/src/drop-mobile && eas build --platform all --profile preview --non-interactive
```

## Execution Order

```
Phase 1 (B1: Azure infra)  ──┐
Phase 2 (B2: E2E tests)    ──┼──→ Phase 5 (V1: Validate all)
Phase 3 (B4: Landing)      ──┤
Phase 4 (B3: Mobile)       ──┘
```

All 4 builder phases run IN PARALLEL. Validator runs after all complete.

## Risk Mitigations

| Risk | Mitigation |
|------|------------|
| Azure VM resource limits (4GB RAM) | Use Container Apps if needed, or scale VM |
| DNS propagation delay | Pre-configure Azure IP, use low TTL |
| Playwright flaky on CI | Use retry: 1, serial mode for auth tests |
| Mobile EAS build queue | Use local builds as fallback |
| Hook permission blocks | Builders use worktree isolation |

## Notes

- Backend is Hono/TS (migration to Kotlin pending MC #5124) — we deploy CURRENT stack, not migrated
- BankID + Bank mock already exist — no need to build from scratch
- Demo mode (DROP_MODE=demo) is the target, not production with real banking
- Cards feature stays disabled (feature-flagged off)

# drop-srbija-plan

# Plan: Drop Srbija — Phase 2 Build Plan

**Created:** 2026-04-16
**Product:** Drop Srbija — Serbian market phone-based payment app
**Scaffold:** ~/ALAI/products/DropSrbija/
**Status:** Scaffold complete. Phase 2 build begins.

---

## Research Summary (5-Expert Gap Analysis)

### Critical Discoveries

| # | Finding | Severity | Expert |
|---|---------|---------|--------|
| 1 | NBS IPS is ISO 20022 XML via mTLS — NOT REST. Blueprint's `https://ips.nbs.rs/api/v1` endpoint DOES NOT EXIST | P0 | Markos Zachariadis |
| 2 | Drop cannot connect to NBS IPS directly — must go through a licensed bank partner | P0 | Markos Zachariadis |
| 3 | No Serbian d.o.o. legal entity → NBS PI license application impossible | P0 | Thaer Sabri |
| 4 | No NBS PI license application initiated — operating live IPS is criminal violation | P0 | Thaer Sabri |
| 5 | OTP stored as `hash($otp)` = plaintext string — NOT bcrypt. Security theater. | P0 | Petter Graff / Angie Jones |
| 6 | Phone-to-IBAN resolution missing — NBS IPS needs IBAN, not phone. No Serbian registry exists | P0 | Markos Zachariadis |
| 7 | Phase 3 (live IPS) BEFORE Phase 4 (KYC) = ZPNFTM AML violation — sequence is illegal | P0 | Thaer Sabri |
| 8 | 69 test cases needed, 0 exist. No test infrastructure, no src/test directory | P0 | Angie Jones |
| 9 | NBS IPS P2P transfers are FREE by regulation — per-transaction fee model impossible for consumers | P1 | Markos Zachariadis + BA |
| 10 | IPS QR must use DinaCard standard — custom HMAC QR unreadable by all Serbian bank apps | P1 | Markos Zachariadis |
| 11 | No SMS provider integrated (Twilio referenced but not implemented) | P1 | Petter Graff |
| 12 | Sanctions screening references "NBS SDN list" — doesn't exist. Must use UN + EU + Serbian lists | P1 | Thaer Sabri |
| 13 | Pre-transaction disclosure screen missing — legally required by Law on Payment Services | P1 | Thaer Sabri |
| 14 | USPNFT AML reporting module entirely absent from architecture | P1 | Thaer Sabri |
| 15 | No CI/CD pipeline, no Dockerfile, no docker-compose.yml for DropSrbija | P1 | Petter Graff |

### Revenue Model Reality
- **Consumer P2P:** Must be FREE (NBS regulation mandate)
- **Merchant QR:** 0.5–1.2% per transaction (competitive vs. card at 1.5–1.8%)
- **B2B payroll:** Flat fee per batch
- **NBS IPS cap:** 300,000 RSD (~€2,550) per transaction hard limit
- **Bank partner timeline:** 12–18 months to live IPS integration

### CEO-Level Decisions Required
1. **Incorporate Drop Srbija d.o.o. in Serbia** — min capital EUR 125,000
2. **Bank partner outreach** — Priority: Raiffeisen Serbia, MTS Banka (Telekom Srbija subsidiary — phone data synergy)
3. **NBS PI license application** — 12–14 months, requires Serbian legal counsel

---

## Objective

Build Drop Srbija from scaffold to production-ready MVP: fix all P0 security/legal blockers, implement bank partner adapter architecture, build KYC/AML compliance layer, write 69+ test cases, set up CI/CD — in correct regulatory sequence (KYC → IPS, not IPS → KYC).

---

## Team Orchestration

### Team Members
| ID | Name | Role | Company | Agent Type |
|----|------|------|---------|------------|
| B1 | petter-graff | Backend architecture + security fixes | CodeCraft | backend-builder |
| B2 | finverge | Payments compliance + AML architecture | Finverge | finverge |
| B3 | lexicon | Legal docs + ZZPL compliance | Lexicon | lexicon |
| B4 | proveo | QA — all test suites | Proveo | proveo |
| B5 | flowforge | DevOps — CI/CD, Docker | FlowForge | devops-dev |
| B6 | vizu | Frontend — disclosure screens, complaints UI | Vizu | vizu |
| V1 | angie-jones | Test validation — all builds | Proveo | angie-jones |
| V2 | sentinel-validator | Cross-reference final report | SENTINEL | sentinel-validator |

---

## Step-by-Step Tasks

### Phase 0: CEO Decisions (Escalated — Not Delegated to Builders)

**Task 0a:** Incorporate Drop Srbija d.o.o. + initiate NBS PI license
- Owner: Alem Basic (CEO)
- Estimated cost: EUR 125,000 minimum capital + Serbian legal counsel fees
- Lexicon can draft the application package; Serbian advocate must sign/submit
- Timeline: 12–18 months to authorization

**Task 0b:** Bank partner outreach
- Priority 1: Raiffeisen Bank Srbija (developer portal, fintech-friendly)
- Priority 2: MTS Banka (Telekom Srbija — phone-to-IBAN synergy)
- Documents prepared by: sentinel-ba (Task 10 below)

---

### Phase 1: Security P0 Fixes (CodeCraft)

**Task 1: Fix OTP Security (CRITICAL BLOCKER)**
- Owner: B1 (petter-graff)
- BlockedBy: none
- Acceptance:
  - [ ] `PhoneOtpService.hashOtp()` uses bcrypt (BCrypt.hashpw, cost factor 12)
  - [ ] `PhoneOtpService.verifyOtpHash()` uses BCrypt.checkpw
  - [ ] Stored OTP in DB is bcrypt hash, not `"hash($otp)"` string
  - [ ] Existing unit tests pass (or are updated to match)
  - [ ] No plaintext OTP ever written to logs

**Task 2: Fix Phone Regex + Add Serbian Format Normalisation**
- Owner: B1 (petter-graff)
- BlockedBy: none
- Acceptance:
  - [ ] Regex updated: `^\+381[0-9]{8,9}$` (8–9 digits after country code)
  - [ ] Normalisation: `0641234567` → `+381641234567` at route layer
  - [ ] Landline prefix (+38111, +38121 etc.) rejected for OTP
  - [ ] Test fixtures documented in code

**Task 3: Per-Phone OTP Rate Limiting**
- Owner: B1 (petter-graff)
- BlockedBy: none
- Acceptance:
  - [ ] 3 OTP requests per phone per minute → 4th returns 429
  - [ ] 10 OTP requests per phone per hour → returns 429
  - [ ] Redis used as rate limit store (already in docker-compose)
  - [ ] Rate limit headers in response (X-RateLimit-Remaining)

**Task 4: SMS Provider Integration (SmsGateway abstraction + Twilio)**
- Owner: B1 (petter-graff)
- BlockedBy: Task 1
- Acceptance:
  - [ ] `SmsGateway` interface with `sendOtp(phone: String, otp: String): SmsResult`
  - [ ] `TwilioSmsGateway` implementation using Twilio REST API
  - [ ] `StubSmsGateway` for dev/test (prints OTP to logs)
  - [ ] `PhoneOtpService` injects `SmsGateway` (DI via Koin/manual)
  - [ ] Twilio credentials from environment variables (not hardcoded)
  - [ ] ENV: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_FROM_NUMBER

**Task 5: Validate Task 1–4 (Security Fixes)**
- Owner: V1 (angie-jones)
- BlockedBy: Tasks 1, 2, 3, 4
- Acceptance:
  - [ ] OTP in `phone_verifications` table is bcrypt hash (verified via SELECT)
  - [ ] Attempting brute force OTP (6 wrong guesses) returns correct HTTP 400
  - [ ] 4th OTP request in 1 minute returns HTTP 429
  - [ ] Phone `+381123456` (too short) rejected at route layer

---

### Phase 2: Architecture (CodeCraft)

**Task 6: NBS IPS Bank Partner Adapter Pattern**
- Owner: B1 (petter-graff)
- BlockedBy: Task 1
- Acceptance:
  - [ ] `BankPartnerAdapter` interface with `initiateTransfer()`, `checkStatus()`, `resolvePhoneToAccount()`
  - [ ] `StubBankPartnerAdapter` — realistic mock with ISO 20022 status codes (ACCP, ACSC, RJCT, PDNG)
  - [ ] `RaiffeisenBankAdapter` — skeleton with mTLS config hooks
  - [ ] Amount validation: `amount > 300_000 RSD` → HTTP 422 `AMOUNT_EXCEEDS_IPS_LIMIT`
  - [ ] `NbsIpsLogs.request_body` stores ISO 20022 XML (not JSON stub)
  - [ ] `partner_bank` column added via Flyway V2 migration

**Task 7: Phone-to-IBAN Resolution Layer**
- Owner: B1 (petter-graff)
- BlockedBy: Task 6
- Acceptance:
  - [ ] `linked_accounts` table via Flyway V3 (id, user_id, iban, bank_name, is_primary, verified_at)
  - [ ] IBAN validation: Serbian RS + 20 digits with checksum
  - [ ] `AccountLinkingService.resolvePhoneToIban(phone)` returns primary IBAN or null
  - [ ] `GET /v1/accounts`, `POST /v1/accounts/link`, `PATCH /v1/accounts/{id}/set-primary`
  - [ ] `POST /v1/ips/initiate` returns 404 `RECIPIENT_NOT_REGISTERED` if phone not linked
  - [ ] Onboarding flow requires IBAN link before first payment

**Task 8: Transaction Idempotency**
- Owner: B1 (petter-graff)
- BlockedBy: Task 7
- Acceptance:
  - [ ] `Idempotency-Key` header on `POST /v1/ips/initiate`
  - [ ] Duplicate request with same key returns original response (not double charge)
  - [ ] Idempotency key stored in Transactions table
  - [ ] 60-minute idempotency window

**Task 9: Validate Task 6–8 (Architecture)**
- Owner: V1 (angie-jones)
- BlockedBy: Tasks 6, 7, 8
- Acceptance:
  - [ ] POST /v1/ips/initiate with amount 300,001 → HTTP 422
  - [ ] POST /v1/ips/initiate to unlinked phone → HTTP 404
  - [ ] Duplicate initiate with same Idempotency-Key → single transaction in DB

---

### Phase 3: Compliance Architecture (Finverge + CodeCraft)

**Task 10: KYC Service (Veriff/Sumsub + JMBG)**
- Owner: B2 (finverge)
- BlockedBy: Task 6
- Acceptance:
  - [ ] `KycService.kt` with `createKycSession()`, `handleKycWebhook()`, `updateKycStatus()`
  - [ ] `kyc_sessions` table via Flyway V5
  - [ ] JMBG field added to Users: `jmbg_encrypted`, `jmbg_hash` (Flyway V6)
  - [ ] `KycRequiredPlugin` gates `/v1/ips/initiate` → 403 if kyc_status ≠ VERIFIED
  - [ ] Human-in-the-loop review step before VERIFIED status
  - [ ] Phase sequence enforced: KYC BEFORE live IPS

**Task 11: AML Monitoring + USPNFT Reporting**
- Owner: B2 (finverge)
- BlockedBy: Task 10
- Acceptance:
  - [ ] Velocity rules: flag user exceeding 120,000 RSD in 24h
  - [ ] Structuring detection: multiple sub-threshold transactions in pattern
  - [ ] STR workflow: alert → compliance review → USPNFT eUprava export (XML)
  - [ ] Sanctions screening: UN consolidated + EU restrictive + Serbian Government lists
  - [ ] All references to "NBS SDN list" removed/corrected in codebase + docs
  - [ ] `aml_flags` table with risk_level, flag_reason, reviewed_by, resolved_at

**Task 12: Pre-Transaction Disclosure + Post-Settlement Receipt**
- Owner: B1 (petter-graff) backend + B6 (vizu) frontend
- BlockedBy: Task 11
- Acceptance:
  - [ ] Confirmation screen before POST /v1/ips/initiate: amount, fee, execution time, exchange rate
  - [ ] `disclosure_acknowledged: true` flag in initiation payload + stored in Transactions
  - [ ] NBS IPS settlement webhook handler → push notification/in-app receipt
  - [ ] Receipt contains: tx reference, amount, fee, value date, recipient ID
  - [ ] Flyway migration adds `disclosure_acknowledged` to Transactions table

**Task 13: Complaints Handling Module**
- Owner: B1 (petter-graff) backend + B6 (vizu) frontend
- BlockedBy: Task 12
- Acceptance:
  - [ ] `complaints` table (id, user_id, transaction_id, category, status, submitted_at, resolved_at)
  - [ ] `POST /v1/complaints` (authenticated user)
  - [ ] `GET /admin/complaints` (compliance officer)
  - [ ] SLA alert: flag if complaint > 10 working days unresolved
  - [ ] Resolution letter template in Serbian with NBS escalation notice

**Task 14: Legal Documents Package (Lexicon)**
- Owner: B3 (lexicon)
- BlockedBy: none (parallel)
- Acceptance:
  - [ ] Privacy policy in Serbian (ZZPL Article 23 compliant)
  - [ ] DPIA for KYC biometric processing (ZZPL Article 54)
  - [ ] NBS PI license application package (business plan, org chart, AML programme)
  - [ ] Framework contract for payment service users (Serbian, Law on Payment Services Articles 60-70)
  - [ ] NBS PISP license requirements checklist
  - [ ] Bank partnership pitch document
  - [ ] Incident notification procedure (NBS 4h initial, NBS 72h detailed, Poverenik 72h)
  - [ ] All saved to: ~/ALAI/products/DropSrbija/comms/decisions/

**Task 15: Validate Task 10–13 (Compliance)**
- Owner: V1 (angie-jones)
- BlockedBy: Tasks 10, 11, 12, 13
- Acceptance:
  - [ ] User with kyc_status = PENDING cannot initiate payment → 403
  - [ ] Payment attempt with sanctioned phone → 403 + audit log entry
  - [ ] Complaint submission → DB record + SLA timer started
  - [ ] Disclosure screen appears before payment confirmation

---

### Phase 4: DevOps (FlowForge)

**Task 16: Dockerfile + Docker Compose for DropSrbija**
- Owner: B5 (flowforge)
- BlockedBy: Task 4 (SMS env vars needed)
- Acceptance:
  - [ ] `Dockerfile.drop-srbija-api` with non-root user (uid 1001)
  - [ ] Multi-stage build (builder + runtime)
  - [ ] `docker-compose.yml` with all 4 services: postgres:5434, redis:6380, api:3003, frontend:3000
  - [ ] `docker-compose.production.yml` with SEED_DEMO=false
  - [ ] Health checks on all containers
  - [ ] `.env.example` with all required variables documented

**Task 17: CI/CD Pipeline**
- Owner: B5 (flowforge)
- BlockedBy: Task 16
- Acceptance:
  - [ ] `.github/workflows/test.yml` — run Kotest on every PR
  - [ ] `.github/workflows/build.yml` — docker build on push to develop
  - [ ] `.github/workflows/deploy-staging.yml` — deploy to staging on merge to develop
  - [ ] Quality gate: fails if test coverage < 60%
  - [ ] Sonar integration (reuse pattern from Drop Norway)

**Task 18: Validate Task 16–17 (DevOps)**
- Owner: B5 (flowforge) + V1 (angie-jones)
- BlockedBy: Tasks 16, 17
- Acceptance:
  - [ ] `docker-compose up` starts all 4 containers healthy
  - [ ] `GET http://localhost:3003/health` returns 200
  - [ ] CI pipeline runs on test PR without errors

---

### Phase 5: Test Suites (Proveo + CodeCraft)

**Task 19: Kotest + Testcontainers + WireMock Infrastructure**
- Owner: B1 (petter-graff)
- BlockedBy: Task 16
- Acceptance:
  - [ ] `build.gradle.kts` test dependencies: kotest-runner-junit5, kotest-assertions-core, testcontainers-postgresql, wiremock-jre8, ktor-server-test-host
  - [ ] `AbstractIntegrationTest` base class: starts PG16 container, runs Flyway, truncates tables between tests
  - [ ] `FakeSmsGateway` implementation
  - [ ] `docker-compose.test.yml` (PG only)
  - [ ] `Makefile` target `make test`

**Task 20: PhoneOtpService Tests (10 cases)**
- Owner: B4 (proveo)
- BlockedBy: Task 19
- Acceptance:
  - [ ] `PhoneOtpServiceTest.kt` — 10 test cases per Angie Jones spec
  - [ ] OTP bcrypt storage verified
  - [ ] Expiry, attempts lock, re-request all tested
  - [ ] All 10 pass

**Task 21: OTP Rate Limiting Tests (5 cases)**
- Owner: B4 (proveo)
- BlockedBy: Tasks 3, 19
- Acceptance:
  - [ ] 5 rate limiting scenarios tested
  - [ ] Per-phone independence verified
  - [ ] All 5 pass

**Task 22: NBS IPS WireMock Tests (9 cases)**
- Owner: B4 (proveo)
- BlockedBy: Tasks 6, 19
- Acceptance:
  - [ ] WireMock stubs for ACCP, RJCT, 500, timeout, 429 scenarios
  - [ ] NbsIpsLogs verified after each scenario
  - [ ] All 9 pass

**Task 23: Amount Validation + AML Threshold Tests (19 cases)**
- Owner: B4 (proveo)
- BlockedBy: Tasks 8, 11, 19
- Acceptance:
  - [ ] Amount edge cases: 0, -1, MAX_INT, decimal, >300k RSD
  - [ ] AML threshold: >120k RSD flags correctly
  - [ ] Sanctioned phone → 403
  - [ ] All 19 pass

**Task 24: JWT Security Tests (10 cases)**
- Owner: B4 (proveo)
- BlockedBy: Task 19
- Acceptance:
  - [ ] Wrong issuer, expired, tampered, wrong audience all → 401
  - [ ] 401 responses don't leak internal info
  - [ ] All 10 pass

**Task 25: Playwright E2E Tests (6 journeys, Serbian locale)**
- Owner: B4 (proveo)
- BlockedBy: Task 16 (needs running stack)
- Acceptance:
  - [ ] `playwright.config.ts` with sr-RS locale, Belgrade timezone, iPhone 14 viewport
  - [ ] 6 user journeys: happy login, invalid phone, wrong OTP, network error, session persist, logout
  - [ ] All 6 pass against running docker-compose stack

**Task 26: Validate All Test Suites**
- Owner: V1 (angie-jones)
- BlockedBy: Tasks 20, 21, 22, 23, 24, 25
- Acceptance:
  - [ ] `./gradlew test` — all test suites pass
  - [ ] `npx playwright test` — all E2E journeys pass
  - [ ] Total test count ≥ 69
  - [ ] No test suite has 0 tests
  - [ ] Coverage report shows ≥ 60% on modules with tests

---

### Phase 6: Business Development (Skybound/BA)

**Task 27: Bank Partnership Outreach Package**
- Owner: B2 (finverge) + Skybound BA
- BlockedBy: none (parallel)
- Acceptance:
  - [ ] `serbian-banks-api-landscape.md` — Raiffeisen, MTS Banka, ProCredit, NLB with API capabilities
  - [ ] `serbian-bank-partnership-pitch.md` — one-page pitch deck content
  - [ ] `nbs-pisp-license-requirements.md` — full checklist, capital requirements, timeline
  - [ ] Recommendation: start as bank agent → own license Year 2
  - [ ] Saved to ~/ALAI/products/DropSrbija/comms/decisions/

---

### Phase 7: Validation (End-to-End)

**Task 28: Full E2E Scaffold + Feature Validation**
- Owner: V1 (angie-jones) + V2 (sentinel-validator)
- BlockedBy: All Phase 1–6 tasks
- Acceptance:
  - [ ] docker-compose up — all 4 containers healthy
  - [ ] OTP flow end-to-end (request → verify → JWT)
  - [ ] IBAN link → IPS initiate → stub PENDING response
  - [ ] KYC gate enforced (unverified user blocked)
  - [ ] AML flag triggered on large transaction
  - [ ] All DB tables exist (8 tables including new ones)
  - [ ] Frontend compiles (`next build`)
  - [ ] Evidence: screenshots, curl outputs, DB query results

---

### Phase 8: Documentation (Skillforge)

**Task 29: BookStack Documentation**
- Owner: Skillforge
- BlockedBy: Task 28
- Acceptance:
  - [ ] BookStack page: Drop Srbija architecture overview
  - [ ] Regulatory compliance notes (NBS, ZPNFTM, ZZPL)
  - [ ] Developer onboarding guide
  - [ ] Runbook: what to do when NBS IPS goes down
  - [ ] Decision log: bank adapter pattern rationale

---

## Validation Commands

```bash
# Backend tests
cd ~/ALAI/products/DropSrbija/backend
./gradlew test --info

# E2E
cd ~/ALAI/products/DropSrbija/frontend
npx playwright test --reporter=html

# Docker stack
cd ~/ALAI/products/DropSrbija
docker-compose up -d
curl http://localhost:3003/health

# DB check
docker exec -it dropsrbija-postgres psql -U dropsrbija -d dropsrbija_dev -c '\dt'
```

---

## Priority Matrix

| Priority | Tasks | Rationale |
|---------|-------|-----------|
| **P0 — Build Now** | 1, 2, 3, 4, 6, 7, 14, 16, 19 | Security, architecture, legal, DevOps foundations |
| **P1 — Build Next** | 5, 8, 10, 11, 17, 20–25, 27 | Compliance, CI, test suites |
| **P2 — Build After** | 12, 13, 15, 18, 26, 28, 29 | UX, validation, docs |
| **CEO Decision** | 0a, 0b | Capital commitment, bank outreach |

---

**Last Updated:** 2026-04-16
**Experts consulted:** Markos Zachariadis (Finverge), Thaer Sabri (Lexicon), Angie Jones (Proveo), Petter Graff (CodeCraft), Sentinel BA (Skybound)

# drop-srbija-followup-plan-2026-04-23

# Drop Srbija — Follow-Up Plan

**Author:** John (AI Director, ALAI Holding AS)
**Date:** 2026-04-23
**Status:** ✅ APPROVED (CEO Alem Basic, 2026-04-24) — Track A+B formally signed off, de-facto already executing (MC #8807, #8808)
**Supersedes:** Phase 0 Task 0a of `drop-srbija-plan.md` (incorporation)
**References:**
- Original plan: `~/system/specs/drop-srbija-plan.md` (434 lines, 9 phases, 138 tasks)
- Legal entity decision: `~/.claude/projects/-Users-makinja/memory/project_drop_srbija_legal_entity.md`

---

## 1. Zašto Follow-Up?

Original Drop Srbija plan (2026-04-16) je napisan prije CEO odluke o legal entity. Trenutno stanje:

- **41 taskova u `[review]` statusu** — builderi završili, čekaju Proveo/validator
- **1 task u `[open]`** — nije jasno šta
- **Critical gap:** originalni plan kaže "Incorporate Drop Srbija d.o.o. (EUR 125,000 capital)" — ODLUKA JE bila **ALAI Tech d.o.o.** (konsolidovan entitet). Ovo nije propagirano kroz plan dokumente.
- **External blokeri** (NBS PI license 12–18 mjeseci, bank partnership 12–18 mjeseci, kapital EUR 125K) **ograničavaju obim dostupnog rada**
- **Dan-po-dan napredak zaustavljen** — fokus ALAI-a je Bilko + admin procesi za srpski market (ePorezi, SEF) koji su zajednički za Bilko Srbija i Drop Srbija

Follow-up plan razrješava ove neslaganja i daje **konkretne taskove za ovu sedmicu** (ne 138).

---

## 2. Stvarno Stanje — Audit

### Šta JE gotovo (verified u MC kao review)

| Kategorija | MC taskovi | Status |
|---|---|---|
| Security (OTP, bcrypt, rate limit, SMS) | #7984–7988 | Review |
| NBS IPS Adapter + Phone-to-IBAN | #7989, #7990 | Review |
| AML + USPNFT reporting | #7994 | Review |
| Legal Docs (Serbian) | #7997 | Review |
| DevOps (Docker, CI/CD) | #7999, #8000, #8046 | Review |
| Test suites (WireMock, AML, E2E) | #7991, #7995, #7996, #8005, #8006, #8008 | Review |
| Bank partnership package | #8010 | Review |
| v2 Phase 1 cleanup (Angie validation) | #8249 | Review |

**Ukupno:** 41 taskova u review koji su implementirali ~80% Phase 1–5 originalnog plana.

### Šta NIJE gotovo

**Blokirano na eksterno:**
- Phase 0 Task 0a — incorporation → **ALAI Tech d.o.o. već postoji**, ali EUR 125K kapital za NBS PI → čeka #1927 (Alem action)
- Phase 0 Task 0b — bank partnership → outreach package spreman (#8010), nije poslat
- Phase 0 Task 0c — NBS PI license → 12–18 mj timeline, #1650 blocker

**Nije krenulo:**
- Phase 7 — E2E validacija (čeka Phase 1–6 da sve bude done, ne review)
- Phase 8 — Documentation (BookStack) — Skillforge
- Update legal dokumenata sa "ALAI Tech d.o.o." umjesto "Drop Srbija d.o.o."

---

## 3. Šta Možemo SADA (bez eksternih blokera)

### Track A — Validacija backlog-a (41 taskova)

**Cilj:** Smanjiti `review` → `done` za taskove koji su stvarno gotovi. Smanjuje noise.

**Akcija:** Proveo bulk review (sličan pattern kao #8779 za RAG) — jedan dispatch, prolazi kroz svih 41 taskova, označava done ili vraća u rework.

**Evidence zahtjev:** svaki task mora imati verifikovani artifact (test output, file path, curl response). Ako nema — vraća se.

**Trajanje:** 2–3 sata agent time.

### Track B — Legal Dokumenti Ispravka

**Cilj:** Sve legalne dokumente prebaciti sa "Drop Srbija d.o.o." na "ALAI Tech d.o.o." (per memorija).

**Dokumenti:**
- `~/ALAI/products/DropSrbija/comms/decisions/nbs-pi-license-application-package.md`
- `~/ALAI/products/DropSrbija/comms/decisions/privacy-policy-sr.md`
- `~/ALAI/products/DropSrbija/comms/decisions/framework-contract-payment-users-sr.md`
- `~/ALAI/products/DropSrbija/comms/decisions/recommendation-year1-vs-year2.md`
- BookStack "Regulatory Compliance" stranica

**Vlasnik:** Lexicon (legal agent)

**Trajanje:** 1 sat agent time.

### Track C — Bank Partnership Outreach (čeka CEO go-ahead)

**Status:** Package pripremljen (#8010, review), priority 1 Raiffeisen + priority 2 MTS Banka

**Blok:** Nije poslat jer čeka Alem "pokrenuo sam kapital" decision — bank neće razgovarati ozbiljno dok ne vide EUR 125K u banci i NBS PI application u toku.

**Akcija:** **ČEKA #1927** (Alem kapital akcija). Ne dispatch-ujemo dok ne dođe greenlight.

### Track D — Phase 7 E2E (nakon Track A)

**Cilj:** Kad Track A završi (41 → done), Proveo + Angie Jones pokreću end-to-end scenario test.

**Predmet:** 6 user journeys na Serbian locale (sr-RS), sa NBS IPS WireMock, AML treshold testove, phone-to-IBAN rezolucija.

**Trajanje:** 4–6 sati agent time.

### Track E — Skillforge Documentation

**Cilj:** BookStack Drop Srbija book — ADR-ovi, regulatorni sažetak, developer onboarding runbook, NBS PI aplikacija handover za advokat.

**Trajanje:** 2 sata agent time.

---

## 4. Šta ČEKAMO (eksterno, ne agent work)

| # | Blok | Čeka | Timeline |
|---|---|---|---|
| #1650 | PSD2 licenca / Finanstilsynet | Alem — application submission | 12–18 mj |
| #1927 | EUR 50K kapital za PI licencu (note: plan kaže EUR 125K za NBS — uskladiti) | Alem — bank transfer | 1–4 sedmice |
| #4955 | Styre — 5 styremedlemmer rekrutacija (finansforetaksloven § 8-4) | Alem — HR process | 2–3 mjeseca |
| Bank partner | Raiffeisen/MTS pregovori | Alem — nakon kapital/NBS | 6–12 mj |
| NBS PI license | Primjena + autorizacija | Serbian advocate + Alem | 12–18 mj |
| #8673 #8674 | ePorezi + SEF registracije (ALAI Tech d.o.o.) | Alem (već dispatchovano) | 2–4 sedmice |

---

## 5. Konkretni Akcije Za Ovu Sedmicu

### Odmah (može danas)

1. **Dispatch Proveo bulk review** 41 Drop Srbija review taskova → Track A
2. **Dispatch Lexicon** legal dokumenti ispravka (Drop Srbija d.o.o. → ALAI Tech d.o.o.) → Track B
3. **Reconcile kapital broj:** plan kaže EUR 125K, MC #1927 kaže EUR 50K — Lexicon + Finverge da provjere stvarnu NBS Srbija regulativu, ažuriraju plan

### Nakon Track A završi (2–3 dana)

4. **Dispatch Proveo + Angie E2E validation** (Phase 7) — 6 user journeys, sve end-to-end
5. **Dispatch Skillforge BookStack dokumentacija** (Phase 8) — Drop Srbija book + ADR sync

### Čeka CEO odluke (ne dispatch dok ne potvrdiš)

6. Bank outreach #8010 — **ne šalji** dok nemamo kapital + NBS PI aplikaciju u toku
7. NBS PI license filing — **pokreni tek** kad kapital sjedne i advokat potpiše

---

## 6. Preporuka

**Track A + B paralelno danas.** Track D + E nakon što A očisti noise. Track C ostaje parked dok Alem ne potvrdi kapital (pitanje bank partnera ne ide prije).

Follow-up plan ne ruši originalni, samo:
- Ažurira legal entity odluku (ALAI Tech d.o.o.)
- Prioritizuje review backlog cleanup
- Parkira bank/NBS track eksplicitno dok se kapital ne potvrdi
- Dodaje reconciliation kapital broja (EUR 50K vs EUR 125K)

---

## 7. ZAKON PLAN Check

- ✅ **Validation task:** Track A (Proveo bulk review) + Track D (Proveo + Angie E2E)
- ✅ **Documentation task:** Track E (Skillforge BookStack)

Plan je COMPLETE per ZAKON PLAN.

---

## 8. Sljedeći Korak

Odobri plan → dispatch Track A + B odmah (2 paralelna agenta). Javit ću kad stignu rezultati + Track C reconciliation.

# ALAI Project Blueprint v1.0

# ALAI Project Blueprint — v1.0

**Status:** CANONICAL — All ALAI Holding projects and companies align to this document.  
**Owner:** John (AI Director) | Architect: Petter Graff | CEO: Alem Basic  
**Last Updated:** 2026-04-29 (MC #10043 System Reform)  
**Sources:** Research doc system-reform-research-20260429T032241Z.md  
**Review cadence:** Quarterly (next: 2026-07-01)

> "Build systems that build systems." — ALAI mission statement

*Full canonical text at:* `/Users/makinja/system/specs/ALAI-PROJECT-BLUEPRINT.md` (1236 lines)

This is the canonical engineering and organizational standard for all ALAI entities. It draws from Toyota lean manufacturing, Google SRE, Spotify Engineering Culture, Netflix Full Cycle Developers, 12-Factor App, ADR pattern, Diátaxis docs, and Atomic Design.

## Key Sections

- 1. Executive Summary &amp; Reform Philosophy
- 2. Repo &amp; Filesystem Topology
- 3. GitHub Org &amp; Repo Conventions
- 4. Lifecycle Documentation (Diátaxis-Aligned)
- 5. Brand &amp; Design System
- 6. Secrets &amp; Credential Hygiene
- 7. Observability Stack
- 8. Cost Discipline
- 9. AI Agent Integration
- 10. Migration Philosophy
- 11. Templates Appendix (11 templates)
- 12. Glossary

**File location:** /Users/makinja/system/specs/ALAI-PROJECT-BLUEPRINT.md  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff

# System Reform CEO Brief (Apr 2026)

# CEO Morning Brief — System Reform

**Filed by:** Petter Graff (architect agent), MC #10043  
**Date:** 2026-04-29 (overnight autonomous session)  
**For:** Alem Basic, CEO, ALAI Holding AS

## TL;DR (3 bullets)

1. **CRITICAL SECURITY INCIDENT DISCOVERED:** RSA private keys (SSL/TLS certificates) are committed to git and pushed to GitHub (repo: johnatbasicas/vivacare, project: client/lumiscare). These must be treated as compromised. Your first action of the morning: determine if these certificates protect a live endpoint, then revoke them.
2. **STRUCTURE IS CONSISTENT BUT UNIVERSALLY INCOMPLETE:** All 30 entities (13 companies + 17 projects) follow a similar pattern — they have CLAUDE.md but universally lack blueprint standards (no .alai/manifest.yaml, no brand/, no legal/, no ops/, no RUNBOOK.md, no ADRs). This is fixable with 6-8 weeks of disciplined execution across agents.
3. **THE SYSTEM WORKS — NOW IT NEEDS HARDENING:** The agent routing (John + specialists), task management (mc.js), and knowledge base (BookStack) are ahead of market. The gaps are documentation, CI/CD, and secret hygiene — all mechanical fixes, not architectural rewrites.

## Top 5 Critical Gaps

1. CRITICAL: Private SSL Keys in Git (client/lumiscare)
2. HIGH: Zero ADRs Across All Projects
3. HIGH: No RUNBOOK.md on Any Project
4. HIGH: Tim.html — Internal Pricing Page Publicly Accessible
5. HIGH: Active Client Work Without Confirmed Contracts

## Top 5 Quick Wins (≤2 hours each)

1. Add .alai/manifest.yaml to all 30 entities
2. Add FreeMyEV-v2 .gitignore
3. Add .github/CODEOWNERS + PR template to snowit-site
4. Update bih-tenders CLAUDE.md status to "stalled"
5. Update all company.json files to reference manifest.yaml schema

## 3 Questions Only You Can Answer

1. CRITICAL (answer TODAY): Are MyPrivate.key and CAPrivate.key protecting any live SSL/TLS endpoint?
2. Should bih-tenders be formally deprecated?
3. Should ~/projects/tools/ be renamed to ~/projects/autocoder/?

**File location:** /Users/makinja/system/specs/system-reform-CEO-BRIEF.md  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, CEO-brief

# System Reform Industry Research

# ALAI System Reform — Industry Research

**Author:** Petter Graff (architect agent), MC #10043  
**Date:** 2026-04-29  
**Status:** verified-local where cited from memory; web-search-verified where marked \[WEB\]  
**Purpose:** Distill best-in-class engineering org templates applicable to ALAI Holding AS (one-person AI dev agency, 13 companies, 20+ projects)

## Sources (10)

1. Spotify Engineering Culture (Kniberg &amp; Ivarsson 2012)
2. Google SRE (Beyer et al. 2018)
3. Netflix Full Cycle Developers (Burrell 2018)
4. ThoughtWorks Technology Radar (Vol. 30-32, 2024-2025)
5. 12-Factor App (Wiggins 2011)
6. ADR Pattern (Nygard 2011)
7. Diátaxis Documentation Framework (Procida 2021)
8. Atomic Design (Brad Frost 2016)
9. Toyota Production System (Ohno 1988, Liker 2004)
10. DORA DevOps Research (2023 State of DevOps Report)

## Key Principles Extracted (47)

Across Spotify, Google SRE, Netflix, Toyota, 12-Factor App, ADR, Diátaxis, Atomic Design, and ThoughtWorks radar — 47 principles extracted and adapted for ALAI context.

**File location:** /Users/makinja/system/specs/system-reform-research-20260429T032241Z.md (338 lines)  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, research

# System Reform Open Questions (3 CEO Decisions)

# Open Questions — 3 CEO Decisions Pending

## OPEN QUESTION #1 — CRITICAL SECURITY

**Timestamp:** 2026-04-29T05:50:00Z  
**Project:** client/lumiscare (repo: github.com/johnatbasicas/vivacare)  
**Decision needed:** RSA private key files (MyPrivate.key, CAPrivate.key) are git-tracked and pushed to GitHub.

**CEO action required:**

1. Determine if these keys are in use (do they protect any live SSL endpoint?)
2. If yes: revoke and reissue immediately (treat as compromised)
3. Remove from git history via git filter-repo (requires force push)
4. Add \*.key, \*.pem to .gitignore in all client sub-projects

**Blast radius:** If keys are live SSL/TLS certs, they could be used for MITM attacks on whatever service they protect. If VivaCare/Lumiscare app, patient data could be at risk.

**Recommendation:** REVOKE IMMEDIATELY regardless of current use status. Exposed private keys are non-recoverable.

## OPEN QUESTION #2 — bih-tenders sunset

**Project:** bih-tenders  
**Decision needed:** CLAUDE.md says "Active" but MEMORY context confirms "BiH dead" post-Intesa/HR pivot. Should bih-tenders be formally deprecated and archived?

**Recommendation:** YES, deprecate. Mark status: "deprecated" in CLAUDE.md, run sunset procedure (Blueprint Section 4.9), archive project. No blast radius — not deployed, no customers.

## OPEN QUESTION #3 — tools/ directory confusion

**Project:** ~/projects/tools/ vs ~/system/tools/  
**Decision needed:** Should ~/projects/tools/ be renamed to ~/projects/autocoder/ or ~/projects/internal-tools/ to remove ambiguity?

**Recommendation:** Rename to ~/projects/autocoder/. This removes the naming collision with ~/system/tools/ (canonical ALAI runtime).

**File location:** /Users/makinja/system/specs/system-reform-open-questions.md  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, CEO-decision

# Gap Analysis — client/lumiscare (CRITICAL)

# Gap Analysis: client

**Blueprint Reference:** ALAI-PROJECT-BLUEPRINT.md Sections 2.3, 4.1, 6.2, 9  
**Date:** 2026-04-29 | **Analyst:** Petter Graff (MC #10043)  
**Tool-verified:** Bash ls, secrets scan (grep), cd lumiscare + git ls-files, git remote -v

## SUMMARY

Client deliverables workspace containing multiple sub-projects: lumiscare, lumiscare-alpha through epsilon, klofta-il, nordfit, rendrom, vivacareusa. **CRITICAL SECURITY FINDING:** RSA private keys git-tracked in lumiscare sub-project (committed to GitHub johnatbasicas/vivacare). Only top-level workspace has CLAUDE.md. No sub-project has standard blueprint structure.

## RISK

- **CRITICAL:** RSA private keys (MyPrivate.key, CAPrivate.key) are committed to git and pushed to github.com/johnatbasicas/vivacare. If these keys protect any live SSL/TLS endpoint (Lumiscare patient app, VivaCare), they must be treated as COMPROMISED.
- **HIGH:** Multiple client projects (klofta-il, nordfit, vivacareusa, lumiscare) with no individual documentation — agents operating on these sub-projects are flying blind.
- **HIGH:** No CI on any sub-project — broken builds are invisible.

**Priority:** CRITICAL (due to private key exposure)  
**Blueprint compliance score:** 10/100

**File location:** /Users/makinja/system/specs/gap-analysis/client.md  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, gap-analysis, CRITICAL

# Build Plan — client/lumiscare CRITICAL Security

# Build Plan: client/lumiscare — CRITICAL Security Remediation

**Gap Analysis Reference:** gap-analysis/client.md  
**Priority:** CRITICAL  
**Blueprint Sections:** 6.2 (Zero-Secrets-In-Repos), 3.7 (Secrets Scanning)  
**Date:** 2026-04-29 | Planner: Petter Graff (MC #10043)

## OBJECTIVE

Remove RSA private keys (MyPrivate.key, CAPrivate.key) from git history in lumiscare repo (github.com/johnatbasicas/vivacare), implement gitleaks to prevent recurrence, and establish proper SSL certificate management procedure. Target state: zero private keys in git history, all certificates managed via Vaultwarden or infrastructure secrets manager.

## WORK BREAKDOWN

### Step 1 — CEO Decision: Revoke or Confirm Keys (BLOCKING)

**Action:** CEO determines if MyPrivate.key and CAPrivate.key protect any live endpoint.  
**Who:** CEO Alem Basic — cannot be delegated  
**Effort:** S (30 min)  
**Acceptance:** CEO written decision in MC task comment

### Step 2 — Remove Keys from Git History

**Who:** Codecraft (FlowForge/kelsey-hightower.md for git operations)  
**Effort:** M (2 hours including testing)  
**Acceptance:** git log returns no results for key files; GitHub repo confirms no key files in any branch or tag

### Step 3 — Add .key and .pem to .gitignore

**Who:** Codecraft  
**Effort:** S (15 min)

### Step 4 — Install gitleaks Pre-Commit Hook

**Who:** Securion (parisa-tabriz.md)  
**Effort:** S (1 hour)

### Step 5 — Add CI Secret Scanning

**Who:** Securion  
**Effort:** M (1.5 hours)

**TOTAL EFFORT:** 4-5 hours (after CEO decision)  
**VALIDATION:** Proveo verifies no secrets in git history + pre-commit hook functional

**File location:** /Users/makinja/system/specs/build-plans/client-lumiscare-CRITICAL.md  
**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, build-plan, CRITICAL

# System Reform Index — All Deliverables

# ALAI System Reform Index (MC #10043)

**Date:** 2026-04-29  
**Architect:** Petter Graff  
**Status:** CEO review pending (3 decisions required)

## Core Documents

1. **ALAI Project Blueprint v1.0** — Canonical engineering standard (1236 lines)  
    File: /Users/makinja/system/specs/ALAI-PROJECT-BLUEPRINT.md
2. **CEO Brief** — TL;DR: 3 critical findings, 5 quick wins, 5 strategic moves  
    File: /Users/makinja/system/specs/system-reform-CEO-BRIEF.md
3. **Industry Research** — 10 sources, 47 principles extracted  
    File: /Users/makinja/system/specs/system-reform-research-20260429T032241Z.md
4. **Open Questions** — 3 CEO decisions (1 CRITICAL: RSA private keys)  
    File: /Users/makinja/system/specs/system-reform-open-questions.md

## Gap Analyses (18 entities)

**Directory:** /Users/makinja/system/specs/gap-analysis/

- AdnanCesko.md
- alai-cli.md
- Basicconsulting.md
- bih-tenders.md
- bookstack-api.md
- client.md (CRITICAL — private keys in git)
- companies-ALAI-entities.md (13 companies)
- FreeMyEV-v2.md
- hexadb.md
- internal.md
- KloftaIL.md
- knowit-minvei-krav.md
- merdzanovic-ba.md
- pa.md
- personal.md
- snowit-site.md
- snowit.md
- tools.md

## Build Plans (8 plans)

**Directory:** /Users/makinja/system/specs/build-plans/

- alai-cli.md
- bookstack-api.md
- client-lumiscare-CRITICAL.md (security remediation)
- companies-universal-uplift.md (batch: all 13 companies)
- hexadb.md
- pa.md
- remaining-projects-batch.md
- snowit-site.md

## Metrics

<table id="bkmrk-metriccountarchives-"><tr><th>Metric</th><th>Count</th></tr><tr><td>Archives created</td><td>3</td></tr><tr><td>Research sources cited</td><td>10</td></tr><tr><td>Principles distilled</td><td>47</td></tr><tr><td>Blueprint lines</td><td>1,236</td></tr><tr><td>Templates written</td><td>11</td></tr><tr><td>Gap analyses</td><td>18</td></tr><tr><td>Build plans</td><td>8</td></tr><tr><td>Critical findings</td><td>2</td></tr><tr><td>Estimated agent-hours to 80% compliance</td><td>~100 hours (6-8 weeks)</td></tr></table>

**MC Task:** #10043  
**Tags:** system-reform-2026-04, MC-10043, petter-graff, index

# drop-monorepo-refactor-plan

# Plan: Drop Monorepo Refactor (src/ → apps/+packages/)

**MC:** #10051
**Owner:** John (orchestrator) + CodeCraft + Proveo + Skillforge
**Estimated total:** ~15h CodeCraft + 2h validation + 1h docs = **~18h fleet time**
**Strategy:** 4-PR staged migration (NOT atomic) per Petter's research
**Research:** `/tmp/drop-monorepo-research-10051.md` (347 lines, Petter Graff)

---

## Research Summary

Drop monorepo currently uses npm workspaces under `src/` (3 registered: `src/shared`, `src/drop-api`, `src/drop-app`; `src/drop-mobile` is on separate release cycle, NOT in workspace config but referenced in 3 mobile workflows). Migrating to `apps/`+`packages/` requires touching **80+ hardcoded paths** across 8 GitHub Actions workflows, 2 root Dockerfiles, buildspec.yml (just merged in PR #3), 2 docker-compose files, and root package.json lint-staged.

### Top 3 production risks
1. `Dockerfile.drop-app:59,67` — `WORKDIR /app/src/drop-app` baked into Next.js standalone runner stage. Silent build success / runtime fail on App Runner cold start.
2. `ci.yml:49-53` paths-filter must update atomically with rename. Wrong order = silent CI skip = unreviewed code reaches main.
3. `Dockerfile.api` uses raw source-copy to `/shared/` (not workspace symlinks) — structurally inconsistent with `Dockerfile.drop-app`. Audit separately.

### Why multi-PR (NOT atomic)
- Each PR is reviewable, revertable, testable independently
- CI workflow update (PR 3) is highest-risk single change → its own PR
- Pre-Finanstilsynet: every CI/security-gate change deserves explicit review
- 4 conflict-able PRs > 1 monster PR with 80+ touched paths

---

## Objective

Restructure Drop monorepo to industry-standard Turborepo/pnpm convention (`apps/` for deployables, `packages/` for shared libs) WITHOUT introducing behavior changes, downtime, or production deploys breaking. Land in 4 staged PRs over 2-3 days, with staging validation before prod merge.

---

## Team Orchestration

### Team Members

| ID | Name | Role | Agent Type |
|----|------|------|------------|
| B1 | codecraft-pkg | PR 1: workspace config + packages/ rename | codecraft |
| B2 | codecraft-apps | PR 2: apps/ rename + Dockerfiles + buildspec | codecraft |
| B3 | codecraft-ci | PR 3: 8 GitHub workflow files update | codecraft |
| B4 | codecraft-cleanup | PR 4: delete old src/ + final validation | codecraft |
| V1 | proveo-staging | Staging validation pre-PR-4 merge | proveo |
| V2 | proveo-prod | Production validation post-PR-4 merge | proveo |
| V3 | securion-fintech | Pre-Finanstilsynet security audit | securion |
| D1 | skillforge-docs | BookStack page + memory entries | skillforge |
| R1 | gemini-reviewer | Review every PR before merge | gemini-reviewer |

### Step-by-Step Tasks

---

#### Phase 1 — Internal packages rename (low risk, isolated)

**Task 1: PR 1 — Rename `src/shared` → `packages/shared`**
- Owner: B1 (codecraft)
- BlockedBy: none (research deliverable already done)
- Branch: `feat/monorepo-pr1-packages-shared`
- Scope:
  - `git mv src/shared packages/shared` (preserves history)
  - Root `package.json` workspaces array: `"src/shared"` → `"packages/shared"`
  - `npm install` regenerate lockfile (in Linux container per ZAKON LOCKFILE PORTABILITY)
  - Verify `apps/drop-app/node_modules/@drop/shared` symlink resolves (post-PR-2; for PR 1 verify root `node_modules/@drop/shared` resolves)
  - Update lint-staged glob if `src/shared/` referenced
- Acceptance:
  - [ ] `npm install --ignore-scripts --no-audit` exit 0
  - [ ] `cd src/drop-app && npx tsc --noEmit` exit 0 (shared resolves via root workspace)
  - [ ] `cd src/drop-api && npx tsc --noEmit` exit 0
  - [ ] CI Quality Gate REQUIRED green on PR
  - [ ] No file outside `packages/shared/` and `package.json` + `package-lock.json` modified

**Task 2: Gemini review PR 1**
- Owner: R1
- BlockedBy: 1
- Acceptance: VERDICT: APPROVE

**Task 3: Squash merge PR 1 to main**
- Owner: John
- BlockedBy: 2
- Post-merge: verify main CI green

---

#### Phase 2 — Apps rename + Docker (highest production risk)

**Task 4: PR 2 — Rename `src/drop-*` → `apps/drop-*` + Dockerfile + buildspec updates**
- Owner: B2 (codecraft)
- BlockedBy: 3 (PR 1 merged)
- Branch: `feat/monorepo-pr2-apps-docker`
- Scope:
  - `git mv src/drop-app apps/drop-app`, `src/drop-api apps/drop-api`, `src/drop-mobile apps/drop-mobile`
  - Update root `package.json` workspaces: `"src/drop-api"` → `"apps/drop-api"`, `"src/drop-app"` → `"apps/drop-app"`
  - Update lint-staged globs (3 patterns) + `cd src/drop-*` commands
  - Update `Dockerfile.drop-app`: ALL `src/drop-app` → `apps/drop-app`, `src/shared` → `packages/shared`. **CRITICAL: both `WORKDIR /app/src/drop-app` lines (59 + 67) → `/app/apps/drop-app`**
  - Update `Dockerfile.api`: `src/drop-api` → `apps/drop-api`, `src/shared` → `packages/shared` AND `/monorepo/src/shared/` → `/monorepo/packages/shared/` (the absolute-path hack)
  - Update `buildspec.yml`: 7 occurrences of `src/drop-app` → `apps/drop-app`, `src/shared` → `packages/shared`
  - Update `docker-compose.yml` + `docker-compose.dev.yml` build contexts + bind mounts
  - Verify `next.config.ts` `outputFileTracingRoot: ../../` still resolves to repo root from `apps/drop-app/` (it does — same depth)
  - Local Docker build MANDATORY (ZAKON LOCAL DOCKER BUILD): `docker buildx build --platform linux/amd64 -f Dockerfile.drop-app .` exit 0
- Acceptance:
  - [ ] `npm install --ignore-scripts --no-audit` exit 0
  - [ ] `cd apps/drop-app && npx eslint .` 0 errors
  - [ ] `cd apps/drop-app && npx tsc --noEmit` exit 0
  - [ ] `docker buildx build --platform linux/amd64 -f Dockerfile.drop-app .` exit 0 (LOCAL evidence required)
  - [ ] `docker buildx build --platform linux/amd64 -f Dockerfile.api .` exit 0
  - [ ] CI Quality Gate REQUIRED green on PR
  - [ ] No CI workflow file modified in this PR (saved for PR 3)
  - [ ] No `src/` directory deleted yet (saved for PR 4)

**Task 5: Gemini review PR 2**
- Owner: R1
- BlockedBy: 4
- Acceptance: VERDICT: APPROVE
- Special review focus: Dockerfile WORKDIR lines, Dockerfile.api absolute path, lockfile linux-x64 variants

**Task 6: Squash merge PR 2 to main — CAUTION**
- Owner: John
- BlockedBy: 5
- Note: This DOES trigger `deploy.yml` auto-deploy. Production App Runner will build with new paths. Monitor `aws apprunner describe-service` until RUNNING status. If deploy fails, immediate rollback via CF CNAME flip + previous SHA redeploy per DEPLOY-MAP.md.
- Pre-merge mandatory: external `curl -sI https://app.getdrop.no` shows current production health
- Post-merge mandatory: external `curl -sI https://app.getdrop.no` shows healthy within 10min of CF CNAME flip

---

#### Phase 3 — CI workflows update (silent skip risk)

**Task 7: PR 3 — Update 8 GitHub Actions workflows**
- Owner: B3 (codecraft)
- BlockedBy: 6 (PR 2 merged + production verified RUNNING)
- Branch: `feat/monorepo-pr3-ci-workflows`
- Scope:
  - `ci.yml`: paths-filter (5 lines), working-directory, cache-dependency-path, cd commands, artifact paths
  - `deploy.yml`: docker build context path
  - `deploy-staging.yml`: paths, working-directory, cache-dependency-path, playwright report path
  - `hotfix.yml`: working-directory x2, cache-dependency-path
  - `deploy-aws.yml`: working-directory, docker build context
  - `mobile-ci.yml`: paths-filter, working-directory x3, cache-dependency-path, artifact path
  - `mobile-deploy.yml`: paths-filter, working-directory, git add path
  - `mobile-release.yml`: working-directory, cache-dependency-path, VERSION_FILE path, git add path
- Acceptance:
  - [ ] All 8 workflow files modified, no other code changes
  - [ ] CI Quality Gate REQUIRED green on PR
  - [ ] `gh workflow view "CI — Quality Gate"` runs against new paths
  - [ ] `mobile-ci.yml` triggers on `apps/drop-mobile/**` paths-filter when mobile code changed (test by including 1-line mobile change in PR)

**Task 8: Gemini review PR 3**
- Owner: R1
- BlockedBy: 7
- Acceptance: VERDICT: APPROVE
- Special review focus: paths-filter glob accuracy (silent CI skip is the danger)

**Task 9: Squash merge PR 3**
- Owner: John
- BlockedBy: 8
- Note: deploy-staging.yml manual workflow_dispatch test BEFORE merge, against PR HEAD SHA, to confirm staging path resolution

---

#### Phase 4 — Cleanup + production validation

**Task 10: PR 4 — Delete legacy `src/` directory + ALAI memory updates**
- Owner: B4 (codecraft)
- BlockedBy: 9 (PR 3 merged + main CI green)
- Branch: `feat/monorepo-pr4-cleanup`
- Scope:
  - `git rm -r src/` (now empty after PR 1 + PR 2 moves)
  - Update `BUILD-BLUEPRINT.md` repo structure section + build commands
  - Update `DEPLOY-MAP.md` paths
  - Update `RUNBOOK.md` if any path references
  - Update `project/architecture/*.md` references
  - Update `~/.claude/projects/-Users-makinja/memory/MEMORY-products.md` Drop entry
  - Update any `~/.claude/projects/-Users-makinja/memory/project_drop_*.md` referring to old paths
- Acceptance:
  - [ ] `find . -path "./src" -type d` empty
  - [ ] `grep -r "src/drop-" --include="*.md" --include="*.yml" --include="*.json"` 0 hits in repo (excluding old git history)
  - [ ] CI Quality Gate REQUIRED green on PR

**Task 11: Proveo validation — STAGING (pre-merge)**
- Owner: V1 (proveo)
- BlockedBy: 10 (PR 4 ready, but NOT merged yet)
- Action: trigger `deploy-staging.yml` workflow_dispatch against PR 4 head SHA
- Acceptance:
  - [ ] Staging App Runner reaches RUNNING state within 15min
  - [ ] `curl -s https://staging.getdrop.no/api/health | jq .status` == "ok"
  - [ ] Playwright smoke test (login + dashboard + send-money flow) passes
  - [ ] No new Sentry errors in staging post-deploy
  - [ ] Evidence written to `/tmp/proveo-monorepo-staging-10051.json`

**Task 12: Gemini review PR 4**
- Owner: R1
- BlockedBy: 11
- Acceptance: VERDICT: APPROVE

**Task 13: Securion fintech security audit (pre-prod merge)**
- Owner: V3 (securion / Parisa Tabriz)
- BlockedBy: 11
- Scope: monorepo path rename security implications — secrets accidentally exposed via different paths, .gitignore coverage, JWT/auth boundary integrity, OWASP Top 10 quick check
- Acceptance:
  - [ ] No secret leaks introduced by path changes
  - [ ] Auth middleware paths still correct
  - [ ] No new attack surface introduced
  - [ ] Report at `/tmp/securion-monorepo-10051.md`

**Task 14: Squash merge PR 4 to main**
- Owner: John
- BlockedBy: 12, 13
- Triggers: `deploy.yml` auto-deploy to production
- Pre-merge mandatory: external curl baseline, gh run list main shows green
- Post-merge mandatory: monitor App Runner deploy

**Task 15: Proveo validation — PRODUCTION**
- Owner: V2 (proveo)
- BlockedBy: 14
- Action: post-deploy verification per DEPLOY-MAP.md ZAKON PI2 protocol
- Acceptance:
  - [ ] `curl -s https://app.getdrop.no/api/health | jq .status` == "ok"
  - [ ] Playwright screenshot of dashboard (live data load)
  - [ ] No CI deploy.yml errors
  - [ ] No Sentry error spike in 30min post-deploy
  - [ ] App Runner blue/green flip verified
  - [ ] Evidence at `/tmp/proveo-monorepo-prod-10051.json` + screenshot

**Task 16: Skillforge documentation**
- Owner: D1 (skillforge)
- BlockedBy: 15
- Scope:
  - BookStack page "Drop Monorepo Architecture" — apps/+packages/ structure, deploy paths, conventions
  - Memory entry `project_drop_monorepo_refactor_2026-XX-XX.md` with lessons learned + path migration patterns
  - Update `~/system/rules/zakon-local-docker-build.md` if monorepo-specific gotchas surfaced
- Acceptance:
  - [ ] BookStack page exists and synced
  - [ ] Memory entry created
  - [ ] MEMORY.md index updated

---

## Risk Register

| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Dockerfile WORKDIR baked path miss (E1) | MED | HIGH (prod cold start fail) | Mandatory local Docker build in PR 2 |
| paths-filter wrong = silent CI skip (E4) | LOW | CRITICAL (unreviewed code in main) | PR 3 has explicit paths-filter glob review by R1 |
| Lockfile platform variants missing (E6) | MED | MED (CodeBuild fail) | Regenerate in Linux container per ZAKON LOCKFILE PORTABILITY |
| @drop/shared symlink resolution fails (E5) | MED | HIGH (build fail) | PR 1 isolated test before PR 2 starts |
| Sentry source maps un-symbolicate (E8) | LOW | LOW (debug noise only) | Verify post-deploy Sentry dashboard |
| Dockerfile.api absolute path break (E3) | MED | HIGH (drop-api fail) | Local docker build evidence MANDATORY in PR 2 |
| AWS App Runner deploy fails post-merge | LOW | CRITICAL (prod outage) | CF CNAME rollback < 10s + previous SHA redeploy |
| backend/ legacy dir confusion (E9) | LOW | LOW | Document as legacy, do not rename in this MC |
| Mobile workflow paths-filter coupling (E7) | LOW | MED | Tested in PR 3 with 1-line mobile change |

---

## Rollback Plan (per PR)

| PR | Rollback action |
|---|---|
| PR 1 | `git revert <merge-commit>` — pure rename, no behavior change. < 5min recovery. |
| PR 2 | `git revert <merge-commit>` + ECR previous-SHA redeploy via `apprunner start-deployment` + CF CNAME flip. < 15min recovery. |
| PR 3 | `git revert <merge-commit>` — workflows revert. CI may briefly run against wrong paths during revert window (acceptable). < 5min. |
| PR 4 | `git revert <merge-commit>` — restores src/ from history. Same as PR 2 if production-affecting deploy triggers. |

---

## Validation Checkpoints (cumulative across phases)

After EACH PR merge to main:
1. `gh run list --branch main --limit 1` last run = SUCCESS for "CI — Quality Gate"
2. `curl -sI https://app.getdrop.no` returns HTTP 200
3. `aws apprunner describe-service --service-arn <drop-web-arn>` Status: RUNNING
4. No new Sentry errors in 15min after deploy
5. `git log --oneline main` shows clean linear history (squash merges)

After PR 4 (final cleanup):
6. `find /Users/makinja/ALAI/products/Drop -type d -name "src"` returns 0 results
7. Repo size reduced (lockfile + node_modules can re-regenerate in fresh clone)
8. Skillforge BookStack page accessible at https://docs.alai.no

---

## Estimate of Total Time

| Phase | CodeCraft hours | Calendar days |
|---|---|---|
| PR 1 (packages/) | 1.5h | Day 1 |
| PR 2 (apps/ + Docker) | 5h | Day 1-2 |
| PR 3 (CI workflows) | 2.5h | Day 2 |
| PR 4 (cleanup) | 2h | Day 2-3 |
| Proveo + Securion validation | 2h | Day 2-3 |
| Skillforge docs | 1h | Day 3 |
| **Total** | **~14h** | **3 days** |

Add 30% buffer for failure recovery iterations = **~18h fleet, 3 calendar days**.

---

## Required Follow-ups

1. `backend/` legacy directory at repo root — confirm dead code in separate MC, then either rename or delete. NOT in scope of this MC.
2. `mobile-release.yml` cross-references `src/drop-app/src/config/app-versions.json` from within mobile workflow — clarify ownership boundary in followup.
3. Migration to true Turborepo (turbo.json) or pnpm — separate MC. This refactor only renames paths, does NOT change tooling.
4. Sentry post-deploy verification — if source maps un-symbolicate, separate MC for Sentry config update.

---

## Per ZAKON PLAN compliance

- [x] **Validation task** — Tasks 11 (Proveo staging) + 15 (Proveo prod) — end-to-end Playwright + curl + Sentry monitoring (NOT dry-run)
- [x] **Documentation task** — Task 16 (Skillforge) — BookStack page + memory entry

---

## Approval

Plan ready for CEO review. To execute:

```
/build-plan ~/system/specs/drop-monorepo-refactor-plan.md
```

Or dispatch first phase manually:
```
/mehanik "Drop monorepo PR 1: packages/shared rename + workspace config" /Users/makinja/ALAI/products/Drop <new_MC_id>
```

# drop-api-secrets-migration-evidence

# drop-api Secrets Migration Evidence
**Date:** 2026-04-29T14:18:08Z
**Operator:** Parisa Tabriz (Securion)
**MC:** #10150
**Outcome:** BLOCKED — IAM permission gap prevents completion. Remediation documented below.

---

## Pre-Migration State (drop-api)

**Service ARN:** arn:aws:apprunner:eu-west-1:324480209768:service/drop-api/bdebb303a47c409393691ef8f5530144
**Status:** RUNNING
**RuntimeEnvironmentVariables (plain-text — NAMES ONLY, no values):**
- DATABASE_URL (CRITICAL — plain text, 1 of 2 secrets)
- JWT_SECRET (CRITICAL — plain text, 2 of 2 secrets)
- DROP_MODE (non-sensitive)
- PORT (non-sensitive)
Total: 4 vars, 2 plain-text secrets

**RuntimeEnvironmentSecrets:** EMPTY (none configured)
**InstanceRoleArn:** NULL (no instance role attached)
**AccessRoleArn:** arn:aws:iam::324480209768:role/AppRunnerECRAccessRole (ECR pull only)

---

## AWS Secrets Manager Verification (Step 3)

Both target secrets confirmed PRESENT and VALUES MATCH current plain-text:

| Secret Name | ARN | Value Match |
|---|---|---|
| drop/production/jwt_secret | arn:aws:secretsmanager:eu-west-1:324480209768:secret:drop/production/jwt_secret-QEsMUJ | MATCH |
| drop/production/database_url | arn:aws:secretsmanager:eu-west-1:324480209768:secret:drop/production/database_url-QEsMUJ | MATCH |

No new secrets needed. Existing SM entries are current and correct.

---

## IAM Analysis (Step 4 — BLOCKER FOUND)

### Finding 1: drop-api has NO instance role
drop-web (working SM integration) uses:
- **InstanceRoleArn:** arn:aws:iam::324480209768:role/drop-production-apprunner-instance
- This role has secretsmanager:GetSecretValue on drop/production/* secrets

drop-api has:
- **InstanceRoleArn:** NULL

Without an instance role, App Runner instances cannot call Secrets Manager. Attaching `RuntimeEnvironmentSecrets` without an instance role would cause service startup failure.

### Finding 2: alai-cli-deployer lacks iam:PassRole
`aws apprunner update-service` requires `iam:PassRole` on both:
1. The ECR access role (AppRunnerECRAccessRole) — needed even for config-only updates
2. The instance role (drop-production-apprunner-instance) — needed to attach it

**Exact errors:**
```
AccessDeniedException: User alai-cli-deployer is not authorized to perform: iam:PassRole 
  on resource: arn:aws:iam::324480209768:role/drop-production-apprunner-instance
  
AccessDeniedException: User alai-cli-deployer is not authorized to perform: iam:PassRole 
  on resource: arn:aws:iam::324480209768:role/AppRunnerECRAccessRole
```

---

## Target State (READY TO APPLY — pending IAM fix)

New source configuration built and saved to /tmp/drop-api-NEW-source-config.json (chmod 600):

**RuntimeEnvironmentVariables (post-migration):**
- DROP_MODE (non-sensitive, remains plain)
- PORT (non-sensitive, remains plain)

**RuntimeEnvironmentSecrets (post-migration):**
- DATABASE_URL → arn:aws:secretsmanager:eu-west-1:324480209768:secret:drop/production/database_url-QEsMUJ
- JWT_SECRET → arn:aws:secretsmanager:eu-west-1:324480209768:secret:drop/production/jwt_secret-QEsMUJ

**InstanceRoleArn to attach:** arn:aws:iam::324480209768:role/drop-production-apprunner-instance

---

## Required IAM Grants (what must be added before migration can complete)

An IAM administrator (or role with iam:PutUserPolicy / iam:AttachUserPolicy) must grant alai-cli-deployer:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": [
        "arn:aws:iam::324480209768:role/AppRunnerECRAccessRole",
        "arn:aws:iam::324480209768:role/drop-production-apprunner-instance"
      ],
      "Condition": {
        "StringEquals": {
          "iam:PassedToService": "build.apprunner.amazonaws.com"
        }
      }
    }
  ]
}
```

---

## Execute Command (ready — run after IAM grant)

```bash
aws apprunner update-service \
  --service-arn arn:aws:apprunner:eu-west-1:324480209768:service/drop-api/bdebb303a47c409393691ef8f5530144 \
  --source-configuration file:///tmp/drop-api-NEW-source-config.json \
  --instance-configuration InstanceRoleArn=arn:aws:iam::324480209768:role/drop-production-apprunner-instance \
  --profile alai-cli-deployer \
  --region eu-west-1
```

Then verify:
```bash
aws apprunner describe-service \
  --service-arn arn:aws:apprunner:eu-west-1:324480209768:service/drop-api/bdebb303a47c409393691ef8f5530144 \
  --profile alai-cli-deployer --region eu-west-1 | jq '.Service.Status'

curl -sI https://app.getdrop.no/api/health | head -5
```

Expected: Status = RUNNING, HTTP 200.

---

## Rollback Path

Rollback file: /tmp/drop-api-rollback-vars.json (chmod 600, contains plain-text values — /tmp only)
Pre-migration full config: /tmp/drop-api-PRE-migration.json (chmod 600)

Rollback command (restores plain-text state, removes secrets indirection):
```bash
aws apprunner update-service \
  --service-arn arn:aws:apprunner:eu-west-1:324480209768:service/drop-api/bdebb303a47c409393691ef8f5530144 \
  --source-configuration "$(jq '.Service.SourceConfiguration' /tmp/drop-api-PRE-migration.json)" \
  --instance-configuration Cpu=1024,Memory=2048 \
  --profile alai-cli-deployer --region eu-west-1
```
Note: Rollback also requires iam:PassRole — same IAM grant needed.

---

## Smoke Test (pending migration)

- Health endpoint: https://app.getdrop.no/api/health (per DEPLOY-MAP)
- Expected: HTTP 200, status: "ok"
- NOT YET EXECUTED — service not updated

---

## Files (sensitive — /tmp only, never committed)

- /tmp/drop-api-PRE-migration.json (chmod 600) — full pre-state including plain values
- /tmp/drop-api-rollback-vars.json (chmod 600) — extracted plain env vars for rollback
- /tmp/drop-api-NEW-source-config.json — new source config ready to apply