Specs & Plans

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

System Specifications

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

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.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

1.3 Compliance Deadlines

1.4 Enforcement


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:

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:

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, 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:

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:

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:

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:

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:


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:

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:

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:

Example Error Component:

{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:


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:

    npm install --save-dev @axe-core/playwright axe-html-reporter
    
  2. Create Playwright accessibility test file:

    // 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:

    # .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):

    await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
      .disableRules(['color-contrast']) // Disable if testing on non-production colors
      .analyze();
    

Severity Levels:

CI Behavior:

3.3 Manual Testing (Tier 2)

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

Checklist:

Keyboard Navigation

Form Validation

Color Contrast

Text Resizing

Responsive Design

Headings and Landmarks

Tools:

3.4 Assistive Technology Testing (Tier 3)

Devices:

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:

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):

Reference: Radix UI Accessibility Docs

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:

## 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)

P1 — High (Fix this sprint)

P2 — Medium (Fix next sprint)

P3 — Low (Fix when time allows)

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:

# .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:

Warn but allow merge if:

Implementation:

// 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:

# .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

# 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

10.2 Template (Norwegian)

Create page at /tilgjengelighet:

# 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:

11.2 Coding Guidelines (Quick Reference)

Semantic HTML First

// ❌ Bad
<div onClick={handleClick}>Click me</div>

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

Always Label Inputs

// ❌ Bad
<input type="email" placeholder="Email" />

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

Focus Indicators

/* ❌ 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

// ❌ Bad
{error && <p className="text-red-500">{error}</p>}

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

Loading States

// ❌ Bad
<button disabled={loading}>
  {loading ? <Spinner /> : "Send"}
</button>

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

Images

// ❌ 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:


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:

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:

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:

Operable:

Understandable:

Robust:

Level AA (25 additional criteria)

Perceivable:

Operable:

Understandable:

Robust:


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:

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

System Specifications

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

  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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:


Time to First Transaction (TTFT)

Definition: Median time from registration to first completed transaction.

SQL Query:

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:

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:

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:

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):

-- 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:

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):

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).

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).

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.

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:

{
  "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:

Response:

{
  "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:

{
  "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:

{
  "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:

{
  "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:

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

Anonymized Data


7.2 What's NOT Tracked

Explicitly excluded from analytics:


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:


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).


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

Categories:


7.5 Audit Trail (PSD2 Requirement)

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

Implementation:

-- 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:

# 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:

// 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

{
  "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:

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:

Tool: Vitest (already used in Drop).


12.2 Integration Tests

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

Coverage:


12.3 E2E Tests

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

Coverage:

Tool: Playwright (already used in Drop).


13. Monitoring & Observability

13.1 PostHog Self-Monitoring

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

Metrics:

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:

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

Middleware:

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:


14.3 SQL Injection (Admin Queries)

Risk: Dynamic date range filtering could be exploited.

Mitigation:

Example (SAFE):

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


17.2 Tools & Libraries


17.3 Industry Benchmarks


18. Appendix: SQL Query Examples

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

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)

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)

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

System Specifications

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:

Non-goals (future):


2. Database Schema

2.1 support_tickets Table

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:

2.2 ticket_messages Table

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:


3. Status Flow

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

Status definitions:

Priority:


4. API Endpoints

4.1 User Endpoints

POST /api/support/tickets

Create new support ticket.

Request:

{
  "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):

{
  "data": {
    "id": "tkt_abc123",
    "subject": "...",
    "description": "...",
    "category": "transaction_issue",
    "status": "open",
    "priority": "normal",
    "created_at": "2026-02-17T10:30:00Z"
  }
}

Validation:

Auto-priority logic:

Side effects:


GET /api/support/tickets

List user's tickets.

Query params:

Response:

{
  "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:

{
  "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:


POST /api/support/tickets/[id]/messages

Add user message to conversation.

Request:

{
  "message": "I tried again and it worked this time"
}

Response (201):

{
  "data": {
    "id": "msg_3",
    "ticket_id": "tkt_abc123",
    "sender_type": "user",
    "message": "I tried again...",
    "created_at": "2026-02-17T12:00:00Z"
  }
}

Side effects:


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:

Response:

{
  "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:

{
  "status": "in_progress",
  "priority": "high"
}

Response:

{
  "data": { /* updated ticket */ }
}

Side effects:


POST /api/admin/support/tickets/[id]/messages

Admin reply to ticket.

Request:

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

Response (201):

{
  "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:


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:


6. UI Pages

6.1 /support — Help Center (User)

Layout:

Design:


6.2 /support/tickets — Ticket List (User)

Layout:

Empty state:


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

Layout:


6.4 /support/new — Create Ticket (User)

Form fields:

Validation:

Success:


6.5 /admin/support — Admin Dashboard

Layout:

Click ticket:


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

Layout:

Audit trail (bottom):


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.


When user creates ticket with transaction_id:


7.3 Email Notifications (Future)

Placeholder implementation:


8. File List

8.1 Database Migration

File: src/lib/db.ts Changes:


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

  1. src/app/api/admin/support/tickets/route.ts — GET (list all)
  2. src/app/api/admin/support/tickets/[id]/route.ts — PATCH (update status/priority)
  3. 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

  1. src/app/admin/support/page.tsx — Admin dashboard (ticket table)
  2. 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

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

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

9.2 API Endpoints

9.3 Authorization

9.4 UI Pages

9.5 Integration

9.6 Validation

9.7 Edge Cases


10. Dependencies

10.1 Existing Infrastructure

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

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

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:
    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)

11.2 Integration Tests (Future)

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

14.2 Security

14.3 Performance

14.4 Norwegian Text

All UI text in Norwegian (nb):


15. Spec Version History

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

END OF SPEC

System Specifications

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:

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:

1.2 Drop's Liability Model

Unauthorized transactions (fraud):

Authorized but disputed (service issue):

Technical failure (Drop's fault):


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

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:

3.2 dispute_messages Table

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:

3.3 dispute_actions Table (Audit Trail)

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:


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:

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:

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:

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

Response (201):

{
  "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:

Auto-priority logic:

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:


GET /api/disputes

List user's disputes.

Query params:

Response:

{
  "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:

{
  "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:


POST /api/disputes/[id]/messages

Add user message (provide additional evidence).

Request:

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

Response (201):

{
  "data": {
    "id": "msg_3",
    "disputeId": "dsp_abc123",
    "senderType": "user",
    "message": "I have contacted my bank...",
    "createdAt": "2026-02-17T12:00:00Z"
  }
}

Side effects:


POST /api/disputes/[id]/withdraw

User withdraws dispute.

Request:

{
  "reason": "Resolved directly with recipient"
}

Response:

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

Side effects:


5.2 Admin Endpoints

All admin endpoints require requireAdmin() middleware.

GET /api/admin/disputes

List ALL disputes with filters.

Query params:

Response:

{
  "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:

{
  "status": "under_review",
  "priority": "critical",
  "notes": "Escalating due to large amount"
}

Response:

{
  "data": { /* updated dispute */ }
}

Side effects:


POST /api/admin/disputes/[id]/messages

Admin reply to user.

Request:

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

Response (201):

{
  "data": {
    "id": "msg_4",
    "disputeId": "dsp_abc123",
    "senderType": "admin",
    "message": "We have reviewed your case...",
    "createdAt": "2026-02-17T13:00:00Z"
  }
}

Side effects:


POST /api/admin/disputes/[id]/resolve

Manually resolve dispute.

Request:

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

Response:

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

Side effects:


POST /api/admin/disputes/[id]/escalate

Escalate to Finansklagenemnda (FinKN).

Request:

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

Response:

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

Side effects:


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):

// 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):

// 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:

Empty state:


7.2 /disputes/new — Create Dispute (User)

Step 1: Select Transaction

Step 2: Dispute Type

Step 3: Details

Step 4: Review & Submit


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

Layout:


7.4 External Complaint (FinKN)

Route: /disputes/[id]/escalate

Content:


7.5 /admin/disputes — Admin Dashboard

Layout:


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

Layout:


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:


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

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:

// 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,
  });
}

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:

// 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]
  );
}

Table: complaints (already exists in db.ts)

Relationship:

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

10.2 API Endpoints

10.3 Authorization

10.4 SLA Management

10.5 UI Pages

10.6 Integration

10.7 Validation

10.8 Edge Cases


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

12.2 New Dependencies

None. All features use existing infrastructure.

12.3 External Services (Future)


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:

Drop's obligations:

Liability shift:

15.2 PSD2 Article 74 (Complaint Handling)

Requirements:

Drop's implementation:

15.3 GDPR Compliance

Data retention:

User rights:

Data minimization:


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:

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

SLA breaches:

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

Average resolution time:

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:

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:

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:

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


Q4: Dispute Notification Channels

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

Options:

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


Q5: Dispute Evidence (Future)

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

Options:

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
  2. What is PSD2 everything to know for compliance - Adyen
  3. The Payment Services Contract: PSD2 Requirements and PSD3 Perspectives - ILP Abogados
  4. What is PSD2? How it Impacts Banks, Businesses & Chargebacks911
  5. How European merchants can reduce chargebacks and protect revenue in 2026 | GR4VY

Regulatory bodies:


END OF SPEC

System Specifications

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:

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:

Cons:

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

SendGrid

Pros:

Cons:

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

AWS SES

Pros:

Cons:

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:


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:

// 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:

# 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)

// 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)

// 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)

// 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:

Retry Logic (Fire-and-Forget for MVP):

Template Loading:

// 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/:

Templates to CREATE:

1. email-verification.html

Purpose: Verify email address (sent after registration) Placeholders:

Content:

<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:

Content:

<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:

Content:

<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:


3.3 Database Schema

New Tables:

email_verification_tokens

-- 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

-- 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

-- 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:


3.4 API Endpoints

POST /api/auth/verify-email

Purpose: Verify email with token or OTP code Request:

{
  "token": "uuid-v4-token",  // From email link
  "code": "123456"           // Optional: OTP code (if user can't click link)
}

Response (200):

{
  "data": {
    "verified": true,
    "userId": "usr_xxx"
  }
}

Errors:

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:

{
  "email": "user@example.com"
}

Response (200):

{
  "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:

{
  "token": "uuid-v4-token",
  "newPassword": "NewP@ssw0rd123"
}

Response (200):

{
  "data": {
    "message": "Password reset successful. You can now log in."
  }
}

Errors:

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:

{
  "email": "user@example.com"
}

Response (200):

{
  "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:

// 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:

After transaction status = 'completed':

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:

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:

-- 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:


4. Dependencies

Add to package.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:

npm install resend @sendgrid/mail nodemailer
npm install -D @types/nodemailer

5. Env Vars

Add to .env.example:

# --- 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
  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:

Database:

API Endpoints:

Integration:

Templates:

Provider Setup:


8. Implementation Plan

Phase 1: Email Service Layer (2h)

Phase 2: Database Schema (1h)

Phase 3: API Endpoints (3h)

Phase 4: Templates (2h)

Phase 5: Integration (2h)

Phase 6: Production Setup (1h)

Total: 11 hours


9. Rollout Strategy

Staging:

Production (Gradual):

Monitoring:


10. Success Metrics

Week 1:

Month 1:

Quarter 1:


END OF SPEC

System Specifications

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

1.2 Competitive Landscape

Wise (market leader):

Remitly:

Drop's Positioning:

1.3 Pass-Through Model Implications

Drop does NOT:

Drop DOES:

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:

-- 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

API Endpoints:

UI Component: /src/components/pre-payment-disclosure.tsx

Fee Calculation: Hardcoded in /src/app/api/transactions/remittance/route.ts

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

API Base URL: https://data.norges-bank.no/api/data/EXR/

Example Request:

# 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:

Limitations:

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

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

-- 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:

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:

4.3 New Table: fee_configs

Make fees configurable instead of hardcoded:

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:

4.4 New Table: fx_rate_alerts

Monitor rate drift and API failures:

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:


5. API Endpoints

5.1 Enhanced GET /api/rates

Current: Returns all rates from DB New: Add metadata, source attribution, staleness indicator

Request:

GET /api/rates?base=NOK&symbols=EUR,USD,RSD

Response:

{
  "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:

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:

{
  "type": "remittance",
  "amount": 5000,
  "recipientId": "rec_abc123"
}

Response:

{
  "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:

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:


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):

[
  { "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:

// /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:

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:
<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>
  1. PSD2 Regulatory Disclosure:
<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):

Maximum execution time:

All charges payable with breakdown:

Actual or reference exchange rate:

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:

// /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:

Acceptance Criteria:

Effort: 3 days (1 builder agent)


Phase 2: Norges Bank Integration (Week 1) — Primary Data Source

Deliverables:

Acceptance Criteria:

Effort: 2 days (1 builder agent)


Phase 3: Commercial Provider Integration (Week 2) — Real-Time Fallback

Deliverables:

Acceptance Criteria:

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:

Acceptance Criteria:

Effort: 3 days (1 builder + 1 designer agent)


Phase 5: Admin Tools & Monitoring (Week 3) — Ops Dashboard

Deliverables:

Acceptance Criteria:

Effort: 2 days (1 builder agent)


Phase 6: Rate Locking (Future — Deferred) — Advanced Feature

Deliverables:

Acceptance Criteria:

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

13.2 Non-Functional Requirements

13.3 Compliance Requirements


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:

Total: 12 days (~2.5 weeks) for full PSD2-compliant FX transparency system


Sources


End of Specification

System Specifications

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:


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:

Sources:

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:

Sources:

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:

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:

Sources:


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

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:


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

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:


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

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:


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

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:


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

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:


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

export const options = {
  stages: [
    { duration: '5m', target: 100 },  // Ramp up
    { duration: '20m', target: 100 }, // Sustain
    { duration: '5m', target: 0 },    // Ramp down
  ],
};

Expected Behavior:


5.2 Peak Load (500 Concurrent Users)

Scenario: Weekend evening peak (users sending remittances) Duration: 15 minutes Ramp: Linear over 3 minutes

export const options = {
  stages: [
    { duration: '3m', target: 500 },
    { duration: '10m', target: 500 },
    { duration: '2m', target: 0 },
  ],
};

Expected Behavior:


5.3 Stress Test (1000 Concurrent Users)

Scenario: Black Friday sale (merchant QR payments spike) Duration: 10 minutes Ramp: Linear over 2 minutes

export const options = {
  stages: [
    { duration: '2m', target: 1000 },
    { duration: '5m', target: 1000 },
    { duration: '3m', target: 0 },
  ],
};

Expected Behavior:


5.4 Spike Test (0 → 500 in 30 Seconds)

Scenario: Marketing campaign goes viral (sudden traffic surge) Duration: 10 minutes

export const options = {
  stages: [
    { duration: '30s', target: 500 }, // Spike
    { duration: '5m', target: 500 },  // Sustain
    { duration: '2m', target: 0 },
  ],
};

Expected Behavior:


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:


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:

Connection Pool Config (pgBouncer):

[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:

sqlite3 data/drop.db
sqlite> EXPLAIN QUERY PLAN SELECT * FROM transactions WHERE user_id = ? ORDER BY created_at DESC LIMIT 10;

PostgreSQL Query Analysis:

-- 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:

node --cpu-prof src/drop-app/server.js
# Generates CPU profile → analyze with Chrome DevTools

Memory Profiling:

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:

Custom Metrics (add to scripts):

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:

# 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):


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

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):

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:


10. Next.js 16 Performance Optimizations

10.1 Server Components (Default in Next.js 16)

Impact:

Usage:

// 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:


10.2 Caching with "use cache" Directive (New in Next.js 16)

Impact:

Usage:

// app/transactions/page.tsx
'use cache';

export default async function TransactionsPage() {
  const transactions = await getTransactions();
  return <TransactionList transactions={transactions} />;
}

Load Test Impact:

Sources:


10.3 React Compiler (Stable in Next.js 16)

Impact:

Configuration:

// next.config.ts
export default {
  experimental: {
    reactCompiler: true,
  },
};

Load Test Impact:

Sources:


10.4 Turbopack (Fast Dev Server)

Impact:

Usage:

npm run dev -- --turbo

Load Test Impact:


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:


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

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

PSD2 Compliance & Performance

Next.js 16 Performance

Database Performance


16. Approval

Reviewed by: Alem (CEO) Status: Pending Next Steps: Implement Phase 1 (Setup & Baseline)

System Specifications

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:


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, i18n library comparison

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)
  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)
  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, Next.js 16 i18n guide


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)
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)

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:

Date Formatting:

Number Formatting:


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

{
  "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

{
  "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):

<span className="text-xs text-[#1E293B]">Hjem</span>

Example (After):

<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:

Example (API Route — Before):

return jsonError("unauthorized", "Invalid credentials", 401);

Example (API Route — After):

return jsonError("unauthorized", "errors.invalid_credentials", 401);
// Note: Second param is translation KEY, not message

Example (Frontend):

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

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:

const html = await renderWelcomeEmail(user.preferredLanguage || 'nb-NO', {verifyUrl});
await sendEmail({to: user.email, subject: t('email.welcome_subject'), html});

Scope: Terms of Service, Privacy Policy, Fee Schedule

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/
        └── ...
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):

English Locale (en):

Swedish Locale (sv):

Implementation (next-intl):

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, Norwegian Bokmål locale formatting

6.2 Date & Time Formatting

Norwegian (nb-NO):

English (en):

Implementation:

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):

English (en):

Implementation:

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.jsonen.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:

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:

Example:

Database:

INSERT INTO notifications (user_id, message_key, data_json) VALUES
(123, 'notification.transfer_completed', '{"amount": 500, "recipient": "John Doe"}');

Display (React component):

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:

{
  "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:

Example:

{
  "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:

Pros:

Cons:

Implementation:

File: src/middleware.ts

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

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/dashboarden)
  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

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:

ALTER TABLE users ADD COLUMN preferred_language TEXT DEFAULT 'nb-NO';

API Route to Update Preference:

// 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:

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.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).

Implication: Terms of Service, Privacy Policy, and Fee Schedule MUST be available in Norwegian.

Compliance:

Requirement: User must consent in a language they understand.

Strategy:

Database:

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:


11. Testing Strategy

11.1 Unit Tests

Framework: Vitest (already in use)

Test Files:

Example:

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:

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:

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:

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:

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:
      // .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:


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 or Crowdin 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

Localization Best Practices

Library Comparisons


18. Appendix: Translation File Examples

A. Complete nb-NO.json (Sample)

See Section 4.2 for full structure.

B. ESLint Rule for Hardcoded Strings

// .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

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

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)?
System Specifications

drop-make-integration-plan

Plan: Drop Figma Make Frontend Integration

Research Summary

Make Export (source: ~/ALAI/products/Drop/mockups/figma-make-export/)

Existing Drop (source: ~/ALAI/products/Drop/src/drop-app/)

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

Task 2: Validate foundation


Phase 2: Screen Conversion (parallel — 3 builders)

Task 3: Convert Login, Onboarding, Dashboard, Profile

Task 4: Convert SendMoney, BankAccounts, TransactionHistory

Task 5: Convert ScanQR, Notifications, MerchantDashboard

Task 6: Validate all 10 screens


Phase 3: Integration & Build

Task 7: Build test + fix issues

Task 8: Final validation

Validation Commands

# 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

System Specifications

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:

Core Principles:


2. Current State Assessment

2.1 What Exists

Landing Page (getdrop.no):

SEO Meta Tags Present:

<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:

Domain:

2.2 What's Missing

Critical Gaps:

Legal/Compliance Gaps:


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:

<!-- 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:

B. Social Proof

Current state: Generic testimonial placeholders

Add Real Metrics (when available):

<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:

Comparison Table:

<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:

3.2 Mobile Optimization

Issues:

Fixes:

@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:

<div class="mobile-cta-bar">
  <a href="#cta" class="btn-gradient">Registrer deg gratis</a>
</div>

3.3 Page Speed

Current Performance (estimate):

Optimizations:

Target Metrics:


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:

English Keywords (Secondary — expat market):

4.2 On-Page SEO Implementation

A. Meta Tags Enhancement

Homepage:

<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):

B. Structured Data (JSON-LD)

Organization Schema:

{
  "@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):

{
  "@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 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:

D. Robots.txt

User-agent: *
Allow: /
Disallow: /api/

Sitemap: https://getdrop.no/sitemap.xml

4.3 Off-Page SEO

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:

B. Local SEO

Google My Business (when applicable):

Local Citations:

4.4 Technical SEO

Checklist:

Hreflang Example (if English page added):


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):

Secondary Keywords:

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)

# 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:

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:

Category:

Content 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:


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:

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

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:

// 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

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

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:

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:

// 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):

// 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:

// 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':

// 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:

// 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

// 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

// First transfer must be >= 500 kr to qualify
if (amount < 500) {
  // Don't issue rewards
  return;
}

Rule 3: Monthly Credit Cap

// 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

// 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

// 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:

<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

<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

<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

<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:

7.2 UTM Tracking Implementation

A. Client-Side Capture (Landing Page)

Add to index.html (before waitlist form):

// 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:

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:

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:

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:

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

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

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)

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:

<!-- 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:

// In waitlist form submit handler
if (response.ok) {
  fbq('track', 'Lead'); // Facebook standard event
}

Track Registration:

// In /api/auth/register success callback
fbq('track', 'CompleteRegistration'); // Facebook standard event

Google Ads Conversion Tracking

Add to <head>:

<!-- 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:

// 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

Email 2 (Day 7): Educational

Email 3 (Day 14): Social Proof

Email 4 (Day 21): Product Demo

Email 5 (Pre-Launch -7 days): Launch Countdown

Email 6 (Launch Day): App Available

B. Onboarding Series (Post-Registration)

Email 1 (After Registration): Welcome

Email 2 (Day 3, if no BankID): Nudge

Email 3 (Day 7, if no first transfer): First Transfer Incentive

C. Re-engagement Series (Dormant Users)

Trigger: User hasn't sent a transfer in 30 days

Email 1 (Day 30): Gentle Reminder

Email 2 (Day 60): Win-back Offer

Email 3 (Day 90): Feedback Request

8.2 Marketing Consent Management

<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):

-- 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):

<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:

// 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:


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>:

<script defer data-domain="getdrop.no" src="https://plausible.io/js/script.js"></script>

Track Custom Events:

// 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):

9.3 In-App Analytics (Mixpanel)

Events to Track:

Acquisition

Activation

Engagement

Retention

Referral

Revenue

Mixpanel People Properties:

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:

Activation Metrics:

Engagement Metrics:

Retention Metrics:

Referral Metrics:

Revenue Metrics:

Campaign Performance:


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

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):

// 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:

// In waitlist form submit handler
plausible('Waitlist Signup', {
  props: {
    ab_hero_cta: variant,
    utm_source: utm.source
  }
});

Analysis:

-- 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:


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:

<!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:


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):

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

12.3 Content Pillars

Pillar 1: Product Education (40%)

Pillar 2: Customer Stories (30%)

Pillar 3: Industry Insights (20%)

Pillar 4: Culture & Values (10%)

12.4 Content Calendar (First Month)

Week 1:

Week 2:

Week 3:

Week 4:

12.5 Paid Social Ads (Post-Launch)

Budget: 10,000 NOK/month (testing phase)

Campaign 1: Waitlist Signups (Pre-Launch)

Campaign 2: App Installs (Post-Launch)

Campaign 3: Retargeting (Post-Launch)


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:

3. Misleading Marketing Prohibition: Cannot make false or misleading claims. All claims must be substantiable.

Examples of Compliant Claims:

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

Implementation (Cookie Consent Banner):

<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:

## 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:

Calculation:

400 conversions × 50 kr (referrer) = 20,000 kr
400 conversions × 50 kr (referee) = 20,000 kr
Total referral rewards: 40,000 kr

ROI:

Budget Allocation:


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:


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:


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:


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:


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:


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:


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:


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:


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

Risk 2: High Customer Acquisition Cost (CAC)

Risk 3: Poor App Store Visibility

Risk 4: Email Deliverability Issues

Risk 5: Referral Fraud


18. Acceptance Criteria

18.1 Landing Page

18.2 SEO

18.3 UTM & Attribution

18.4 Referral System

18.5 App Store Listings

18.6 Email Marketing

18.7 Analytics

18.8 Social Media


19. Rollout Timeline

Pre-Launch (Weeks 1-10):

Launch Week (Week 11):

Post-Launch (Weeks 12-16):


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:

App Store Optimization:

Referral Programs & Fraud Prevention:


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?
System Specifications

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:

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:

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

Phase 2: Testing (5 iterations per testing.md)

Task 2: Run 5 test iterations across all levels, fix failures

Phase 3: Deploy to Staging

Task 3: Prepare Docker for SQLite deployment

Task 4: Validate Docker deployment

Task 5: Deploy to staging (Railway or Hetzner)

Task 6: Validate staging deployment

Phase 4: Close Pipeline

Task 7: Update PIPELINE.md, close tasks, create summary

Validation Commands

# 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

System Specifications

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:


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:


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:

Validation Rules:

// 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:

<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:

{
  "email": "user@example.no",
  "password": "SecureP@ss123",
  "firstName": "Alem",
  "lastName": "Basic",
  "phone": "+4712345678",
  "dateOfBirth": "1990-01-01"
}

Validation:

// 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:

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:

{
  "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:

// 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:

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:

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:

// 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:

Backend (api/auth/verify-otp/route.ts)

Endpoint: POST /api/auth/verify-otp

Request Body:

{
  "phone": "+4712345678",
  "otp": "842759"
}

Validation Flow:

// 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:

{
  "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:

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:

// 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:

PIN Indicator:

<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:

{
  "userId": "usr_abc123",
  "pin": "1234"
}

Backend Logic:

// 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:

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:

Gap Analysis:


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:

Progress Indicator:

<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

Screen 2: Benefits

Screen 3: BankID Connection

Screen 4: Ready

Navigation Controls:

Backend Implementation

Missing Backend Logic: No backend tracking of onboarding completion.

Required Implementation:

Endpoint: POST /api/onboarding/complete

Request Body:

{
  "userId": "usr_abc123"
}

Backend Logic:

// 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:

ALTER TABLE users ADD COLUMN onboarding_completed INTEGER DEFAULT 0;
ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT;

Skip Handling:

// Allow skip but still mark as completed
// This is UX flexibility — user chose to skip educational content

Gap Analysis:


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):

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:

Required Implementation:

Dashboard BankID Prompt (dashboard/page.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:

Backend (api/auth/bankid/route.ts)

Endpoint: GET /api/auth/bankid

Current Implementation:

// 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:

// 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:

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:

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:


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:

if (isDemoMode()) {
  return { status: "approved" };
}

Production Mode:

// 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:

// 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:

// 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:

ALTER TABLE users ADD COLUMN kyc_external_id TEXT;
ALTER TABLE users ADD COLUMN kyc_verified_at TEXT;

KYC Status UI:

Dashboard Pending State:

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:

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:

// 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:


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:

<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:

// 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

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.

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:

// 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:

if (age < 18) {
  setError("Du må være minst 18 år for å bruke Drop");
  return;
}

Backend:

if (age < 18) {
  return jsonError("underage", "Du må være minst 18 år for å bruke Drop", 403);
}

UI Treatment:

6.2 BankID Verification Failure

Scenario 1: BankID returns fødselsnummer indicating age < 18

Handling:

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:

// /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:

// 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:

// /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:

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);

const tokenResponse = await fetch(tokenUrl, {
  signal: controller.signal,
  // ...
});

clearTimeout(timeoutId);

Retry Strategy:

6.3 KYC Rejection

Scenario: Sumsub rejects user identity verification

Handling:

// 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:

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:

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:

// 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:

// 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:

{
  "userId": "usr_abc123",
  "phone": "+4712345678"
}

Backend Logic:

// 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:

// 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):

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:

-- 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:

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:

<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:

// 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):

-- 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

{
  "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

{
  "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

{
  "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):

<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:

// 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>

Terms of Service:

Privacy Policy:

PSD2 AISP/PISP Consent:

Database Schema:

-- 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:

// 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:

// 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:

// 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:

// 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:


10. Acceptance Criteria

10.1 Functional Requirements

Registration:

Phone OTP Verification:

PIN Setup:

Onboarding Tour:

BankID Verification:

KYC Check:

10.2 Non-Functional Requirements

Performance:

Security:

Accessibility:

Usability:

10.3 Edge Case Coverage


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)

13.2 Beta Launch (Week 2-3)

13.3 Limited Public Launch (Week 4-6)

13.4 Full Public Launch (Week 7+)


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
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)?
System Specifications

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:

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:

// 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).

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:


2.3 Platform-Specific Implementations

A. Web Push API (PRIMARY — MVP)

Tech Stack:

VAPID (Voluntary Application Server Identification):

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):

// 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:

{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}

Env Vars:

# 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:

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):

// 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:

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:

{
  "dependencies": {
    "firebase-admin": "^12.0.0"
  }
}

Env Vars:

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:

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:

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:

{
  "dependencies": {
    "apn": "^2.2.0"
  }
}

Env Vars:

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:

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:

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:

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:

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:

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:

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:

-- 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:

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:

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:

Quiet hours logic:


3.2 Schema Changes to Existing Tables

notifications (existing table — ADD columns)

Additions:

-- 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:

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:

{
  "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):

{
  "data": {
    "deviceId": "dtk_abc123",
    "registered": true
  }
}

Errors:

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):

{
  "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):

{
  "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:

{
  "account": { "enabled": true },
  "promotional": { "enabled": false },
  "quietHours": {
    "enabled": true,
    "start": "22:00",
    "end": "08:00"
  }
}

Response (200):

{
  "data": { "updated": true }
}

Errors:

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:

Response (200):

{
  "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):

{
  "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:

After transaction status = 'completed':

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:

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:

-- 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:

Pattern (same for all):

// 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:

// 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
  }
}
<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:

Audit trail:

-- 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:

Art. 7 — Conditions for consent:

Art. 17 — Right to erasure:


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:

Example (GOOD):

{
  "title": "Ny transaksjon",
  "body": "Du mottok kr 2 500,00",
  "data": {
    "transactionId": "tx_abc123",
    "url": "/dashboard/transactions/tx_abc123"
  }
}

Example (BAD):

{
  "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):

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:

Implementation:

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):

-- 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:

Implementation (future):

// 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)

Day 2: Frontend Integration (6h)

Day 3: Integration + Testing (6h)

Total: 18 hours (3 days @ 6h/day)


Phase 2: FCM (Android) — 1 day (FUTURE)

When: React Native Android app ready for testing.

Tasks:


Phase 3: APNs (iOS) — 1 day (FUTURE)

When: React Native iOS app ready for testing.

Tasks:


Phase 4: Advanced Features — 2 days (FUTURE)

Features:


12. Testing Strategy

12.1 Unit Tests

Test files to create:

Coverage:


12.2 Integration Tests

Test files to create:

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


13. Success Metrics

Week 1 (Post-Deployment)

Month 1

Quarter 1


14. Rollout Strategy

Staging (1 week)

Production (Gradual)


15. Acceptance Criteria

Push Service Layer:

Database:

API Endpoints:

Integration:

Compliance:

UI:


16. Dependencies

Add to package.json:

{
  "dependencies": {
    "web-push": "^3.6.7"
  }
}

Future (when mobile apps ship):

{
  "dependencies": {
    "firebase-admin": "^12.0.0",
    "apn": "^2.2.0"
  }
}

Install:

cd ~/ALAI/products/Drop/src/drop-app
npm install web-push

17. Env Vars

Add to .env.example:

# --- 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:

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

System Specifications

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

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

Task 2: Validate logo and brand assets

Phase 2: App Pages (parallel)

Task 3: Rebrand Login + Onboarding

Task 4: Rebrand Dashboard + Home + Send + Scan

Task 5: Rebrand Cards + Accounts + Profile + History + Merchant

Task 6: Validate all app pages

Phase 3: Landing Pages (parallel)

Task 7: Rebrand landing index + product pages

Task 8: Rebrand landing company + legal pages

Task 9: Validate all landing pages

Phase 4: Email & Remaining Branding

Task 10: Create email templates + OG images

Task 11: Validate email templates and branding

Validation Commands

# 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
System Specifications

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:


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)

Decision: Use Twilio for MVP. Re-evaluate cost after 1000 transactions.

2.2 Cost Analysis

Assumptions:

Monthly Cost:

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

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?

Security mitigations:


4. API Endpoints

4.1 POST /api/otp/send

Purpose: Generate and send OTP via SMS

Request:

{
  "purpose": "remittance",
  "transactionId": "tx_rem_xyz123",
  "phoneNumber": "+4798765432"
}

Response (200):

{
  "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:

4.2 POST /api/otp/verify

Request:

{
  "otpId": "otp_abc123",
  "code": "123456",
  "transactionId": "tx_rem_xyz123"
}

Response (200):

{
  "success": true,
  "data": {
    "verified": true,
    "transactionId": "tx_rem_xyz123"
  }
}

4.3 POST /api/otp/resend

Request:

{
  "otpId": "otp_abc123"
}

Response (200):

{
  "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

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

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:

TWILIO_ACCOUNT_SID=AC...
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+47XXXXXXXX

9. Frontend Integration

9.1 UI Components

OtpInput.tsx — 6-digit code input

OtpDialog.tsx — Modal for OTP entry


10. Testing Strategy

Unit Tests:

Integration Tests:

E2E Tests (Playwright):


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

const OTP_ENABLED = process.env.FEATURE_OTP_2FA === 'true';

Rollout: 10% → monitor → 100%


12. Acceptance Criteria

Functional:

Non-Functional:

Security:


13. Cost & Timeline

Cost:

Timeline:


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


16. References

Research:

Internal Docs:


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).

System Specifications

drop-supporting-systems-plan

Plan: Drop Supporting Systems — Monitoring, Logging, Alerts, Backups

Research Summary

What Exists

What's Missing (from docs/infrastructure/MONITORING.md)

Tech Stack Context

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

Task 2: Implement audit log table + middleware

Task 3: Validate logging + audit log


Phase 2: Error Tracking + Monitoring + Alerting

Task 4: Integrate Sentry error tracking

Task 5: Add health check monitoring + Slack alerting hook

Task 6: Validate monitoring + alerting


Phase 3: Automated Backups + CI Security

Task 7: Create automated backup script + CI security scanning

Task 8: Validate backups + CI security


Validation Commands

# 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).

System Specifications

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:

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:

Basic error handling:

30-second timeout:

1.2 What's Missing (Critical Gaps)

State machine enforcement:

Retry logic:

Background reconciliation:

Partial failure handling:

User communication:

Admin tools:


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

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:

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:

// 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:

3.2 Best Practices

Client implementation:

// 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

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):

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:

Cons:

Option B: Background Job Queue (Production-Grade)

Move retries to background worker using job queue:

Tech stack:

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:

Cons:

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:
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.'
);
  1. Send Slack/email to ops team (via webhook or existing notification system)

  2. 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

// 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

// 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:

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?


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:

ALTER TABLE transactions ADD COLUMN compensation_status TEXT CHECK(
  compensation_status IN ('none', 'pending', 'completed', 'failed')
) DEFAULT 'none';

Flow:

// 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:

// 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:

{
  "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
processingcompleted "Payment Complete" "Your 500 NOK payment to Mama Jasmina is complete"
processingfailed "Payment Failed" "Your 500 NOK payment failed. Tap to view details"
timeoutcompleted "Payment Complete" "Your payment has been confirmed by the bank"
partially_completedfailed "Refund Processed" "Your 500 NOK has been refunded to your account"

Implementation:

// 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:

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:

<!-- 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:

// 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:

// 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:

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:

{
  "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:

// 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:

{
  "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:

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

-- 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:

-- 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:

-- 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

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):

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)

Phase 2: Retry Logic (Week 2)

Phase 3: Timeout Recovery (Week 2-3)

Phase 4: User Communication (Week 3)

Phase 5: Admin Tools (Week 4)

Phase 6: Partial Failure Handling (Future — After FX Provider Integration)


11. Testing Strategy

11.1 Unit Tests

Retry logic:

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:

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

12.2 Idempotency

12.3 Retry Logic

12.4 Timeout Recovery

12.5 Partial Failure

12.6 User Communication

12.7 Admin Tools


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:

SELECT COUNT(*) FROM transactions
WHERE status IN ('processing', 'timeout')
  AND created_at < datetime('now', '-10 minutes');

Average resolution time:

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:

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:

{
  "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:

{
  "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:

{
  "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:

Rate limiting:

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):

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:

Reconciliation costs:

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?

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"?

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?

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

System Specifications

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:

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):

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

Task 2: Wire validators into amount routes

Task 3: Validate Task 1 + Task 2

Phase 2: Frontend Validation (Client-side)

Task 4: Add proper validation to registration form

Task 5: Add email validation to login form

Task 6: Validate Task 4 + Task 5

Phase 3: Test Updates

Task 7: Update e2e tests for new validation rules

Task 8: Final validation — full test suite

Files Modified

Backend (API)

Frontend

Tests

Validation Commands

# 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

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:

Drop nema:


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)

Gate: Alem kaže GO

Phase 2: Provision (Week 1)

FlowForge SRE → implementacija:

2a. BetterStack Uptime (30 min)

2b. Sentry Re-integracija (1-2h)

2c. CloudWatch Logs (1h)

Phase 3: Deploy (Week 2)

3a. RDS Performance Insights (15 min)

3b. Cloudflare Analytics (15 min)

3c. Alerting Escalation (30 min)

Phase 4: Monitor (Week 3-4)

4a. Business Metrics Endpoint (2-3h)

4b. Application Metrics (2-3h)

4c. SLO Dashboard (1-2h)

Phase 5: Optimize (Ongoing)

HelixSupport preuzima:


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


Approval

CEO Decision Required:

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)

# 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

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

Page Specifications

Per-page UI and feature specifications

Page 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:

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

<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

<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

<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

<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

<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 Specifications

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:

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

<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

<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

<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

<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

<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 Specifications

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

Secondary CTA

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

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 Specifications

Page: Login

Page Spec: Login

Route

/login

Architecture Status

Core

Figma Reference

login.png

Visual Description from Figma

The login page shows:

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

<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

<div className="rounded-2xl bg-white p-6 shadow-sm space-y-4">
  {/* form fields + buttons */}
</div>

Email Input

<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

<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

<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

<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

<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>
<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

<p className="text-sm text-[#EF4444] bg-[#EF4444]/10 rounded-md p-2">{error}</p>

Data Displayed

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 Specifications

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:

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

<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)

<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)

<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

<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

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

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 Specifications

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:

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

<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

<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

<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

Divider

<div className="h-px bg-[#E5E7EB] mx-5" />

Logout Button

<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>
<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 Specifications

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:

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

<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)

<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

<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)

<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)

<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)

<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

<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

<p className="text-sm text-[#EF4444] bg-[#EF4444]/10 rounded-md p-2">{error}</p>

Data Displayed

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 Specifications

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:

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

<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

<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

<p className="text-center text-sm text-[#9CA3AF]">
  Pek kameraet mot butikkens QR-kode
</p>

Simulate Button (Demo Only)

<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 Specifications

Page: Send

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:

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

<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

<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

<div className="py-12 text-center">
  <p className="text-sm text-[#6B7280]">Ingen mottakere funnet</p>
</div>

Add Recipient Button

<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)

<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 Specifications

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:

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

<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

<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

<div className="px-6 pt-4 pb-2">
  <p className="text-xs font-semibold uppercase tracking-wider text-[#9CA3AF]">
    {dateLabel}
  </p>
</div>

Transaction Card

<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)

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

Task 2: Drizzle schema for all 25 tables

Task 3: Validate database migration

Task 4: Redis + BullMQ infrastructure

Task 5: Validate infrastructure


Phase 2: Auth + Security (Week 2)

Task 6: BankID-only authentication

Task 7: Validate auth + security


Phase 3: API Routes (Week 2-3)

Task 8: Webhook handling API (FR-076)

Task 9: Reconciliation API (FR-073)

Task 10: Circuit breaker service (FR-075)

Task 11: Dispute/refund API (FR-077)

Task 12: Validate API routes


Phase 4: UI Alignment (Week 3-4)

Task 13: Align existing screens with Figma Make export

Task 14: Admin portal UI (EP-09)

Task 15: Validate UI


Phase 5: Integration + Testing (Week 4-5)

Task 16: Mock services for all external integrations

Task 17: End-to-end test suite

Task 18: Final validation


Validation Commands

# 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:

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

Task 2: Scale Claude CLI budget by complexity

Task 3: Add human-active backoff

Task 4: Validate Phase 1 fixes

Phase 2: Harden Ollama Tool Agent

Task 5: Increase MAX_TURNS and add JSON repair

Task 6: Validate ollama-tool-agent changes

Phase 3: HiveMind TTL + Outcome Tracking

Task 7: Add aggressive TTLs to HiveMind

Task 8: Add task outcome tracking to pi-orchestrator

Task 9: Selective HiveMind feedback

Task 10: Validate Phase 3

Validation Commands

# 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)

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:

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:

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:

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:

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:

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:

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:

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:

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 validationtransactions.idempotency_key + uniqueIndex. Kotlin must validate this BEFORE any external PISP call. HTTP 409 on duplicate.
  2. Rate lockrate_lock_expires_at column. If rate lock expired, re-fetch rate before proceeding.
  3. Circuit breakercircuit_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 trackingpisp_attempts, pisp_timeout_count, pisp_last_attempt_at.
  5. Refund trackingrefund_status, refund_amount_ore, refunded_at.

All monetary amounts MUST remain in integer øre. No floating point arithmetic on money values.

Acceptance criteria:

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:

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:

Acceptance criteria:

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:

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 SLAcomplaints 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 consentconsents.withdrawn_at is nullable. Withdrawal sets this timestamp. Do NOT delete the consent record (audit trail requirement).
  4. Disputessla_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:

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:

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:

Dependencies: Phase 5 complete.

Risk: MEDIUM. Operational, not user-facing critical path.


Phase 7: Admin + Merchants + Cards + Remaining

Route files migrated:

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:

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):

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:


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):

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):

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:

What's missing:

CEO constraints:

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

Task 1.2: Configure CI/CD for Azure deploy


Phase 2: E2E Test Suite (B2) — PARALLEL with Phase 1

Task 2.1: Create comprehensive Playwright E2E test suite (~100 scenarios)


Phase 3: Landing Page Azure Deploy (B4) — PARALLEL with Phase 1 & 2

Task 3.1: Deploy landing page to Azure


Phase 4: Mobile Build Verification (B3) — PARALLEL

Task 4.1: Verify and fix React Native mobile builds


Phase 5: Validation (V1) — AFTER Phases 1-4

Task 5.1: Validate entire Drop deployment


Validation Commands

# 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

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

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

Task 0b: Bank partner outreach


Phase 1: Security P0 Fixes (CodeCraft)

Task 1: Fix OTP Security (CRITICAL BLOCKER)

Task 2: Fix Phone Regex + Add Serbian Format Normalisation

Task 3: Per-Phone OTP Rate Limiting

Task 4: SMS Provider Integration (SmsGateway abstraction + Twilio)

Task 5: Validate Task 1–4 (Security Fixes)


Phase 2: Architecture (CodeCraft)

Task 6: NBS IPS Bank Partner Adapter Pattern

Task 7: Phone-to-IBAN Resolution Layer

Task 8: Transaction Idempotency

Task 9: Validate Task 6–8 (Architecture)


Phase 3: Compliance Architecture (Finverge + CodeCraft)

Task 10: KYC Service (Veriff/Sumsub + JMBG)

Task 11: AML Monitoring + USPNFT Reporting

Task 12: Pre-Transaction Disclosure + Post-Settlement Receipt

Task 13: Complaints Handling Module

Task 15: Validate Task 10–13 (Compliance)


Phase 4: DevOps (FlowForge)

Task 16: Dockerfile + Docker Compose for DropSrbija

Task 17: CI/CD Pipeline

Task 18: Validate Task 16–17 (DevOps)


Phase 5: Test Suites (Proveo + CodeCraft)

Task 19: Kotest + Testcontainers + WireMock Infrastructure

Task 20: PhoneOtpService Tests (10 cases)

Task 21: OTP Rate Limiting Tests (5 cases)

Task 22: NBS IPS WireMock Tests (9 cases)

Task 23: Amount Validation + AML Threshold Tests (19 cases)

Task 24: JWT Security Tests (10 cases)

Task 25: Playwright E2E Tests (6 journeys, Serbian locale)

Task 26: Validate All Test Suites


Phase 6: Business Development (Skybound/BA)

Task 27: Bank Partnership Outreach Package


Phase 7: Validation (End-to-End)

Task 28: Full E2E Scaffold + Feature Validation


Phase 8: Documentation (Skillforge)

Task 29: BookStack Documentation


Validation Commands

# 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:


1. Zašto Follow-Up?

Original Drop Srbija plan (2026-04-16) je napisan prije CEO odluke o legal entity. Trenutno stanje:

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:

Nije krenulo:


3. Šta Možemo SADA (bez eksternih blokera)

Track A — Validacija backlog-a (41 taskova)

Cilj: Smanjiti reviewdone 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.

Cilj: Sve legalne dokumente prebaciti sa "Drop Srbija d.o.o." na "ALAI Tech d.o.o." (per memorija).

Dokumenti:

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)

  1. Dispatch Proveo + Angie E2E validation (Phase 7) — 6 user journeys, sve end-to-end
  2. Dispatch Skillforge BookStack dokumentacija (Phase 8) — Drop Srbija book + ADR sync

Čeka CEO odluke (ne dispatch dok ne potvrdiš)

  1. Bank outreach #8010 — ne šalji dok nemamo kapital + NBS PI aplikaciju u toku
  2. 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:


7. ZAKON PLAN Check

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

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 & 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

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/

Build Plans (8 plans)

Directory: /Users/makinja/system/specs/build-plans/

Metrics

MetricCount
Archives created3
Research sources cited10
Principles distilled47
Blueprint lines1,236
Templates written11
Gap analyses18
Build plans8
Critical findings2
Estimated agent-hours to 80% compliance~100 hours (6-8 weeks)

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,67WORKDIR /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)


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/sharedpackages/shared

Task 2: Gemini review PR 1

Task 3: Squash merge PR 1 to main


Phase 2 — Apps rename + Docker (highest production risk)

Task 4: PR 2 — Rename src/drop-*apps/drop-* + Dockerfile + buildspec updates

Task 5: Gemini review PR 2

Task 6: Squash merge PR 2 to main — CAUTION


Phase 3 — CI workflows update (silent skip risk)

Task 7: PR 3 — Update 8 GitHub Actions workflows

Task 8: Gemini review PR 3

Task 9: Squash merge PR 3


Phase 4 — Cleanup + production validation

Task 10: PR 4 — Delete legacy src/ directory + ALAI memory updates

Task 11: Proveo validation — STAGING (pre-merge)

Task 12: Gemini review PR 4

Task 13: Securion fintech security audit (pre-prod merge)

Task 14: Squash merge PR 4 to main

Task 15: Proveo validation — PRODUCTION

Task 16: Skillforge documentation


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


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):

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:

drop-api has:

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):

RuntimeEnvironmentSecrets (post-migration):

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:

{
  "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)

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:

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):

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)


Files (sensitive — /tmp only, never committed)