System Specifications
- drop-accessibility-spec
- drop-analytics-bi-spec
- drop-customer-support-spec
- drop-dispute-handling-spec
- drop-email-system-spec
- drop-fx-transparency-spec
- drop-load-testing-spec
- drop-localization-spec
- drop-make-integration-plan
- drop-marketing-infra-spec
- drop-mvp-pipeline-plan
- drop-onboarding-flow-spec
- drop-push-notifications-spec
- drop-rebrand-plan
- drop-sms-otp-spec
- drop-supporting-systems-plan
- drop-transaction-failure-spec
- drop-validation-hardening-plan
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:
- WCAG 2.1 Level AA — International accessibility standard
- Norwegian Law: Likestillings- og diskrimineringsloven § 18 (Equality and Anti-Discrimination Act)
- IKT-forskriften — Regulation for Universal Design of ICT Solutions
- Digitaliseringsdirektoratet (Digdir) Guidelines — Norwegian government digital accessibility requirements
Legal Context: Norway requires universal design of digital services for both public and private sectors. Non-compliance can result in fines from Digitaliseringsdirektoratet. WCAG 2.1 AA is the legal minimum standard in Norway.
Tech Stack: Drop uses Next.js 16 + React 19 + Radix UI (which has built-in accessibility features) + Tailwind v4. The application is mobile-first, with most users on phones.
1. Norwegian Legal Requirements
1.1 Applicable Laws
| Law/Regulation | Section | Requirement |
|---|---|---|
| Likestillings- og diskrimineringsloven | § 18 | Prohibition of discrimination based on disability; duty to provide universal design |
| Forskrift om universell utforming av IKT | All sections | Universal design of ICT solutions for public and private sectors (education, banking, commerce) |
| Finanstilsynet Guidelines | N/A | Financial services must be accessible to all customers |
| GDPR (DSGVO) | Article 25 | Privacy by design — accessible consent forms and data access requests |
1.2 Regulatory Bodies
- Digitaliseringsdirektoratet (Digdir): Supervises universal design of ICT
- Likestillings- og diskrimineringsombudet (LDO): Investigates discrimination complaints
- Finanstilsynet: Financial services regulator (monitors customer protection)
1.3 Compliance Deadlines
- 2025 Goal: Norway universally designed by 2025 (national action plan)
- Drop Target: Full WCAG 2.1 AA compliance before public launch (Q2 2026)
- Annual Monitoring: Digdir annually monitors compliance — results reported to ESA/EU
1.4 Enforcement
- Fines: Digitaliseringsdirektoratet can issue fines for non-compliance
- Discrimination Claims: Users can file complaints with LDO
- Reputational Risk: Public disclosure of accessibility failures impacts trust in fintech sector
2. WCAG 2.1 AA Success Criteria Checklist
WCAG 2.1 AA includes all 50 success criteria from WCAG 2.0 Level A/AA plus 17 new criteria from WCAG 2.1. Below is the complete checklist mapped to Drop features.
2.1 Principle 1: Perceivable
Information and user interface components must be presentable to users in ways they can perceive.
Guideline 1.1: Text Alternatives
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 1.1.1 | Non-text Content | A | All images, icons, QR codes, logos have alt text or ARIA labels |
Drop Implementation:
- Drop logo:
<img alt="Drop logo - green rounded square with dollar icon" /> - Country flags in recipient list:
<span role="img" aria-label="Serbia flag">🇷🇸</span> - QR scanner UI:
<button aria-label="Start QR code scanner">+ screen reader announcement when camera opens - Transaction status icons: CheckCircle, Shield icons have
aria-hidden="true"+ descriptive text nearby - Decorative SVG patterns:
aria-hidden="true"
Guideline 1.2: Time-based Media
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 1.2.1 | Audio-only and Video-only | A | Not applicable — Drop has no audio/video content |
| 1.2.2 | Captions | A | Not applicable |
| 1.2.3 | Audio Description or Media Alternative | A | Not applicable |
| 1.2.4 | Captions (Live) | AA | Not applicable |
| 1.2.5 | Audio Description (Prerecorded) | AA | Not applicable |
Future Consideration: If Drop adds tutorial videos or customer support videos, add Norwegian captions + audio descriptions.
Guideline 1.3: Adaptable
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 1.3.1 | Info and Relationships | A | Semantic HTML: <h1>, <nav>, <form>, <label>, <button>. Tables use <table>, <th>, <tbody> |
| 1.3.2 | Meaningful Sequence | A | Tab order follows visual order. Reading order is logical (header → main content → navigation) |
| 1.3.3 | Sensory Characteristics | A | Instructions do NOT rely on shape/color/sound alone. "Click green button" → "Click 'Send Money' button" |
| 1.3.4 | Orientation | AA | No orientation lock. App works in portrait and landscape |
| 1.3.5 | Identify Input Purpose | AA | autocomplete attributes on all form fields (email, name, phone, address) |
Drop Implementation:
- Login form:
<input type="email" autocomplete="email">,<input type="password" autocomplete="current-password"> - Registration form:
<input autocomplete="given-name">,<input autocomplete="family-name">,<input autocomplete="tel"> - Send money form: Amount input has
<label for="amount">Du sender</label> - All headings hierarchical: h1 (page title) → h2 (sections) → h3 (subsections)
Guideline 1.4: Distinguishable
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 1.4.1 | Use of Color | A | Color is NOT the only way to convey information. Success state = green checkmark icon + "Sendt!" text |
| 1.4.2 | Audio Control | A | Not applicable — no auto-playing audio |
| 1.4.3 | Contrast (Minimum) | AA | 4.5:1 for normal text, 3:1 for large text. Audit all color pairs |
| 1.4.4 | Resize Text | AA | Text resizable up to 200% without loss of content or functionality |
| 1.4.5 | Images of Text | AA | No images of text except logos. Use web fonts (Inter, Fraunces) |
| 1.4.10 | Reflow | AA | Content reflows for 320px viewport without horizontal scrolling |
| 1.4.11 | Non-text Contrast | AA | 3:1 for UI components (buttons, inputs, focus indicators) and graphical objects |
| 1.4.12 | Text Spacing | AA | No loss of content when user increases line-height, letter-spacing, word-spacing, paragraph spacing |
| 1.4.13 | Content on Hover or Focus | AA | Tooltips dismissible, hoverable, persistent. Not applicable if no tooltips |
Drop Color Audit (Priority 1):
| Element | Foreground | Background | Contrast Ratio | WCAG AA |
|---|---|---|---|---|
| Primary button text | #FFFFFF | #0B6E35 | TBD | ✅ Pass (likely 8:1+) |
| Body text | #1E293B | #F8FAFC | TBD | Test |
| Secondary text | #64748B | #F8FAFC | TBD | Test (risky — gray text often fails) |
| Error text | #EF4444 | #FFFFFF | TBD | Test |
| Link text | #0B6E35 | #F8FAFC | TBD | Test |
| Disabled button | #94A3B8 | #F8FAFC | TBD | Exempt (disabled state), but should still aim for 3:1 |
| Focus indicator | #0B6E35 | #FFFFFF | TBD | Test (3:1 minimum for non-text) |
Tools: Use WebAIM Contrast Checker, axe DevTools, or Lighthouse.
Remediation: If any text fails 4.5:1, darken text color or lighten background. For #64748B (gray), consider #475569 or #334155 (darker grays).
2.2 Principle 2: Operable
User interface components and navigation must be operable.
Guideline 2.1: Keyboard Accessible
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 2.1.1 | Keyboard | A | All functionality available via keyboard (Tab, Enter, Space, Arrow keys) |
| 2.1.2 | No Keyboard Trap | A | User can navigate away from any focused component using keyboard alone |
| 2.1.4 | Character Key Shortcuts | A | If single-key shortcuts exist, provide way to turn off or remap. Check if any exist. |
Drop Implementation:
- Tab order: Header → Main content → Bottom navigation → Footer
- Custom components: QR scanner button, recipient cards, amount input, confirm button — all keyboard-accessible
- Modals/dialogs: Focus trapped inside modal while open, Esc closes modal and returns focus
- Skip links: Add "Skip to main content" link (hidden until focused) at top of page
- Mobile touch targets: Also keyboard-focusable (44x44px minimum)
Test Plan:
Guideline 2.2: Enough Time
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 2.2.1 | Timing Adjustable | A | Session timeout warnings with option to extend |
| 2.2.2 | Pause, Stop, Hide | A | No auto-updating content except notifications (user can disable in Settings) |
Drop Implementation:
- JWT token expires in 1 hour. Show warning 5 minutes before expiry: "Your session will expire in 5 minutes. [Extend session]"
- Dashboard transaction list: NOT auto-refreshing (user clicks Refresh button)
- Notifications: User can toggle push notifications in
/profile/notifications - No carousels, no auto-playing animations
Guideline 2.3: Seizures and Physical Reactions
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 2.3.1 | Three Flashes or Below Threshold | A | No flashing content. No animations flash more than 3 times per second |
Drop Implementation:
- Loading spinners: Slow rotation (1 rotation per 1.5 seconds), no flashing
- Success animation: Fade-in checkmark, no rapid flashing
- No video, no GIF animations
Guideline 2.4: Navigable
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 2.4.1 | Bypass Blocks | A | Skip links to bypass repetitive content |
| 2.4.2 | Page Titled | A | Every page has unique, descriptive <title> tag |
| 2.4.3 | Focus Order | A | Tab order is logical and predictable |
| 2.4.4 | Link Purpose (In Context) | A | Link text describes destination. Avoid "click here" or "read more" |
| 2.4.5 | Multiple Ways | AA | More than one way to find a page (menu, search, sitemap) |
| 2.4.6 | Headings and Labels | AA | Headings and labels are clear and descriptive |
| 2.4.7 | Focus Visible | AA | Keyboard focus indicator visible (outline or border) |
Drop Implementation:
- Skip link:
<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>(Tailwind screen-reader-only class) - Page titles:
/login→ "Logg inn — Drop"/dashboard→ "Oversikt — Drop"/send→ "Send penger — Drop"/transactions→ "Transaksjoner — Drop"
- Focus indicator: Default browser outline (blue) or custom 3px solid #0B6E35 outline
- Navigation: Bottom nav (Dashboard, Send, Scan, Accounts, Profile) + header back button
- Breadcrumbs: Not applicable (flat navigation structure)
- Link text: "Opprett konto" (not "Click here to register"), "Se alle transaksjoner" (not "See more")
Guideline 2.5: Input Modalities
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 2.5.1 | Pointer Gestures | A | No multipoint or path-based gestures required. Single-tap/click works everywhere |
| 2.5.2 | Pointer Cancellation | A | Click/tap activated on up-event (not down-event). Allows user to drag away to cancel |
| 2.5.3 | Label in Name | A | Visible label text matches accessible name. Button text = aria-label |
| 2.5.4 | Motion Actuation | A | No shake-to-undo or tilt-to-zoom. All features work without device motion |
Drop Implementation:
- No swipe gestures required (all actions have buttons)
- QR scanner: Manual "Start Scan" button (no auto-activate on tilt)
- Touch targets: 44x44px minimum (WCAG 2.2 Target Size guideline)
2.3 Principle 3: Understandable
Information and the operation of user interface must be understandable.
Guideline 3.1: Readable
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 3.1.1 | Language of Page | A | <html lang="nb"> (Norwegian Bokmål) |
| 3.1.2 | Language of Parts | AA | If mixing languages, mark with lang attribute. Example: <span lang="en">Drop</span> |
Drop Implementation:
- Primary language: Norwegian Bokmål (
lang="nb") - Brand name "Drop" is English but used as proper noun (no lang tag needed)
- Currency codes (NOK, RSD, BAM) are ISO codes (no lang tag needed)
- Future: If adding English version, separate
/en/route with<html lang="en">
Guideline 3.2: Predictable
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 3.2.1 | On Focus | A | Focusing on element does NOT trigger automatic context change |
| 3.2.2 | On Input | A | Changing setting does NOT auto-submit form unless user is warned |
| 3.2.3 | Consistent Navigation | AA | Navigation in same order on every page |
| 3.2.4 | Consistent Identification | AA | Icons/buttons with same function labeled consistently |
Drop Implementation:
- No auto-submitting forms. User must click "Bekreft sending" button.
- Bottom nav order consistent: Dashboard → Send → Scan → Accounts → Profile
- Back button (ChevronLeft icon) always top-left corner
- "Send penger" button always labeled "Send penger" (not sometimes "Transfer" or "Pay")
Guideline 3.3: Input Assistance
| ID | Criterion | Level | Drop Application |
|---|---|---|---|
| 3.3.1 | Error Identification | A | Errors clearly identified in text (not color alone) |
| 3.3.2 | Labels or Instructions | A | Every form field has label or instruction |
| 3.3.3 | Error Suggestion | AA | Suggest fix for errors. "Ugyldig e-postadresse" → show example: "Eksempel: din@epost.no" |
| 3.3.4 | Error Prevention (Legal, Financial, Data) | AA | Confirmation step before financial transactions |
Drop Implementation:
- Login errors: "Feil e-post eller passord" (clear, not vague "Error 401")
- Registration errors: "E-post og passord er påkrevd" + field highlighted red border
- Send money errors: "Utilstrekkelig saldo. Du har 1,500 NOK tilgjengelig." (specific, actionable)
- Confirmation step: Step 3 of 4 shows full transaction details before "Bekreft sending" button
- Error styling: Red text (#EF4444) + red border on invalid field + error icon +
aria-invalid="true"attribute - Error announcement: Use
role="alert"oraria-live="polite"for screen reader announcement
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:
- HTML validation: Run W3C validator on all pages. Fix parsing errors.
- ARIA roles: Radix UI components have built-in ARIA. Custom components need manual ARIA.
- Status messages:
- "Pengene er på vei!" (success) →
<div role="status" aria-live="polite"> - "Sender..." (loading) →
<button aria-busy="true">Sender...</button> - "Feil e-post eller passord" (error) →
<div role="alert">
- "Pengene er på vei!" (success) →
- Custom components:
- Recipient card button:
<button aria-label="Send penger til {recipient.name}">(not just icon) - QR scanner:
<button aria-label="Start QR-skanning">+<div role="status">when camera opens
- Recipient card button:
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:
-
Install dependencies:
npm install --save-dev @axe-core/playwright axe-html-reporter -
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([]); }); } -
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/ -
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:
- Critical: Blocks screen reader users (missing alt text, no keyboard access)
- Serious: Major barrier (insufficient contrast, missing labels)
- Moderate: Usability issue (redundant links, small touch targets)
- Minor: Best practice (missing lang attribute on secondary content)
CI Behavior:
- Fail build on Critical violations
- Warn on Serious violations (block merge if count increases)
- Report Moderate/Minor violations (don't block)
3.3 Manual Testing (Tier 2)
Testers: QA, developers, accessibility specialist Frequency: Every major feature, every release candidate
Checklist:
Keyboard Navigation
- All interactive elements reachable via Tab key
- Tab order follows visual order (left-to-right, top-to-bottom)
- Focus indicator visible on all elements (3px outline minimum)
- No keyboard traps (can navigate away from modals, dropdowns)
- Enter/Space activates buttons and links
- Escape closes modals and returns focus to trigger
- Arrow keys navigate within dropdowns, radio groups (if applicable)
- Skip link visible when focused (bypasses header to main content)
Form Validation
- All form fields have visible labels (not just placeholder text)
- Required fields marked with
requiredattribute oraria-required="true" - Error messages appear near the field (not just at top of form)
- Error messages announce to screen readers (
role="alert") - Error messages descriptive ("Ugyldig e-postadresse" not "Error")
- Invalid fields have
aria-invalid="true"attribute - Success messages announced (
role="status")
Color Contrast
- All text meets 4.5:1 contrast (normal text) or 3:1 (large text ≥18pt)
- UI components meet 3:1 contrast (buttons, inputs, focus indicators)
- Links distinguishable from surrounding text (underline or 3:1 contrast)
- Error/warning/success states not conveyed by color alone (icon + text)
Text Resizing
- Text resizable to 200% via browser zoom (Cmd/Ctrl + +)
- No horizontal scrolling at 200% zoom
- No content cut off or overlapping at 200% zoom
- Buttons and inputs remain usable at 200% zoom
Responsive Design
- App works at 320px viewport width (iPhone SE)
- No horizontal scrolling on mobile
- Touch targets at least 44x44px (thumb-friendly)
- No text smaller than 16px on mobile (prevents zoom on iOS)
Headings and Landmarks
- One
<h1>per page (page title) - Heading hierarchy logical (h1 → h2 → h3, no skipping levels)
-
<main>landmark for main content -
<nav>landmark for navigation -
<header>and<footer>landmarks (if applicable) - ARIA landmarks used correctly (not overused)
Tools:
- Browser DevTools: Chrome/Edge DevTools → Lighthouse → Accessibility audit
- Chrome Extensions: axe DevTools, WAVE, Accessibility Insights
- Firefox Extensions: Accessibility Inspector
- Keyboard only: Unplug mouse for 30 minutes, test all flows
3.4 Assistive Technology Testing (Tier 3)
Devices:
- iOS: VoiceOver on iPhone (iOS 16+)
- Android: TalkBack on Pixel or Samsung (Android 12+)
- macOS: VoiceOver on MacBook (macOS 13+)
- Windows: NVDA (free, open-source) on Windows 10/11
Test Flows:
| Flow | Steps | Screen Reader Expectations |
|---|---|---|
| Registration | 1. Open app → 2. Tap "Opprett konto" → 3. Fill form → 4. Submit | Each field announces label + type + required state. Error messages read aloud. Success confirmation read. |
| Login | 1. Open app → 2. Fill email/password → 3. Submit | Fields announce labels. "Logg inn" button announces "button" role. Error read aloud. Dashboard loads. |
| Send Money | 1. Dashboard → 2. "Send penger" → 3. Select recipient → 4. Enter amount → 5. Confirm | Recipient list announces "list" + number of items. Amount input announces "Du sender" label. Confirmation screen reads all details. Success message announces. |
| QR Payment | 1. Dashboard → 2. "Skann" → 3. Scan QR → 4. Confirm | "Start QR-skanning" button announces. Camera opens + status message "Kamera åpnet". Scanned amount read aloud. Confirmation flow same as Send Money. |
| Transaction History | 1. Dashboard → 2. "Transaksjoner" → 3. Browse list | Transaction list announces "list" + number of items. Each item announces date, amount, recipient. Filter buttons announce state. |
Common Issues to Check:
- Too much information: Screen reader announces entire page on load (use
aria-live="polite"sparingly) - Too little information: Button announces "button" but no label (needs
aria-label) - Wrong order: Visual order ≠ DOM order (reorder HTML, don't use CSS to reorder)
- Unlabeled regions: "Region" announced without name (add
aria-labelto<section>) - Redundant announcements: "Link, link, link" (avoid nested links)
- Abbreviations: "NOK" announced as "knock" (use
<abbr title="Norwegian Kroner">NOK</abbr>)
Testing Protocol:
- Turn on screen reader
- Close eyes or look away from screen (simulate blind user)
- Navigate using screen reader shortcuts only (swipe on mobile, arrow keys on desktop)
- Can you complete the task without seeing the screen?
- Are announcements clear and not too verbose?
- Are interactive elements announced with correct role (button, link, checkbox)?
Documentation: Record screen reader output via screen recording. Share with team to demonstrate issues.
4. Component Audit Plan
Every Drop component and page must be checked for accessibility. Below is the component-by-component audit plan.
4.1 UI Components (Radix UI Primitives)
Drop uses Radix UI, which has built-in WCAG compliance. However, custom styling and usage can break accessibility, so all components must be audited.
| Component | File | WCAG Concerns | Audit Tasks |
|---|---|---|---|
| Button | components/ui/button.tsx |
Focus visible, contrast, disabled state | ✅ Check focus outline 3:1 contrast, ✅ Check disabled opacity not too low |
| Input | components/ui/input.tsx |
Label association, error state, autocomplete | ✅ Check all inputs have <label>, ✅ Check aria-invalid on errors, ✅ Check autocomplete attributes |
| Select | components/ui/select.tsx |
Keyboard nav, ARIA roles | ✅ Test arrow keys, Enter, Esc, ✅ Check aria-expanded, aria-activedescendant |
| Dialog | components/ui/dialog.tsx |
Focus trap, Esc close, focus return | ✅ Tab stays inside modal, ✅ Esc closes and returns focus, ✅ Check aria-labelledby |
| Alert | components/ui/alert.tsx |
Screen reader announcement | ✅ Check role="alert" or aria-live="assertive" for critical alerts |
| Card | components/ui/card.tsx |
Semantic structure | ✅ Check not used as button (use <button> wrapper if clickable) |
| Tabs | components/ui/tabs.tsx |
ARIA roles, keyboard nav | ✅ Check role="tablist", role="tab", role="tabpanel", ✅ Arrow keys switch tabs |
| Sheet | components/ui/sheet.tsx |
Focus trap, Esc close | Same as Dialog |
| Badge | components/ui/badge.tsx |
Not interactive | ✅ Verify NOT focusable (decorative only) |
| Skeleton | components/ui/skeleton.tsx |
Loading state announcement | ✅ Check aria-busy="true" or role="status" with "Laster..." text |
Radix UI Accessibility Features (Built-in):
- Focus management (focus trap in modals, focus return on close)
- Keyboard navigation (Arrow keys, Enter, Space, Esc)
- ARIA attributes (
role,aria-expanded,aria-selected,aria-labelledby,aria-describedby) - Screen reader announcements (live regions for dynamic content)
Reference: Radix UI Accessibility Docs
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:
- Manual entry option: Merchant shows amount on screen, blind user asks sighted person or merchant to read amount, user enters manually.
- NFC tap-to-pay (future): Accessible alternative to QR codes (works with screen curtain on iPhone).
- 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)
- Login form has no labels → Screen reader users cannot log in
- Payment confirmation button not keyboard-accessible → Keyboard users cannot send money
- Error messages shown only as red border (no text) → Blind users don't know what's wrong
- Modal dialog traps focus (cannot Esc close) → Keyboard users stuck
P1 — High (Fix this sprint)
- Body text (#64748B on #F8FAFC) has 3.2:1 contrast (fails WCAG AA 4.5:1)
- "Click here" link text (destination unclear)
- Transaction list missing semantic structure (
<div>instead of<ul>) - Loading spinner has no announcement (user doesn't know page is loading)
P2 — Medium (Fix next sprint)
- Touch target is 40x40px (should be 44x44px)
- Heading hierarchy skips level (h1 → h3)
- Page title not unique ("Drop" on every page)
- Image has alt text but it's too verbose ("Green rounded square logo with white dollar icon circular arrows and gold dot top-right")
P3 — Low (Fix when time allows)
- ARIA label redundant with visible text (
<button aria-label="Send penger">Send penger</button>) - Missing
lang="en"on "Drop" brand name - Abbreviation not expanded (
NOKshould be<abbr title="Norske kroner">NOK</abbr>)
7.3 Triage Process
- Run automated tests → Generate violation report
- Classify violations → Assign P0/P1/P2/P3 priority
- Create tasks → P0/P1 go into current sprint backlog
- Assign owners → Developer + QA pair for each P0/P1 issue
- Verify fixes → Re-run automated tests + manual testing
- 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:
- ≥ 1 Critical violation (blocking issue)
- ≥ 5 Serious violations (major accessibility barriers)
Warn but allow merge if:
- < 5 Serious violations (review required)
- Any number of Moderate/Minor violations (fix later)
Implementation:
// 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
- Compliance level: WCAG 2.1 Level AA
- Date of last audit: [Date]
- Known issues: List any non-compliant features (if any)
- Contact for accessibility issues: Email or form
- Complaint process: How to file a complaint with Digitaliseringsdirektoratet
10.2 Template (Norwegian)
Create page at /tilgjengelighet:
# Tilgjengelighetserklæring for Drop
**Sist oppdatert:** [YYYY-MM-DD]
Drop forplikter seg til å gjøre våre tjenester tilgjengelige for alle, inkludert personer med funksjonsnedsettelser. Denne erklæringen beskriver i hvilken grad Drop oppfyller kravene til universell utforming av IKT-løsninger.
## Overholdelse av WCAG 2.1
Drop sikter mot å oppfylle **WCAG 2.1 nivå AA** i henhold til forskrift om universell utforming av IKT-løsninger.
## Kjente begrensninger
Per [dato] har vi identifisert følgende områder som ikke fullt ut oppfyller WCAG 2.1 AA:
- [Liste eventuelle kjente problemer, eller skriv "Ingen kjente begrensninger"]
Vi arbeider kontinuerlig med å forbedre tilgjengeligheten til våre tjenester.
## Tilbakemelding
Har du opplevd problemer med tilgjengeligheten til Drop? Vi vil gjerne høre fra deg.
**Kontakt:** support@getdrop.no
Beskriv problemet så detaljert som mulig, inkludert:
- Hvilken side eller funksjon du prøvde å bruke
- Hvilken enhet og nettleser du brukte
- Eventuell hjelpeteknologi du brukte (f.eks. skjermleser)
Vi vil svare på henvendelser om tilgjengelighet innen 5 virkedager.
## Klageadgang
Dersom du ikke er fornøyd med vårt svar, kan du klage til Digitaliseringsdirektoratet:
**Digitaliseringsdirektoratet**
Postboks 1382 Vika, 0114 Oslo
E-post: post@digdir.no
Nettside: https://www.digdir.no/
## Revisjon av erklæringen
Denne erklæringen ble opprettet [dato] og sist oppdatert [dato]. Vi gjennomgår og oppdaterer denne erklæringen minst én gang per år.
10.3 English Version
Also provide English version at /en/accessibility for international users.
11. Training & Developer Guidelines
Accessibility is everyone's responsibility. All developers must understand basic WCAG principles.
11.1 Developer Onboarding Checklist
New developers must complete:
- Read this accessibility spec (30 min)
- Watch "Introduction to Screen Readers" video (20 min)
- Install axe DevTools Chrome extension
- Run accessibility audit on 1 page (hands-on exercise)
- Fix 1 accessibility bug (pair with senior dev)
11.2 Coding Guidelines (Quick Reference)
Semantic HTML First
// ❌ 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:
- All interactive elements keyboard-accessible (Tab, Enter, Space)
- All form inputs have labels (visible or
aria-label) - Color contrast meets WCAG AA (4.5:1 text, 3:1 UI)
- Error messages have
role="alert" - Success messages have
role="status" - Images have alt text (or
alt=""if decorative) - Buttons/links have descriptive text (not "Click here")
- Focus indicators visible on all interactive elements
- No
outline: nonewithout custom focus style - Page has unique
<title>tag - Headings hierarchical (h1 → h2 → h3)
- ARIA used correctly (not overused or misused)
12. Implementation Plan (Phased Approach)
Full accessibility compliance is a multi-sprint effort. Below is a phased rollout plan.
Phase 1: Foundation (Sprint 1-2) — Before MVP Launch
Goal: Fix all P0 (critical) violations. Minimum viable accessibility.
| Task | Owner | Acceptance Criteria |
|---|---|---|
| Add labels to all form inputs | Dev Team | All inputs have <label> or aria-label |
| Fix keyboard navigation | Dev Team | All pages navigable via Tab key |
| Add focus indicators | Dev Team | All interactive elements have visible focus (3px outline) |
| Fix critical color contrast | Design + Dev | Body text meets 4.5:1, buttons meet 3:1 |
| Add error announcements | Dev Team | All errors have role="alert" |
| Add page titles | Dev Team | All pages have unique <title> |
| Set up axe-core CI tests | DevOps | CI fails on critical violations |
| Create accessibility statement | Content Team | /tilgjengelighet page published |
Deliverable: Lighthouse accessibility score ≥ 80, 0 critical violations
Phase 2: Compliance (Sprint 3-4) — Pre-Public Launch
Goal: Full WCAG 2.1 AA compliance. Pass Digdir audit.
| Task | Owner | Acceptance Criteria |
|---|---|---|
| Fix all P1 (high) violations | Dev Team | Serious violations < 5 |
| Screen reader testing (iOS, Android, Windows) | QA + Accessibility Specialist | All critical flows completable |
| Add skip links | Dev Team | Skip to main content on all pages |
| Improve heading hierarchy | Dev Team | All pages have h1, logical h2/h3 structure |
| Add ARIA landmarks | Dev Team | <main>, <nav>, <header>, <footer> on all pages |
| Fix text resizing issues | Dev Team | Content usable at 200% zoom |
| Add autocomplete attributes | Dev Team | All form fields have autocomplete |
| Manual keyboard testing (all flows) | QA | No keyboard traps, tab order logical |
| Full accessibility audit report | Accessibility Specialist | Report delivered to CEO |
Deliverable: Lighthouse accessibility score ≥ 90, WCAG 2.1 AA compliant
Phase 3: Optimization (Sprint 5-6) — Post-Launch
Goal: Exceed WCAG 2.1 AA. Best-in-class accessibility for fintech.
| Task | Owner | Acceptance Criteria |
|---|---|---|
| Fix all P2 (medium) violations | Dev Team | Moderate violations < 10 |
| Add alternative text improvements | Content Team | All alt text concise and descriptive |
| Improve ARIA label clarity | Dev Team | Labels tested with real screen reader users |
| Add QR code alternatives | Dev Team | Manual entry option visible and easy to find |
| Improve transaction announcements | Dev Team | Screen reader announces amounts clearly |
| Add accessibility preference controls | Dev Team | User can reduce motion, increase contrast |
| Conduct user testing with disabled users | Research Team | 5+ users with disabilities test app |
| WCAG 2.2 upgrade (optional) | Dev Team | Evaluate new success criteria (Target Size 2.5.8, Focus Not Obscured 2.4.11) |
Deliverable: Lighthouse accessibility score ≥ 95, user testimonials from disabled users
13. Budget & Resources
13.1 Estimated Effort
| Phase | Effort (Dev Days) | Timeline |
|---|---|---|
| Phase 1: Foundation | 10-15 days | Sprint 1-2 (2 weeks) |
| Phase 2: Compliance | 15-20 days | Sprint 3-4 (2 weeks) |
| Phase 3: Optimization | 10-15 days | Sprint 5-6 (2 weeks) |
| Total | 35-50 days | 6 weeks |
Assumptions:
- 2 full-time developers (50% time on accessibility)
- 1 QA engineer (25% time on accessibility testing)
- 1 accessibility specialist (contractor, 20 hours consultation)
13.2 Tool Costs
| Tool | Cost | Justification |
|---|---|---|
| axe-core (open-source) | Free | CI integration, automated testing |
| Lighthouse (built into Chrome) | Free | Manual testing, audit reports |
| NVDA (open-source) | Free | Windows screen reader testing |
| VoiceOver (built into iOS/macOS) | Free | Apple screen reader testing |
| TalkBack (built into Android) | Free | Android screen reader testing |
| Accessibility Insights (Microsoft) | Free | Guided manual testing |
| Optional: Accessibility Specialist | ~$2,000 USD (20 hours @ $100/hr) | External audit, training, consultation |
| Total | $0 - $2,000 | Optional contractor cost |
13.3 ROI (Return on Investment)
Compliance Benefits:
- Legal: Avoid fines from Digitaliseringsdirektoratet
- Market: 15% of population has disabilities — expand addressable market
- Reputation: First fully accessible fintech in Norway → competitive advantage
- SEO: Better semantic HTML → better Google rankings
- Usability: Accessible design benefits all users (clear labels, logical flow, keyboard shortcuts)
Example: If Drop has 10,000 users, 15% (1,500) have some disability. If accessibility unlocks this segment, revenue increase = 15%.
Appendix A: WCAG 2.1 AA Complete Criteria List
Full list of all 50 WCAG 2.1 Level A/AA success criteria:
Level A (25 criteria)
Perceivable:
- 1.1.1 Non-text Content
- 1.2.1 Audio-only and Video-only (Prerecorded)
- 1.2.2 Captions (Prerecorded)
- 1.2.3 Audio Description or Media Alternative (Prerecorded)
- 1.3.1 Info and Relationships
- 1.3.2 Meaningful Sequence
- 1.3.3 Sensory Characteristics
- 1.4.1 Use of Color
- 1.4.2 Audio Control
Operable:
- 2.1.1 Keyboard
- 2.1.2 No Keyboard Trap
- 2.1.4 Character Key Shortcuts
- 2.2.1 Timing Adjustable
- 2.2.2 Pause, Stop, Hide
- 2.3.1 Three Flashes or Below Threshold
- 2.4.1 Bypass Blocks
- 2.4.2 Page Titled
- 2.4.3 Focus Order
- 2.4.4 Link Purpose (In Context)
- 2.5.1 Pointer Gestures
- 2.5.2 Pointer Cancellation
- 2.5.3 Label in Name
- 2.5.4 Motion Actuation
Understandable:
- 3.1.1 Language of Page
- 3.2.1 On Focus
- 3.2.2 On Input
- 3.3.1 Error Identification
- 3.3.2 Labels or Instructions
Robust:
- 4.1.1 Parsing
- 4.1.2 Name, Role, Value
Level AA (25 additional criteria)
Perceivable:
- 1.2.4 Captions (Live)
- 1.2.5 Audio Description (Prerecorded)
- 1.3.4 Orientation
- 1.3.5 Identify Input Purpose
- 1.4.3 Contrast (Minimum)
- 1.4.4 Resize Text
- 1.4.5 Images of Text
- 1.4.10 Reflow
- 1.4.11 Non-text Contrast
- 1.4.12 Text Spacing
- 1.4.13 Content on Hover or Focus
Operable:
- 2.4.5 Multiple Ways
- 2.4.6 Headings and Labels
- 2.4.7 Focus Visible
Understandable:
- 3.1.2 Language of Parts
- 3.2.3 Consistent Navigation
- 3.2.4 Consistent Identification
- 3.3.3 Error Suggestion
- 3.3.4 Error Prevention (Legal, Financial, Data)
Robust:
- 4.1.3 Status Messages
Appendix B: Norwegian Accessibility Law Summary
Primary Law: Likestillings- og diskrimineringsloven (Equality and Anti-Discrimination Act)
Key Section: § 18 — Duty to provide universal design
Scope: Public and private sector digital services (including fintech, e-commerce, education)
Standard: WCAG 2.1 Level AA (minimum)
Enforcement: Digitaliseringsdirektoratet (Norwegian Digitalisation Agency)
Penalties: Fines for non-compliance, discrimination complaints to LDO (Equality and Anti-Discrimination Ombud)
Deadline: Norway universally designed by 2025 (national goal)
Drop's Obligation: As a fintech service provider operating in Norway, Drop MUST comply with universal design requirements before public launch. Failure to comply risks fines, legal liability, and reputational damage.
Document Control
Version History:
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-02-17 | Architect Agent | Initial draft — full WCAG 2.1 AA spec + Norwegian law + audit methodology |
Approvals:
- Architect Agent — 2026-02-17
- John (AI Director) — Pending
- Alem (CEO) — Pending
Next Steps:
- Review by John (validate Norwegian law references, tool recommendations)
- Approval by Alem (finalize budget and timeline)
- Share with Dev Team (begin Phase 1 implementation)
- Log to HiveMind (post intel entry)
END OF SPECIFICATION
drop-analytics-bi-spec
Drop Analytics & Business Intelligence — Architecture Specification
Version: 1.0 Date: 2026-02-17 Author: architect agent (Sonnet 4.5) Status: Draft MC Task: #1195
Executive Summary
This document specifies the analytics and business intelligence system for Drop, a Norwegian fintech payment app operating under PSD2 pass-through model. The system must track user engagement, transaction metrics, conversion funnels, and operational health while maintaining strict GDPR compliance (Datatilsynet requirements).
Recommendation: PostHog self-hosted (EU instance) for event tracking + Custom SQLite/PostgreSQL queries for financial KPIs + Admin dashboard built on existing Next.js stack.
1. Platform Decision
1.1 Requirements Matrix
| Requirement | Weight | PostHog (Self-hosted) | Mixpanel | Custom (SQL + Dashboard) |
|---|---|---|---|---|
| GDPR compliance (Datatilsynet) | CRITICAL | ✅ Data stays in Norway | ⚠️ US-based, SCCs required | ✅ Full control |
| PSD2 audit trail | CRITICAL | ⚠️ Not built for fintech | ⚠️ Not built for fintech | ✅ Native to DB |
| Cost (10K MAU) | HIGH | €0 (self-hosted) | ~$1000/month | €0 (existing infra) |
| Real-time dashboards | MEDIUM | ✅ Built-in | ✅ Built-in | ⚠️ Build from scratch |
| Session replay | LOW | ✅ Built-in | ❌ Paid extra | ❌ N/A |
| Funnel analysis | HIGH | ✅ Built-in | ✅ Built-in | ⚠️ Custom SQL |
| Setup complexity | MEDIUM | Medium (Docker) | Low (SaaS) | Low (existing stack) |
| Financial metrics | CRITICAL | ❌ Not fintech-specific | ❌ Not fintech-specific | ✅ Direct DB access |
1.2 Decision: Hybrid Approach
Recommended Stack:
- PostHog (self-hosted) — User behavior, funnels, session replay, A/B testing
- Custom SQL queries — Financial KPIs (transaction volume, revenue, error rates)
- Admin Dashboard (Next.js) — Internal BI dashboard at
/admin/analytics
Rationale:
- GDPR: Self-hosted PostHog keeps data in Norway (Datatilsynet compliant)
- PSD2 Audit: Financial metrics pulled directly from
transactions,audit_logtables (source of truth) - Cost: PostHog self-hosted is free (infrastructure cost only), custom queries have zero marginal cost
- Separation of concerns: Product analytics (PostHog) vs financial compliance (SQL)
Cost Analysis:
| Solution | Setup | Monthly Cost (10K MAU) |
|---|---|---|
| PostHog Cloud | 1 day | ~€299/month (EU hosting) |
| PostHog Self-hosted | 2 days | ~€50/month (Fly.io 2GB RAM) |
| Mixpanel | 1 day | ~$1000/month |
| Custom only | 5 days | €0 (existing infra) |
| Hybrid (recommended) | 3 days | ~€50/month |
2. KPI Definitions (Precise Logic)
2.1 User Engagement Metrics
DAU (Daily Active Users)
Definition: Unique users who performed any action in the app within the last 24 hours.
SQL Query:
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
- Register — User creates account (
/registerpage,register_successaudit action) - Verify KYC — User completes BankID verification (
kyc_approvedaudit action) - Link Bank — User connects bank account via AISP (
bank_account_linkedaudit action) - First Transfer — User completes first transaction (
first_transactionaudit 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:
- Event 1:
register_success - Event 2:
kyc_approved - Event 3:
bank_account_linked - Event 4:
first_transaction
Time to First Transaction (TTFT)
Definition: Median time from registration to first completed transaction.
SQL Query:
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:
period—7d,30d,90d,1y
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
Implementation: Client-side CSV generation (no server processing).
6.4 Filters
| Filter | Options |
|---|---|
| Date Range | Last 7 days, Last 30 days, Last 90 days, Custom |
| Transaction Type | All, Remittance, QR Payment |
| User Segment | All, Verified, Unverified |
| Country (Remittance) | All, RS (Serbia), BA (Bosnia), TR (Turkey), etc. |
7. Privacy & GDPR Compliance (Datatilsynet)
7.1 What's Tracked
Personal Data Tracked
| Data | Purpose | Legal Basis | Retention |
|---|---|---|---|
user_id |
Product analytics | Legitimate interest (GDPR Art. 6(1)(f)) | Until account deletion |
ip_address (anonymized) |
Fraud prevention | Legitimate interest | 90 days |
device_type, os_version |
Product optimization | Legitimate interest | Until account deletion |
transaction_amount |
Business metrics | Contract performance (GDPR Art. 6(1)(b)) | 5 years (PSD2 requirement) |
Anonymized Data
- Last octet of IP address replaced with
0(e.g.,192.168.1.123→192.168.1.0) - PostHog
$ipproperty disabled (no IP tracking) - Session recordings disabled by default (opt-in only for support cases)
7.2 What's NOT Tracked
Explicitly excluded from analytics:
password,pin(never logged anywhere)bank_account,iban(PII, not needed for analytics)national_id_hash(sensitive PII)- Full IP addresses (only anonymized)
- Email addresses (not needed for event tracking)
7.3 User Rights (GDPR)
Right to Access (Art. 15)
Implementation: Export all analytics events for a user via /api/admin/analytics/user/{user_id}/export.
Format: JSON file with all events, properties, timestamps.
Right to Erasure (Art. 17)
Implementation:
- Delete from
analytics_eventstable:DELETE FROM analytics_events WHERE user_id = ? - PostHog: Use PostHog API to delete user:
POST /api/person/{person_id}/delete - Audit log: Retain for 5 years (legal requirement), but anonymize
user_id→anonymized_{timestamp}
Right to Object (Art. 21)
Implementation: Add analytics_consent field to consents table. If withdrawn, stop tracking events.
7.4 Datatilsynet Requirements
Data Processing Agreement (DPA)
PostHog self-hosted: No DPA needed (data stays in-house).
PostHog Cloud (if used): DPA required (PostHog provides standard agreement).
Data Localization
Requirement: PSD2 payment data must stay in EEA.
Implementation: PostHog self-hosted on Fly.io EU region (Frankfurt or Amsterdam).
Consent Banner
Required: Display cookie consent banner on landing page (already implemented in src/components/cookie-consent.tsx).
Categories:
- Necessary: Session cookies, auth tokens (no consent required)
- Analytics: PostHog tracking (opt-in required)
- Marketing: None (Drop doesn't use third-party ads)
7.5 Audit Trail (PSD2 Requirement)
Requirement: All analytics queries accessing financial data must be logged.
Implementation:
-- 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:
- Transaction volume spike — >2x daily average
- Transaction volume drop — <50% daily average
- Error rate spike — >5% failure rate
- 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:
- Fly.io 2GB RAM instance: ~€40/month
- PostgreSQL (for PostHog): Included (shared instance)
- ClickHouse (analytics DB): Included (single-node)
Total: ~€40-50/month for 10K MAU
Scalability: Up to 100K MAU on same instance (ClickHouse is highly efficient).
10.2 PostHog Cloud (Alternative)
Pricing: €299/month for 10K MAU (EU hosting).
Pros: Zero maintenance, auto-scaling.
Cons: 6x more expensive than self-hosted.
10.3 Custom Only (No PostHog)
Cost: €0 (runs on existing infrastructure).
Effort: 5 additional days to build funnel analysis, session tracking, etc.
Recommendation: Not worth the time savings — PostHog provides 90% of features out-of-the-box.
11. Implementation Plan (Phased Approach)
Phase 1: Foundation (Day 1-2)
Goal: Basic KPI queries + Admin API endpoints.
Tasks:
- Add
analytics_eventstable to schema - Create
daily_metricsandfunnel_stagesviews - 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
- 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:
- Create
/admin/analyticspage - Implement 4 chart components (Recharts):
- KPI summary cards
- Transaction volume line chart
- Conversion funnel bar chart
- Error rate table
- Add date range filter
- 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:
- Deploy PostHog to Fly.io (Docker Compose)
- Integrate PostHog client SDK (
posthog-js) - Instrument 10 core events (see Event Taxonomy)
- Set up funnel analysis in PostHog UI
- 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:
- Create
scripts/analytics/anomaly-detector.js - Implement threshold checks (see Alerting section)
- Integrate with
src/lib/alerts.ts(Slack) - 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:
- Implement
/api/admin/analytics/user/{user_id}/export - Implement user deletion in
analytics_eventstable - Add PostHog person deletion API call
- Update cookie consent banner to include analytics opt-in
- Document data retention policy
Deliverable: GDPR-compliant analytics system.
Total Effort: 8 days (1 developer)
MVP (Phases 1-2 only): 4 days (if PostHog is deferred)
12. Testing Strategy
12.1 Unit Tests
File: src/lib/__tests__/analytics.test.ts
Coverage:
- KPI calculation functions (DAU, MAU, ATV, etc.)
- SQL query builders
- Date range parsing
Tool: Vitest (already used in Drop).
12.2 Integration Tests
File: tests/integration/admin-analytics.test.ts
Coverage:
/api/admin/analytics/*endpoints- Response format validation
- Role-based access control (non-admin should get 403)
12.3 E2E Tests
File: tests/e2e/admin-dashboard.spec.ts
Coverage:
Tool: Playwright (already used in Drop).
13. Monitoring & Observability
13.1 PostHog Self-Monitoring
Health Check: /api/posthog/health — Check PostHog is reachable.
Metrics:
- Event ingestion rate (events/second)
- Query latency (avg response time for dashboards)
- Database size (ClickHouse disk usage)
Tool: PostHog built-in /instance/status page.
13.2 Admin Dashboard Monitoring
Sentry: Track errors in admin dashboard (already integrated).
Alert: If admin API error rate >5%, send Slack alert.
14. Security Considerations
14.1 Admin Access Control
Role: Add admin role to users table.
Migration:
-- 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:
- Admin routes protected by
requireAdmin()middleware - No PII in chart labels (use aggregated data only)
- Audit log all admin queries (see GDPR section)
14.3 SQL Injection (Admin Queries)
Risk: Dynamic date range filtering could be exploited.
Mitigation:
- Use parameterized queries ONLY (no string concatenation)
- Whitelist allowed period values (
7d,30d,90d,1y)
Example (SAFE):
const allowedPeriods = { '7d': '-7 days', '30d': '-30 days' };
const period = allowedPeriods[req.query.period] || '-30 days';
const sql = `SELECT COUNT(*) FROM transactions WHERE created_at >= datetime('now', ?)`;
const result = await query(sql, [period]);
15. Future Enhancements (Post-MVP)
15.1 Predictive Analytics
Goal: Forecast transaction volume using ML.
Tool: TensorFlow.js (client-side) or Prophet (Python backend).
Use Case: Predict cash flow for liquidity planning (relevant when Drop holds funds in future).
15.2 Real-time Dashboard (WebSockets)
Goal: Live-updating dashboard without refresh.
Tool: Socket.io or Server-Sent Events (SSE).
Use Case: Monitor transaction spikes during marketing campaigns.
15.3 A/B Testing
Goal: Test onboarding flow variations.
Tool: PostHog Feature Flags + Experiments.
Use Case: Test "Skip KYC for low-value transactions" flow.
15.4 Custom Funnels (Dynamic)
Goal: Allow admins to create custom funnels in UI.
Tool: PostHog Funnels API.
Use Case: Analyze drop-off in specific remittance corridors (e.g., Norway → Serbia).
16. Success Metrics (How to Measure Analytics System)
| Metric | Target | Measurement |
|---|---|---|
| Dashboard load time | <2 seconds | Chrome DevTools Performance |
| API response time (p95) | <500ms | Sentry Performance Monitoring |
| PostHog event ingestion lag | <10 seconds | PostHog /instance/status |
| Admin dashboard uptime | >99.5% | UptimeRobot (monitor /admin/analytics) |
| GDPR compliance audit | 100% pass | Manual checklist (see section 7) |
17. References
17.1 Norwegian Regulations
- Datatilsynet (Data Protection Authority): https://www.datatilsynet.no/en/
- PSD2 Audit Requirements: Regulation (EU) 2018/389 (RTS on SCA and CSC)
- Financial Supervision Authority (Finanstilsynet): https://www.finanstilsynet.no/en/
17.2 Tools & Libraries
- PostHog: https://posthog.com/docs/self-host
- Recharts: https://recharts.org/en-US/
- Sentry: https://docs.sentry.io/platforms/javascript/guides/nextjs/
17.3 Industry Benchmarks
- Fintech KPIs: Andreessen Horowitz "16 Startup Metrics" (a16z.com)
- Conversion Funnel Benchmarks: Mixpanel "Product Benchmarks Report 2025"
- Retention Standards: Lenny's Newsletter "Retention Benchmarks by Industry"
18. Appendix: SQL Query Examples
A. Top 10 Users by Transaction Volume (Last 30 Days)
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:
- Review spec with Alem (prioritize phases)
- Create MC tasks for each phase
- Assign builder agent for Phase 1 implementation
Document Status: Draft (awaiting approval) Last Updated: 2026-02-17 Version: 1.0
drop-customer-support-spec
Drop Customer Support System — Implementation Spec
Project: Drop Fintech App MC Task: #1187 Created: 2026-02-17 Architecture: Software Architect Agent
1. Overview
This spec defines a built-in customer support ticket system for Drop. No external dependencies (no Zendesk, no Intercom). MVP scope.
Scope:
- Support ticket creation (user-initiated)
- Ticket conversation thread (user + admin messages)
- Admin ticket management dashboard
- Integration with audit logging
- Email notifications when admin responds
- Link tickets to transactions when relevant
Non-goals (future):
- Live chat
- Chatbot / AI responses
- Multi-language support in UI (Norwegian only for MVP)
- File attachments (future enhancement)
- Public knowledge base / FAQ system
2. Database Schema
2.1 support_tickets Table
CREATE TABLE IF NOT EXISTS support_tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
subject TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL CHECK(category IN ('transaction_issue','account_access','verification','general','dispute')),
status TEXT DEFAULT 'open' CHECK(status IN ('open','in_progress','waiting_user','resolved','closed')),
priority TEXT DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
transaction_id TEXT REFERENCES transactions(id), -- optional link to transaction
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
resolved_at TEXT,
closed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_support_tickets_user ON support_tickets(user_id);
CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status);
CREATE INDEX IF NOT EXISTS idx_support_tickets_category ON support_tickets(category);
CREATE INDEX IF NOT EXISTS idx_support_tickets_created ON support_tickets(created_at);
CREATE INDEX IF NOT EXISTS idx_support_tickets_transaction ON support_tickets(transaction_id);
PostgreSQL version:
- Replace
datetime('now')withCURRENT_TIMESTAMP - No other changes needed (TEXT and CHECK constraints work in both)
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP
3. Status Flow
open → in_progress → waiting_user → resolved → closed
↓ ↓ ↓ ↓
└─────────┴─────────────┴─────────────┘
(can reopen if needed)
Status definitions:
- open — newly created, not assigned yet
- in_progress — admin is working on it
- waiting_user — admin replied, waiting for user response
- resolved — issue resolved, pending user confirmation
- closed — ticket closed (auto-close after 7 days resolved, or manual)
Priority:
- urgent — transaction blocked, account locked
- high — verification failed, payment issue
- normal — general inquiry, feature request
- low — informational
4. API Endpoints
4.1 User Endpoints
POST /api/support/tickets
Create new support ticket.
Request:
{
"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:
- subject: required, 5-200 chars, sanitized
- description: required, 20-2000 chars, sanitized
- category: must be one of enum values
- transaction_id: optional, must exist if provided
Auto-priority logic:
- category=dispute → priority=high
- category=account_access → priority=high
- category=transaction_issue → priority=normal
- category=verification → priority=normal
- category=general → priority=low
Side effects:
- Audit log:
support.ticket_created - Initial message in ticket_messages (sender_type=user, message=description)
GET /api/support/tickets
List user's tickets.
Query params:
page(default: 1)limit(default: 10, max: 50)status(optional filter: open, in_progress, waiting_user, resolved, closed)
Response:
{
"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 */ }
}
}
- User can only view their own tickets
- Return 404 if ticket belongs to another user
POST /api/support/tickets/[id]/messages
Add user message to conversation.
Request:
{
"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:
- Update ticket.updated_at
- If status was 'waiting_user' or 'resolved', change to 'open'
- Audit log:
support.message_added
4.2 Admin Endpoints
All admin endpoints require requireAdmin() middleware (TBD: define admin role or separate admin auth).
GET /api/admin/support/tickets
List ALL tickets with filters.
Query params:
page,limitstatus(filter)priority(filter)category(filter)sort(created_at_desc, created_at_asc, updated_at_desc, priority_desc)
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:
- Update ticket.updated_at
- If status changed to 'resolved', set resolved_at
- If status changed to 'closed', set closed_at
- Audit log:
support.ticket_updated
POST /api/admin/support/tickets/[id]/messages
Admin reply to ticket.
Request:
{
"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:
- Update ticket.updated_at
- If change_status provided, update ticket.status
- Change status to 'waiting_user' if not explicitly set
- Audit log:
support.admin_reply - Email notification to user (see section 5)
5. Email Notifications
When admin replies, send email to user:
Subject: "Svar på din support-henvendelse [#{ticket_id}]"
Body (plain text):
Hei,
Vi har svart på din support-henvendelse:
Emne: {ticket.subject}
Svar: {admin_message}
Logg inn på Drop for å se hele samtalen:
{NEXT_PUBLIC_APP_URL}/support/tickets/{ticket_id}
Hvis du har flere spørsmål, kan du svare direkte i billetten.
Vennlig hilsen,
Drop Support
Implementation:
- Use existing MCP email integration pattern (see ~/system/tools/manifest.md)
- No need to implement email in MVP — create draft and leave as TODO for now
- Future: use
email-to-ticket.jspattern from ALAI tools
6. UI Pages
6.1 /support — Help Center (User)
Layout:
- Header: "Trenger du hjelp?"
- FAQ section (static, hardcoded for MVP):
- "Hvordan sender jeg penger?"
- "Hvorfor ble betalingen min avvist?"
- "Hvordan verifiserer jeg kontoen min?"
- "Hva er gebyrene?"
- Each FAQ expands to show answer (accordion)
- CTA button: "Opprett support-billett"
Design:
- Match existing Drop UI (see mockups/figma-make-export/)
- Green accent color (#00E5A0)
- Dark background (#09090b)
6.2 /support/tickets — Ticket List (User)
Layout:
- Header: "Mine support-billetter"
- Filter tabs: All | Open | Resolved | Closed
- Ticket list:
- Subject (clickable)
- Category badge
- Status badge (color-coded)
- Created date
- Unread indicator if admin replied
Empty state:
- "Ingen billetter ennå"
- "Opprett billett" button
6.3 /support/tickets/[id] — Conversation (User)
Layout:
- Ticket header:
- Subject
- Status badge
- Category
- Created date
- Transaction link (if transaction_id set): "Relatert transaksjon: [link]"
- Message thread (chat-style):
- User messages (left-aligned, gray background)
- Admin messages (right-aligned, green accent)
- Timestamps
- Reply form (if status != closed):
- Text area (5 rows)
- "Send svar" button
- Disabled if status=closed with message "Denne billetten er lukket"
6.4 /support/new — Create Ticket (User)
Form fields:
- Subject (text input, required)
- Category (dropdown, required)
- "Transaksjonsproblem"
- "Tilgangsproblem"
- "Verifisering"
- "Generelt"
- "Tvist"
- Transaction (optional, dropdown from user's transactions):
- "Velg transaksjon (valgfritt)"
- Show recent 10 transactions
- Description (textarea, 5 rows, required)
- "Opprett billett" button
Validation:
- Subject: 5-200 chars
- Description: 20-2000 chars
- Show character count below textarea
Success:
- Redirect to /support/tickets/[new_id]
- Toast: "Billett opprettet. Vi svarer så snart som mulig."
6.5 /admin/support — Admin Dashboard
Layout:
- Header: "Support-billetter"
- Filter bar:
- Status dropdown (all, open, in_progress, waiting_user, resolved, closed)
- Priority dropdown (all, urgent, high, normal, low)
- Category dropdown (all, transaction_issue, account_access, verification, general, dispute)
- Sort dropdown (Nyeste, Eldste, Oppdatert sist, Prioritet)
- Ticket table:
- ID (clickable)
- User (email)
- Subject
- Category badge
- Status badge
- Priority badge
- Created
- Updated
Click ticket:
6.6 /admin/support/[id] — Admin Ticket Detail
Layout:
- Ticket header:
- Subject
- User info: email, phone, KYC status
- Status dropdown (editable)
- Priority dropdown (editable)
- "Lagre endringer" button
- Transaction link (if set): "Se transaksjon" button
- Message thread (same as user view)
- Admin reply form:
- Text area (10 rows)
- "Endre status til:" dropdown (optional)
- "Send svar" button
Audit trail (bottom):
- Show ticket_messages with timestamps
- Show status changes from audit_log
7. Integration Points
7.1 Audit Logging
All support actions logged to audit_log table:
| Action | Resource Type | Details |
|---|---|---|
| support.ticket_created | support_ticket | ticket_id, category, priority |
| support.message_added | ticket_message | ticket_id, sender_type |
| support.admin_reply | ticket_message | ticket_id, admin message preview |
| support.ticket_updated | support_ticket | ticket_id, old_status, new_status |
Implementation: Use auditLog() helper from @/lib/middleware.
7.2 Transaction Linking
When user creates ticket with transaction_id:
- Validate transaction exists and belongs to user
- Display transaction summary in ticket detail:
- Type (remittance / qr_payment)
- Amount
- Recipient / Merchant
- Status
- Created date
- Link to /transactions/[id]
7.3 Email Notifications (Future)
Placeholder implementation:
- Create
src/lib/email-support.ts:export async function notifyUserTicketReply(ticketId: string, userId: string, adminMessage: string) { // TODO: Integrate with MCP email (see ~/system/tools/manifest.md) console.log(`[Email] Notify user ${userId} about ticket ${ticketId}`); } - Call from admin reply endpoint
- Move to MCP email when ready
8. File List
8.1 Database Migration
File: src/lib/db.ts
Changes:
- Add
support_ticketsandticket_messagesschemas toSQLITE_SCHEMAconstant - Add same schemas to
PG_SCHEMAconstant (replacedatetime('now')withCURRENT_TIMESTAMP) - No other changes needed
8.2 API Routes
User Endpoints
src/app/api/support/tickets/route.ts— GET (list), POST (create)src/app/api/support/tickets/[id]/route.ts— GET (detail)src/app/api/support/tickets/[id]/messages/route.ts— POST (add message)
Admin Endpoints
src/app/api/admin/support/tickets/route.ts— GET (list all)src/app/api/admin/support/tickets/[id]/route.ts— PATCH (update status/priority)src/app/api/admin/support/tickets/[id]/messages/route.ts— POST (admin reply)
8.3 UI Pages
User Pages
src/app/support/page.tsx— Help center (FAQ + create button)src/app/support/tickets/page.tsx— Ticket listsrc/app/support/tickets/[id]/page.tsx— Conversation viewsrc/app/support/new/page.tsx— Create ticket form
Admin Pages
src/app/admin/support/page.tsx— Admin dashboard (ticket table)src/app/admin/support/[id]/page.tsx— Admin ticket detail
8.4 Components
src/components/support/ticket-card.tsx— Ticket list item (subject, status, date)src/components/support/message-bubble.tsx— Chat message (user/admin differentiation)src/components/support/status-badge.tsx— Status indicator (open, in_progress, resolved, closed)src/components/support/category-badge.tsx— Category indicatorsrc/components/support/priority-badge.tsx— Priority indicator (urgent, high, normal, low)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
- support_tickets table created with all fields and constraints
- ticket_messages table created with foreign key to support_tickets
- Indexes created for performance (user_id, status, category, created_at, transaction_id)
- Schema works for both SQLite (dev) and PostgreSQL (staging)
9.2 API Endpoints
- POST /api/support/tickets creates ticket + initial message
- GET /api/support/tickets returns user's tickets with pagination
- GET /api/support/tickets/[id] returns ticket + messages
- POST /api/support/tickets/[id]/messages adds user message
- GET /api/admin/support/tickets returns all tickets (admin only)
- PATCH /api/admin/support/tickets/[id] updates status/priority (admin only)
- POST /api/admin/support/tickets/[id]/messages adds admin reply + sends email notification
9.3 Authorization
- Users can only view their own tickets (404 on unauthorized access)
- Admin endpoints require admin role (403 if not admin)
- CSRF protection via validateOrigin() middleware
9.4 UI Pages
- /support shows FAQ + create button
- /support/tickets shows user's tickets with status filter
- /support/tickets/[id] shows conversation thread + reply form
- /support/new shows create form with validation
- /admin/support shows all tickets with filters (status, priority, category)
- /admin/support/[id] shows ticket detail + admin reply form
9.5 Integration
- All support actions logged to audit_log
- Tickets can be linked to transactions
- Transaction summary displayed in ticket detail (if linked)
- Admin reply triggers email notification (placeholder implementation)
9.6 Validation
- Subject: 5-200 chars, sanitized
- Description: 20-2000 chars, sanitized
- Category: must be valid enum value
- Transaction ID: must exist and belong to user (if provided)
- Status transitions validated (open → in_progress → resolved → closed)
9.7 Edge Cases
- Closed tickets cannot receive new messages (UI shows message)
- Reopening resolved ticket changes status back to 'open'
- Empty ticket list shows "Ingen billetter" message
- Character count shown in textarea (5-200 for subject, 20-2000 for description)
10. Dependencies
10.1 Existing Infrastructure
- Database:
src/lib/db.ts(SQLite/PostgreSQL dual driver) - Auth:
src/lib/auth.ts(getCurrentUser, JWT validation) - Middleware:
src/lib/middleware.ts(requireAuth, sanitizeText, auditLog) - Utils:
src/lib/utils-server.ts(randomId)
10.2 New Dependencies
None. All features use existing infrastructure.
10.3 Admin Role (TBD)
Current state: Users table has role field with values user or merchant.
Requirement: Add admin role for support staff.
Two options:
Option A: Extend existing role enum
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:
- Add migration to extend role enum
- 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 }; } - Use in all admin endpoints
11. Testing Checklist
11.1 Unit Tests (Future)
- getAutoPriority() returns correct priority for each category
- getCategoryLabel() returns Norwegian labels
- Status validation logic
11.2 Integration Tests (Future)
- Create ticket → appears in user's ticket list
- Admin reply → user sees message in conversation
- Status change → audit log entry created
- Transaction link → transaction summary displayed
11.3 Manual Testing (MVP)
-
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'
-
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
-
Authorization:
- User A cannot view User B's tickets (404)
- Non-admin cannot access /admin/support (403)
-
Transaction linking:
- Create ticket linked to transaction
- Verify transaction summary shows in ticket detail
12. Implementation Order
Phase 1: Database + Types (Day 1)
- Add schemas to
src/lib/db.ts - Create
src/types/support.ts - Create
src/lib/support-utils.ts - Add
requireAdmin()tosrc/lib/middleware.ts
Phase 2: User API (Day 1-2)
- POST /api/support/tickets (create)
- GET /api/support/tickets (list)
- GET /api/support/tickets/[id] (detail)
- POST /api/support/tickets/[id]/messages (reply)
Phase 3: User UI (Day 2-3)
- /support (help center)
- /support/new (create form)
- /support/tickets (list)
- /support/tickets/[id] (conversation)
- Components: ticket-card, message-bubble, status-badge, category-badge, priority-badge, faq-accordion
Phase 4: Admin API (Day 3)
- GET /api/admin/support/tickets (list all)
- PATCH /api/admin/support/tickets/[id] (update)
- POST /api/admin/support/tickets/[id]/messages (admin reply)
Phase 5: Admin UI (Day 4)
- /admin/support (dashboard)
- /admin/support/[id] (detail)
Phase 6: Integration (Day 4-5)
- Audit logging for all actions
- Transaction linking
- Email notification (placeholder)
Phase 7: Testing + Refinement (Day 5)
- Manual testing of all flows
- Edge case validation
- UI polish (spacing, colors, responsive)
13. Future Enhancements (Out of Scope)
- File attachments — allow users to upload screenshots
- Multi-language support — English, Bosnian translations
- Public FAQ system — CMS-backed knowledge base
- Live chat — real-time messaging via WebSocket
- AI chatbot — auto-respond to common questions
- SLA tracking — response time targets per priority
- Auto-close old tickets — after 7 days in 'resolved' status
- Admin assignment — assign tickets to specific support staff
- Internal notes — admin-only notes not visible to user
- Ticket templates — pre-filled forms for common issues
14. Notes for Implementation
14.1 Design Consistency
- Match existing Drop UI patterns (see mockups/figma-make-export/)
- Use green accent (#00E5A0) for primary actions
- Dark theme (#09090b background)
- Ensure mobile responsive (all pages must work on 375px width)
14.2 Security
- All text inputs sanitized via
sanitizeText()from middleware - CSRF protection via
validateOrigin()middleware - Audit logging for all support actions
- Users cannot view other users' tickets (authorization check)
14.3 Performance
- Pagination on all list endpoints (default 10, max 50)
- Indexes on user_id, status, category, created_at for fast queries
- Lazy load transaction details only when needed
14.4 Norwegian Text
All UI text in Norwegian (nb):
- "Opprett billett" (Create ticket)
- "Mine support-billetter" (My support tickets)
- "Trenger du hjelp?" (Need help?)
- "Send svar" (Send reply)
- "Under behandling" (In progress)
- "Venter på deg" (Waiting for you)
15. Spec Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-02-17 | Initial spec — database, API, UI, integration |
END OF SPEC
drop-dispute-handling-spec
Drop Dispute & Chargeback Handling — Implementation Spec
Project: Drop Fintech App MC Task: #1190 Created: 2026-02-17 Author: John (Software Architect Agent) Status: DRAFT — Awaiting Alem approval
Executive Summary
This specification defines comprehensive dispute and chargeback handling for Drop's PSD2 pass-through payment system. Drop operates as a PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.
Unique challenges for PSD2 PISP:
- No merchant account — Drop doesn't process card payments (no Visa/Mastercard chargebacks)
- Bank-to-bank transfers — Disputes go through banking system, not card networks
- Regulatory framework — PSD2 requires timely dispute resolution (1 business day for unauthorized, 8 weeks for authorized)
- Limited control — Bank makes final decision on refunds, not Drop
- Trust is everything — Users trust us with their bank access, disputes must be handled perfectly
Core principles:
- Transparency — User always knows status of dispute
- Speed — Respond within SLA (1 day unauthorized, 13 months time limit)
- Documentation — Every dispute fully documented for compliance
- Bank coordination — Work with user's bank and recipient bank
- User protection — Unauthorized transactions refunded immediately (PSD2 requirement)
1. Regulatory Context (PSD2)
1.1 PSD2 Requirements for PISP Disputes
Source: Payment Services Directive 2 (EU 2015/2366), Articles 71-74
| Requirement | Value | Enforcement |
|---|---|---|
| Unauthorized transaction refund | Within 1 business day | Mandatory — user's bank must refund |
| Dispute window | Up to 13 months from transaction date | User can dispute any time within window |
| Complaint response | Within 15 business days | PISP must respond with decision |
| Escalation to external body | Finansklagenemnda (FinKN) | If user not satisfied with resolution |
| Documentation retention | 5 years | Audit trail of all disputes |
Key differences from card chargebacks:
- No chargeback network (no Visa/Mastercard dispute system)
- Bank-initiated refunds — PISP requests refund from user's bank, not from merchant
- Strong Customer Authentication (SCA) — If SCA was used, liability shifted to bank (not PISP)
- No recurring transaction liability — PISP not liable for subscription fraud if SCA used on initial transaction
1.2 Drop's Liability Model
- User claims they did NOT authorize the payment
- Drop's liability: €0 if SCA (BankID) was used correctly
- Bank's liability: Refund user within 1 business day
- Drop's action: Facilitate dispute with bank, provide transaction proof (SCA logs)
Technical failure (Drop's fault):
- Payment failed due to Drop system error (e.g., wrong amount sent)
- Drop's liability: 100% — immediate refund + compensation
- Drop's action: Refund from Drop's operational account, file incident report
2. Dispute Types
2.1 Classification
| Type | User Claim | Drop's Role | Time Limit | Outcome |
|---|---|---|---|---|
| Unauthorized | "I didn't make this payment" | Facilitate bank refund | 13 months | Bank refunds (1 day) |
| Incorrect amount | "Wrong amount was sent" | Investigate + refund if Drop error | 13 months | Refund if Drop fault |
| Duplicate payment | "Charged twice" | Check idempotency logs | 13 months | Refund duplicate |
| Service not received | "Recipient didn't deliver" | Provide evidence only | 60 days (informal) | Commercial dispute |
| Technical failure | "Payment stuck/failed but money gone" | Investigate + reconcile | 13 months | Refund if Drop fault |
| Refund request | "Recipient agreed to refund" | Facilitate reverse transfer | No limit | Process reverse payment |
2.2 Priority Levels
| Priority | Definition | Response SLA | Examples |
|---|---|---|---|
| Critical | Unauthorized transaction, large amount (>10,000 NOK) | 4 hours | Fraud, account takeover |
| High | Unauthorized <10k NOK, technical failure | 24 hours | Wrong amount sent, duplicate charge |
| Normal | Service not received, refund request | 5 business days | Merchant didn't deliver, user wants refund |
| Low | Informational, general inquiry | 15 business days | "How do I dispute?", status check |
3. Database Schema
3.1 disputes Table
CREATE TABLE IF NOT EXISTS disputes (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
transaction_id TEXT NOT NULL REFERENCES transactions(id),
dispute_type TEXT NOT NULL CHECK(dispute_type IN (
'unauthorized',
'incorrect_amount',
'duplicate',
'service_not_received',
'technical_failure',
'refund_request'
)),
status TEXT DEFAULT 'submitted' CHECK(status IN (
'submitted', -- User filed dispute
'under_review', -- Drop investigating
'evidence_requested', -- Need more info from user
'bank_contacted', -- Sent to bank (unauthorized cases)
'resolved_approved', -- Dispute valid, refund issued
'resolved_denied', -- Dispute invalid, no refund
'escalated', -- Sent to FinKN (external complaint)
'withdrawn' -- User withdrew dispute
)),
priority TEXT DEFAULT 'normal' CHECK(priority IN ('low','normal','high','critical')),
-- Dispute details
claimed_amount INTEGER NOT NULL, -- øre (amount user claims is wrong)
actual_amount INTEGER NOT NULL, -- øre (actual transaction amount)
reason TEXT NOT NULL, -- User's explanation (free text)
-- Evidence
evidence_files TEXT, -- JSON array of file URLs (future)
user_statement TEXT, -- Detailed statement from user
recipient_response TEXT, -- If recipient contacted
-- Resolution
resolution_type TEXT CHECK(resolution_type IN (
'refund_full',
'refund_partial',
'no_refund',
'reversed_payment'
)),
refund_amount INTEGER, -- øre (actual refund issued)
refund_reference TEXT, -- Bank reference or external_id
resolution_reason TEXT, -- Why resolved this way
-- Timeline
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
responded_at TEXT, -- When Drop first responded
resolved_at TEXT,
escalated_at TEXT,
-- Compliance
sla_deadline TEXT, -- When response is due (24h for unauthorized)
breach_sla INTEGER DEFAULT 0, -- Did we miss deadline?
external_case_id TEXT -- FinKN case number if escalated
);
CREATE INDEX IF NOT EXISTS idx_disputes_user ON disputes(user_id);
CREATE INDEX IF NOT EXISTS idx_disputes_transaction ON disputes(transaction_id);
CREATE INDEX IF NOT EXISTS idx_disputes_status ON disputes(status);
CREATE INDEX IF NOT EXISTS idx_disputes_priority ON disputes(priority);
CREATE INDEX IF NOT EXISTS idx_disputes_sla ON disputes(sla_deadline, status);
CREATE INDEX IF NOT EXISTS idx_disputes_created ON disputes(created_at);
PostgreSQL version:
- Replace
datetime('now')withCURRENT_TIMESTAMP - No other changes needed
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP
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:
- Replace
datetime('now')withCURRENT_TIMESTAMP
4. Dispute Lifecycle
4.1 State Machine
┌──────────────┐
│ submitted │────────────────┐
└──────┬───────┘ │
│ │ (withdraw)
▼ ▼
┌──────────────┐ ┌───────────┐
│ under_review │ │ withdrawn │ (terminal)
└──────┬───────┘ └───────────┘
│
├───────────────────┬───────────────────┬─────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐ ┌──────────────┐
│evidence_requested│ │bank_contacted│ │resolved_approved │ │resolved_denied│
└─────────┬───────┘ └──────┬───────┘ │ (terminal) │ │ (terminal) │
│ │ └──────────────────┘ └──────────────┘
│ │
│ ▼
│ ┌──────────────┐
└───────────▶│ under_review │
└──────┬───────┘
│
▼
┌──────────────┐
│ escalated │ (terminal — sent to FinKN)
└──────────────┘
Valid transitions:
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:
- Transaction must exist and belong to user (404 if not found)
- Transaction must be completed (can't dispute pending/failed transactions)
- Dispute window: max 13 months since transaction (400 if expired)
- No duplicate dispute for same transaction (409 if exists)
- Reason: 20-2000 chars, sanitized
Auto-priority logic:
if (disputeType === 'unauthorized' && amount > 1000000) priority = 'critical'; // >10k NOK
else if (disputeType === 'unauthorized') priority = 'high';
else if (['technical_failure', 'incorrect_amount', 'duplicate'].includes(disputeType)) priority = 'normal';
else priority = 'low';
Side effects:
- Calculate SLA deadline based on type + priority
- Create initial message in dispute_messages (sender_type=user, message=reason)
- Audit log:
dispute.created - If unauthorized → auto-transition to
bank_contacted+ notify bank (see Section 6.1)
GET /api/disputes
List user's disputes.
Query params:
page(default: 1)limit(default: 10, max: 50)status(optional filter)sort(created_at_desc, created_at_asc, sla_deadline_asc)
Response:
{
"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"
}
]
}
}
- User can only view their own disputes (404 if not theirs)
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:
- Update dispute.updated_at
- If status was
evidence_requested, transition tounder_review - Audit log:
dispute.message_added
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:
- Transition to
withdrawnstatus (terminal) - Audit log:
dispute.withdrawn - System message: "User withdrew dispute: [reason]"
5.2 Admin Endpoints
All admin endpoints require requireAdmin() middleware.
GET /api/admin/disputes
List ALL disputes with filters.
Query params:
page,limitstatus(filter)priority(filter)disputeType(filter)breachSla(boolean filter: show only SLA breaches)sort(created_at_desc, sla_deadline_asc, priority_desc)
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:
- Update dispute.updated_at
- If status changed to
resolved_approvedorresolved_denied, set resolved_at - Audit log:
dispute.status_changed - System message added to dispute_messages
POST /api/admin/disputes/[id]/messages
Admin reply to user.
Request:
{
"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:
- Update dispute.updated_at
- If changeStatus provided, update dispute.status
- Set dispute.responded_at (first admin response)
- Audit log:
dispute.admin_reply - Email notification to user (subject: "Svar på din dispute #{id}")
- Push notification: "Drop har svart på din dispute"
POST /api/admin/disputes/[id]/resolve
Manually resolve dispute.
Request:
{
"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:
- Update all resolution fields
- Set resolved_at
- Audit log:
dispute.resolved - System message: "Dispute resolved: [resolutionReason]"
- Email + push notification to user
POST /api/admin/disputes/[id]/escalate
Escalate to Finansklagenemnda (FinKN).
Request:
{
"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:
- Transition to
escalated(terminal) - Set escalated_at + external_case_id
- Audit log:
dispute.escalated - Email user with FinKN contact info
6. Integration with Banking System
6.1 Unauthorized Transaction Flow
Scenario: User claims they didn't authorize payment (fraud).
Drop's actions:
-
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."
- Transition dispute to
-
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
-
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
-
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
- If refund approved → transition to
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:
-
Immediate acknowledgment (within 4 hours):
- Transition to
under_review - System message: "We are investigating this issue."
- Transition to
-
Investigation:
- Check transaction logs, audit trail
- Verify actual vs claimed amount
- Check if idempotency key was reused (duplicate)
- Review PISP provider logs
-
If Drop's fault confirmed:
- Transition to
resolved_approved - Issue refund from Drop's operational account (not via bank)
- Resolution type:
refund_fullorrefund_partial - Create incident report in
admin_alertstable - Notify ops team (Slack webhook)
- Transition to
-
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)
- Transition to
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:
-
Acknowledge (within 5 business days):
- Transition to
under_review - System message: "We are reviewing your case."
- Transition to
-
Provide transaction evidence:
- Recipient details (name, bank account, country)
- Transaction timestamp and amount
- Payment confirmation from PISP
- Recommendation: "Contact recipient directly to request refund"
-
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
- Transition to
No refund from Drop — user must resolve with recipient or pursue legal action.
7. UI Pages
7.1 /disputes — Dispute Center (User)
Layout:
- Header: "Mine tvister"
- Filter tabs: All | Active | Resolved | Escalated
- Dispute list:
- Transaction summary (recipient/merchant, amount, date)
- Dispute type badge
- Status badge (color-coded)
- SLA indicator (red if breached)
- Created date
- Unread indicator if admin replied
Empty state:
- "Ingen tvister"
- "Har du et problem med en betaling? Opprett en tvist"
- CTA button: "Opprett tvist"
7.2 /disputes/new — Create Dispute (User)
Step 1: Select Transaction
- Show user's completed transactions (last 13 months)
- Filter: Remittance | QR Payment
- Each transaction shows:
- Recipient/merchant name
- Amount + date
- Status (completed)
- "Tvist denne betalingen" button
Step 2: Dispute Type
Step 3: Details
- Reason (textarea, 20-2000 chars, required)
- Claimed amount (prefilled with transaction amount, editable for incorrect_amount)
- Character count indicator
- Upload evidence (future — buttons disabled for MVP)
Step 4: Review & Submit
- Summary of dispute
- Transaction details
- Disclaimer (based on type):
- Unauthorized: "Vi vil kontakte banken din umiddelbart. Du skal få refusjon innen 1 virkedag."
- Service not received: "Dette er en kommersiell tvist mellom deg og mottakeren. Drop kan ikke refundere, men vi vil gi deg dokumentasjon."
- Technical failure: "Vi vil undersøke dette og refundere hvis det er vår feil."
- "Send tvist" button
7.3 /disputes/[id] — Dispute Detail (User)
Layout:
-
Dispute header:
- Status badge + priority badge
- Transaction summary (link to /transactions/[id])
- Dispute type
- Amount claimed
- Created date
- SLA deadline (if not resolved) — show countdown timer if < 24h
- SLA breach indicator (if missed deadline)
-
Timeline:
- Initial submission
- Status changes
- Admin responses
- Resolution (if applicable)
-
Message thread (chat-style):
- User messages (left-aligned)
- Admin messages (right-aligned, green accent)
- System messages (centered, gray)
- Timestamps
-
Action panel:
- If status =
evidence_requested: "Gi mer informasjon" button → message form - If status =
resolved_denied: "Send til Finansklagenemnda" button (see Section 7.4) - If status =
submittedorunder_review: "Trekk tilbake tvist" button - If status = terminal: No actions
- If status =
7.4 External Complaint (FinKN)
Route: /disputes/[id]/escalate
Content:
-
Heading: "Send klage til Finansklagenemnda"
-
Explanation:
- "Hvis du ikke er fornøyd med vår avgjørelse, kan du sende klagen til Finansklagenemnda (FinKN)."
- "FinKN er en uavhengig tvisteløsningsinstans for finansielle tjenester."
- "Drop vil samarbeide fullt ut med FinKN i deres undersøkelse."
-
FinKN contact info:
- Address: Postboks 53 Skøyen, 0212 Oslo
- Website: finansklagenemnda.no
- Email: post@finkn.no
- Phone: +47 23 13 19 60
-
"Send til FinKN" button:
- Transitions dispute to
escalatedstatus - Sends email to user with FinKN contact info + case summary
- Creates admin alert for ops team
- Transitions dispute to
7.5 /admin/disputes — Admin Dashboard
Layout:
-
Header: "Dispute Management"
-
Summary cards:
- Active disputes (count)
- SLA breaches (count, red if > 0)
- Resolved last 7 days (count)
- Average resolution time
-
Filter bar:
- Status dropdown (all, submitted, under_review, etc.)
- Priority dropdown (all, critical, high, normal, low)
- Dispute type dropdown (all, unauthorized, etc.)
- SLA breach toggle (show only breached)
- Sort dropdown (Newest, SLA Deadline, Priority)
-
Dispute table:
- ID (clickable)
- User (name + email)
- Transaction (ID + amount)
- Dispute type badge
- Status badge
- Priority badge
- Created
- SLA deadline (color-coded: green >6h, yellow 1-6h, red <1h or breached)
- Actions: "View", "Resolve"
7.6 /admin/disputes/[id] — Admin Detail
Layout:
-
Dispute header (same as user view)
-
User info: email, phone, KYC status, account created date
-
Transaction info: type, amount, recipient, bank account, completed date
-
Dispute details: reason, claimed amount, evidence
-
Status & Priority controls:
- Status dropdown (editable)
- Priority dropdown (editable)
- "Lagre endringer" button
-
Message thread (same as user view)
-
Admin action panel:
- Text area for admin reply (10 rows)
- "Change status to:" dropdown (optional)
- "Send svar" button
-
Resolution panel (if not yet resolved):
- Resolution type: dropdown (refund_full, refund_partial, no_refund, reversed_payment)
- Refund amount: input (øre)
- Refund reference: input (bank ref)
- Resolution reason: textarea
- "Resolve Dispute" button
-
Escalation panel:
- "Escalate to FinKN" button
- External case ID: input (optional)
- Reason: textarea
-
Audit trail (bottom):
- All actions from dispute_actions table
- Expandable JSON details
8. Email Notifications
8.1 Dispute Submitted (Auto-Confirmation)
Subject: "Tvist opprettet - #{dispute_id}"
Body (Norwegian):
Hei,
Vi har mottatt din tvist angående transaksjon #{transaction_id}.
Tvisttype: {dispute_type_label}
Beløp: {claimed_amount} NOK
Status: Under behandling
{type_specific_message}
Du kan følge statusen her:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}
Forventet responstid: {sla_deadline}
Vennlig hilsen,
Drop Kundestøtte
Type-specific messages:
8.2 Admin Response
Subject: "Svar på din tvist #{dispute_id}"
Body:
Hei,
Vi har svart på din tvist:
{admin_message}
Logg inn på Drop for å se hele samtalen:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}
Hvis du har flere spørsmål, kan du svare direkte i tvisten.
Vennlig hilsen,
Drop Kundestøtte
8.3 Dispute Resolved
Subject (approved): "Tvist godkjent - Refusjon behandles" Subject (denied): "Tvist avslått - Se forklaring"
Body (approved):
Hei,
Din tvist har blitt godkjent.
Refusjon: {refund_amount} NOK
Referanse: {refund_reference}
Forklaring: {resolution_reason}
{refund_timeline_message}
Se detaljer:
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}
Vennlig hilsen,
Drop Kundestøtte
Body (denied):
Hei,
Din tvist har blitt avslått.
Forklaring: {resolution_reason}
Hvis du ikke er fornøyd med avgjørelsen, kan du sende klagen til Finansklagenemnda (FinKN):
{NEXT_PUBLIC_APP_URL}/disputes/{dispute_id}/escalate
Vennlig hilsen,
Drop Kundestøtte
9. Integration with Existing Systems
9.1 Link to Transaction Failure Spec
Spec: /Users/makinja/system/specs/drop-transaction-failure-spec.md
Integration points:
-
Transaction stuck detection:
- If transaction stuck in
processingortimeoutfor >24h → auto-create dispute with type=technical_failure - Priority: high
- Reason: "Transaction failed to complete after 24 hours"
- If transaction stuck in
-
Failed transaction with money deducted:
- If transaction status=
failedBUT bank debited user's account → user can dispute - Dispute type:
technical_failure - Drop investigates via reconciliation-worker.ts
- If transaction status=
-
Partial failure compensation:
- If transaction has
compensation_status=failed→ auto-create dispute - Dispute type:
technical_failure - Priority: critical
- Reason: "Refund failed after partial payment"
- If transaction has
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,
});
}
9.2 Link to Customer Support Spec
Spec: /Users/makinja/system/specs/drop-customer-support-spec.md
Integration points:
-
Dispute from support ticket:
- If support ticket category=
dispute→ create dispute automatically - Copy ticket description to dispute reason
- Link ticket to dispute (bidirectional)
- If support ticket category=
-
Support ticket from dispute:
- User can open support ticket from dispute detail page
- "Trenger du hjelp?" button → creates ticket with context
-
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]
);
}
9.3 Link to Complaints System
Table: complaints (already exists in db.ts)
Relationship:
- Disputes are structured (transaction-specific, formal resolution)
- Complaints are unstructured (general feedback, service quality)
Integration:
-
Complaint → Dispute:
- If complaint category=
transaction→ suggest creating dispute - "Opprett formell tvist" button in complaint detail page
- If complaint category=
-
Dispute → Complaint:
- If user is unsatisfied with resolved_denied → can file general complaint
- Complaint linked to original dispute for context
10. Acceptance Criteria
10.1 Database
- disputes table created with all fields and constraints
- dispute_messages table created with foreign key to disputes
- dispute_actions table created for audit trail
- Indexes created for performance (user_id, transaction_id, status, sla_deadline)
- Schema works for both SQLite (dev) and PostgreSQL (production)
10.2 API Endpoints
- POST /api/disputes creates dispute + calculates SLA deadline
- GET /api/disputes returns user's disputes with pagination
- GET /api/disputes/[id] returns dispute + messages + transaction + actions
- POST /api/disputes/[id]/messages adds user message
- POST /api/disputes/[id]/withdraw marks dispute withdrawn
- GET /api/admin/disputes returns all disputes (admin only)
- PATCH /api/admin/disputes/[id] updates status/priority (admin only)
- POST /api/admin/disputes/[id]/messages adds admin reply + sends email
- POST /api/admin/disputes/[id]/resolve resolves dispute with refund details
- POST /api/admin/disputes/[id]/escalate escalates to FinKN
10.3 Authorization
- Users can only view their own disputes (404 on unauthorized access)
- Admin endpoints require admin role (403 if not admin)
- Transaction ownership validated (can't dispute someone else's transaction)
- CSRF protection via validateOrigin() middleware
10.4 SLA Management
- SLA deadline calculated correctly based on dispute type + priority
- Business hours calculation (Mon-Fri 09:00-17:00, skip weekends + holidays)
- SLA breach flag set if deadline missed
- Admin dashboard shows SLA breaches prominently
10.5 UI Pages
- /disputes shows user's disputes with status filter
- /disputes/new shows multi-step dispute creation form
- /disputes/[id] shows conversation thread + action buttons
- /disputes/[id]/escalate shows FinKN contact info + escalation button
- /admin/disputes shows all disputes with filters (status, priority, type, SLA breach)
- /admin/disputes/[id] shows detail + admin action panel
10.6 Integration
- All dispute actions logged to audit_log table
- Disputes linked to transactions (bidirectional)
- Transaction detail page shows dispute status (if exists)
- Email notifications sent for dispute events (submitted, admin reply, resolved)
- Push notifications sent for status changes
- Auto-dispute created for stuck transactions (>24h)
- Support tickets can create disputes (if category=dispute)
10.7 Validation
- Transaction must be completed (can't dispute pending/failed)
- Dispute window: max 13 months since transaction
- No duplicate dispute for same transaction
- Reason: 20-2000 chars, sanitized
- Status transitions validated (finite state machine)
10.8 Edge Cases
- User can't create multiple disputes for same transaction
- Resolved disputes can't be reopened (only escalated if denied)
- Withdrawn disputes are final (can't un-withdraw)
- SLA deadline doesn't count weekends or Norwegian public holidays
- Email notifications handle missing user email gracefully
11. Implementation Order
Phase 1: Database + Types (Day 1)
- Add schemas to
src/lib/db.ts(SQLite + PostgreSQL versions) - Create
src/types/dispute.ts - Create
src/lib/dispute-utils.ts(SLA calculation, status validation) - Add
requireAdmin()tosrc/lib/middleware.ts(if not exists)
Phase 2: User API (Day 1-2)
- POST /api/disputes (create)
- GET /api/disputes (list)
- GET /api/disputes/[id] (detail)
- POST /api/disputes/[id]/messages (add message)
- POST /api/disputes/[id]/withdraw (withdraw)
Phase 3: User UI (Day 2-3)
- /disputes (list page)
- /disputes/new (multi-step creation form)
- /disputes/[id] (conversation view)
- /disputes/[id]/escalate (FinKN escalation page)
- Components: dispute-card, dispute-badge, message-bubble, sla-indicator
Phase 4: Admin API (Day 3)
- GET /api/admin/disputes (list all)
- PATCH /api/admin/disputes/[id] (update status/priority)
- POST /api/admin/disputes/[id]/messages (admin reply)
- POST /api/admin/disputes/[id]/resolve (manual resolution)
- POST /api/admin/disputes/[id]/escalate (escalate to FinKN)
Phase 5: Admin UI (Day 4)
- /admin/disputes (dashboard with filters)
- /admin/disputes/[id] (detail + action panel)
Phase 6: Integration (Day 4-5)
- Audit logging for all dispute actions
- Email notifications (submitted, admin reply, resolved)
- Push notifications for status changes
- Link to transaction failure spec (auto-dispute for stuck transactions)
- Link to support ticket spec (dispute from ticket)
Phase 7: Bank Integration (Day 5)
Phase 8: Testing + Refinement (Day 5-6)
- Manual testing of all flows (user + admin)
- Edge case validation
- UI polish (spacing, colors, responsive)
- SLA calculation accuracy test
- Email template review
12. Dependencies
12.1 Existing Infrastructure
- Database:
src/lib/db.ts(SQLite/PostgreSQL dual driver) - Auth:
src/lib/auth.ts(getCurrentUser, JWT validation) - Middleware:
src/lib/middleware.ts(requireAuth, sanitizeText, auditLog) - Utils:
src/lib/utils-server.ts(randomId) - Email: Existing email service (to be determined)
- Push notifications: FCM (Firebase Cloud Messaging) or APNS
12.2 New Dependencies
None. All features use existing infrastructure.
12.3 External Services (Future)
- Bank dispute API — For automated refund requests (unauthorized transactions)
- FinKN API — For automated case escalation (if available)
- SMS notifications — For critical dispute updates (optional)
13. Testing Checklist
13.1 User Flows
-
Create dispute (unauthorized):
- Select transaction
- Choose "unauthorized" type
- Submit reason
- Verify auto-transition to
bank_contacted - Verify email sent with SLA deadline
-
Create dispute (service not received):
- Select transaction
- Choose "service not received" type
- Submit reason
- Verify status =
submitted - Verify email sent
-
Add message to dispute:
- Open dispute detail
- Add message
- Verify status changes to
under_review(if wasevidence_requested)
-
Withdraw dispute:
- Open dispute detail
- Click "Trekk tilbake"
- Confirm
- Verify status =
withdrawn(terminal)
-
Escalate to FinKN:
- Open resolved_denied dispute
- Click "Send til FinKN"
- Verify status =
escalated - Verify email with FinKN contact info
13.2 Admin Flows
-
View all disputes dashboard:
- Filter by status, priority, type
- Sort by SLA deadline
- Verify SLA breach indicator
-
Reply to dispute:
- Open dispute detail
- Add admin message
- Change status to
under_review - Verify email sent to user
-
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
-
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
-
Escalate to FinKN (admin):
- Open dispute detail
- Click "Escalate to FinKN"
- Enter external case ID + reason
- Verify status =
escalated
13.3 Edge Cases
-
Duplicate dispute:
- Try to create second dispute for same transaction
- Verify 409 Conflict error
-
Expired dispute window:
- Try to create dispute for transaction >13 months old
- Verify 400 Bad Request error
-
SLA deadline calculation:
- Create dispute on Friday 16:00
- Verify SLA deadline is Monday 10:00 (skip weekend)
-
Authorization:
- User A tries to view User B's dispute
- Verify 404 Not Found
-
Status transition validation:
- Try to transition from
resolved_approvedtounder_review - Verify 400 Bad Request error
- Try to transition from
14. Future Enhancements (Out of Scope)
- File attachments — Allow users to upload evidence (screenshots, receipts)
- Video evidence — Record screen for fraud proof
- Multi-language support — English, Bosnian translations
- AI dispute classification — Auto-detect dispute type from user's description
- Automated refund triggers — For specific patterns (e.g., duplicate transactions)
- Bank API integration — Direct API calls instead of email for unauthorized disputes
- FinKN API integration — Automated case filing
- Dispute templates — Pre-filled forms for common issues
- Internal notes — Admin-only notes not visible to user
- 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:
- Notify user's bank immediately (within 24 hours)
- Provide transaction evidence (SCA logs, IP, device ID)
- Keep audit trail for 5 years
- Do NOT delay refund process
Liability shift:
- If SCA was performed correctly → bank liable (not Drop)
- If SCA was not performed → Drop liable (refund from operational account)
- If user was grossly negligent (shared BankID) → user liable (no refund)
15.2 PSD2 Article 74 (Complaint Handling)
Requirements:
- Respond to complaint within 15 business days
- Provide clear explanation of decision
- Inform user of right to escalate to FinKN
- FinKN contact info must be easily accessible
Drop's implementation:
- SLA: 5 business days for normal, 1 business day for unauthorized
- Email notification with resolution reason
- Escalation button in UI after
resolved_denied - FinKN contact info on
/disputes/[id]/escalatepage
15.3 GDPR Compliance
Data retention:
- Dispute records: 5 years (regulatory requirement)
- User messages: 5 years
- Audit trail: 5 years
- After 5 years: Archive to cold storage or delete (per GDPR)
User rights:
- Right to access: User can view all disputes and messages
- Right to rectification: User can add messages to correct information
- Right to erasure: Limited (regulatory retention overrides)
- Right to data portability: User can export dispute data (future)
Data minimization:
- Only collect necessary information (reason, amount, transaction ID)
- No excessive evidence requests
- File attachments limited to 5MB each (future)
16. Monitoring & Alerting
16.1 Metrics to Track
| Metric | Threshold | Alert If |
|---|---|---|
| Active disputes (count) | 10 | > 50 |
| SLA breaches (count) | 0 | > 0 |
| Average resolution time (days) | 3 | > 7 |
| Unauthorized disputes (%) | 5% | > 15% |
| Dispute approval rate (%) | 70% | < 50% |
| Escalations to FinKN (count) | 1/month | > 5/month |
16.2 Dashboard Queries
Active disputes:
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:
-
SLA breach (any dispute misses deadline)
- Channel: #ops
- Priority: high
- Message: "Dispute #{id} missed SLA deadline (type: {type}, priority: {priority}, user: {email})"
-
Critical unauthorized dispute (>10k NOK)
- Channel: #fraud
- Priority: critical
- Message: "High-value unauthorized dispute created: #{id} ({amount} NOK, user: {email})"
-
Escalation to FinKN
- Channel: #ops
- Priority: normal
- Message: "Dispute #{id} escalated to FinKN by {user_email}. Case ID: {external_case_id}"
17. Open Questions (For Alem)
Q1: Refund Implementation
Question: How should we handle refunds for technical failures?
Options:
- Option A: Manual bank transfer (MVP) — Admin processes refund via bank UI
- Option B: PISP reverse payment (future) — Integrate with bank API for automatic refund
- Option C: Drop holds refund balance (NOT ALLOWED — breaks pass-through model)
Recommendation: Option A for MVP, migrate to Option B when bank APIs available.
Q2: Admin Role
Question: Who should have admin access to dispute dashboard?
Options:
- Option A: CEO (Alem) only
- Option B: CEO + finance team
- Option C: CEO + finance + support team
Recommendation: Option C — support team needs access to respond quickly.
Q3: FinKN Escalation Process
Question: Should we automate FinKN escalation or keep it manual?
Options:
- Option A: Manual (user clicks button, we send email)
- Option B: Semi-automated (user clicks button, we pre-fill FinKN web form)
- Option C: Fully automated (API integration if available)
Recommendation: Option A for MVP. Check if FinKN has API.
Q4: Dispute Notification Channels
Question: Email + push notifications both? Or only one?
Options:
- Option A: Email only (simpler, no push infra needed)
- Option B: Push only (faster, modern)
- Option C: Both (redundancy, user preference)
Recommendation: Option C. Email is fallback if user disabled push.
Q5: Dispute Evidence (Future)
Question: Should we allow file uploads (screenshots, receipts)?
Options:
- Option A: No (text only, simpler MVP)
- Option B: Yes (better evidence, but needs file storage + moderation)
Recommendation: Option A for MVP. Add file uploads in Phase 2 when we have S3/Cloudflare R2 storage.
18. Next Steps
- Review this spec with Alem
- Approve/reject sections (or request changes)
- Answer open questions (Q1-Q5)
- Prioritize phases (which to implement first?)
- Assign to builder agent (one phase at a time)
- 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:
- PSD2: Impacts and Compliance for Merchants
- What is PSD2 everything to know for compliance - Adyen
- The Payment Services Contract: PSD2 Requirements and PSD3 Perspectives - ILP Abogados
- What is PSD2? How it Impacts Banks, Businesses & Chargebacks911
- How European merchants can reduce chargebacks and protect revenue in 2026 | GR4VY
Regulatory bodies:
- Finanstilsynet (Norway) — Financial regulator
- Finansklagenemnda (FinKN) — External dispute resolution
END OF SPEC
drop-email-system-spec
Drop Transactional Email System — Implementation Spec
Project: Drop (Fintech Payment App) Task: MC #1188 Date: 2026-02-17 Author: John (AI Director)
1. Executive Summary
Drop currently has placeholder email templates but no email service layer. This spec defines a production-ready transactional email system covering:
- Email service abstraction (provider-agnostic)
- Token-based workflows (email verification, password reset)
- Transactional emails (welcome, transaction receipt, login alerts)
- Database schema for tokens and logs
- API endpoints for verification flows
- Integration points with existing auth and transaction systems
MVP Recommendation: Resend — Next.js native, simplest setup (5 min vs 30-60 min for SendGrid), generous free tier (3,000 emails/month), React Email integration, automatic DKIM/SPF/DMARC.
2. Provider Comparison
Resend (RECOMMENDED for MVP)
Pros:
- Next.js/React native integration (React Email built-in)
- Automatic DKIM/SPF/DMARC setup (5 min vs 30-60 min for SendGrid)
- Free tier: 3,000 emails/month (100/day limit)
- Pro: $20/month for 50,000 emails (no daily limits)
- Modern developer experience
- Simple API (3 lines to send email)
Cons:
- Newer service (launched 2023, less mature than SendGrid)
- Lower volume ceiling (enterprise-scale needs SendGrid)
- Fewer features (no email validation, inbound parsing, visual template editor)
Best for: Drop MVP — we need transactional emails (not marketing campaigns), React templates, fast setup.
SendGrid
Pros:
- Mature platform (better deliverability reputation)
- More features: email validation, inbound parsing, visual templates, marketing campaigns
- Higher volume handling (enterprise-scale)
- Free trial: 100 emails/day for 60 days
- Essentials: $19.95/month for 50,000-100,000 emails
Cons:
- Complex setup (30-60 min for DNS records)
- Separate pricing for marketing campaigns
- Dedicated IPs cost extra
- Heavier integration (more API complexity)
Best for: Post-MVP if we need email validation, marketing campaigns, or >100k emails/month.
AWS SES
Pros:
- Cheapest at scale ($0.10 per 1,000 emails)
- No monthly minimums
- Built into AWS ecosystem (if we use AWS)
Cons:
- Requires AWS account and IAM setup
- Manual DNS configuration
- No template management
- No built-in analytics
Best for: High-volume, low-cost scenario (10M+ emails/year), already on AWS.
MVP Decision: Start with Resend. Migrate to SendGrid if we need email validation or marketing campaigns. All email logic abstracted via src/lib/email.ts — provider swap is 10 lines of code.
Sources:
- Resend vs SendGrid (2026) - Developer Email API Comparison | Sequenzy
- Resend vs Sendgrid Comparison (2026)
- Resend vs SendGrid in 2026: Email APIs Compared | DevPick
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:
- Max 10 emails per user per hour (prevent abuse)
- Use existing
rate_limitstable with keyemail:{userId}:{hour} - Helper:
async function checkEmailRateLimit(userId: string): Promise<boolean>
Retry Logic (Fire-and-Forget for MVP):
- No retry for MVP (email service handles retries internally)
- Log failures to
email_logtable for manual review - Post-MVP: Add job queue (BullMQ/Agenda) for guaranteed delivery
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/:
welcome.html— Welcome email (placeholder:{{verifyUrl}})transaction-receipt.html— Transaction confirmation (placeholders:{{transactionDate}},{{amount}},{{fromName}},{{toName}}, etc.)password-reset.html— Password reset (placeholders:{{resetUrl}},{{userEmail}})
Templates to CREATE:
1. email-verification.html
Purpose: Verify email address (sent after registration) Placeholders:
{{firstName}}— User's first name{{verifyUrl}}— Verification link with token (1-hour expiry){{otpCode}}— 6-digit OTP code (backup method if link doesn't work)
Content:
<h1>Verifiser e-posten din</h1>
<p>Hei {{firstName}},</p>
<p>Klikk på lenken under for å verifisere e-postadressen din:</p>
<a href="{{verifyUrl}}" style="...">Verifiser e-post</a>
<p>Alternativt, skriv inn denne koden: <strong>{{otpCode}}</strong></p>
<p>Lenken utløper om 1 time.</p>
2. transfer-received.html
Purpose: Notify user they received money Placeholders:
{{firstName}}— Recipient first name{{amount}}— Amount received (e.g., "kr 2 500,00"){{currency}}— Currency code (e.g., "NOK"){{senderName}}— Sender's name{{transactionDate}}— Timestamp{{transactionId}}— Transaction ID
Content:
<h1>Du mottok penger</h1>
<p>Hei {{firstName}},</p>
<p>Du har mottatt <strong>{{amount}} {{currency}}</strong> fra {{senderName}}.</p>
<p>Dato: {{transactionDate}}</p>
<p>Transaksjons-ID: {{transactionId}}</p>
<a href="https://getdrop.no/dashboard" style="...">Åpne Drop</a>
3. login-alert.html
Purpose: Security alert for new device/location login Placeholders:
{{firstName}}— User's first name{{device}}— Device/browser (e.g., "Chrome on macOS"){{location}}— IP or location (e.g., "Oslo, Norway"){{timestamp}}— Login time{{securityUrl}}— Link to account security settings
Content:
<h1>Ny pålogging oppdaget</h1>
<p>Hei {{firstName}},</p>
<p>Vi har oppdaget en pålogging fra en ny enhet:</p>
<ul>
<li>Enhet: {{device}}</li>
<li>Plassering: {{location}}</li>
<li>Tidspunkt: {{timestamp}}</li>
</ul>
<p>Hvis dette ikke var deg, <a href="{{securityUrl}}">endre passordet ditt</a> umiddelbart.</p>
4. support-ticket-update.html (Future)
Purpose: Notify user their support ticket has a response Placeholders:
{{firstName}},{{ticketId}},{{ticketSubject}},{{updateUrl}}
3.3 Database Schema
New Tables:
email_verification_tokens
-- 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:
- Add to
src/lib/db.tsinSQLITE_SCHEMAandPG_SCHEMAconstants - Tables auto-create on
initDb()(existing pattern)
3.4 API Endpoints
POST /api/auth/verify-email
Purpose: Verify email with token or OTP code Request:
{
"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:
- 400: Missing token/code
- 404: Token not found
- 410: Token expired or already used
Logic:
- Look up token in
email_verification_tokens - Check expiry (
expires_at < now()→ error 410) - Check
used_at IS NOT NULL→ error 410 - If
codeprovided, validateotp_codematches - Update
used_at = now() - Mark user as verified (add
email_verifiedcolumn touserstable) - 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:
- Look up user by email
- If user not found → return 200 anyway (security: don't leak account existence)
- Generate UUID token, 1-hour expiry
- Insert into
password_reset_tokens - Send
password-reset.htmlemail with{{resetUrl}}=/reset-password?token=xxx - 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:
- 400: Invalid password (reuse validation from
/api/auth/register) - 404: Token not found
- 410: Token expired or already used
Logic:
- Look up token in
password_reset_tokens - Check expiry and usage (same as verify-email)
- Validate new password (8+ chars, 1 uppercase, 1 lowercase, 1 digit, 1 special)
- Hash new password (
hashPassword()) - Update
users.password_hash - Mark token
used_at = now() - Revoke all user sessions (security: force re-login)
- 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:
- Look up user by email
- If not found → return 200 anyway
- If already verified → return 200 (idempotent)
- Invalidate old tokens (
UPDATE email_verification_tokens SET used_at = now() WHERE user_id = ? AND used_at IS NULL) - Generate new token and OTP
- Send verification email
- 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:
src/app/api/transactions/remittance/route.ts(remittance)src/app/api/transactions/qr-payment/route.ts(QR payment)
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:
POST /api/support/tickets/:id/respond→ sendssupport-ticket-update.html
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):
- Sign up at resend.com
- Add domain:
getdrop.no - Add DNS records (DKIM, SPF, DMARC) — Resend provides exact records
- Generate API key
- Set
RESEND_API_KEYin production env
6. File List
Files to CREATE:
src/lib/email.ts # Email service layer
src/email-templates/email-verification.html # Email verification template
src/email-templates/transfer-received.html # Transfer received template
src/email-templates/login-alert.html # Login alert template
src/app/api/auth/verify-email/route.ts # Email verification endpoint
src/app/api/auth/forgot-password/route.ts # Password reset request endpoint
src/app/api/auth/reset-password/route.ts # Password reset endpoint
src/app/api/auth/resend-verification/route.ts # Resend verification endpoint
Files to MODIFY:
src/lib/db.ts # Add email_verification_tokens, password_reset_tokens, email_log tables
src/app/api/auth/register/route.ts # Add email verification send
src/app/api/auth/login/route.ts # Add login alert for new devices
src/app/api/transactions/remittance/route.ts # Add transaction receipt email
src/app/api/transactions/qr-payment/route.ts # Add transaction receipt email
src/lib/services/email.ts # DELETE (replaced by src/lib/email.ts)
.env.example # Add EMAIL_* env vars
package.json # Add resend, @sendgrid/mail, nodemailer deps
Total: 8 new files, 7 modified files.
7. Acceptance Criteria
Email Service Layer:
-
sendEmail()sends via Resend in production -
sendEmail()logs to console in demo mode -
sendEmail()falls back to SMTP if Resend fails - All template helpers (
sendWelcomeEmail(), etc.) render templates correctly - Rate limiting blocks >10 emails/user/hour
Database:
- All tables created on
initDb() - Tokens auto-expire after 1 hour
-
email_logtable logs all sent/failed emails
API Endpoints:
-
POST /api/auth/verify-emailverifies token and OTP -
POST /api/auth/verify-emailrejects expired tokens (410) -
POST /api/auth/forgot-passwordsends reset email -
POST /api/auth/reset-passwordupdates password -
POST /api/auth/resend-verificationresends email - All endpoints rate limited (10 req/min per IP)
Integration:
- Registration sends verification email
- Transaction completion sends receipt to sender
- Transfer completion sends notification to recipient (if Drop user)
- Login from new device sends security alert
Templates:
- All templates render with correct placeholders
- All templates display correctly in Gmail, Outlook, Apple Mail
- All templates mobile-responsive (375px width)
Provider Setup:
- Resend domain verified (
getdrop.no) - DKIM, SPF, DMARC records added to DNS
- API key set in production env
- Test email sent successfully
8. Implementation Plan
Phase 1: Email Service Layer (2h)
- Create
src/lib/email.tswith Resend integration - Add Resend dependency
- Set up env vars
- Test send via Resend dashboard
Phase 2: Database Schema (1h)
- Add 3 tables to
src/lib/db.ts - Test
initDb()creates tables correctly
Phase 3: API Endpoints (3h)
- Create 4 API routes (verify, forgot, reset, resend)
- Add validation and rate limiting
- Test with Postman/curl
Phase 4: Templates (2h)
- Create 3 new templates (email-verification, transfer-received, login-alert)
- Test rendering with placeholders
- Test display in Gmail/Outlook
Phase 5: Integration (2h)
- Modify register route (send verification email)
- Modify transaction routes (send receipts)
- Modify login route (send alert)
- Test end-to-end flows
Phase 6: Production Setup (1h)
- Set up Resend account
- Verify domain (getdrop.no)
- Add DNS records
- Generate API key
- Deploy to staging
Total: 11 hours
9. Rollout Strategy
Staging:
- Deploy to staging environment
- Test all email flows with real email addresses
- Verify DNS records propagated
- Check spam score (mail-tester.com)
Production (Gradual):
- Enable email verification for NEW users only
- Monitor
email_logfor failures - After 1 week: enable password reset
- After 2 weeks: enable transaction receipts
- After 3 weeks: enable login alerts
Monitoring:
- Daily check of
SELECT * FROM email_log WHERE status = 'failed' - Alert if >5% failure rate
- Weekly review of Resend dashboard (bounce rate, spam rate)
10. Success Metrics
Week 1:
- 0 email send failures
- <1% bounce rate
- <0.1% spam rate
Month 1:
- >90% email open rate (verification, password reset)
- >50% email open rate (transaction receipts)
- 0 support tickets about missing emails
Quarter 1:
- Email verification integrated into BankID flow
- Transaction receipts sent for 100% of transactions
- Login alerts sent for 100% of new devices
END OF SPEC
drop-fx-transparency-spec
Drop FX Rate Transparency & Fee Breakdown Specification
Task: MC #1193 Created: 2026-02-17 Author: John (Architect Agent) Status: DRAFT — Awaiting Alem approval Product: Drop — Fintech Payment App (Pass-through PSD2 PISP model)
Executive Summary
This specification defines the architecture for real-time exchange rate transparency and fee breakdown for Drop's remittance payment service. Drop operates as a PSD2 PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money.
Regulatory Requirement: PSD2 Directive 2015/2366/EU Article 45 mandates that payment service providers disclose:
- All charges payable by the payment service user with a breakdown
- The actual or reference exchange rate to be applied to the payment transaction
- Maximum execution time for the payment service
Current State: Drop uses mocked/seeded exchange rates from database (exchange_rates table). Rates refresh hourly from EXCHANGE_RATE_API_URL (env var, not set). Fee calculation is hardcoded at 0.5% for remittances.
Goal: Live FX rates (Norges Bank official data), transparent fee breakdown shown BEFORE user confirms transfer, full PSD2 compliance.
1. Business Context
1.1 Norwegian Context
- Base Currency: NOK (Norwegian Krone)
- Target Users: ALL residents in Norway (NOT limited to diaspora)
- Corridors: 30+ countries (seeded in
recipientstable — RSD, EUR, USD, GBP, SEK, DKK, PLN, etc.) - Regulatory Body: Finanstilsynet (Norwegian Financial Supervisory Authority)
- Reference Rate: Norges Bank publishes daily exchange rates at ~16:00 CET for 40+ currencies
1.2 Competitive Landscape
Wise (market leader):
- Uses mid-market rate with 0.5-1% transparent fee
- Full breakdown shown BEFORE transfer: amount + fee + FX rate + total + "You send X, they get Y"
- Calculator on homepage (no login required)
Remitly:
- Variable FX markup 0.5-3% (not transparent, hidden in rate)
- Fee varies by speed (Express vs Economy), payment method, new vs returning user
- Less transparent than Wise — users complain about hidden fees
Drop's Positioning:
- Transparent like Wise — show mid-market reference rate + markup + fee separately
- Norwegian-first — NOK base, Norges Bank as reference
- Cheaper than traditional banks (DNB, Nordea charge 2-5%)
1.3 Pass-Through Model Implications
Drop does NOT:
- Hold customer money
- Perform FX conversion itself (that's the bank's job via PISP)
- Need forex license (we're PISP, not EMI)
Drop DOES:
- Show user the expected FX rate BEFORE transfer
- Charge a transparent service fee (0.5% for remittance)
- Display what recipient will receive (calculated estimate)
- Send payment instruction to user's bank via Open Banking
Key Point: FX rate shown to user is reference/estimate. Actual conversion happens at user's bank. Drop must disclose this clearly (PSD2 Article 45).
2. Current Implementation Analysis
2.1 What Exists (Good Foundation)
Database Schema:
-- exchange_rates table (seeded with 30+ currencies)
CREATE TABLE exchange_rates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_currency TEXT NOT NULL,
to_currency TEXT NOT NULL,
rate REAL NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(from_currency, to_currency)
);
Service Layer: /src/lib/services/rates.ts
getExchangeRates()— returns all rates from DBgetRate(from, to)— returns single raterefreshRatesIfStale()— fetches fromEXCHANGE_RATE_API_URLif last update > 1 hour- Graceful degradation — falls back to cached rates on API failure
API Endpoints:
GET /api/rates— returns all ratesGET /api/rates/[currency]— returns single ratePOST /api/transactions/disclosure— calculates fee + FX preview (BEFORE user confirms)
UI Component: /src/components/pre-payment-disclosure.tsx
- Shows breakdown: amount + fee + FX rate + receive amount + total + ETA
- Modal dialog — user must confirm before transaction submits
- Norwegian language, good UX (matches Wise pattern)
Fee Calculation: Hardcoded in /src/app/api/transactions/remittance/route.ts
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:
- Free: No API key required, fully open
- Authoritative: Official central bank rates used by Norwegian businesses
- Daily Updates: Published ~16:00 CET
- 40+ Currencies: Covers all major Drop corridors
- SDMX Standard: Industry-standard format for statistical data
Limitations:
- Daily Frequency: Not real-time (updated once per business day)
- No Weekends: Last Friday rate used on weekends
- Spot Rate Only: No forward rates, no historical intraday
Cost: $0 (completely free)
3.2 Commercial FX Provider (Real-Time Fallback)
For high-volume corridors (EUR, USD, GBP) where users expect near-real-time rates:
Option A: ExchangeRate-API.com
- Free Tier: 1,500 requests/month, hourly updates
- Paid Tier: $9/month for 100k requests, 10-min updates
- Currencies: 160+
- Format: Simple JSON:
{ "NOK": { "EUR": 0.0867 } } - Reliability: 99.9% uptime SLA (paid tier)
Recommendation: Start with Norges Bank (free) + ExchangeRate-API.com free tier (1,500 requests/month = 50/day, enough for MVP). Migrate to paid tier when transaction volume > 50/day.
Hybrid Strategy:
- Norges Bank: Daily reference rate for all corridors (free, authoritative)
- ExchangeRate-API: Real-time updates for EUR, USD, GBP (high volume)
- 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:
- Compliance: PSD2 requires we log what rate we showed user
- Transparency: User can compare promised rate vs actual rate
- Dispute Resolution: Evidence for customer complaints ("you said 10.5, I got 10.3")
- Rate Locking (Future): Store locked rate + expiry
4.3 New Table: fee_configs
Make fees configurable instead of hardcoded:
CREATE TABLE fee_configs (
id TEXT PRIMARY KEY,
corridor TEXT NOT NULL UNIQUE, -- 'NOK-RSD', 'NOK-EUR', '*' (default)
fee_type TEXT NOT NULL CHECK(fee_type IN ('percentage', 'flat', 'tiered')),
fee_percentage REAL, -- For percentage type (0.005 = 0.5%)
fee_flat REAL, -- For flat type (25 NOK)
fee_tiers TEXT, -- JSON for tiered: [{"max":1000,"rate":0.01},{"max":null,"rate":0.005}]
min_fee REAL DEFAULT 0, -- Minimum fee (NOK)
max_fee REAL, -- Maximum fee cap (NOK, NULL = no cap)
effective_from TEXT NOT NULL DEFAULT (datetime('now')),
effective_until TEXT, -- NULL = indefinite
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- Seed default config
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_default', '*', 'percentage', 0.005, 10.0); -- 0.5%, min 10 NOK
-- Corridor-specific example (cheaper EUR corridor)
INSERT INTO fee_configs (id, corridor, fee_type, fee_percentage, min_fee)
VALUES ('fee_eur', 'NOK-EUR', 'percentage', 0.003, 5.0); -- 0.3%, min 5 NOK
Benefits:
- A/B Testing: Change fees without code deploy
- Promotional Pricing: Set temporary fee discounts (effective_from/until)
- Corridor Optimization: Cheaper fees for high-volume corridors (EUR, USD)
- Tiered Pricing: Lower fee percentage for large transfers (e.g., >10k NOK)
4.4 New Table: fx_rate_alerts
Monitor rate drift and API failures:
CREATE TABLE fx_rate_alerts (
id TEXT PRIMARY KEY,
alert_type TEXT NOT NULL CHECK(alert_type IN ('stale_rate', 'rate_drift', 'api_failure', 'missing_rate')),
severity TEXT NOT NULL CHECK(severity IN ('low', 'medium', 'high', 'critical')),
from_currency TEXT NOT NULL,
to_currency TEXT, -- NULL for API failure alerts
details TEXT, -- JSON: {"expected":10.5,"actual":11.2,"drift_pct":6.7}
resolved INTEGER DEFAULT 0, -- 0 = open, 1 = resolved
created_at TEXT DEFAULT (datetime('now')),
resolved_at TEXT
);
CREATE INDEX idx_fx_alerts_unresolved ON fx_rate_alerts(alert_type, resolved, created_at);
Alert Examples:
stale_rate: EUR/NOK not updated in 6 hours (should refresh hourly)rate_drift: Norges Bank shows 11.5, ExchangeRate-API shows 11.9 (3.5% drift — investigate)api_failure: ExchangeRate-API returned 503 for 3 consecutive attemptsmissing_rate: User tried to transfer to THB, no rate in DB
5. API Endpoints
5.1 Enhanced GET /api/rates
Current: Returns all rates from DB New: Add metadata, source attribution, staleness indicator
Request:
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:
rate: Mid-market reference rate (from Norges Bank or provider)markup: Drop's markup percentageeffectiveRate:rate * (1 + markup)— what user actually payssource: Which API provided this rateisStale: True if rate hasn't refreshed within threshold
Caching: Cache-Control: public, max-age=300 (5 minutes)
5.2 Enhanced POST /api/transactions/disclosure
Current: Calculates fee + FX preview
New: Return full PSD2-compliant disclosure, log to fx_rate_history
Request:
{
"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:
- High-Volume Corridors (EUR, USD, GBP): Users expect near-real-time rates. 10-min refresh acceptable.
- Regional Corridors (RSD, SEK, PLN): Daily Norges Bank rate sufficient (low volatility).
- Long-Tail Corridors: 48-hour stale threshold (rarely used, rate changes minimal).
6.2 Refresh Triggers
Automatic:
- Cron Job: Every 10 min, check stale rates and refresh high-volume corridors
- API Request: When user requests
/api/transactions/disclosure, check if corridor rate is stale → refresh synchronously (max 5s timeout) - Daily Batch: At 16:30 CET (30 min after Norges Bank publishes), fetch all 40 currencies
Manual:
- Admin endpoint:
POST /api/admin/fx/refresh(force refresh all rates)
7. Fee Calculation Engine
7.1 Fee Structure
Fee Types:
- Percentage:
amount * fee_percentage(most common) - Flat: Fixed amount (e.g., 25 NOK for all transfers)
- 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:
- 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>
- 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:
- Drop: "1-2 business days" (EEA) or "2-4 business days" (non-EEA)
- Shown in disclosure modal + transaction receipt
✅ All charges payable with breakdown:
- Drop: "Gebyr (0.5%): 25 NOK" + "Total kostnad: 5025 NOK"
- Breakdown: base amount + fee = total
✅ Actual or reference exchange rate:
- Drop: Shows Norges Bank reference rate + markup + effective rate
- Disclosure: "Referansekurs (Norges Bank): 1 NOK = 10.1700 RSD, Drop's påslag (0.5%): + 0.0509 RSD, Effektiv kurs: 1 NOK = 10.2209 RSD"
9.2 Disclosure Language (Norwegian + English)
PSD2-Compliant Text:
Norwegian (Primary):
VIKTIG INFORMASJON OM VALUTAVEKSLING
Dette er et estimat basert på dagens valutakurs fra Norges Bank (oppdatert [TIMESTAMP]).
Din bank vil bruke sin egen valutakurs ved gjennomføring av betalingen.
Det endelige beløpet mottaker får kan avvike fra dette estimatet.
Drop legger til et påslag på [X]% på referansekursen. Dette er inkludert i "Effektiv kurs" ovenfor.
Gebyret på [FEE] NOK er fast og vil ikke endres.
Ved å bekrefte godtar du at totalkostnaden på [TOTAL] NOK trekkes fra din bankkonto.
English (Secondary):
IMPORTANT INFORMATION ABOUT CURRENCY CONVERSION
This is an estimate based on today's exchange rate from Norges Bank (updated [TIMESTAMP]).
Your bank will apply its own exchange rate when executing the payment.
The final amount received may differ from this estimate.
Drop applies a markup of [X]% on the reference rate. This is included in the "Effective rate" above.
The fee of [FEE] NOK is fixed and will not change.
By confirming, you agree that the total cost of [TOTAL] NOK will be debited from your bank account.
10. Monitoring & Alerting
10.1 Metrics to Track
| Metric | Threshold | Alert If | Action |
|---|---|---|---|
| Stale rate count | 0 | > 5 | Investigate API provider |
| Rate drift (vs Norges Bank) | <1% | >3% | Check provider, notify admin |
| API failure rate | <1% | >5% | Switch to fallback source |
| Rate refresh latency | <5s | >10s | Optimize API calls |
| Fee revenue (daily) | N/A | Sudden drop >50% | Check fee config changes |
10.2 Slack Alerts
Webhook URL: SLACK_WEBHOOK_URL env var
Alert Conditions:
// /lib/services/fx-alerts.ts
export async function checkRateDrift(
from: string,
to: string,
norgesBankRate: number,
providerRate: number
) {
const driftPercent = Math.abs((providerRate - norgesBankRate) / norgesBankRate) * 100;
if (driftPercent > 3) {
const alertId = randomId('alert');
await run(`
INSERT INTO fx_rate_alerts (
id, alert_type, severity, from_currency, to_currency, details
) VALUES (?, 'rate_drift', 'high', ?, ?, ?)
`, [
alertId,
from,
to,
JSON.stringify({ norgesBankRate, providerRate, driftPercent })
]);
await sendSlackAlert({
severity: 'high',
title: `Rate drift detected: ${from}-${to}`,
message: `Norges Bank: ${norgesBankRate}, Provider: ${providerRate} (${driftPercent.toFixed(2)}% drift)`,
alertId,
});
}
}
11. Implementation Phases
Phase 1: Foundation (Week 1) — Core Infrastructure
Deliverables:
- Enhanced
exchange_ratestable schema (source, markup, is_stale columns) fee_configstable + seeded datafx_rate_historytablefx_rate_alertstable- Fee calculation engine (
/lib/services/fees.ts) - Enhanced rate service with staleness detection
- Unit tests for fee calculation + rate retrieval
Acceptance Criteria:
calculateFee()correctly handles percentage, flat, tiered feesgetRate()detects stale rates and flags them- Database migrations run successfully on SQLite + PostgreSQL
Effort: 3 days (1 builder agent)
Phase 2: Norges Bank Integration (Week 1) — Primary Data Source
Deliverables:
- Norges Bank API client (
/lib/providers/norges-bank.ts) - SDMX-JSON parser for Norges Bank response format
- Daily refresh cron job (16:30 CET)
- Seed all 40 currencies from Norges Bank
- Integration tests (mock Norges Bank API responses)
Acceptance Criteria:
- All 30+ Drop corridors have rates from Norges Bank
- Rates update automatically every business day at 16:30 CET
- Parser handles SDMX-JSON format correctly
- Graceful handling of Norges Bank API downtime (use cached rates)
Effort: 2 days (1 builder agent)
Phase 3: Commercial Provider Integration (Week 2) — Real-Time Fallback
Deliverables:
- ExchangeRate-API client (
/lib/providers/exchangerate-api.ts) - Hybrid refresh strategy (Norges Bank daily + ExchangeRate-API 10-min for EUR/USD/GBP)
- 10-minute cron job for high-volume corridors
- Rate drift detection (compare Norges Bank vs ExchangeRate-API)
- Admin alert on >3% drift
Acceptance Criteria:
- EUR, USD, GBP rates refresh every 10 minutes
- Other corridors use Norges Bank daily rate
- Drift alerts appear in
fx_rate_alertstable - Slack webhook fires for high drift (>3%)
Effort: 2 days (1 builder agent)
Cost: $0 (free tier: 1,500 requests/month = 50/day, sufficient for MVP)
Phase 4: Enhanced Disclosure UI (Week 2) — Frontend
Deliverables:
- Enhanced
pre-payment-disclosure.tsxcomponent- Rate breakdown section (reference + markup + effective)
- Rate validity countdown timer
- PSD2 regulatory text
- Fee breakdown (tiered if applicable)
- Homepage rate calculator widget
- Updated
/api/transactions/disclosureendpoint (return full breakdown)
Acceptance Criteria:
- Disclosure modal shows: reference rate, markup, effective rate, source, last updated
- Countdown timer shows "Rate valid for X minutes"
- PSD2 text in Norwegian + English
- Calculator works without login (public API route)
Effort: 3 days (1 builder + 1 designer agent)
Phase 5: Admin Tools & Monitoring (Week 3) — Ops Dashboard
Deliverables:
/admin/fx-monitoringdashboard pageGET /api/admin/fx/alertsendpointPOST /api/admin/fx/refreshmanual refresh endpoint- Slack webhook integration for alerts
- Rate drift monitoring (background job every 10 min)
- Stale rate detection (background job every 10 min)
Acceptance Criteria:
- Dashboard shows all rates, staleness, drift, alerts
- Manual refresh button works (triggers immediate API fetch)
- Slack alerts received for high/critical issues
- Unresolved alerts badge in admin nav
Effort: 2 days (1 builder agent)
Phase 6: Rate Locking (Future — Deferred) — Advanced Feature
Deliverables:
POST /api/fx/lock-rateendpoint- Lock expiry background job (expire after 10 min)
- UI: "Lock this rate" button in disclosure modal (for transfers >10k NOK)
- Backend: Verify
lockIdduring transaction submission
Acceptance Criteria:
- User can lock rate for 10 minutes
- Locked rate guaranteed during checkout
- Expired locks show error message
- Lock usage logged in
fx_rate_history
Effort: 2 days (1 builder agent)
Deferral Reason: Not MVP-critical. Can ship without rate locking. Add when user feedback indicates need.
12. Cost Analysis
12.1 Infrastructure Costs
| Component | Provider | Cost | Notes |
|---|---|---|---|
| Norges Bank API | Norges Bank | $0 | Free, open API |
| ExchangeRate-API (Free Tier) | ExchangeRate-API.com | $0 | 1,500 requests/month (50/day) |
| ExchangeRate-API (Paid Tier) | ExchangeRate-API.com | $9/month | 100k requests (when traffic grows) |
| Cron Jobs (Vercel) | Vercel | $0 | Included in Hobby/Pro plan |
| Slack Webhook | Slack | $0 | Free tier sufficient |
Total Monthly Cost (MVP): $0 Total Monthly Cost (Production >50 txs/day): $9
12.2 Revenue Impact
Fee Revenue Projection:
| Scenario | Avg Transfer | Fee % | Txs/Month | Monthly Revenue |
|---|---|---|---|---|
| MVP (Beta) | 2,000 NOK | 0.5% | 100 | 1,000 NOK ($95) |
| Growth | 3,000 NOK | 0.5% | 500 | 7,500 NOK ($710) |
| Scale | 4,000 NOK | 0.5% | 2,000 | 40,000 NOK ($3,800) |
Break-Even: 10 transactions/month covers $9 API cost (at 0.5% fee on 2,000 NOK avg)
13. Acceptance Criteria
13.1 Functional Requirements
- Live Rates: EUR, USD, GBP refresh every 10 minutes from ExchangeRate-API
- Official Rates: All other corridors use Norges Bank daily rate (16:00 CET)
- Fee Transparency: User sees breakdown: amount + fee (%) + total BEFORE confirming
- FX Transparency: User sees: reference rate (Norges Bank) + markup + effective rate
- Source Attribution: Disclosure shows "Rate from Norges Bank, updated X minutes ago"
- Rate Validity: User told "Rate valid for 10 minutes" with countdown timer
- PSD2 Compliance: Disclosure text matches Article 45 requirements (Norwegian + English)
- Configurable Fees: Admin can change fees via
fee_configstable (no code deploy) - Corridor-Specific Fees: Different fees for different corridors (e.g., EUR cheaper than RSD)
13.2 Non-Functional Requirements
- Performance: Disclosure endpoint responds in <2s (including rate fetch)
- Availability: Graceful degradation if API fails (use cached rates + show staleness warning)
- Accuracy: Rate shown to user logged in
fx_rate_historyfor audit - Monitoring: Slack alerts for rate drift >3%, API failures, stale rates >6h
- Security: API keys stored in env vars (never hardcoded), admin endpoints require auth
13.3 Compliance Requirements
- PSD2 Article 45: All charges + exchange rate disclosed BEFORE payment execution
- PSD2 Record-Keeping:
fx_rate_historyretained for 5+ years (never auto-deleted) - Finanstilsynet Compliance: Norges Bank used as official reference (Norwegian requirement)
- Consumer Protection: Clear language ("This is an estimate, your bank may use different rate")
14. Open Questions (For Alem)
Q1: Rate Locking Priority
Question: Should we implement rate locking in Phase 1 (MVP) or defer to Phase 2?
Recommendation: Defer. Rate locking adds complexity and is not required for PSD2 compliance. Ship MVP faster, validate user demand first.
Q2: Commercial FX Provider Choice
Question: Which paid FX API should we use?
Recommendation: ExchangeRate-API.com — Best price/performance ratio ($9/month), simplest integration, Norwegian NOK supported as base currency.
Q3: Fee Structure
Question: What fee structure should we launch with?
Recommendation: Flat 0.5% for MVP, then A/B test tiered pricing after 100 transactions. Simple beats clever for launch.
Q4: Homepage Calculator
Question: Should we show fee breakdown in homepage calculator (public, no login)?
Recommendation: Yes. Full transparency, matches Wise UX. Show: "Total cost: 5025 NOK (includes 25 NOK fee)" even before login.
15. Next Steps
- Review this spec with Alem (approve/request changes)
- Answer Open Questions (Q1-Q4 above)
- Prioritize phases (which to implement first?)
- Assign Phase 1 to builder agent (schema changes + fee calculation engine)
- Validate after Phase 1 (validator agent checks DB schema + tests)
- Iterate through Phases 2-5 (one phase at a time, validate each)
Estimated Timeline:
- Phase 1: 3 days (schema + fee engine)
- Phase 2: 2 days (Norges Bank integration)
- Phase 3: 2 days (ExchangeRate-API integration)
- Phase 4: 3 days (UI + disclosure enhancements)
- Phase 5: 2 days (admin dashboard + monitoring)
Total: 12 days (~2.5 weeks) for full PSD2-compliant FX transparency system
Sources
- Norges Bank Exchange Rates
- Norges Bank Data Warehouse
- PSD2 Directive 2015/2366/EU
- PSD2 Article 45 Information Requirements
- Wise Remitly Fee Comparison
- ExchangeRate-API Documentation
- Remitly vs Wise Transparency Analysis
End of Specification
drop-load-testing-spec
Drop Load Testing & Performance Benchmarks Specification
Version: 1.0 Date: 2026-02-17 Author: architect (Sonnet 4.5) Status: Draft MC Task: #1200
1. Executive Summary
This specification defines the load testing strategy, performance benchmarks, and capacity planning for the Drop fintech application. The goal is to establish baseline performance metrics, identify bottlenecks, and ensure the system can handle realistic user load before production scale deployment.
Tool Choice: k6 (Grafana k6) — open-source, JavaScript-based, excellent Grafana ecosystem integration, active community.
Performance Target: Support 100 concurrent users (baseline), 500 concurrent users (peak), 1000 concurrent users (stress test) with P95 API response times < 300ms and P99 < 500ms.
Timeline: Phased implementation over 5 days (see Section 11).
2. Tool Selection: k6 vs Artillery vs Gatling
2.1 Comparison Matrix
| Criterion | k6 | Artillery | Gatling | Winner |
|---|---|---|---|---|
| Language | JavaScript (Go runtime) | Node.js/YAML/JS | Scala DSL | k6 |
| Learning Curve | Medium (JS familiarity) | Low (YAML config) | High (Scala) | Artillery |
| Performance | Excellent (Go runtime) | Good (Node.js) | Excellent (JVM) | k6/Gatling |
| Scripting Flexibility | High (JS API) | High (JS + YAML) | Medium (Scala DSL) | k6 |
| CI/CD Integration | Native support | Native support | Native support | Tie |
| Reporting | Grafana, JSON, HTML | JSON, HTML, plugins | HTML, Gatling Cloud | k6 |
| Open Banking/PSD2 Testing | Full control (HTTP/headers) | Full control | Full control | Tie |
| Next.js 16 Compatibility | Excellent (HTTP/REST) | Excellent | Excellent | Tie |
| Local + Cloud | Yes (k6 + k6 Cloud) | Yes (Artillery + Cloud) | Yes | Tie |
| Community & Docs | Strong (Grafana Labs) | Strong (Artillery.io) | Strong (Gatling Corp) | Tie |
| Cost | Free (OSS) | Free (OSS) | Free (OSS) | Tie |
2.2 Recommendation: k6
Rationale:
- JavaScript-based scripting — Drop team already uses JS/TS (Next.js, React), no new language learning required
- Grafana ecosystem — Drop will use Grafana for production monitoring (future), k6 integrates natively
- Performance — Go runtime provides excellent performance for simulating 1000+ concurrent users on a single machine
- Flexibility — Full HTTP control for PSD2 API testing (custom headers, SCA flows, OAuth)
- Active development — Grafana Labs maintains k6, frequent updates, strong community
- CI/CD ready — GitHub Actions integration out of the box
Artillery is a close second (easier YAML config), but k6's Grafana integration and Go performance edge win for a fintech app that will scale.
Gatling is excellent but requires Scala knowledge (team uses JS/TS stack).
Sources:
- Load Testing PoC: k6 vs Artillery vs Locust vs Gatling
- Artillery vs k6 - Fork My Brain
- k6 Documentation
3. Performance Requirements (SLA Targets)
3.1 API Response Time Targets
Based on PSD2 best practices and fintech industry standards:
| Endpoint Type | P50 | P95 | P99 | Rationale |
|---|---|---|---|---|
| Auth (login, register, logout) | < 150ms | < 250ms | < 400ms | Critical path, user expects instant |
| Payments (remittance, QR payment) | < 200ms | < 300ms | < 500ms | PSD2 real-time payment expectation |
| Account Info (AISP balance read) | < 100ms | < 200ms | < 350ms | Cached data, should be fast |
| Transaction History | < 150ms | < 250ms | < 400ms | Read-heavy, paginated |
| Admin (audit log, KYC review) | < 300ms | < 500ms | < 800ms | Lower priority, internal use |
| Health Check | < 50ms | < 100ms | < 150ms | Uptime monitoring |
Note on PSD2 Latency:
- PSD3 (upcoming) emphasizes API uptime, latency, and error rate standards (PSD2 had API performance issues)
- Real-time payment initiation expected under PSD3
- Target: 95% of payment initiations complete within 300ms (excluding external bank processing time)
Sources:
3.2 Page Load Time Targets
| Metric | Target | Tool |
|---|---|---|
| FCP (First Contentful Paint) | < 1.5s | Lighthouse |
| LCP (Largest Contentful Paint) | < 2.5s | Lighthouse |
| TTFB (Time to First Byte) | < 200ms | k6, Lighthouse |
| TTI (Time to Interactive) | < 3.5s | Lighthouse |
| CLS (Cumulative Layout Shift) | < 0.1 | Lighthouse |
Next.js 16 Real-World Benchmark:
- Mobile LCP reduced from 26.4s to 0.9s after Next.js 16 migration (218% boost)
- Turbopack dev server: instant HMR
Sources:
3.3 Concurrent User Capacity
| Load Profile | Concurrent Users | Duration | Success Criteria |
|---|---|---|---|
| Baseline | 100 | 10 min | P95 < 300ms, 0% errors |
| Peak | 500 | 10 min | P95 < 300ms, < 1% errors |
| Stress | 1000 | 5 min | P95 < 500ms, < 5% errors |
| Spike | 0→500 in 30s | 5 min | P95 < 400ms, < 2% errors |
Norwegian Market Context:
- Population: ~5.5M
- Realistic Year 1 target: 10K-100K active users
- Peak concurrent (0.5% of active): 50-500 users
- 100 concurrent = realistic baseline, 500 = peak, 1000 = stress test
3.4 Database Performance Targets
| Metric | SQLite (Demo) | PostgreSQL (Prod) |
|---|---|---|
| Query P95 | < 10ms | < 5ms |
| Concurrent Writes | 1 (serialized) | 100+ (MVCC) |
| Max Connections | 1 | 100 (pgBouncer pool) |
| Throughput | ~1K writes/sec | ~10K writes/sec |
SQLite Limitation:
- SQLite uses file-level locking → only 1 write at a time (WAL mode allows concurrent reads)
- Under load: write contention causes SQLITE_BUSY errors
- Conclusion: SQLite is fine for demo/MVP (< 50 concurrent users), PostgreSQL required for production (> 100 concurrent users)
Sources:
4. Load Test Scenarios
4.1 Scenario 1: User Registration & Login Flow
User Journey:
- User visits
/register - Submits phone + PIN + name + DOB
- Receives OTP (mocked)
- Verifies OTP → account created
- Redirected to
/login - Logs in with phone + PIN
- Receives JWT in httpOnly cookie
- 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:
- 100 users: P95 < 250ms, 0% errors
- 500 users: P95 < 300ms, < 1% errors (SQLite may show write contention)
- 1000 users: Expect SQLITE_BUSY errors (write serialization)
4.2 Scenario 2: Send Money (Remittance) Flow
User Journey:
- User logged in (JWT cookie)
- Visits
/send - Selects recipient (or creates new)
- Enters amount + currency (NOK → RSD, BAM, EUR, etc.)
- Reviews exchange rate quote
- Confirms transfer (PISP initiated)
- Receives transaction confirmation
- 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:
- 100 users: P95 < 300ms, 0% errors
- 500 users: P95 < 400ms, < 1% errors (SQLite write contention)
- 1000 users: High error rate (SQLITE_BUSY), P95 > 500ms
4.3 Scenario 3: QR Payment Flow
User Journey:
- User logged in
- Merchant generates QR code (merchant dashboard)
- User visits
/scan - Scans QR code (camera permission)
- Reviews payment amount
- Confirms payment (PISP initiated)
- Receives confirmation
- 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:
- 100 users: P95 < 300ms, 0% errors
- 500 users: P95 < 400ms, < 2% errors
- 1000 users: High error rate (SQLite limit)
4.4 Scenario 4: Bank Account Sync (AISP Call Simulation)
User Journey:
- User logged in
- Visits
/accounts - App triggers AISP balance sync (background)
- Balance updated in
bank_accountstable - 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:
- 200 users: P95 < 200ms, 0% errors (reads are concurrent in SQLite WAL mode)
- 500 users: P95 < 250ms, < 0.5% errors
- 1000 users: P95 < 300ms, < 1% errors
4.5 Scenario 5: Dashboard Load (Mixed Read Endpoints)
User Journey:
- User logs in
- 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)
- User account info (
- 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:
- 300 users: P95 < 250ms, 0% errors (all reads, SQLite handles well)
- 500 users: P95 < 300ms, < 0.5% errors
- 1000 users: P95 < 400ms, < 1% errors
5. Load Profiles (Realistic Traffic Patterns)
5.1 Baseline Load (100 Concurrent Users)
Scenario: Normal weekday traffic Duration: 30 minutes Ramp: Linear over 5 minutes
export const options = {
stages: [
{ duration: '5m', target: 100 }, // Ramp up
{ duration: '20m', target: 100 }, // Sustain
{ duration: '5m', target: 0 }, // Ramp down
],
};
Expected Behavior:
- All endpoints P95 < 300ms
- 0% error rate
- CPU < 50%, Memory < 2GB
- Database: no contention
5.2 Peak Load (500 Concurrent Users)
Scenario: Weekend evening peak (users sending remittances) Duration: 15 minutes Ramp: Linear over 3 minutes
export const options = {
stages: [
{ duration: '3m', target: 500 },
{ duration: '10m', target: 500 },
{ duration: '2m', target: 0 },
],
};
Expected Behavior:
- API endpoints P95 < 300ms
- Write-heavy endpoints (remittance, QR payment) may see P95 < 400ms (SQLite write contention)
- Error rate < 1%
- CPU 60-80%, Memory 3-4GB
- Database: SQLite write serialization causes occasional SQLITE_BUSY (PostgreSQL recommended)
5.3 Stress Test (1000 Concurrent Users)
Scenario: Black Friday sale (merchant QR payments spike) Duration: 10 minutes Ramp: Linear over 2 minutes
export const options = {
stages: [
{ duration: '2m', target: 1000 },
{ duration: '5m', target: 1000 },
{ duration: '3m', target: 0 },
],
};
Expected Behavior:
- SQLite will break — expect high error rate (> 5%)
- API endpoints P95 > 500ms
- Database: SQLITE_BUSY errors on writes
- Conclusion: PostgreSQL required for 1000+ concurrent users
5.4 Spike Test (0 → 500 in 30 Seconds)
Scenario: Marketing campaign goes viral (sudden traffic surge) Duration: 10 minutes
export const options = {
stages: [
{ duration: '30s', target: 500 }, // Spike
{ duration: '5m', target: 500 }, // Sustain
{ duration: '2m', target: 0 },
],
};
Expected Behavior:
- Initial spike: P95 may exceed 400ms (cold start, cache warming)
- After 1 minute: P95 stabilizes < 300ms
- Error rate < 2% during spike, < 1% after stabilization
- Key metric: How quickly does the system recover from sudden load?
6. Database Performance Considerations
6.1 SQLite Limitations
| Aspect | SQLite Behavior | Impact at Scale |
|---|---|---|
| Write Concurrency | 1 write at a time (file lock) | SQLITE_BUSY under load |
| Read Concurrency | Unlimited (WAL mode) | Good read performance |
| Connection Pool | N/A (file-based) | No connection overhead |
| Transactions | Serialized | Bottleneck for payments |
| Max Throughput | ~1K writes/sec (SSD) | Insufficient for 500+ users |
Recommendation:
- Demo/MVP (< 50 concurrent users): SQLite is fine
- Production (> 100 concurrent users): Migrate to PostgreSQL
6.2 PostgreSQL Optimizations
| Optimization | Configuration | Impact |
|---|---|---|
| Connection Pooling | pgBouncer (100 connections) | Reduces connection overhead |
| MVCC | Built-in (PostgreSQL default) | Concurrent reads + writes |
| Indexes | Composite indexes on (user_id, created_at) | Faster transaction queries |
| WAL | Enabled by default | Crash recovery + replication |
| Vacuum | Autovacuum enabled | Prevents table bloat |
Expected Performance:
- 100 users: P95 < 200ms, 0% errors
- 500 users: P95 < 250ms, 0% errors
- 1000 users: P95 < 300ms, < 0.5% errors
Connection Pool Config (pgBouncer):
[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:
http_req_duration— Response time (P50, P95, P99)http_req_failed— Error rate (%)http_reqs— Request rate (req/s)vus— Virtual users (current)iterations— Total iterations completed
Custom Metrics (add to scripts):
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):
- k6 can export metrics to InfluxDB → Grafana dashboards
- Real-time visualization of load test results
- Alerting on threshold breaches
8.3 Key Metrics to Track
| Metric | Warning Threshold | Critical Threshold |
|---|---|---|
| P95 Response Time | > 300ms | > 500ms |
| Error Rate | > 1% | > 5% |
| CPU Usage | > 70% | > 90% |
| Memory Usage | > 80% | > 95% |
| Database Query Time | > 50ms (P95) | > 100ms |
| Database Connections | > 80% pool | 100% pool |
9. CI Integration (Performance Regression Tests)
9.1 GitHub Actions Workflow
File: .github/workflows/load-test.yml
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:
- Compare current test results to baseline
- Alert if P95 increases > 20%
- Fail CI if P95 increases > 50%
10. Next.js 16 Performance Optimizations
10.1 Server Components (Default in Next.js 16)
Impact:
- Reduced client-side JavaScript bundle size
- Faster initial page load (no React hydration for server components)
- Data fetching happens server-side (lower TTFB)
Usage:
// app/dashboard/page.tsx (Server Component by default)
export default async function DashboardPage() {
const user = await getUser(); // Server-side fetch
const transactions = await getTransactions(); // Parallel fetch
return (
<div>
<UserInfo user={user} /> {/* Server Component */}
<TransactionList transactions={transactions} /> {/* Server Component */}
</div>
);
}
Load Test Impact:
- Dashboard load: P95 reduced by 30-40% (less client-side rendering)
- FCP improved by 50%
10.2 Caching with "use cache" Directive (New in Next.js 16)
Impact:
- Explicit cache control for pages, components, and functions
- Reduced API calls (cached responses)
Usage:
// app/transactions/page.tsx
'use cache';
export default async function TransactionsPage() {
const transactions = await getTransactions();
return <TransactionList transactions={transactions} />;
}
Load Test Impact:
- Repeated requests: P95 reduced by 70-80% (cache hit)
- Database load: 90% reduction (cached queries)
Sources:
10.3 React Compiler (Stable in Next.js 16)
Impact:
- Automatic memoization of components
- Reduced re-renders
- Better runtime performance
Configuration:
// next.config.ts
export default {
experimental: {
reactCompiler: true,
},
};
Load Test Impact:
- Client-side rendering: 20-30% faster
- Lighthouse Performance score: +10 points
Sources:
10.4 Turbopack (Fast Dev Server)
Impact:
- Instant HMR (Hot Module Replacement)
- Faster builds
Usage:
npm run dev -- --turbo
Load Test Impact:
- Development iteration speed: 3x faster
- Build time: 2x faster
11. Capacity Planning (Norwegian Market Projections)
11.1 User Growth Projections
| Year | Active Users | Peak Concurrent (0.5%) | Required Capacity |
|---|---|---|---|
| Year 1 (MVP) | 10K | 50 | 100 (2x buffer) |
| Year 2 | 50K | 250 | 500 (2x buffer) |
| Year 3 | 100K | 500 | 1000 (2x buffer) |
| Year 5 | 500K | 2500 | 5000 (2x buffer) |
Norwegian Market Context:
- Population: 5.5M
- Digital payment penetration: 95% (Vipps, BankID)
- Realistic market share Year 1: 0.2% → 10K users
- Realistic market share Year 3: 2% → 100K users
11.2 Infrastructure Scaling Plan
| Users | Database | App Instances | Load Balancer |
|---|---|---|---|
| < 50 | SQLite (demo) | 1x Next.js | None |
| 50-500 | PostgreSQL (single) | 2x Next.js | Nginx |
| 500-5K | PostgreSQL (primary + replica) | 4x Next.js | AWS ALB |
| 5K-50K | PostgreSQL (RDS Multi-AZ) | 8x Next.js | AWS ALB + Auto Scaling |
| 50K+ | PostgreSQL (Aurora) | 16x Next.js | AWS ALB + Auto Scaling + CDN |
11.3 Cost Estimation (Vercel + Supabase/Neon)
Scenario: 10K active users, 50 concurrent peak
| Service | Plan | Cost/Month |
|---|---|---|
| Vercel (Next.js hosting) | Pro | $20 |
| Neon (PostgreSQL) | Launch | $20 |
| Sentry (Error tracking) | Team | $26 |
| Total | $66/month |
Scenario: 100K active users, 500 concurrent peak
| Service | Plan | Cost/Month |
|---|---|---|
| Vercel (Next.js hosting) | Enterprise | $500+ |
| AWS RDS (PostgreSQL Multi-AZ) | db.r6g.large | $300 |
| AWS ALB | Standard | $20 |
| Sentry | Business | $80 |
| Total | $900/month |
12. Implementation Plan (Phased Approach)
Phase 1: Setup & Baseline (Day 1)
Tasks:
- Install k6:
brew install k6(macOS) orapt install k6(Linux) - Create test directory:
src/drop-app/tests/load/ - Write baseline script:
tests/load/scenarios/dashboard-load.js - Run first test:
k6 run tests/load/scenarios/dashboard-load.js - 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:
- Write auth flow script (register + login)
- Write send money script (remittance)
- Write QR payment script
- Write bank sync script
- Run all scripts @ 100 concurrent users
- Document results
Deliverable: 5 load test scripts covering all critical user journeys
Phase 3: Load Profiles (Day 4)
Tasks:
- Run baseline load (100 users, 30 min)
- Run peak load (500 users, 15 min)
- Run stress test (1000 users, 10 min)
- Run spike test (0→500 in 30s)
- Identify bottlenecks (likely: SQLite write contention)
Deliverable: Load profile results + bottleneck analysis report
Phase 4: Optimization & Retest (Day 5)
Tasks:
- Implement optimizations:
- Add database indexes
- Enable Next.js caching
- Optimize slow queries
- Rerun all load tests
- Compare before/after results
- Document performance improvements
Deliverable: Optimization report (before/after metrics)
Phase 5: CI Integration (Day 6)
Tasks:
- Create GitHub Actions workflow (
.github/workflows/load-test.yml) - Add baseline threshold checks
- Test workflow on PR
- 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
- Load Testing PoC: k6 vs Artillery vs Locust vs Gatling
- Artillery vs k6 Comparison
- k6 Official Documentation
- k6 Examples Repository
- Synthetic Testing Next.js with k6 Cloud
- GitHub: PM2 + Next.js + k6 Example
PSD2 Compliance & Performance
- What Should You Expect from PSD3 Rules?
- PSD2 Compliance Guide
- What is PSD2? - Nordea
- PSD2 Technical Security Requirements
Next.js 16 Performance
- Next.js 16 Migration: 218% Performance Boost
- Next.js 16 Features Overview
- Next.js Performance Optimization Guide
- Next.js Performance Best Practices
- Next.js Production Checklist
Database Performance
- SQLite vs PostgreSQL Performance
- PostgreSQL vs SQLite Comparison
- SQLite vs PostgreSQL - Airbyte
- PostgreSQL vs SQLite 2026 Comparison
16. Approval
Reviewed by: Alem (CEO) Status: Pending Next Steps: Implement Phase 1 (Setup & Baseline)
drop-localization-spec
Drop Localization (i18n) — Architect Specification
Project: Drop Fintech Payment App Component: Internationalization (i18n) — NO/EN/SV Author: John (Architect Agent) Date: 2026-02-17 Status: Draft for Review
Executive Summary
This specification defines the complete internationalization (i18n) architecture for Drop, enabling support for Norwegian Bokmål (primary/default), English, and Swedish. The system will extract all hardcoded Norwegian strings from the current codebase, implement a type-safe translation framework using next-intl (the de facto standard for Next.js 16 App Router), and establish a sustainable translation workflow.
Key Decisions:
- Framework: next-intl (App Router native, type-safe, 0 client-side JS for translations)
- Primary Language: Norwegian Bokmål (
nb-NO) — default, required by law for legal documents - Additional Languages: English (
en), Swedish (sv) - Routing Strategy: Subdirectory-based (
/no/,/en/,/sv/) with automatic language detection - Migration Approach: Phased rollout (Phase 1: Framework + UI, Phase 2: Email templates, Phase 3: Legal docs)
1. Framework Selection
1.1 Evaluation Criteria
| Library | Next.js 16 App Router | Type Safety | Bundle Size | Maintenance | Verdict |
|---|---|---|---|---|---|
| next-intl | ✅ Native support | ✅ Full TS support | ~5KB gzipped | ✅ Active (2024-2026) | RECOMMENDED |
| react-i18next | ❌ Client-side only | ⚠️ Partial (manual) | ~18KB gzipped | ✅ Active | Not suitable |
| next-international | ✅ App Router support | ✅ Full TS support | ~8KB gzipped | ⚠️ Smaller community | Alternative |
| react-intl | ❌ Requires workarounds | ⚠️ Partial | ~19KB gzipped | ✅ Active | Not suitable |
Source: next-intl vs react-i18next comparison, i18n library comparison
1.2 Why next-intl?
- App Router Native: Built specifically for Next.js 16 App Router with server component support (next-intl App Router guide)
- Zero Client-Side JS: Translations preloaded server-side, sent as props to server components
- Type Safety: Auto-completion for message keys, compile-time checks for missing translations
- Routing Built-In:
[locale]dynamic segment integration out of the box (routing setup) - Format Functions: ICU message syntax, date/time formatting, number formatting per locale
- 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):
-
UI Components:
src/components/bottom-nav.tsx— Navigation labels (Hjem, Kontoer, Historikk, Profil)src/app/dashboard/page.tsx— Greetings, account labelssrc/app/login/page.tsx— Form labels, validation errors, button textsrc/app/register/page.tsx— Registration flow textsrc/app/send/page.tsx— Remittance formsrc/app/scan/page.tsx— QR payment UI
-
API Routes:
src/app/api/auth/login/route.ts— "Invalid credentials", "Email and password required"src/app/api/transactions/*/route.ts— Transaction error messages
-
Email Templates:
src/email-templates/welcome.html— Full Norwegian welcome emailsrc/email-templates/transaction-receipt.html— Receipt emailsrc/email-templates/password-reset.html— Password reset email
-
Legal Pages:
src/app/terms/page.tsx— Full terms of service (Norwegian)src/app/privacy/page.tsx— Privacy policysrc/app/fees/page.tsx— Fee schedule
3.2 Special Cases
Currency Formatting:
- Current:
user.totalBalance.toLocaleString("nb-NO", { minimumFractionDigits: 0 })(hardcoded locale) - New: Use next-intl's
useFormatter()hook for locale-aware formatting
Date Formatting:
- Current: No date formatting found (transactions show ISO strings in tests)
- New: Implement
formatDateTime()from next-intl
Number Formatting:
- Current: Hardcoded "kr" currency symbol, hardcoded "NOK" suffix
- New:
formatNumber(value, {style: 'currency', currency: 'NOK'})
4. Translation File Structure
4.1 Directory Layout
src/drop-app/
├── messages/ # Translation files (JSON)
│ ├── nb-NO.json # Norwegian Bokmål (default)
│ ├── en.json # English
│ ├── sv.json # Swedish
│ └── README.md # Translation guidelines
├── i18n/ # i18n configuration
│ ├── config.ts # Locale definitions, default locale
│ └── request.ts # next-intl request config (App Router)
├── middleware.ts # Locale detection middleware
└── app/
└── [locale]/ # Locale-based routing
├── layout.tsx # Root layout with NextIntlClientProvider
├── page.tsx # Redirects to /dashboard
├── dashboard/
├── login/
└── ... # All existing routes nested under [locale]
4.2 Namespace Strategy
Single JSON file per locale (initial approach): For Drop MVP, all translations in one file per locale. Future: split into namespaces as app grows.
File: messages/nb-NO.json
{
"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:
- Search for JSX text content:
<span>Text</span>→<span>{t('key')}</span> - Search for string literals in className, title, aria-label
- 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:
- Backend API routes return error keys (not localized strings)
- Frontend translates error keys using next-intl
- Fallback to English for unknown keys
Example (API Route — Before):
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:
- Create template functions that accept locale parameter
- Store email translations in same
messages/*.jsonfiles underemail.*namespace - 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});
5.4 Legal Documents (Priority 3 — Manual Translation Required)
Scope: Terms of Service, Privacy Policy, Fee Schedule
Legal Requirement: Norwegian version MUST exist and be primary (PSD2 Norway implementation). English/Swedish versions are optional.
Strategy:
- Phase 1 (MVP): Norwegian-only legal docs (current state)
- Phase 2 (Post-MVP): Professional translation of legal docs to English (external translator)
- Store legal content as Markdown files in
messages/legal/[locale]/directory - Render Markdown server-side using
@next/mdxor similar
Structure:
messages/
└── legal/
├── nb-NO/
│ ├── terms.md
│ ├── privacy.md
│ └── fees.md
├── en/
│ ├── terms.md
│ ├── privacy.md
│ └── fees.md
└── sv/
└── ...
Legal Page Component:
import fs from 'fs/promises';
import { compileMDX } from 'next-mdx-remote/rsc';
export default async function TermsPage({params}: {params: {locale: string}}) {
const locale = params.locale || 'nb-NO';
const source = await fs.readFile(`messages/legal/${locale}/terms.md`, 'utf8');
const {content} = await compileMDX({source});
return <div className="prose">{content}</div>;
}
Fallback: If English/Swedish legal docs don't exist, show Norwegian version with disclaimer: "Legal documents available in Norwegian only."
6. Formatting Standards
6.1 Currency Formatting
Norwegian Locale (nb-NO):
- Format:
1 234,56 kr(space as thousand separator, comma as decimal, suffix "kr") - Alternative (banking):
NOK 1 234,56(ISO code prefix) - Drop Standard: Use
1 234 kr(no decimals for whole amounts) to match UX mockups
English Locale (en):
- Format:
NOK 1,234.56(ISO code, comma thousand separator, period decimal)
Swedish Locale (sv):
- Format:
1 234,56 kr(same as Norwegian)
Implementation (next-intl):
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):
- Short date:
17.02.2026(DD.MM.YYYY) - Long date:
17. februar 2026 - Time:
14:30(24-hour clock)
English (en):
- Short date:
02/17/2026(MM/DD/YYYY) or17/02/2026(international) - Long date:
February 17, 2026 - Time:
2:30 PM(12-hour clock)
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):
- Decimal separator:
,(comma) - Thousand separator:
(non-breaking space) - Example:
1 234 567,89
English (en):
- Decimal separator:
.(period) - Thousand separator:
,(comma) - Example:
1,234,567.89
Implementation:
format.number(1234567.89, {maximumFractionDigits: 2});
// nb-NO: "1 234 567,89"
// en: "1,234,567.89"
7. Translation Workflow
7.1 Roles & Responsibilities
| Role | Responsibility | Tools |
|---|---|---|
| Developer | Extract strings to translation files, add translation keys to code | VSCode, ESLint |
| Content Lead (Alem) | Review Norwegian translations for accuracy, approve final content | GitHub PR review |
| External Translator | Translate nb-NO.json → en.json, sv.json (Phase 2) |
JSON editor, CAT tool |
| Legal Team | Translate legal documents (terms, privacy, fees) | Markdown editor |
| QA | Test all locales, verify formatting, check for missing translations | Browser, Playwright |
7.2 Translation Process
Phase 1: Initial Extraction (Developer)
- Create
messages/nb-NO.jsonfrom existing Norwegian strings - Structure translation keys by namespace (
common,nav,login, etc.) - Replace hardcoded strings in components with
t('key')calls - Run build to verify no missing keys (TypeScript will catch errors)
- Test Norwegian locale thoroughly (should match current behavior exactly)
Phase 2: English Translation (External Translator)
- Export
messages/nb-NO.json - Translator creates
messages/en.json(JSON structure preserved, values translated) - Developer imports
en.json, runs build - QA tests English locale
Phase 3: Swedish Translation (Future)
Same process as Phase 2.
7.3 Quality Assurance
Pre-Deployment Checklist:
- All UI screens tested in all 3 locales (nb-NO, en, sv)
- Currency formatting correct for each locale
- Date formatting correct for each locale
- No missing translation keys (TypeScript build passes)
- Email templates render correctly in all locales
- Legal documents exist for required locales (nb-NO mandatory)
- Language switcher works (Profile → Language)
- URL routing works (
/no/dashboard,/en/dashboard,/sv/dashboard) - Fallback to Norwegian works if user selects unsupported locale
- Browser language detection works (first visit)
Automated Tests:
- Playwright E2E: Test key user flows in all 3 locales
- Unit Tests: Test
formatCurrency(),formatDate()with mock locales - Snapshot Tests: Compare rendered output in different locales
8. Database Content Localization
8.1 User-Generated Content
Scope: User names, recipient names, custom notes on transactions.
Strategy: NOT translated. User-generated content stored as-is in database.
8.2 System-Generated Content
Scope: Transaction status messages, notification content, system emails.
Strategy:
- Store translation keys in database, not localized text
- Render localized text at display time based on user's preferred language
Example:
Database:
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:
- Store in separate
reference-datanamespace - Pre-translate all reference data (drop countries, supported currencies)
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:
/no/dashboard— Norwegian/en/dashboard— English/sv/dashboard— Swedish/— Root, redirects to/no/(default)
Pros:
- SEO-friendly (separate URL per language)
- Shareable links preserve language
- Server-side rendering compatible
- No cookie/session required for language persistence
Cons:
- URL changes when switching language (acceptable trade-off)
Implementation:
File: src/middleware.ts
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:
- URL locale (
/en/dashboard→en) - User profile
preferred_language(if logged in) - Browser
Accept-Languageheader (first visit) - 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. Legal Requirements (PSD2 / Finanstilsynet)
10.1 Mandatory Norwegian Disclosure
Requirement: Payment service providers in Norway must comply with host state rules on disclosure obligations and customer protection (PSD2 Norway implementation).
Implication: Terms of Service, Privacy Policy, and Fee Schedule MUST be available in Norwegian.
Compliance:
- Norwegian legal documents are mandatory (P0)
- English/Swedish legal documents are optional (P2, improves UX for non-Norwegian speakers)
- If non-Norwegian user selects English, show English UI but link to Norwegian legal docs with disclaimer:
"Legal documents are provided in Norwegian only, as required by Norwegian law. For an unofficial translation, please use a translation service."
10.2 Consent & Agreement Language
Requirement: User must consent in a language they understand.
Strategy:
- During onboarding, detect user's language preference
- Display Terms of Service in user's language (if available)
- If not available, show Norwegian with disclaimer + user confirms they understand
- Log consent with
language_of_consentin database
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:
- Support email (
support@getdrop.no) responds in Norwegian (primary) and English (secondary) - Swedish support on-demand (Google Translate fallback initially)
11. Testing Strategy
11.1 Unit Tests
Framework: Vitest (already in use)
Test Files:
src/lib/i18n/formatters.test.ts— Currency, date, number formattingsrc/lib/i18n/translations.test.ts— Translation key coverage
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:
- Language Switcher: Switch from Norwegian → English → Swedish, verify UI updates
- Currency Formatting: Check dashboard balance shows correct format per locale
- Date Formatting: Check transaction history dates render correctly
- Email Templates: Generate email in each locale, verify content
- 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:
- Install next-intl:
npm install next-intl - 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
- Navigation (
- Setup routing:
- Create
app/[locale]/directory - Move all existing routes under
[locale]/ - Add middleware for locale detection
- Create
- Update components:
- Replace
"Hjem"witht('nav.home') - Replace
toLocaleString("nb-NO")withformat.number()
- Replace
- Test: Verify app works exactly as before (Norwegian-only, but via next-intl)
Deliverables:
messages/nb-NO.json(complete)- All UI components use
useTranslations()hook - Zero hardcoded Norwegian strings in TSX files
- Build passes, Playwright tests pass
Success Criteria: App looks/behaves identical to current version, but all strings come from translation file.
Phase 2: English + Email Templates (Week 3)
Goal: Add English locale, translate email templates.
Tasks:
- Translate UI to English:
- Create
messages/en.json(external translator or Alem review) - Add "English" option to language switcher
- Create
- Refactor email templates:
- Convert
email-templates/*.htmltolib/email-templates.tsfunctions - Add
email.*namespace to translation files - Update email sending logic to accept locale parameter
- Convert
- Test emails:
- Send test emails in Norwegian and English
- Verify formatting (date/currency in emails)
Deliverables:
messages/en.json(complete)- Email templates support both locales
- Language switcher functional
Success Criteria: Users can switch to English, all UI + emails render correctly in English.
Phase 3: Swedish + Legal Docs (Week 4)
Goal: Add Swedish locale, translate legal documents (or defer if not ready).
Tasks:
- Translate UI to Swedish:
- Create
messages/sv.json - Add "Svenska" to language switcher
- Create
- 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
- Production deployment:
- Deploy with all 3 locales
- Monitor for missing translations (Sentry alerts)
Deliverables:
messages/sv.json(complete)- Legal document translation strategy finalized
- Production-ready i18n system
Success Criteria: All 3 locales functional, legal compliance maintained.
Phase 4: Polish & Optimization (Week 5+)
Goal: Refine translations, add missing edge cases, optimize bundle size.
Tasks:
- Translation review:
- Native speakers review Norwegian/Swedish translations
- Collect user feedback on clarity
- Namespace splitting:
- Split large
nb-NO.jsonintocommon.json,auth.json,dashboard.json, etc. - Lazy-load translation namespaces for faster initial load
- Split large
- 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.' } ] }
- Add ESLint rule to prevent future hardcoded strings:
- Performance audit:
- Measure bundle size impact of next-intl
- Verify server-side rendering works (translations in initial HTML)
Success Criteria: i18n system stable, maintainable, performant.
13. Implementation Estimates
| Phase | Tasks | Effort | Owner |
|---|---|---|---|
| Phase 1: Framework + Core UI | Install next-intl, extract strings, setup routing, update components | 2-3 days | Builder agent |
| Phase 2: English + Email Templates | Translate UI, refactor email templates, test | 1-2 days | Builder + Translator |
| Phase 3: Swedish + Legal Docs | Translate UI, legal doc strategy, deploy | 1-2 days | Builder + Legal |
| Phase 4: Polish & Optimization | Review, namespace split, ESLint rule, audit | 1-2 days | Builder + Validator |
| Total | Full i18n implementation | 5-9 days | Team |
Assumptions:
- Developer familiar with next-intl (1 day learning curve included)
- External translator available for English (1 day turnaround)
- Legal document translation deferred to post-MVP (Phase 3 can proceed with Norwegian-only legal docs)
14. Risks & Mitigation
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
| Missing translations break production | High | Medium | TypeScript type-checking catches missing keys at build time. Add runtime fallback to Norwegian. |
| Translation quality poor (machine translation) | Medium | High | Use professional translator for English. Native speaker review for Swedish. |
| Legal documents not compliant in English | High | Low | Keep Norwegian legal docs as source of truth. English is "best effort" unofficial translation. |
| Performance regression (bundle size) | Low | Low | next-intl adds ~5KB gzipped. Negligible for Drop's use case. |
| URL routing breaks SEO | Medium | Low | Subdirectory routing (/en/) is SEO-friendly. Submit all locales to Google Search Console. |
| User confusion with language switcher | Low | Medium | Add clear UI labels, remember user preference in profile. |
| Email templates render incorrectly in some locales | Medium | Medium | Test emails in all locales before production. Use email testing tool (Litmus, Email on Acid). |
15. Success Metrics
Post-Deployment (30 days):
| Metric | Target | Measurement |
|---|---|---|
| Locale adoption (English) | >10% of users | Analytics: % of users with /en/ routes |
| Translation coverage | 100% of UI strings | Automated script: check all keys exist in all locales |
| Zero missing translation errors | 0 Sentry errors | Sentry: no TranslationKeyNotFound errors |
| Email deliverability (all locales) | >95% delivery rate | Email service provider metrics |
| Legal compliance | 100% Norwegian terms visible | Manual audit: all users see Norwegian legal docs |
16. Future Enhancements (Post-MVP)
- Right-to-Left (RTL) Support: Arabic, Somali for diaspora remittance corridors
- Translation Management Platform: Use Locize or Crowdin for professional translation workflow
- A/B Testing: Test Norwegian vs English CTAs for conversion optimization
- Voice of Customer: Collect user feedback on translation quality, iterate
- Automatic Language Detection: Use IP geolocation to suggest locale (Norway → Norwegian, USA → English)
17. References
Documentation
- next-intl Official Docs
- next-intl App Router Setup
- Next.js 16 i18n Tutorial
- Norwegian Bokmål Locale Formatting
Legal & Compliance
Localization Best Practices
- Norwegian Bokmål vs Nynorsk Localization
- Currency Formatting for Localization
- Microsoft Currency Formatting Guide
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:
- Review this spec with Alem for approval
- Create MC task for Phase 1 implementation
- Assign to builder agent with this spec as reference
- Schedule external translator for Phase 2
Questions for Alem:
- Approve next-intl as framework choice?
- Defer Swedish to post-MVP or include in initial release?
- Budget for professional legal document translation (English)?
- Timeline preference: Fast rollout (2 weeks) vs thorough rollout (4 weeks)?
drop-make-integration-plan
Plan: Drop Figma Make Frontend Integration
Research Summary
Make Export (source: ~/ALAI/products/Drop/mockups/figma-make-export/)
- 10 screens, all custom Tailwind styling (NO shadcn imports despite package.json having them)
- react-router navigation (Link, useLocation, useState)
- All hardcoded mock data — no API calls
- 2 shared components: BottomNav (5 items: Hjem, Kontoer, Skann, Historikk, Profil), Logo (gradient square + wordmark)
- lucide-react icons throughout
- Norwegian text, DM Sans + Fraunces fonts, #0B6E35 green + #D4A017 gold
- Total: ~1,200 lines across 12 component files
Existing Drop (source: ~/ALAI/products/Drop/src/drop-app/)
- 10 routes, all "use client" components with real API integration
- API calls: auth (login/register), transactions (list/create), recipients, rates, cards, merchants
- Auth: useAuth hook, JWT httpOnly cookies
- Feature flags: cards gated behind useFeatureFlag()
- shadcn/ui components used (Card, Button, Badge, Tabs, ScrollArea, etc.)
- BottomNav with different icons (Home, Send, QrScan, CreditCard, User)
- DropLogo components (broken SVG from task #947 — needs replacement)
- Total: ~2,350 lines across page components
Key Difference
Make export = beautiful UI with mock data. Existing Drop = ugly UI with working backend. Goal: merge both — Make's design + Drop's API integration.
Conversion Needs Per Screen
| Make Screen | react-router → Next.js | Mock Data → API | Lines |
|---|---|---|---|
| Login | Link → next/link, form → POST /api/auth/login | Remove mock, add auth | 80 |
| Onboarding | Link → next/link, form → POST /api/auth/register | Remove mock, add validation | 117 |
| Dashboard | Link → next/link | Mock txns → GET /api/transactions, mock balance → GET /api/bank-accounts | 129 |
| SendMoney | Link → next/link, useState kept | Mock recipients → GET /api/recipients, mock rates → GET /api/rates, submit → POST /api/transactions/remittance | 168 |
| BankAccounts | Link → next/link | Mock accounts → GET /api/bank-accounts | 82 |
| TransactionHistory | Link → next/link, useState kept | Mock txns → GET /api/transactions | 181 |
| ScanQR | Link → next/link, useState kept | Mock merchant → POST /api/transactions/qr-payment | 125 |
| Profile | Link → next/link | Mock user → GET /api/auth/me, logout → POST /api/auth/logout | 113 |
| Notifications | Link → next/link | Mock notifs → static for MVP (no API yet) | 96 |
| MerchantDashboard | useState kept | Mock stats → GET /api/merchants/dashboard, mock txns → GET /api/merchants/transactions | 147 |
Objective
Replace all 10 Drop frontend page components with Figma Make export designs, converting from Vite/react-router to Next.js App Router, and wiring mock data to existing Drop API endpoints. Backend stays 100% untouched.
Team Orchestration
Team Members
| ID | Name | Role | Agent Type | Model |
|---|---|---|---|---|
| B1 | foundation-builder | Setup shared components + theme | builder | sonnet |
| V1 | foundation-validator | Validate shared components | validator | sonnet |
| B2 | screens-builder-1 | Convert Login, Onboarding, Dashboard, Profile | builder | sonnet |
| B3 | screens-builder-2 | Convert SendMoney, BankAccounts, TransactionHistory | builder | sonnet |
| B4 | screens-builder-3 | Convert ScanQR, Notifications, MerchantDashboard | builder | sonnet |
| V2 | screens-validator | Validate all 10 screens build + API wiring | validator | sonnet |
| B5 | integration-builder | Final integration, build test, fix issues | builder | sonnet |
| V3 | final-validator | Full app build + visual check | validator | sonnet |
Step-by-Step Tasks
Phase 1: Foundation (shared components + theme)
Task 1: Replace shared components and merge theme
- Owner: B1
- BlockedBy: none
- Files owned:
src/components/bottom-nav.tsx— REPLACE with Make's BottomNav (convert react-router Link/useLocation → next/link + usePathname)src/components/drop-logo.tsx— REPLACE with Make's Logo componentsrc/app/globals.css— MERGE Make's theme.css variables into existing globals.css (keep existing Tailwind @theme inline, add missing CSS vars from Make)src/components/ui/— keep all existing shadcn components, no changes
- Read FIRST:
- Make:
mockups/figma-make-export/src/app/components/BottomNav.tsx - Make:
mockups/figma-make-export/src/app/components/Logo.tsx - Make:
mockups/figma-make-export/src/styles/theme.css - Existing:
src/components/bottom-nav.tsx - Existing:
src/components/drop-logo.tsx - Existing:
src/app/globals.css
- Make:
- Conversion rules:
import { Link } from 'react-router'→import Link from 'next/link'import { useLocation } from 'react-router'→import { usePathname } from 'next/navigation'const { pathname } = useLocation()→const pathname = usePathname()to=→href=- Add
"use client"at top of each file - BottomNav: 5 items — Hjem(/dashboard), Kontoer(/accounts), Skann QR(/scan, center green circle), Historikk(/transactions), Profil(/profile)
- Acceptance:
- bottom-nav.tsx uses next/link + usePathname, 5 items, center QR button elevated
- drop-logo.tsx renders green gradient square + gold dot + "drop" wordmark
- globals.css has all Make theme variables merged (--primary-green, --gold-accent, etc.)
- No react-router imports remain
-
npx tsc --noEmitpasses for changed files
Task 2: Validate foundation
- Owner: V1
- BlockedBy: 1
- Acceptance:
- bottom-nav.tsx: no react-router, uses next/link + usePathname, "use client" present
- drop-logo.tsx: renders correctly, no broken SVG paths, "use client" present
- globals.css: valid CSS, no duplicate custom properties, Tailwind @theme inline still present
- All existing imports of BottomNav and DropLogo from other files still resolve
Phase 2: Screen Conversion (parallel — 3 builders)
Task 3: Convert Login, Onboarding, Dashboard, Profile
- Owner: B2
- BlockedBy: 1
- Files owned:
src/app/login/page.tsx— REPLACE with Make's Login.tsx + add POST /api/auth/loginsrc/app/register/page.tsx— REPLACE with Make's Onboarding.tsx + add POST /api/auth/register (keep age validation, OTP, PIN from existing)src/app/dashboard/page.tsx— REPLACE with Make's Dashboard.tsx + add GET /api/transactions + GET /api/bank-accountssrc/app/profile/page.tsx— REPLACE with Make's Profile.tsx + add GET /api/auth/me + POST /api/auth/logout
- Read FIRST:
- Make screens: Login.tsx, Onboarding.tsx, Dashboard.tsx, Profile.tsx
- Existing pages: login/page.tsx, register/page.tsx, dashboard/page.tsx, profile/page.tsx
- API spec: ~/ALAI/products/Drop/project/architecture/api-specification.md
- Conversion rules (SAME for all screens):
import { Link } from 'react-router'→import Link from 'next/link'to=→href=- Add
"use client"at top - Replace hardcoded mock data with
useState+useEffect+fetch('/api/...')from existing pages - Import BottomNav from
@/components/bottom-nav - Import Logo from
@/components/drop-logo(use DropLogo/DropLogoFull as appropriate) - Keep Make's visual layout/structure, replace data layer
- Login: add form onSubmit → POST /api/auth/login, router.push('/dashboard') on success
- Register: keep existing multi-step validation (age 18+, password strength, OTP) but use Make's visual design
- Dashboard: fetch transactions + bank accounts on mount, show real data in Make's layout
- Profile: show real user data from /api/auth/me, logout → POST /api/auth/logout + router.push('/login')
- Acceptance:
- All 4 pages render with Make's visual design
- Login form submits to /api/auth/login
- Register validates age 18+, password, creates account
- Dashboard shows real API data (transactions, bank accounts)
- Profile shows real user, logout works
- No react-router imports, all use next/link
-
npx tsc --noEmitpasses
Task 4: Convert SendMoney, BankAccounts, TransactionHistory
- Owner: B3
- BlockedBy: 1
- Files owned:
src/app/send/page.tsx— REPLACE with Make's SendMoney.tsx + add GET /api/recipients, GET /api/rates, POST /api/transactions/remittancesrc/app/accounts/page.tsx— REPLACE with Make's BankAccounts.tsx + add GET /api/bank-accountssrc/app/transactions/page.tsx— REPLACE with Make's TransactionHistory.tsx + add GET /api/transactions
- Read FIRST:
- Make screens: SendMoney.tsx, BankAccounts.tsx, TransactionHistory.tsx
- Existing pages: send/page.tsx, accounts/page.tsx, transactions/page.tsx
- API spec: ~/ALAI/products/Drop/project/architecture/api-specification.md
- Conversion rules: Same as Task 3 + specific:
- SendMoney: 4-step flow (recipient → amount → review → success), real recipients from API, real exchange rates, POST remittance on confirm
- BankAccounts: real bank accounts from GET /api/bank-accounts, no mock data
- TransactionHistory: real transactions with filters (all/sendt/qr), grouped by date
- Acceptance:
- SendMoney fetches real recipients + rates, creates real remittance
- BankAccounts shows real linked accounts from API
- TransactionHistory shows real transactions with filter tabs
- No react-router imports
-
npx tsc --noEmitpasses
Task 5: Convert ScanQR, Notifications, MerchantDashboard
- Owner: B4
- BlockedBy: 1
- Files owned:
src/app/scan/page.tsx— REPLACE with Make's ScanQR.tsx + add POST /api/transactions/qr-paymentsrc/app/notifications/page.tsx— REPLACE with Make's Notifications.tsx (keep mock data for MVP — no notifications API yet)src/app/(merchant)/merchant/page.tsxor appropriate route — REPLACE with Make's MerchantDashboard.tsx + add GET /api/merchants/dashboard + GET /api/merchants/transactions
- Read FIRST:
- Make screens: ScanQR.tsx, Notifications.tsx, MerchantDashboard.tsx
- Existing pages: scan/page.tsx, notifications/page.tsx
- API spec sections 7, 9 (QR Payment, Merchants)
- Conversion rules: Same as Task 3 + specific:
- ScanQR: keep demo simulation mode, POST /api/transactions/qr-payment on confirm, show bank account info
- Notifications: keep Make's mock data (no backend API for notifications in MVP)
- MerchantDashboard: real stats from /api/merchants/dashboard, real transactions from /api/merchants/transactions
- Acceptance:
- ScanQR simulates scan, creates real QR payment
- Notifications renders with demo data
- MerchantDashboard shows real merchant stats
- No react-router imports
-
npx tsc --noEmitpasses
Task 6: Validate all 10 screens
- Owner: V2
- BlockedBy: 3, 4, 5
- Acceptance:
- All 10 page.tsx files exist and compile
- No react-router imports anywhere in src/
- All pages have "use client" directive
- All API calls match api-specification.md endpoints
- BottomNav appears on all pages except Login and Onboarding
- All text is Norwegian
- Brand colors (#0B6E35, #D4A017) used consistently
-
npx tsc --noEmitpasses for entire project
Phase 3: Integration & Build
Task 7: Build test + fix issues
- Owner: B5
- BlockedBy: 6
- Steps:
- Run
npm run build(ornext build) - Fix any TypeScript errors
- Fix any import path issues
- Fix any missing dependencies
- Ensure all routes accessible
- Remove unused old components if any
- Remove /cards route or keep as feature-flagged placeholder
- Run
- Files owned: Any file that needs fixing from build errors
- Acceptance:
-
next buildsucceeds with 0 errors -
next devstarts without errors - All 10 routes load in browser
- No console errors on any page
-
Task 8: Final validation
- Owner: V3
- BlockedBy: 7
- Steps:
- Verify
next buildpasses - Check each route renders (curl localhost:3000/login, /dashboard, etc.)
- Verify API endpoints still work (GET /api/auth/me, GET /api/transactions)
- Check no old broken SVG logos remain
- Verify BottomNav navigation works between pages
- Verify
- Acceptance:
- Build passes
- All 10 routes return 200
- API health check passes
- No broken imports or missing components
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
- Backup first:
cp -r src/drop-app/src src/drop-app/src-backup-pre-makebefore any changes - 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.
- Cards route: Make export has no Cards screen (replaced with BankAccounts per pass-through model). Keep /cards as feature-flagged placeholder or remove.
- Merchant route: May need new route directory if doesn't exist. Check first.
- Landing page (/): NOT touched. Existing landing page stays as-is.
Estimated Scope
- Phase 1: 3 files changed (foundation)
- Phase 2: 10 files replaced (screens) — 3 builders in parallel
- Phase 3: Bug fixes from build
- Total: ~13 files modified, 0 new files, backend untouched
drop-marketing-infra-spec
Drop Marketing Infrastructure — Implementation Spec
Project: Drop (Fintech Payment App) Task: MC #1197 Date: 2026-02-17 Author: John (AI Director) Status: Draft — Awaiting Approval
1. Executive Summary
Drop currently has a basic landing page (getdrop.no) with waitlist functionality but lacks comprehensive marketing infrastructure to support user acquisition, retention, and viral growth. This spec defines the complete marketing technology stack covering:
- Landing page optimization and conversion tracking
- SEO strategy for Norwegian fintech market
- App Store/Play Store listing preparation
- Referral system with fraud prevention
- Campaign tracking (UTM parameters and attribution)
- Email marketing integration
- A/B testing framework
- Analytics and reporting
Core Principles:
- Norwegian-first: All copy, SEO, and campaigns prioritize Norwegian market
- GDPR + markedsføringsloven compliant: Strict consent management
- Pass-through branding: Drop never holds money — emphasize transparency
- Viral growth: Referral program as primary acquisition channel
- Data-driven: Every decision backed by metrics
2. Current State Assessment
2.1 What Exists
Landing Page (getdrop.no):
- Single-page marketing site with hero, features, how-it-works, CTA
- Waitlist form (
POST /api/waitlist) with honeypot anti-spam - Mobile-responsive design (DM Sans + Fraunces fonts)
- Basic SEO meta tags (title, description, og:tags)
- Trust signals: "Regulert i Norge", "0.5% gebyr", "30+ land"
- Deployed to Vercel (pages/ directory for static content)
<title>Drop — Send penger. Enklere. Billigere.</title>
<meta name="description" content="Drop er den nye standarden for internasjonale overforinger i Skandinavia. 0,5% gebyr. QR-betaling i butikk. Regulert i Norge.">
<meta property="og:title" content="Drop — Enklere betalinger. Lavere gebyrer.">
<meta property="og:description" content="Send penger til 30+ land med 0,5% gebyr. QR-betaling i butikk. Regulert i Norge.">
<meta property="og:image" content="https://getdrop.no/drop-logo.png">
Branding:
- Logo: Green rounded square with white $ icon + gold dot
- Colors:
--drop-green: #0B6E35,--drop-gold: #D4A017 - Tagline: "Enklere betalinger. Lavere gebyrer."
- Value props: 0.5% remittance fee, 1% merchant QR fee, 30+ countries
Domain:
- Primary: getdrop.no (registered via one.com)
- drop.no owned by TV2 (not available)
2.2 What's Missing
Critical Gaps:
- No conversion tracking (Google Analytics, Plausible, or similar)
- No UTM parameter handling for campaign attribution
- No App Store/Play Store listings (not launched)
- No referral system (database schema or logic)
- No email marketing integration (Resend configured but no campaigns)
- No A/B testing framework
- No SEO sitemap or robots.txt
- No structured data (JSON-LD) for rich snippets
- No blog/content marketing infrastructure
- No social media presence (@dropnorge handles not claimed)
Legal/Compliance Gaps:
- Marketing consent collection not integrated with email system
- No cookie consent banner (required for analytics tracking)
- No marketing-specific terms in vilkår.html
3. Landing Page Optimization
3.1 Conversion Optimization
Goal: Convert 25% of visitors to waitlist signups (industry benchmark: 15-20%)
Improvements:
A. Hero Section
Current state: Generic CTA "Last ned gratis" (app not available yet)
Optimization:
<!-- Replace generic CTA with value-focused copy -->
<div class="hero-actions">
<a class="btn-gradient" href="#cta">Bli med på ventelisten</a>
<span class="hero-cta-subtext">De første 1000 får 5 gratis overføringer</span>
</div>
Value Prop Enhancement:
- Add "Ingen binding. Ingen skjulte kostnader." under CTA
- Add trust badge: "Regulert av Finanstilsynet" (when applicable)
- Replace phone mockup with actual app screenshot from Figma Make export
B. Social Proof
Current state: Generic testimonial placeholders
Add Real Metrics (when available):
<div class="trust-bar">
<div class="trust-item">
<div class="trust-number">2,340</div>
<div class="trust-label">På ventelisten</div>
</div>
<div class="trust-item">
<div class="trust-number">kr 12M</div>
<div class="trust-label">Spart i gebyrer (demo)</div>
</div>
<div class="trust-item">
<div class="trust-number">4.8★</div>
<div class="trust-label">Snittrating (beta)</div>
</div>
</div>
Source: Pull waitlist count from database, update dynamically
C. Features Section
Current state: 3 feature cards (Send, QR, Wallet)
Optimization:
- Add FAQ accordion below features: "Er Drop en bank?" → "Nei, vi er en betalingstjeneste..."
- Add comparison table: Drop vs. Western Union vs. Wise vs. Banks
Comparison Table:
<table class="comparison-table">
<thead>
<tr><th>Tjeneste</th><th>Gebyr</th><th>Hastighet</th><th>Lojalitet</th></tr>
</thead>
<tbody>
<tr><td><strong>Drop</strong></td><td>0.5%</td><td>Minutter</td><td>Bonuspoeng</td></tr>
<tr><td>Western Union</td><td>5-10%</td><td>1-3 dager</td><td>—</td></tr>
<tr><td>Wise</td><td>0.7-1.2%</td><td>1-2 dager</td><td>—</td></tr>
<tr><td>Tradisjonell bank</td><td>50-200 kr + FX</td><td>3-5 dager</td><td>—</td></tr>
</tbody>
</table>
D. CTA Section
Current state: Email input + submit button
Optimization:
- Add GDPR-compliant marketing consent checkbox
- Add "Hva skjer etter?" explainer below form:
1. Du får bekreftelses-epost 2. Vi varsler deg når appen lanseres (Mars 2026) 3. Du får tidlig tilgang før alle andre - Add exit-intent popup (trigger on mouse leave): "Vent! Ta med 100 kr gratis når du registrerer deg nå."
3.2 Mobile Optimization
Issues:
- Phone mockup too large on mobile (600px → overflow)
- Nav menu doesn't collapse properly
Fixes:
@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):
- FCP (First Contentful Paint): ~1.5s
- LCP (Largest Contentful Paint): ~2.5s
- CLS (Cumulative Layout Shift): 0.05
Optimizations:
- Preload hero fonts:
<link rel="preload" href="..." as="font"> - Lazy load phone mockup below fold
- Add
loading="lazy"to all images except hero - Minify CSS (inline critical CSS, defer non-critical)
- Add service worker for offline caching (PWA)
Target Metrics:
- FCP: < 1.2s
- LCP: < 2.0s
- CLS: < 0.1
- Lighthouse Score: 95+
4. SEO Strategy
4.1 Target Keywords (Norwegian)
Primary Keywords (High Volume, High Intent):
| Keyword | Monthly Searches (est.) | Difficulty | Priority |
|---|---|---|---|
| send penger utlandet | 1,200 | Medium | HIGH |
| billig pengeoverføring | 800 | Low | HIGH |
| internasjonale overføringer | 600 | Medium | HIGH |
| qr betaling norge | 400 | Low | MEDIUM |
| vipps alternativ | 300 | High | MEDIUM |
| western union alternativ | 250 | Low | HIGH |
| wise alternativ | 200 | Low | MEDIUM |
| mobilbetaling | 1,500 | High | LOW |
Long-tail Keywords:
- "hvor mye koster det å sende penger til utlandet"
- "billigste måte å sende penger til utlandet"
- "qr kode betaling butikk"
- "send penger uten gebyr"
English Keywords (Secondary — expat market):
- "send money from norway"
- "international money transfer norway"
- "cheap remittance norway"
4.2 On-Page SEO Implementation
A. Meta Tags Enhancement
Homepage:
<title>Send Penger Utlandet — 0.5% Gebyr | Drop Norge</title>
<meta name="description" content="Send penger til 30+ land med bare 0.5% gebyr. Raskere enn banker, billigere enn Western Union. Regulert i Norge. Registrer deg gratis.">
<meta name="keywords" content="send penger utlandet, billig pengeoverføring, internasjonale overføringer, qr betaling, vipps alternativ">
<link rel="canonical" href="https://getdrop.no/">
Subpages (need to be created):
/send-penger— Dedicated remittance page/qr-betaling— QR payment explainer/priser— Pricing comparison/hvordan-det-virker— How it works (detailed)/sikkerhet— Security explainer
B. Structured Data (JSON-LD)
Organization Schema:
{
"@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:
- Google Search Console
- Bing Webmaster Tools
D. Robots.txt
User-agent: *
Allow: /
Disallow: /api/
Sitemap: https://getdrop.no/sitemap.xml
4.3 Off-Page SEO
A. Backlink Strategy
Target Sources:
- Norwegian fintech blogs: kryptografen.no, shifter.no, digi.no
- Business directories: proff.no, 1881.no (ALAI Holding AS listing → link to Drop)
- Press releases: mynewsdesk.com, newswire.no
- Partner pages: SpareBank 1 partner page (if approved)
- LinkedIn articles: Alem's personal posts about Drop
Content for Outreach:
- "Slik sparer nordmenn penger på internasjonale overføringer"
- "Drop vs. Western Union: Detaljert sammenligning"
- "Hvorfor vi bygde et nytt betalingsalternativ for Skandinavia"
B. Local SEO
Google My Business (when applicable):
- List Drop under ALAI Holding AS
- Category: "Financial Service"
- Add getdrop.no as website
- Upload Drop logo
Local Citations:
- Register on gulesider.no (if relevant)
- Add to fintech.no directory
4.4 Technical SEO
Checklist:
- HTTPS enabled (Vercel default)
- Canonical tags on all pages
- Hreflang tags for NO/EN (if English landing page added)
- XML sitemap submitted
- robots.txt configured
- 404 page with CTA to homepage
- Redirect strategy (301 for old Zica URLs if any)
- Mobile-friendly test passed
- Core Web Vitals optimized
Hreflang Example (if English page added):
<link rel="alternate" hreflang="no" href="https://getdrop.no/" />
<link rel="alternate" hreflang="en" href="https://getdrop.no/en/" />
<link rel="alternate" hreflang="x-default" href="https://getdrop.no/" />
5. App Store / Play Store Listing Preparation
5.1 App Store Optimization (ASO) Strategy
Goal: Rank in top 5 for "send penger" and "pengeoverføring" in Norwegian App Store
Primary Keywords (App Store):
- send penger (High volume)
- pengeoverføring (Medium volume)
- qr betaling (Medium volume)
- internasjonale overføringer (Low volume)
Secondary Keywords:
- billig, lavere gebyrer, vipps alternativ, remittance, money transfer
5.2 App Store Listing (iOS)
A. App Name (30 chars)
Drop: Send Penger & QR Betal
Rationale: Includes primary keyword "Send Penger" + brand
B. Subtitle (30 chars)
0.5% gebyr. Regulert i Norge
Rationale: USP (0.5% fee) + trust signal (regulated)
C. Promotional Text (170 chars)
De første 1000 brukerne får 5 gratis overføringer. 🎉
Send penger til 30+ land med bare 0.5% gebyr. Betal i butikk med QR. Enklere enn Vipps. Billigere enn Western Union.
Rationale: Urgency + value props + social proof comparison
D. Description (4000 chars)
# 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):
-
Hero/Dashboard — Shows total balance + "Send" and "QR Betal" buttons
- Caption: "Din banksaldo. Alle transaksjoner. Én app."
-
Send Money Flow — Shows "Velg land" → "Skriv beløp" → "Se gebyr" → "Send"
- Caption: "Send til 30+ land med 0.5% gebyr"
-
Fee Comparison — Visual chart: Drop (0.5%) vs Western Union (7%) vs Wise (1%)
- Caption: "Spar opptil 90% på gebyrer"
-
QR Payment — Shows QR scanner screen + "Skann og betal"
- Caption: "Betal i butikk med QR-kode"
-
Transaction History — Shows recent transactions with dates, amounts, statuses
- Caption: "Full kontroll over alle transaksjoner"
-
Security Screen — Shows BankID logo + lock icon + "Pengene forblir i din bank"
- Caption: "Trygt med BankID og PSD2"
Design Notes:
- Use actual Figma Make export screenshots (from mockups/figma-make-export/)
- Add captions in white text with green background
- Ensure all text is legible (16pt minimum)
- No placeholder content ("Lorem ipsum") — real data only
G. App Preview Video (30 sec)
Storyboard:
- 0-5s: Problem — "Sender du penger hjem?" → show high Western Union fees
- 5-10s: Solution — "Drop gir deg 0.5% gebyr" → show app interface
- 10-15s: Demo — Show send flow (select country → amount → confirm)
- 15-20s: QR feature — Show QR scan + payment confirmation
- 20-25s: Social proof — "2,340 nordmenn på ventelisten"
- 25-30s: CTA — "Last ned gratis i dag" → App Store badge
Voiceover Script (Norwegian):
Sender du penger hjem? Western Union tar opptil 10% gebyr.
Med Drop tar vi bare 0.5%. Pengene er fremme på minutter.
Velg land. Skriv beløp. Send. Ferdig.
Betal i butikk med QR. Ingen kort. Ingen kontanter.
Over 2,000 nordmenn bruker allerede Drop.
Last ned gratis i dag.
5.3 Google Play Store Listing
Differences from App Store:
- Title: 30 chars (same as iOS)
- Short Description: 80 chars
Send penger til 30+ land med 0.5% gebyr. QR-betaling i butikk. Regulert i Norge. - Full Description: 4000 chars (same as iOS)
- Graphic Assets:
- Feature Graphic: 1024x500 (banner)
- Icon: 512x512 (already have drop-logo.png)
- Screenshots: 1080x1920 (Android ratio)
Category:
- Primary: Finance
- Secondary: Payments
Content Rating:
- Everyone (PSD2 compliance → 18+ enforced in-app, not store rating)
5.4 Localization Strategy
Phase 1 (Launch): Norwegian only Phase 2 (3 months post-launch): Add English for expats Phase 3 (6 months): Swedish, Danish (Nordic expansion)
Translation Requirements:
- App Store/Play Store listings
- In-app UI strings (already in Norwegian via Figma Make)
- Support docs (FAQ, Help Center)
- Email templates (already covered in email spec)
6. Referral System
6.1 Referral Mechanics
Goal: 65% of new users from referrals (Revolut benchmark)
Incentive Structure (Dual-Sided):
| Action | Referrer Reward | Referee Reward |
|---|---|---|
| Sign up + verify BankID | 0 kr | 0 kr |
| First transfer (min 500 kr) | 50 kr credit | 50 kr credit |
| Second transfer (within 30 days) | +25 kr bonus | — |
Credit Usage:
- Credits apply to next transfer fee (reduces 0.5% fee)
- 50 kr credit = free transfer up to 10,000 kr (0.5% of 10k = 50 kr)
- Credits expire after 90 days (encourages usage)
Cap: Max 500 kr in credits per user per month (prevents farming)
Example Flow:
- Referrer (Maria) shares link:
getdrop.no?ref=maria123 - 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."
- Sara verifies BankID → completes first transfer (1,000 kr) → gets 50 kr credit
- 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:
A. Referral Link Card
<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 | cpc | utm_source=facebook&utm_medium=cpc&utm_campaign=launch_2026 | |
| Instagram Ads | cpc | utm_source=instagram&utm_medium=cpc&utm_campaign=qr_promo | |
| Google Ads | cpc | utm_source=google&utm_medium=cpc&utm_campaign=send_penger | |
| LinkedIn Organic | social | utm_source=linkedin&utm_medium=social&utm_campaign=alem_post | |
| Email Newsletter | newsletter | utm_source=email&utm_medium=newsletter&utm_campaign=waitlist_launch | |
| Partner Link (SB1) | sparebank1 | partner | utm_source=sparebank1&utm_medium=partner&utm_campaign=coop |
| Press Article | shifter | pr | utm_source=shifter&utm_medium=pr&utm_campaign=launch_coverage |
| Referral Program | referral | organic | (handled separately via ?ref= param) |
Naming Conventions:
utm_source: Platform name (lowercase, no spaces)utm_medium: Traffic type (cpc, social, email, partner, pr, organic, display)utm_campaign: Campaign identifier (snake_case, descriptive)utm_content: Ad variation (ad_1, ad_2, hero_cta, footer_link)utm_term: Keyword (Google Ads only)
7.2 UTM Tracking Implementation
A. Client-Side Capture (Landing Page)
Add to index.html (before waitlist form):
// 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
- Subject: "Du er på listen! 🎉"
- Content: Confirm signup, set expectations (launch date), share referral link
- CTA: "Inviter venner — hopp køen"
Email 2 (Day 7): Educational
- Subject: "Derfor tar banker så høye gebyrer"
- Content: Explain remittance market, Western Union fees, Drop's pass-through model
- CTA: "Les mer om Drop"
Email 3 (Day 14): Social Proof
- Subject: "2,000+ nordmenn venter allerede"
- Content: Waitlist count, testimonials (when available), SpareBank 1 partnership mention
- CTA: "Del med venner"
Email 4 (Day 21): Product Demo
- Subject: "Se hvordan Drop fungerer (90 sekunder)"
- Content: Embed demo video, screenshot walkthrough
- CTA: "Se videoen"
Email 5 (Pre-Launch -7 days): Launch Countdown
- Subject: "Drop lanseres om 7 dager!"
- Content: Countdown timer, "first 1000 get 5 free transfers" reminder
- CTA: "Sett påminnelse"
Email 6 (Launch Day): App Available
- Subject: "Drop er her! Last ned nå."
- Content: App Store/Play Store links, onboarding guide
- CTA: "Last ned gratis"
B. Onboarding Series (Post-Registration)
Email 1 (After Registration): Welcome
- Subject: "Velkommen til Drop!"
- Content: What to expect, link BankID reminder, first transfer incentive
- CTA: "Fullfør profilen din"
Email 2 (Day 3, if no BankID): Nudge
- Subject: "Koble BankID — ta 2 minutter"
- Content: Why BankID is required, security explanation
- CTA: "Koble BankID"
Email 3 (Day 7, if no first transfer): First Transfer Incentive
- Subject: "Send din første overføring — få 50 kr"
- Content: Referral credit reminder, fee comparison
- CTA: "Send penger nå"
C. Re-engagement Series (Dormant Users)
Trigger: User hasn't sent a transfer in 30 days
Email 1 (Day 30): Gentle Reminder
- Subject: "Vi savner deg!"
- Content: "It's been a while..." + new features announcement
- CTA: "Send penger igjen"
Email 2 (Day 60): Win-back Offer
- Subject: "25 kr gratis på din neste overføring"
- Content: Exclusive promo code (one-time use)
- CTA: "Bruk koden"
Email 3 (Day 90): Feedback Request
- Subject: "Hvorfor sluttet du å bruke Drop?"
- Content: Survey link (3 questions max)
- CTA: "Del tilbakemelding"
8.2 Marketing Consent Management
Consent Collection (During Registration):
<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:
- Use cron job or scheduled task (runs every hour)
- Query database for users matching trigger conditions
- Call
sendEmail()from email service layer - Mark email as sent in
email_logtable
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):
- Waitlist Signup (pageview: /api/waitlist success)
- App Store Click (custom event)
- Play Store Click (custom event)
- Referral Link Click (custom event)
- Video Play (custom event)
9.3 In-App Analytics (Mixpanel)
Events to Track:
Acquisition
user_registered(properties: utm_source, utm_medium, referral_code)bankid_verified(properties: time_to_verify_seconds)kyc_approved(properties: time_to_approve_seconds)
Activation
first_transfer_completed(properties: amount, currency, destination_country)qr_payment_completed(properties: amount, merchant_name)bank_account_linked(properties: bank_name)
Engagement
app_opened(properties: session_count)screen_viewed(properties: screen_name)transfer_initiated(properties: amount, currency)transfer_cancelled(properties: step, reason)
Retention
transfer_completed(properties: amount, transfer_count, days_since_last_transfer)push_notification_received(properties: type)push_notification_clicked(properties: type)
Referral
Revenue
fee_charged(properties: amount_nok, transaction_type)credit_applied(properties: amount, source)
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:
- Landing page visitors (unique)
- Waitlist signups
- Waitlist → Registration conversion rate
- Registration → BankID verification rate
- BankID → KYC approval rate
- Overall conversion (Visitor → Verified User)
Activation Metrics:
- Time to first transfer (median)
- First transfer completion rate (within 7 days of registration)
- Average first transfer amount
- QR payment adoption rate (% of users who scan at least 1 QR)
Engagement Metrics:
- DAU (Daily Active Users)
- WAU (Weekly Active Users)
- MAU (Monthly Active Users)
- Transfers per user per month
- Average transaction value
Retention Metrics:
- Day 1, 7, 30, 90 retention
- Cohort analysis (users by signup month)
- Churn rate (users with no transfer in 90 days)
Referral Metrics:
- Referral rate (% of users who share link)
- Referral conversion rate (signups per shared link)
- Viral coefficient (new users per existing user)
- Referral ROI (LTV of referred users vs. non-referred)
Revenue Metrics:
- Total fee revenue (kr)
- Revenue per user (kr)
- Revenue per transfer (kr)
- Credit redemption rate (% of credits used)
Campaign Performance:
- Cost per waitlist signup (CPA)
- Cost per registration (CPA)
- Cost per verified user (CPA)
- ROAS (Return on Ad Spend) by channel
10. A/B Testing Framework
10.1 Testing Strategy
Goal: Optimize conversion at every funnel step
Test Prioritization (ICE Framework):
| Test | Impact | Confidence | Ease | ICE Score |
|---|---|---|---|---|
| Hero CTA copy ("Bli med" vs "Registrer deg") | 8 | 9 | 10 | 27 |
| Waitlist incentive ("5 free transfers" vs "50 kr credit") | 9 | 7 | 10 | 26 |
| Feature order (Remittance first vs QR first) | 6 | 8 | 9 | 23 |
| Phone mockup vs real screenshot | 7 | 6 | 8 | 21 |
| Social proof placement (hero vs below features) | 5 | 7 | 9 | 21 |
Test Velocity: 1 test every 2 weeks (minimum 2 weeks per test for statistical significance)
10.2 A/B Testing Tools
Options:
| Tool | Pros | Cons | Cost |
|---|---|---|---|
| Vercel Edge Middleware (RECOMMENDED) | Fast, server-side, free | Manual implementation | Free |
| Google Optimize (Deprecated) | — | Sunset 2023 | — |
| Optimizely | Visual editor, advanced targeting | Expensive | $50k+/year |
| VWO | Visual editor, heatmaps | Complex setup | $199/mo |
| PostHog | Open source, feature flags | Self-hosted complexity | Free (self-hosted) |
Recommendation: Use Vercel Edge Middleware for simple A/B tests (free, fast, server-side). Upgrade to PostHog if feature flags needed.
10.3 Implementation (Vercel Edge Middleware)
File: middleware.ts
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:
- Use https://abtestguide.com/calc/ or similar
- Minimum sample size: 385 per variant (95% confidence, 5% margin of error)
- Run test until both variants reach minimum sample size
11. Content Marketing (Blog)
11.1 Blog Infrastructure
Goal: SEO traffic + thought leadership
Platform Options:
| Option | Pros | Cons |
|---|---|---|
| Next.js MDX (RECOMMENDED) | Same codebase, fast, full control | Manual implementation |
| Substack | Easy setup, newsletter integration | External platform, limited branding |
| Medium | Built-in audience | No domain control, paywall |
| WordPress | SEO plugins, familiar | Separate hosting, slower |
Recommendation: Next.js MDX (markdown files → static pages)
11.2 Blog Structure
Directory:
/landing/blog/
├── index.html # Blog homepage (list of posts)
├── slik-sender-du-penger-til-tyrkia.html
├── drop-vs-western-union.html
├── hvorfor-banker-tar-hoye-gebyrer.html
└── qr-betaling-forklart.html
Blog Post Template:
<!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):
- "Slik sender du penger til Tyrkia fra Norge" (target: "send penger til tyrkia")
- "Drop vs Western Union: Detaljert sammenligning" (target: "western union alternativ")
Week 3-4 (Educational): 3. "Hvorfor tar banker så høye gebyrer på pengeoverføringer?" (thought leadership) 4. "QR-betaling forklart: Slik fungerer det" (target: "qr betaling")
Week 5-6 (Comparison): 5. "Drop vs Wise: Hva er forskjellen?" (target: "wise alternativ") 6. "De beste måtene å sende penger til utlandet i 2026" (target: "billigste pengeoverføring")
Week 7-8 (Use Cases): 7. "Slik sender du penger hjem til familie i utlandet" (emotional, storytelling) 8. "QR-betaling for småbedrifter: Guiden" (merchant-focused)
Content Distribution:
- Publish on blog
- Share on LinkedIn (Alem's profile)
- Send to waitlist (weekly digest)
- Submit to relevant subreddits (r/norway, r/norge — carefully, no spam)
12. Social Media Strategy
12.1 Platform Prioritization
| Platform | Priority | Audience | Content Type | Frequency |
|---|---|---|---|---|
| HIGH | Business audience, partnerships, press | Company updates, thought leadership | 3x/week | |
| MEDIUM | Consumers, visual storytelling | Features, user stories, behind-the-scenes | 5x/week | |
| MEDIUM | Broader consumer base, ads | Features, blog posts, ads | 3x/week | |
| Twitter/X | LOW | Tech audience, customer support | Quick updates, support responses | As needed |
| TikTok | LOW (Future) | Young audience | Short-form video | 0x/week (post-launch) |
12.2 Account Setup
Handles (Claim ASAP):
- Instagram: @dropnorge
- Facebook: facebook.com/dropnorge
- LinkedIn: linkedin.com/company/drop-norge
- Twitter: @dropnorge (or @drop_norge if taken)
Bio Template (Instagram):
Drop — Enklere betalinger. Lavere gebyrer.
💸 0.5% gebyr på internasjonale overføringer
📱 QR-betaling i butikk
🇳🇴 Regulert i Norge
⬇️ Registrer deg for tidlig tilgang
Link in Bio:
- getdrop.no?utm_source=instagram&utm_medium=social&utm_campaign=bio_link
12.3 Content Pillars
Pillar 1: Product Education (40%)
- How Drop works (carousel posts)
- Fee comparison infographics
- Feature announcements
- Tutorial videos
Pillar 2: Customer Stories (30%)
- User testimonials (when available)
- "How I saved X kr with Drop" posts
- Behind-the-scenes (team, product development)
Pillar 3: Industry Insights (20%)
- Fintech news commentary
- Regulatory updates (Finanstilsynet)
- Market trends (remittance, payments)
Pillar 4: Culture & Values (10%)
- Team introductions
- Company values
- Community engagement
12.4 Content Calendar (First Month)
Week 1:
- Mon: Introduce Drop (carousel: What is Drop?)
- Wed: Fee comparison infographic (Drop vs banks)
- Fri: Behind-the-scenes (team building the app)
Week 2:
- Mon: Feature spotlight (QR payment)
- Wed: Blog post share (Why banks charge high fees)
- Fri: Waitlist milestone ("1,000 people signed up!")
Week 3:
- Mon: FAQ carousel (Is Drop a bank? No.)
- Wed: Partnership announcement (SpareBank 1, if approved)
- Fri: User story (when available, or placeholder)
Week 4:
- Mon: Tutorial video (How to send money with Drop)
- Wed: Industry news commentary (PSD2 update)
- Fri: Countdown to launch (if applicable)
12.5 Paid Social Ads (Post-Launch)
Budget: 10,000 NOK/month (testing phase)
Campaign 1: Waitlist Signups (Pre-Launch)
- Platform: Facebook + Instagram
- Objective: Lead generation
- Audience: Norway, age 25-45, interests: fintech, travel, expat groups
- Creative: Carousel (3 slides): Problem → Solution → CTA
- CTA: "Registrer deg gratis"
- Landing page: getdrop.no?utm_source=facebook&utm_medium=cpc&utm_campaign=waitlist_signups
Campaign 2: App Installs (Post-Launch)
- Platform: Google App Campaigns
- Objective: App installs
- Audience: Norway, keywords: "send penger", "pengeoverføring", "western union alternativ"
- Ad copy: "Send penger med 0.5% gebyr. Last ned Drop gratis."
- Bid strategy: Target CPA (50 NOK per install)
Campaign 3: Retargeting (Post-Launch)
- Platform: Facebook + Instagram
- Objective: Conversions (first transfer)
- Audience: Custom audience (registered users who haven't transferred)
- Creative: "You're one step away. Complete your first transfer and get 50 kr."
- Landing page: Deep link to app (drop://send)
13. Legal & Compliance
13.1 Markedsføringsloven Compliance
Key Requirements:
1. Identification of Marketing: All marketing communications must be clearly identifiable as such. Commercial emails must include "Annonse" or "Reklame" in subject line.
Example:
Subject: [Annonse] Spar opptil 90% på pengeoverføringer
2. Opt-In for Electronic Marketing: Electronic marketing (email, SMS) to natural persons requires prior consent (opt-in). Exception: existing customer relationship + same product category.
Implementation:
- Checkbox on registration: "Jeg ønsker å motta tilbud fra Drop" (unchecked by default)
- Record consent in
consentstable with timestamp and IP - Unsubscribe link in every email
3. Misleading Marketing Prohibition: Cannot make false or misleading claims. All claims must be substantiable.
Examples of Compliant Claims:
- ✅ "0.5% gebyr" (true, documented in pricing)
- ✅ "Regulert i Norge" (true when PSD2 license obtained)
- ❌ "Billigst i Norge" (unsubstantiated, use "Fra 0.5% gebyr" instead)
4. Comparative Marketing: Can compare with competitors (Western Union, Wise) if factually accurate and verifiable.
Example:
Drop: 0.5% gebyr
Western Union: 5-10% gebyr*
*Basert på gjennomsnittlig gebyr for 5,000 NOK overføring til Tyrkia, per Western Union's offentlige prisliste (februar 2026).
5. Environmental Claims: Cannot make environmental claims ("grønn", "klimavennlig") without documentation.
Drop's Claims: None planned.
13.2 GDPR Marketing Compliance
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:
- 1,000 users in first 3 months
- 50% referred by existing users (500 referrals)
- 80% complete first transfer (400 conversions)
- 50 kr reward per conversion (both referrer + referee)
Calculation:
400 conversions × 50 kr (referrer) = 20,000 kr
400 conversions × 50 kr (referee) = 20,000 kr
Total referral rewards: 40,000 kr
ROI:
- Cost per acquisition: 40,000 kr / 400 users = 100 kr/user
- LTV estimate (conservative): 500 kr/user (10 transfers × 50 kr avg fee)
- ROI: 500 kr / 100 kr = 5x
Budget Allocation:
- Referral rewards: 40,000 kr (Q1)
- Paid ads: 30,000 kr (Q1)
- Tools: 1,320 kr (Q1)
- Total Q1 Marketing Budget: 71,320 kr
15. Implementation Plan
15.1 Phase 1: Landing Page Optimization (Week 1-2)
Owner: John + Frontend builder agent
Tasks:
-
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)
-
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
-
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)
-
Add comparison table
- Create Drop vs Western Union vs Wise vs Bank table
- Add styling (match landing page design)
-
Mobile optimization
- Add sticky CTA bar on mobile
- Test on iPhone/Android (Safari, Chrome)
-
Page speed optimization
- Preload hero fonts
- Lazy load images below fold
- Minify CSS
- Test with Lighthouse (target: 95+ score)
Acceptance:
- Plausible tracking works (waitlist signups logged)
- Hero CTA updated with incentive
- FAQ section renders correctly
- Comparison table displays on desktop + mobile
- Mobile sticky bar appears below 768px width
- Lighthouse score >= 95
15.2 Phase 2: SEO Foundation (Week 3-4)
Owner: John + Content agent (Ollama)
Tasks:
-
Add structured data
- Add Organization schema to homepage
- Add FAQ schema to FAQ section
- Validate with Google Rich Results Test
-
Create sitemap.xml
- Generate sitemap with all pages
- Submit to Google Search Console
- Submit to Bing Webmaster Tools
-
Create robots.txt
- Allow all except /api/
- Add sitemap reference
-
Create blog infrastructure
- Create /blog/index.html (blog homepage)
- Create blog post template
- Write first 2 blog posts (Tyrkia guide, Drop vs WU)
-
Subpage creation
- Create /send-penger.html (dedicated remittance page)
- Create /qr-betaling.html (QR explainer)
- Create /priser.html (pricing comparison)
Acceptance:
- Structured data validates in Google Rich Results Test
- Sitemap submitted to Google + Bing
- robots.txt accessible at /robots.txt
- Blog homepage lists 2 posts
- 3 subpages created and indexed
15.3 Phase 3: UTM & Attribution (Week 5-6)
Owner: John + Backend builder agent
Tasks:
-
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
-
Update registration flow
- Modify /api/auth/register to capture UTM from localStorage
- Add utm_* columns to users table
- Test registration with UTM params
-
Create attribution reports
- SQL query: Waitlist signups by source
- SQL query: User registrations by source
- SQL query: Revenue by source (post-launch)
-
Add conversion pixels
- Add Facebook Pixel to landing page
- Add Google Ads conversion tracking
- Test pixel firing (Facebook Events Manager)
Acceptance:
- UTM params saved to waitlist table
- UTM params saved to users table
- Attribution reports run successfully
- Facebook Pixel tracks PageView + Lead
- Google Ads tracks conversions
15.4 Phase 4: Referral System (Week 7-10)
Owner: John + Backend builder agent
Tasks:
-
Database schema
- Create referral_codes table
- Create referral_tracking table
- Create referral_credits table
- Create indexes
-
Backend endpoints
- POST /api/referrals/generate-code
- POST /api/referrals/track-click
- Modify /api/auth/register (signup attribution)
- Modify /api/transactions/remittance (reward issuance)
-
Referral dashboard UI
- Create /profile/referrals page
- Referral link card
- Stats card (invites, conversions, earnings)
- Credits balance
- Referral history
-
Share functionality
- WhatsApp share button
- SMS share button
- Email share button
- Copy link button
-
Fraud prevention
- Implement self-referral check (device fingerprint)
- Implement minimum transfer amount (500 kr)
- Implement monthly credit cap (500 kr)
- Implement velocity check (10/day)
Acceptance:
- Referral code auto-generated on first login
- Referral link click tracked
- Signup attributed to referrer
- First transfer triggers reward issuance
- Referral dashboard displays stats
- Share buttons open WhatsApp/SMS/Email
- Fraud checks block abuse
15.5 Phase 5: App Store Listings (Week 11-12)
Owner: John + Designer agent (Ollama) + QA
Tasks:
-
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)
-
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)
-
ASO optimization
- Research keywords in App Store Connect
- A/B test icon variants (internal testing)
- Localize for NO + EN
Acceptance:
- iOS listing drafted (not submitted until app ready)
- Android listing drafted
- Screenshots match Figma design
- App preview video rendered and reviewed
- Keyword research documented
15.6 Phase 6: Email Campaigns (Week 13-14)
Owner: John + Backend builder agent
Tasks:
-
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)
-
Onboarding series
- Create 3 email templates (welcome → first transfer nudge)
- Set up automation triggers (day 0, 3, 7)
- Test emails
-
Re-engagement series
- Create 3 email templates (gentle → win-back → survey)
- Set up automation triggers (day 30, 60, 90)
- Test emails
-
Marketing consent
- Add checkbox to registration form
- Record consent in consents table
- Add unsubscribe link to all emails
- Create unsubscribe endpoint
Acceptance:
- All email templates render correctly in Gmail/Outlook
- Automation triggers fire correctly (test with dummy users)
- Marketing consent checkbox works
- Unsubscribe link works
15.7 Phase 7: Analytics & Reporting (Week 15-16)
Owner: John + Data analyst agent (Ollama)
Tasks:
-
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.)
-
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)
-
Reports
- Weekly funnel report (automated, sent to Alem)
- Monthly cohort analysis
- Campaign performance report (ROI by channel)
Acceptance:
- Mixpanel events tracked in app
- 5 dashboards created in Mixpanel
- Weekly report automation set up
15.8 Phase 8: Social Media Launch (Week 17-18)
Owner: John + Marketer agent (Ollama)
Tasks:
-
Account setup
- Create @dropnorge Instagram
- Create facebook.com/dropnorge
- Create linkedin.com/company/drop-norge
- Design profile pictures + cover photos
-
Content calendar
- Create 30-day content calendar (spreadsheet)
- Design 12 Instagram posts (Canva)
- Write captions + hashtags
-
First month execution
- Schedule posts (Buffer or Hootsuite)
- Engage with comments
- Cross-post to LinkedIn
-
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
- Likelihood: Medium
- Impact: High (referral is primary acquisition channel)
- Mitigation:
- Increase incentive from 50 kr to 75 kr (test)
- Add gamification (leaderboard: "Top 10 referrers get 500 kr bonus")
- Make sharing easier (one-tap WhatsApp share)
- Add social proof ("2,340 people referred their friends")
Risk 2: High Customer Acquisition Cost (CAC)
- Likelihood: Medium
- Impact: High (unprofitable if CAC > LTV)
- Mitigation:
- Start with organic channels (SEO, content, social) before paid ads
- Set strict CPA targets (max 150 kr per user)
- Pause ads if CPA exceeds target 2 weeks in a row
- Focus on viral growth (referral program)
Risk 3: Poor App Store Visibility
- Likelihood: Medium
- Impact: High (low organic installs)
- Mitigation:
- ASO optimization (keyword research, compelling screenshots)
- Incentivize reviews (push notification after 3rd transfer: "Enjoying Drop? Leave a review")
- App Store ads (Apple Search Ads) with strict CPA
- Cross-promote on landing page
Risk 4: Email Deliverability Issues
- Likelihood: Low
- Impact: Medium (users miss onboarding emails)
- Mitigation:
- Use reputable ESP (Resend) with good sender reputation
- Authenticate domain (SPF, DKIM, DMARC)
- Monitor bounce rate (<5% acceptable)
- Avoid spam triggers (no ALL CAPS, excessive exclamation marks)
Risk 5: Referral Fraud
- Likelihood: Medium
- Impact: High (budget drain)
- Mitigation:
- Implement all fraud checks (self-referral, velocity, cap)
- Monitor referral patterns weekly
- Flag accounts with >10 referrals in 24h for manual review
- BankID verification required before rewards
18. Acceptance Criteria
18.1 Landing Page
- Hero CTA updated with incentive ("De første 1000...")
- FAQ section added (5 questions min)
- Comparison table added (Drop vs WU vs Wise vs Bank)
- Mobile sticky CTA bar added
- Plausible tracking installed and tested
- Lighthouse score >= 95
18.2 SEO
- Sitemap.xml created and submitted to Google/Bing
- robots.txt created
- Structured data (Organization + FAQ) added and validated
- 3 subpages created (/send-penger, /qr-betaling, /priser)
- 2 blog posts published
- All pages have unique title + meta description
18.3 UTM & Attribution
- UTM params captured from URL
- UTM params stored in waitlist + users tables
- Attribution reports created (SQL queries)
- Facebook Pixel installed and tracking
- Google Ads conversion tracking installed
18.4 Referral System
- Database schema created (3 tables + indexes)
- Referral code auto-generated on first login
- Referral link click tracked
- Signup attribution works
- First transfer triggers reward issuance
- Referral dashboard UI complete
- Share buttons functional (WhatsApp, SMS, Email)
- Fraud prevention rules implemented
18.5 App Store Listings
- iOS listing drafted (name, subtitle, description, keywords, screenshots, video)
- Android listing drafted (title, short desc, full desc, graphics, screenshots)
- ASO keyword research completed
- All screenshots match Figma design
18.6 Email Marketing
- Waitlist nurture series created (6 emails)
- Onboarding series created (3 emails)
- Re-engagement series created (3 emails)
- Marketing consent checkbox added to registration
- Unsubscribe link works
- All emails tested in Gmail + Outlook
18.7 Analytics
- Plausible installed (landing page)
- Mixpanel installed (app)
- 15 core events tracked (user_registered, first_transfer, etc.)
- 5 dashboards created (Acquisition, Activation, Retention, Referral, Revenue)
- Weekly funnel report automated
18.8 Social Media
- Instagram, Facebook, LinkedIn accounts created
- Profile pictures + bios complete
- 30-day content calendar created
- First 12 posts designed and scheduled
- Paid ads campaign created (5,000 kr budget)
19. Rollout Timeline
Pre-Launch (Weeks 1-10):
- Weeks 1-2: Landing page optimization
- Weeks 3-4: SEO foundation
- Weeks 5-6: UTM & attribution
- Weeks 7-10: Referral system
Launch Week (Week 11):
- Submit app to App Store + Play Store
- Send launch email to waitlist
- Publish launch blog post
- Post on social media (organic)
- Start paid ads (small budget: 2,000 kr/week)
Post-Launch (Weeks 12-16):
- Week 12: Monitor metrics daily, optimize landing page CTA (A/B test)
- Week 13: Scale ads if CPA < 150 kr
- Week 14: Publish 2nd + 3rd blog posts
- Week 15: Analyze first month data, create reports
- Week 16: Iterate on referral incentives based on data
20. Sources
This specification was informed by research on Norwegian fintech regulations, app store optimization best practices, and referral program patterns from successful fintech companies:
Norwegian Regulations:
- Fintech Laws and Regulations Report 2025-2026 Norway
- Marketing - regjeringen.no
- Act relating to the control of marketing and contract terms and conditions, etc - Lovdata
- Electronic marketing in Norway - Data Protection Laws of the World
App Store Optimization:
- Top ASO tips and best practices for 2026, brought to you by ASO experts
- App Store Optimization (ASO) Best Practices for 2026
- App Store Optimization Tips for Fintech Designers
- A short guide to optimising ASO strategies for fintech apps
Referral Programs & Fraud Prevention:
- How Revolut Turned Referrals into a $4B Growth Machine
- How to start a referral program for your fintech startup
- Design a Fraud-Proof Referral Program
- Revolut Referral Rewards: Refer-a-Friend Bonus and Referral Discounts 2026
END OF SPEC
Next Steps:
- Review this spec with Alem for approval
- Create implementation tasks in Mission Control
- Assign tasks to builder agents (Phase 1 → Phase 8)
- Begin Phase 1 (Landing Page Optimization)
Questions for Alem:
- Approve referral incentive structure (50 kr dual-sided)?
- Approve social media ad budget (10,000 kr/month testing)?
- Preferred analytics platform (Plausible + Mixpanel recommended)?
- Timeline for app launch (determines when to prepare App Store listings)?
- Any specific SEO keywords to prioritize beyond the list in Section 4.1?
drop-mvp-pipeline-plan
Plan: Drop MVP Pipeline A-Z
Research Summary
Actual State (13.02.2026)
Phase 4 (Implementation) — 95% done, NOT 80% as PIPELINE.md says:
- 12 frontend pages: ALL wired to real API routes via fetch()
- 24+ API routes: ALL working (auth, transactions, cards, merchants, rates, etc.)
- SQLite DB: 6 tables + seed data, parameterized queries
- Auth: JWT httpOnly cookies, rate limiting
- Validation: hardened (backend + frontend), 18+ age check
- Services: mock-swan/stripe/sumsub with config toggle (expected for MVP)
- Rebrand: ALL pages rebranded to Stitch design
- Tests: 126 unit + 91 e2e = 217 ALL GREEN
What's actually missing for MVP-ready:
- Full remittance E2E flow test (register → login → send money → verify)
- .env.example + environment config for production
- Docker build verification (Dockerfile exists but untested)
- SQLite → needs volume mount for persistence in Docker
- 5 test iterations per testing.md standard
- Deploy to staging (Railway/Hetzner — cost analysis says 10-170 NOK/mo)
- Domain config (getdrop.no)
- Landing page deploy (static HTML, separate from app)
- PIPELINE.md outdated — needs update
Infrastructure already built:
- Dockerfile: 3-stage build, standalone output ✅
- docker-compose.yml: app + postgres, healthchecks ✅
- next.config.ts: standalone, CSP headers, security headers ✅
- Cloud cost analysis: done ✅
NOTE: docker-compose.yml has postgres service but app uses SQLite. For MVP: deploy with SQLite + volume (Railway/Fly persistent disk). PostgreSQL migration is Phase 2 (200+ users) per cost analysis.
Objective
Take Drop from "works on localhost" to "deployed MVP on staging with full test coverage." 4 phases, no detours, no cosmetics.
Team Orchestration
Team Members
| ID | Name | Role | Agent Type |
|---|---|---|---|
| B1 | flow-test-builder | Write full remittance E2E flow + missing flow tests | builder |
| V1 | test-validator | Run all 5 test levels, 5 iterations, verify coverage | validator |
| B2 | deploy-prep-builder | Create .env.example, fix docker-compose for SQLite, test Docker build | builder |
| V2 | deploy-validator | Verify Docker image builds and runs correctly | validator |
| B3 | staging-builder | Deploy to Railway/Hetzner, configure domain, SSL | builder |
| V3 | staging-validator | Verify staging is live, all pages load, API responds | validator |
| B4 | pipeline-closer | Update PIPELINE.md, close MC tasks, create post-mortem | builder |
Step-by-Step Tasks
Phase 1: Complete Implementation (finish Phase 4)
Task 1: Write missing E2E flow tests + update PIPELINE.md status
- Owner: B1
- BlockedBy: none
- Files:
tests/e2e/full-flows.spec.ts(NEW)project/PIPELINE.md(UPDATE).env.example(NEW)
- Instructions:
- Read existing test files to understand patterns
- Write full E2E tests for:
- Complete registration → login → dashboard flow
- Login → send money (remittance) → verify transaction in history
- Login → scan QR → pay → verify in history
- Login → view cards → order virtual card
- Login → view accounts → check balances
- Login → profile → logout → redirect to login
- Create
.env.examplewith all required vars:JWT_SECRET=change-me-in-production NODE_ENV=production NEXT_PUBLIC_SERVICE_MODE=mock - Update PIPELINE.md Phase 4 status to 100%
- Run tests, fix any failures
- Acceptance:
- full-flows.spec.ts has 6+ complete user journey tests
- All E2E tests pass (existing + new)
- .env.example exists with documented vars
- PIPELINE.md Phase 4 marked complete
Phase 2: Testing (5 iterations per testing.md)
Task 2: Run 5 test iterations across all levels, fix failures
- Owner: V1
- BlockedBy: 1
- Instructions:
- Read ~/system/rules/testing.md for requirements
- Run iteration 1:
npx vitest run+npx playwright test - Log results to
tests/logs/iteration-1.txt - Fix any failures found
- Repeat for iterations 2-5
- Check coverage:
npx vitest run --coverage(target 80%+) - Run regression tests:
npx vitest run tests/regression/ - Run performance tests:
npx vitest run tests/performance/ - Final summary of all 5 iterations
- Acceptance:
- 5 iterations logged in tests/logs/
- ALL tests pass on iteration 5
- Coverage report generated
- No regressions
- Performance benchmarks baselined
Phase 3: Deploy to Staging
Task 3: Prepare Docker for SQLite deployment
- Owner: B2
- BlockedBy: 2
- Files:
docker-compose.yml(UPDATE — add SQLite volume, remove postgres for MVP)docker-compose.production.yml(NEW — for future postgres)Dockerfile(UPDATE if needed)
- Instructions:
- Read existing Dockerfile and docker-compose.yml
- Create
docker-compose.mvp.ymlfor SQLite deployment:- App service with SQLite volume mount
- JWT_SECRET from env
- Health check on /api/health
- No postgres (not needed for MVP)
- Keep existing docker-compose.yml as
docker-compose.production.yml(postgres version) - Test:
docker build -t drop-app .— must succeed - Test:
docker run -p 3000:3000 -e JWT_SECRET=test drop-app— must serve pages - Verify /api/health, /login, /onboarding all respond 200
- Acceptance:
- Docker image builds successfully
- Docker container starts and serves pages
- /api/health returns 200
- SQLite data persists across container restart (volume)
- docker-compose.mvp.yml works with
docker-compose -f docker-compose.mvp.yml up
Task 4: Validate Docker deployment
- Owner: V2
- BlockedBy: 3
- Acceptance:
- Docker build < 2 minutes
- Image size < 500MB
- Container starts in < 10s
- All API endpoints respond correctly
- No security warnings in container logs
Task 5: Deploy to staging (Railway or Hetzner)
- Owner: B3
- BlockedBy: 4
- Instructions:
- Read cloud-cost-analysis.md for recommended approach
- Option A (Railway):
railway init+railway up— easiest, persistent disk - Option B (Hetzner): Docker deploy to existing VPS if available
- Configure:
- JWT_SECRET (generate secure random)
- NODE_ENV=production
- Domain: staging.getdrop.no or drop-staging.alai.no
- Set up SSL (Let's Encrypt / Cloudflare)
- Verify all pages load on staging URL
- Deploy landing page (static HTML) to same domain or subdomain
- Acceptance:
- Staging URL accessible via HTTPS
- All 12 app pages load correctly
- Login + registration flow works end-to-end
- API health check passes
- Landing page live
Task 6: Validate staging deployment
- Owner: V3
- BlockedBy: 5
- Instructions:
- Hit staging URL — verify HTTPS, correct domain
- Test all pages load (no 404, no 500)
- Test registration flow end-to-end
- Test login with demo credentials
- Test send money flow
- Test QR scan flow
- Check security headers (CSP, HSTS, X-Frame-Options)
- Check mobile responsiveness (375px viewport)
- Acceptance:
- All pages load via HTTPS
- Registration + login works
- Core flows work (send, scan, cards)
- Security headers present
- Mobile-friendly
Phase 4: Close Pipeline
Task 7: Update PIPELINE.md, close tasks, create summary
- Owner: B4
- BlockedBy: 6
- Instructions:
- Update PIPELINE.md:
- Phase 4: ✅ Done
- Phase 5: ✅ Done (5 iterations, all pass)
- Phase 6: ✅ Done (staging live at URL)
- Phase 7: 🔄 Monitoring
- Close related MC tasks: #526, #608, #791-793 (update status)
- Create post-mortem summary:
- What was built
- Test results
- Staging URL
- Known limitations (mock services, SQLite, no BankID)
- Next steps for production (partner agreements, PostgreSQL, Finanstilsynet)
- Post summary to HiveMind
- Update PIPELINE.md:
- Acceptance:
- PIPELINE.md fully updated
- MC tasks updated
- Post-mortem written
- HiveMind updated
- Alem can access staging URL
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
drop-onboarding-flow-spec
Drop User Onboarding Flow Specification
Version: 1.0 Date: 2026-02-17 Author: John (AI Director) Project: Drop Fintech Payment App MC Task: #1192 Status: Draft — Awaiting Approval
1. Executive Summary
This specification defines the complete user onboarding flow for Drop, a fintech payment app that provides remittance and QR payment services using PSD2 pass-through architecture. The flow must enforce legal requirements (18+ age, Norwegian residency), implement BankID verification, and guide users through KYC compliance before enabling transactions.
Key Constraints:
- Pass-through model: Drop NEVER holds customer money. AISP reads balance, PISP initiates payments from user's bank account.
- Legal requirement: Users must be 18+ and Norwegian residents (from
landing/pages/vilkar.html) - BankID mandatory: Required before any transaction (PSD2 SCA compliance)
- KYC compliance: Sumsub integration for identity verification (auto-approved in demo mode)
2. Flow Overview
2.1 High-Level Journey
Landing Page → Register → Phone OTP → Onboarding Tour → BankID Verification → KYC Check → Dashboard
(1) (2) (3) (4) (5) (6) (7)
2.2 State Diagram
┌─────────────┐
│ VISITOR │
└──────┬──────┘
│
▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ REGISTER │────▶│ PHONE_OTP │────▶│ ONBOARDING │
└─────────────┘ └──────────────┘ └──────┬──────┘
│
▼
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ DASHBOARD │◀────│ KYC_CHECK │◀────│ BANKID │
└─────────────┘ └──────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ VERIFIED │ (can transact)
└─────────────┘
2.3 User States
| State | Description | Can Transact? | Next Action |
|---|---|---|---|
visitor |
Not registered | No | Register |
registered |
Account created, no phone verification | No | Verify phone OTP |
phone_verified |
Phone verified, no BankID | No | Complete onboarding tour |
onboarded |
Tour complete, no BankID | No | Link BankID |
bankid_linked |
BankID verified, pending KYC | No | Wait for KYC approval |
kyc_approved |
Fully verified | Yes | Full access |
kyc_pending |
KYC review in progress | No | Wait for approval |
kyc_rejected |
KYC failed | No | Contact support |
State persistence: Stored in users table:
kyc_statusenum: 'pending', 'approved', 'rejected'phone_verifiedbooleanbankid_verifiedbooleanonboarding_completedboolean (new field)
3. Detailed Flow Steps
Step 1: Landing Page → Register
Route: / → /register
Entry: User clicks "Opprett konto" on landing page
UI Reference: mockups/figma-make-export/src/components/Login.tsx (register section)
Frontend (register/page.tsx)
Current Implementation:
- ✅ Form: First name, last name, email, phone (+47), date of birth, password
- ✅ Client-side validation: email format, password complexity (8+ chars, upper/lower/digit)
- ✅ Age validation: DOB must result in age >= 18
- ✅ XSS prevention: Blocks
<script,javascript:,onerror=in name fields - ✅ Visual step indicator: "Steg 1 av 3"
- ❌ Missing: BankID/Vipps login options (shown but BankID redirects, Vipps disabled)
Validation Rules:
// 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:
- Phone number display: "Vi sendte en 6-sifret kode til +47 12345678"
- 6-digit input field (numeric only, monospace font, letter-spaced)
- Expiry notice: "Koden er gyldig i 5 minutter"
- Shield icon (security indicator)
- "Bekreft" button (disabled until 6 digits entered)
Backend (api/auth/verify-otp/route.ts)
Endpoint: POST /api/auth/verify-otp
Request Body:
{
"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:
- Generic error messages prevent user enumeration attacks
- OTP codes expire after 5 minutes
- One-time use enforced via
usedflag - Rate limiting prevents brute-force attacks (5 attempts/min)
- Audit trail for all verification attempts
Edge Cases:
- OTP expires: User must request new OTP (requires re-registering or resend endpoint)
- Wrong OTP 5 times: Rate limited for 1 minute
- User closes tab: OTP still valid for 5 minutes, can return and verify
- 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:
- Lock icon (security indicator)
- Heading: "Lag din PIN-kode"
- Subtitle: "4-sifret PIN for rask tilgang"
- 4 dots showing PIN entry progress (filled dots scale up)
- 3x4 numeric keypad (1-9, 0, backspace)
- No submit button (auto-submits on 4th digit)
PIN Indicator:
<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:
- PIN stored as bcrypt hash, never plaintext
- Weak PIN patterns rejected (0000, 1234, 1111, 1234, 4321)
- PIN used for quick app unlock (not primary auth)
- Audit trail for PIN changes
Gap Analysis:
- ❌ Backend endpoint missing
- ❌ Frontend doesn't call backend (auto-advances to success without server confirmation)
- ❌ No weak PIN validation
- ❌ No database schema for
pin_hash
Step 4: PIN Setup → Onboarding Tour
Route: /register (step: "success") → /onboarding
Trigger: PIN set successfully
UI Reference: mockups/figma-make-export/src/components/Onboarding.tsx
Frontend (onboarding/page.tsx)
Current Implementation:
// 4-step carousel (lines 8-181)
const STEPS = [
{
title: "Velkommen til Drop!",
description: "Enklere betalinger. Lavere gebyrer.",
content: <WelcomeScreen />, // Features: Remittance, QR, Security
},
{
title: "Dine fordeler",
description: "Hvorfor velge Drop?",
content: <BenefitsScreen />, // Lave gebyrer (0.5%), Raske transaksjoner, Direkte fra bank
},
{
title: "BankID-tilgang",
description: "Koble til din bank",
content: <BankIDScreen />, // BankID/Vipps security info
},
{
title: "Ferdig!",
description: "Du er klar til å bruke Drop",
content: <ReadyScreen />, // Next actions: Send money, Scan QR, View balance
},
];
// Navigation (lines 184-211)
const [currentStep, setCurrentStep] = useState(0);
const handleNext = () => {
if (isLastStep) {
router.push("/dashboard");
} else {
setCurrentStep((prev) => prev + 1);
}
};
const handleSkip = () => {
router.push("/dashboard");
};
Progress Indicator:
<div className="flex items-center gap-2 mb-2">
{STEPS.map((_, index) => (
<div
key={index}
className={`h-2 rounded-full flex-1 transition-colors ${
index <= currentStep ? "bg-[#0B6E35]" : "bg-[#E2E8F0]"
}`}
/>
))}
</div>
<p className="text-sm text-[#64748B]">
Steg {currentStep + 1} av {STEPS.length}
</p>
Content Structure:
Screen 1: Welcome
- Drop logo and tagline
- 3 feature cards with icons:
- 🌍 Send penger til utlandet (30+ land, lave gebyrer)
- 📱 Betal med QR-kode (skann, betal fra bankkonto)
- 🛡️ Trygt og sikkert (BankID koblet)
Screen 2: Benefits
- 3 benefit cards:
- 💰 Lave gebyrer (gradient card) — "Kun halv prosent gebyr på remittance"
- ⚡ Raske transaksjoner — "Pengene er fremme innen 1-2 virkedager"
- 🏦 Direkte fra din bank — "Ingen mellomkonto. Pengene dine forblir i din bank"
Screen 3: BankID Connection
- Security explanation
- BankID + Vipps logos
- 3 checkmarks:
- ✅ Kun du har tilgang til dine kontoer
- ✅ Vi kan aldri flytte penger uten ditt samtykke
- ✅ All data er kryptert og sikret
Screen 4: Ready
- ✅ Success icon (green circle with checkmark)
- "Alt klart!" heading
- Next actions list:
- ✉️ Send penger til utlandet
- 🔍 Skann QR for å betale
- 💼 Se saldo fra dine kontoer
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:
- ❌ Backend endpoint missing
- ❌ No database tracking of onboarding completion
- ❌ Cannot prevent users from accessing dashboard without completing onboarding
- ✅ Frontend flow works correctly (4 screens, navigation, skip)
Step 5: Onboarding Tour → BankID Verification
Route: /onboarding → /dashboard → BankID modal/redirect
Trigger: User clicks "Gå til Dashboard" on onboarding screen 4
UI Reference: mockups/figma-make-export/src/components/Login.tsx (BankID button)
Frontend Flow
Current Implementation:
Login Page BankID Button (login/page.tsx lines 7-21):
function BankIDButton() {
return (
<a
href="/api/auth/bankid"
className="flex-1 py-3 px-4 border rounded-xl font-medium"
>
<svg><!-- BankID logo --></svg>
BankID
</a>
);
}
Missing in Dashboard:
- No BankID verification prompt
- No blocking UI for unverified users
- No "Koble BankID" button or modal
Required Implementation:
Dashboard BankID Prompt (dashboard/page.tsx):
// Check user verification status
const { user } = useAuth();
if (!user.bankid_verified) {
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-2xl p-6 max-w-md mx-4">
<div className="w-16 h-16 bg-[#0B6E35]/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Shield className="w-8 h-8 text-[#0B6E35]" />
</div>
<h2 className="text-2xl font-bold text-center mb-2">Koble til BankID</h2>
<p className="text-[#64748B] text-center mb-6">
For å bruke Drop må du koble din bankkonto via BankID. Dette er påkrevd for sikkerhet.
</p>
<a
href="/api/auth/bankid"
className="w-full bg-[#0B6E35] text-white py-3 rounded-xl font-medium flex items-center justify-center gap-2"
>
<svg><!-- BankID logo --></svg>
Koble BankID
</a>
</div>
</div>
);
}
// Normal dashboard content...
Blocking Strategy:
- Modal overlay (non-dismissable)
- Blocks all dashboard features until BankID verified
- Clear explanation of why it's required
- Single CTA: "Koble BankID"
Backend (api/auth/bankid/route.ts)
Endpoint: GET /api/auth/bankid
Current Implementation:
// 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:
- Fødselsnummer stored as SHA-256 hash, never plaintext
- State token validates CSRF (prevents replay attacks)
- State cookie expires after 5 minutes
- ID token verified with BankID public key
- Age verification double-checked from fødselsnummer
Error Cases:
| Error | HTTP | Reason |
|---|---|---|
invalid_state |
400 | CSRF validation failed (state mismatch) |
underage |
403 | User is < 18 years old (extracted from fødselsnummer) |
bankid_unavailable |
400 | Demo mode (BankID not configured) |
server_error |
500 | Token exchange failed, invalid ID token |
Gap Analysis:
- ❌ Callback route missing implementation
- ❌ No fødselsnummer parsing logic
- ❌ No age verification from fødselsnummer
- ❌ No database schema for
national_id_hash,bankid_verified - ❌ Dashboard doesn't block unverified users
Step 6: BankID → KYC Check
Route: /api/auth/bankid/callback → KYC service → /dashboard
Trigger: BankID verification success
Service: Sumsub (KYC provider)
KYC Service (lib/services/kyc.ts)
Current Implementation:
Demo Mode:
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:
- ✅ KYC service implemented (demo + production modes)
- ❌ Webhook handler missing
- ❌ No UI for pending/rejected KYC states
- ❌ No transaction blocking based on KYC status
- ❌ No notification system for KYC status changes
Step 7: KYC Approved → Full Dashboard Access
Route: /dashboard (fully unlocked)
Trigger: KYC status = 'approved'
UI Reference: mockups/figma-make-export/src/components/Dashboard.tsx
Dashboard Features (Unlocked After KYC)
Available Actions:
-
Send Money →
/send- Remittance to 30+ countries
- PISP initiates payment from user's bank account
- Shows exchange rates, fees, recipient details
-
Scan QR →
/scan- QR code scanner for merchant payments
- PISP initiates payment from user's bank account
- Shows merchant name, amount, confirm screen
-
Bank Accounts →
/accounts- View linked bank account balances (AISP cached reads)
- Connect new bank accounts
- Set primary account
-
Transaction History →
/transactions- Full transaction list with filters (date, type, status)
- Export to PDF/CSV
- Search by recipient, amount, reference
-
Notifications →
/notifications- Push notifications and transaction alerts
- Mark as read, delete
-
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:
- Error message displayed in form
- Red background (#EF4444/10)
- No account creation
- No "try again" option (legal requirement)
Legal Compliance:
- Vilkår.html section 3: "Du må være minst 18 år"
- PSD2 compliance: Strong Customer Authentication requires adult age
- AML regulation: No accounts for minors
6.2 BankID Verification Failure
Scenario 1: BankID returns fødselsnummer indicating age < 18
Handling:
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:
- 30-second timeout per OAuth step
- Max 3 retry attempts
- Exponential backoff: 5s → 10s → 20s
- User-facing error: "BankID er ikke tilgjengelig. Prøv igjen senere."
6.3 KYC Rejection
Scenario: Sumsub rejects user identity verification
Handling:
// 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:
- User can log in but cannot transact
- Profile accessible (change password, email)
- Support ticket creation enabled
- No remittance or QR payment access
- Transaction history remains visible
Support Workflow:
- User contacts support@getdrop.no
- Support agent reviews KYC rejection reason in Sumsub dashboard
- Agent requests additional documents via email
- User uploads documents to support ticket
- Agent manually submits documents to Sumsub
- Sumsub re-reviews → status updated via webhook
- 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
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:
-
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"
-
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)
-
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
<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>
9. Legal Consents
9.1 Required Consents
Terms of Service:
- Checkbox at registration: "Jeg godtar vilkårene for bruk"
- Must be checked to proceed
- Links to
landing/pages/vilkar.html
Privacy Policy:
- Checkbox at registration: "Jeg godtar personvernerklæringen"
- Must be checked to proceed
- Links to
landing/pages/privacy.html
PSD2 AISP/PISP Consent:
- Modal at BankID connection:
Tilgang til bankkontoen din Ved å koble BankID gir du Drop tillatelse til å: ✅ Lese saldo på din bankkonto (AISP) ✅ Initiere betalinger fra din bankkonto (PISP) Du kan trekke tilbake samtykket når som helst. [Godta og fortsett] [Avbryt]
Marketing Consent (Optional):
- Checkbox at registration: "Jeg ønsker å motta tips og tilbud fra Drop (valgfritt)"
- Default: unchecked
- Can be changed later in settings
9.2 Consent Storage
Database Schema:
-- 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:
- Active users: indefinite
- Deleted users: 90 days (then full purge)
- Transaction records: 5 years (AML compliance)
- Audit logs: 7 years (regulatory requirement)
10. Acceptance Criteria
10.1 Functional Requirements
Registration:
- User can register with email, password, name, phone, DOB
- Age validation rejects users < 18 years
- Password complexity enforced (8+ chars, upper, lower, digit, special)
- Norwegian phone number required (+47)
- Duplicate email detection
- OTP generated and logged (SMS not sent in demo mode)
Phone OTP Verification:
- User receives 6-digit OTP (logged to console in demo mode)
- OTP expires after 5 minutes
- OTP marked as used after successful verification
- Rate limiting: 5 OTP attempts per minute per IP
- Generic error messages (no user enumeration)
- Resend OTP functionality
PIN Setup:
- User sets 4-digit PIN
- Weak PIN patterns rejected (0000, 1234, 1111, sequential)
- PIN stored as bcrypt hash
- Auto-advance to onboarding on 4th digit
- Backend endpoint
/api/auth/set-pinimplemented
Onboarding Tour:
- 4-screen carousel (Welcome, Benefits, BankID, Ready)
- Progress indicator shows current step
- Skip button available (except last screen)
- Back button visible (except first screen)
- Tour completion tracked in database
- Backend endpoint
/api/onboarding/completeimplemented
BankID Verification:
- User redirected to BankID OAuth flow
- CSRF protection via state token
- Callback extracts fødselsnummer from ID token
- Age verification from fødselsnummer (reject if < 18)
- Fødselsnummer stored as SHA-256 hash
-
bankid_verifiedflag set to 1 - KYC initiation triggered after BankID success
- Dashboard blocks unverified users with modal
KYC Check:
- Demo mode: auto-approve KYC
- Production mode: redirect to Sumsub widget
- Webhook handler updates KYC status
- Pending state shows yellow banner on dashboard
- Rejected state shows red banner with support link
- Transaction routes blocked until KYC approved
- Push notification sent on KYC approval/rejection
10.2 Non-Functional Requirements
Performance:
- Registration API responds < 500ms (p95)
- OTP verification API responds < 300ms (p95)
- BankID OAuth redirect < 1s
- Onboarding UI loads < 1.5s (FCP)
Security:
- Password hashed with bcrypt (rounds: 12)
- PIN hashed with bcrypt (rounds: 12)
- JWT in httpOnly cookie (no localStorage)
- CSRF protection on BankID OAuth
- Rate limiting on all auth endpoints
- Audit trail for all user actions
- No PII in logs (phone numbers hashed)
Accessibility:
- WCAG 2.1 AA compliance
- Screen reader support
- Keyboard navigation
- Focus indicators on all interactive elements
- Error messages accessible (aria-live regions)
Usability:
- Mobile-first responsive design
- Touch targets >= 44px
- Clear error messages (Norwegian)
- Loading states for all async operations
- Success/error feedback for all actions
Legal Compliance:
- Terms of Service consent required
- Privacy Policy consent required
- PSD2 AISP/PISP consent modal at BankID
- Consent storage in database
- Age requirement enforced (18+)
- Norwegian residency requirement enforced (+47 phone, BankID)
10.3 Edge Case Coverage
- Age < 18 rejected (frontend + backend + BankID)
- BankID timeout handled gracefully
- BankID user cancellation handled
- KYC rejection flow implemented
- OTP expiry handled with resend
- Network errors retry with backoff
- User abandonment tracked in analytics
- Re-engagement emails triggered
11. Implementation Order
Phase 1: Backend Foundations (Priority: HIGH)
Duration: 2 days Owner: Backend agent
-
✅ Database schema updates
- Add new fields to
userstable (pin_hash, bankid_verified, onboarding_completed, national_id_hash) - Create
onboarding_progresstable - Create indexes
- Add new fields to
-
✅ Missing API endpoints
POST /api/auth/set-pinGET /api/auth/bankid/callbackPOST /api/onboarding/completePOST /api/auth/resend-otpPOST /api/webhooks/sumsub
-
✅ Age verification logic
- Extract DOB from fødselsnummer
- Validate age >= 18 in BankID callback
-
✅ Consent storage
- Record consents at registration and BankID
Phase 2: Frontend Fixes (Priority: HIGH)
Duration: 1 day Owner: Frontend agent
-
✅ PIN setup backend integration
- Call
/api/auth/set-pinafter PIN entered - Validate weak PIN patterns
- Handle errors
- Call
-
✅ Onboarding completion tracking
- Call
/api/onboarding/completeon last screen
- Call
-
✅ Dashboard BankID prompt
- Non-dismissable modal for unverified users
- "Koble BankID" button
- Clear explanation
-
✅ 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
-
✅ OTP resend flow
- Backend: Rate limiting (3 per hour)
- Frontend: "Send ny kode" button
-
✅ BankID error handling
- Timeout retry
- User cancellation
- CSRF validation
-
✅ KYC rejection flow
- Webhook handler
- Dashboard blocking UI
- Support ticket creation
Phase 4: Analytics & Re-engagement (Priority: LOW)
Duration: 1 day Owner: Backend + Marketing
-
✅ Drop-off tracking
- Event logging in
onboarding_progress - Funnel report SQL query
- Event logging in
-
✅ Email triggers
- OTP not verified (1h)
- BankID not linked (24h)
- KYC pending (48h)
-
✅ Push notifications
- OTP resend available
- KYC approved
- KYC rejected
Phase 5: Testing & Deployment (Priority: HIGH)
Duration: 2 days Owner: QA agent + DevOps
-
✅ Unit tests
- Age validation (frontend + backend)
- OTP verification
- PIN validation
- BankID callback
-
✅ Integration tests
- Full onboarding flow (register → KYC approved)
- BankID OAuth flow
- KYC webhook
-
✅ E2E tests (Playwright)
- Happy path: Register → Verify → BankID → Dashboard
- Error path: Age < 18 → Rejected
- Error path: BankID timeout → Retry
-
✅ Deployment
- Staging environment
- Smoke tests
- Production rollout
12. Success Metrics (3 Months Post-Launch)
| Metric | Target | Measurement |
|---|---|---|
| Registration Completion | 85% | OTP sent / Registration started |
| OTP Verification | 80% | OTP verified / OTP sent |
| Onboarding Completion | 70% | Tour complete / OTP verified |
| BankID Connection | 75% | BankID verified / Tour complete |
| KYC Approval | 90% | KYC approved / BankID verified |
| Overall Conversion | 40% | Fully verified / Registration started |
| Time to Verify | < 2 hours | Median time from registration to KYC approval |
| Drop-off Rate (Tour) | < 15% | Users who skip tour |
| Re-engagement Open Rate | 25% | Email open rate for re-engagement campaigns |
| Support Tickets (KYC) | < 5% | Tickets per verified user |
13. Rollout Plan
13.1 Soft Launch (Week 1)
- Audience: Internal team (10 users)
- Goal: Validate full flow, catch bugs
- KYC: Demo mode (auto-approve)
- Monitoring: Manual testing, bug reports in Slack
13.2 Beta Launch (Week 2-3)
- Audience: Friends & family (50 users)
- Goal: Gather UX feedback, measure conversion rates
- KYC: Demo mode (auto-approve)
- Monitoring: Analytics dashboard, user feedback survey
13.3 Limited Public Launch (Week 4-6)
- Audience: Invite-only (500 users)
- Goal: Test production BankID + KYC integration
- KYC: Production mode (Sumsub)
- Monitoring: Drop-off tracking, support ticket volume
13.4 Full Public Launch (Week 7+)
- Audience: Open to all (Norway)
- Goal: Scale to 10,000+ users
- KYC: Production mode (Sumsub)
- Monitoring: Weekly funnel reports, monthly conversion review
14. Appendix
14.1 Glossary
| Term | Definition |
|---|---|
| AISP | Account Information Service Provider (PSD2) — reads bank account balance |
| PISP | Payment Initiation Service Provider (PSD2) — initiates payments from bank account |
| BankID | Norwegian eID system (OAuth 2.0 OIDC) for identity verification |
| Fødselsnummer | 11-digit Norwegian national ID (encodes DOB + unique ID) |
| KYC | Know Your Customer — identity verification for AML compliance |
| Sumsub | Third-party KYC provider (document verification) |
| OTP | One-Time Password (6-digit SMS code) |
| SCA | Strong Customer Authentication (PSD2 requirement) |
| Pass-through model | Drop never holds customer money; all funds stay in user's bank |
14.2 References
| Document | Location |
|---|---|
| Architecture Document | project/architecture/architecture-document.md |
| Terms of Service | landing/pages/vilkar.html |
| Privacy Policy | landing/pages/privacy.html |
| Figma Make Export (UI Source of Truth) | mockups/figma-make-export/src/components/ |
| Current Onboarding Flow | src/drop-app/src/app/onboarding/page.tsx |
| Current Register Flow | src/drop-app/src/app/register/page.tsx |
| BankID OAuth | src/drop-app/src/app/api/auth/bankid/route.ts |
| KYC Service | src/drop-app/src/lib/services/kyc.ts |
14.3 Related Tasks
| Task | MC # | Description | Status |
|---|---|---|---|
| Implement user registration | #947 | Email/password registration | ✅ Done |
| Implement BankID OAuth | #948 | BankID integration | 🚧 Partial |
| Implement KYC verification | #949 | Sumsub integration | 🚧 Partial |
| Build onboarding tour | #950 | 4-screen carousel | ✅ Done |
| Add consent tracking | #951 | GDPR compliance | ❌ Not started |
| Analytics integration | #952 | Posthog/Mixpanel | ❌ Not started |
End of Specification
Next Steps:
- Review this spec with Alem for approval
- Create implementation tasks in Mission Control
- Assign tasks to builder agents
- Begin Phase 1 (Backend Foundations)
Questions for Alem:
- Preferred analytics platform (Posthog, Mixpanel, custom)?
- SMS provider for OTP (Twilio, MessageBird)?
- Email provider for re-engagement (SendGrid, Mailgun)?
- KYC provider credentials (Sumsub account setup)?
- BankID production credentials (when to request)?
drop-push-notifications-spec
Drop Push Notification Delivery Service — Implementation Spec
Project: Drop (Fintech Payment App) Task: MC #1196 Date: 2026-02-17 Author: John (AI Director)
1. Executive Summary
Drop currently has notification stubs (database table, UI, demo-mode service) but no actual push delivery infrastructure. This spec defines a production-ready push notification system covering:
- Multi-platform delivery: Web Push (PWA primary), Firebase Cloud Messaging (Android future), APNs (iOS future)
- Notification taxonomy with security and compliance categories
- Device token management and lifecycle
- User preference management per Norwegian marketing laws
- Database schema for device tokens, notification queue, delivery logs
- API endpoints for device registration and preference management
- Integration with existing transaction and auth systems
MVP Recommendation: Web Push (PWA) — Drop currently has web app only, mobile apps planned for future. Start with Web Push API + service workers, add FCM/APNs when React Native mobile apps ship.
2. Architecture Overview
2.1 Unified Push Service Layer
File: src/lib/push.ts (NEW — replaces stub at src/lib/services/notifications.ts)
Provider-agnostic push notification abstraction. All push sending goes through this module.
Interface:
// 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:
- Markedsføringsloven §15: Promotional notifications require EXPLICIT prior consent (opt-in)
- GDPR Art. 6(1)(a): Consent must be freely given, specific, informed, unambiguous
- Implementation: Promotional notifications OFF by default, require explicit user action to enable
- Audit trail: Log consent timestamp and context in
notification_preferencestable
2.3 Platform-Specific Implementations
A. Web Push API (PRIMARY — MVP)
Tech Stack:
- Service worker (
public/sw.js) handles push events - Web Push API (browser native, no SDK required)
web-pushnpm library (server-side, VAPID signing)
VAPID (Voluntary Application Server Identification):
- Generate keys:
npx web-push generate-vapid-keys - Store in env:
VAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,VAPID_SUBJECT(mailto:support@getdrop.no)
Client-Side Flow:
- User visits Drop PWA
- Service worker registers (
/sw.js) - User grants notification permission (browser prompt)
- Client subscribes to push:
registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC_KEY }) - Client sends subscription object to server:
POST /api/notifications/register-device - Server stores subscription in
device_tokenstable
Server-Side Flow:
- Transaction completes → create notification in
notificationstable - Fetch user's Web Push subscriptions from
device_tokensWHERE platform='web' - For each subscription:
- Use
web-pushlibrary to send notification webpush.sendNotification(subscription, JSON.stringify(payload))
- Use
- 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:
- Firebase Cloud Messaging (FCM) HTTP v1 API
firebase-adminSDK (server-side)@react-native-firebase/messaging(client-side)
Setup:
- Create Firebase project at console.firebase.google.com
- Add Android app to Firebase project (package name:
no.getdrop.app) - Download
google-services.json, place in React Native Android project - Download service account key JSON for server
- 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:
- APNs HTTP/2 API
apnnpm library (server-side)@react-native-firebase/messaging(works for both FCM and APNs)
Setup:
- Create iOS app in Apple Developer account
- Create Push Notification certificate or Auth Key (.p8 file)
- Download .p8 file, note Key ID and Team ID
- Set env vars:
APNS_KEY_ID,APNS_TEAM_ID,APNS_KEY_PATH(orAPNS_KEY_CONTENTas 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:
- Transactional: ALWAYS enabled (row doesn't exist = enabled, quiet hours ignored)
- Account: Enabled by default (created on first app launch)
- Promotional: Disabled by default (created on first app launch)
Quiet hours logic:
- If notification type = transactional → send immediately (ignore quiet hours)
- Otherwise, check user's local time (use timezone from user profile or default to Europe/Oslo)
- If current time is between
quiet_hours_startandquiet_hours_end→ queue for later (send atquiet_hours_end)
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:
- 400: Missing platform or token/subscription
- 401: Unauthorized
- 409: Device already registered (returns existing deviceId)
Logic:
- Validate platform ('web', 'android', 'ios')
- For Web Push: validate subscription object (endpoint, keys.p256dh, keys.auth)
- For FCM/APNs: validate token format
- Check if token/endpoint already exists:
- If exists → update
last_used_at, setactive=1, return existing ID - If not exists → insert new row
- If exists → update
- 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:
- Verify device belongs to authenticated user
- 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:
- Fetch rows from
notification_preferencesWHERE user_id = ? - If no rows exist → create defaults (transactional=1, account=1, promotional=0)
- 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:
- 400: Invalid category or quiet hours format
- 403: Attempt to disable transactional notifications
Logic:
- Validate categories (cannot disable transactional)
- Validate quiet hours format (HH:MM, 00:00-23:59)
- UPSERT preferences into
notification_preferencestable - Update
updated_at = now()
File: src/app/api/notifications/preferences/route.ts (NEW, PUT handler)
4.5 GET /api/notifications/history
Purpose: Get user's push notification delivery history (debugging/audit).
Auth: Required.
Query params:
limit(default: 50, max: 100)offset(default: 0)
Response (200):
{
"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:
- Query
notification_logWHERE user_id = ? ORDER BY sent_at DESC LIMIT ? OFFSET ? - Count total rows for pagination
- 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:
src/app/api/transactions/remittance/route.tssrc/app/api/transactions/qr-payment/route.ts
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:
src/app/api/auth/change-password/route.ts(if exists, else create)src/app/api/kyc/verify/route.ts(if exists)src/app/api/bankid/link/route.ts(if exists)
Pattern (same for all):
// 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:
- Permission Status — Shows if browser/OS notifications enabled
- If not enabled → show "Enable Notifications" button → triggers browser permission prompt
- 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)
- 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"
- 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
}
}
Promotional Consent UI:
<div>
<Toggle
checked={promotionalEnabled}
onChange={async (enabled) => {
if (enabled) {
// Show consent dialog
const confirmed = await showConsentDialog(
"Markedsføringsvarsler",
"Jeg samtykker til å motta tilbud, anbefalinger og produktoppdateringer fra Drop. " +
"Jeg kan trekke tilbake samtykket når som helst i innstillinger."
);
if (confirmed) {
await updatePreferences({ promotional: { enabled: true } });
}
} else {
await updatePreferences({ promotional: { enabled: false } });
}
}}
/>
<label>Tilbud og kampanjer</label>
<p className="text-xs text-gray-500">
Motta tilbud, anbefalinger og produktoppdateringer (krever samtykke)
</p>
</div>
7. Norwegian Marketing Law Compliance
7.1 Markedsføringsloven §15 (Marketing Consent)
Law: Promotional electronic messages (email, SMS, push) require EXPLICIT prior consent from recipient.
Drop Implementation:
- Promotional notifications OFF by default
- User must actively enable in settings
- Consent dialog shows clear purpose ("tilbud, anbefalinger, produktoppdateringer")
- Consent timestamp logged in
notification_preferences.updated_at - User can withdraw consent at any time (toggle OFF)
Audit trail:
-- Query: Show consent history for user
SELECT
user_id,
category,
enabled,
created_at,
updated_at
FROM notification_preferences
WHERE user_id = ? AND category = 'promotional';
7.2 GDPR Compliance
Art. 6(1)(a) — Consent as legal basis:
- Consent must be freely given, specific, informed, unambiguous
- Drop: ✅ Toggle + consent dialog = unambiguous affirmative action
- Drop: ✅ Separate toggle for promotional (not bundled with account/transactional)
Art. 7 — Conditions for consent:
- Burden of proof: controller must demonstrate consent was given
- Drop: ✅
notification_preferencestable logs timestamp and enabled status
Art. 17 — Right to erasure:
- User can delete account → all notification preferences deleted (CASCADE on user_id FK)
8. Security Considerations
8.1 No Sensitive Data in Push Payload
Risk: Push notifications travel through third-party servers (FCM, APNs, Web Push service). Payload can be logged/intercepted.
Mitigation:
- NEVER include: passwords, tokens, full card numbers, bank account numbers
- DO include: generic message ("Du mottok penger") + deep link to app
- Sensitive details fetched AFTER user opens app (authenticated API call)
Example (GOOD):
{
"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):
- Encrypt
token,endpoint,p256dh_key,auth_keycolumns at rest - Use AES-256-GCM with key stored in env var (
DEVICE_TOKEN_ENCRYPTION_KEY) - Decrypt on read, encrypt on write
MVP: Store in plain text (same risk level as session tokens — if DB is compromised, session tokens are also exposed). Add encryption in Phase 2.
8.3 Rate Limiting
Risk: Attacker with stolen session token spams push notifications to user or all users.
Mitigation:
- Per-user rate limit: max 100 push notifications per hour
- Global rate limit: max 10,000 push notifications per hour (protect against abuse)
- Rate limit key:
push:{userId}:{hour}
Implementation:
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:
- Delivery rate drops below 90% (hourly check)
- More than 100 failures in 1 hour
- Web Push VAPID keys missing on startup
- FCM/APNs credentials invalid (auth error)
Implementation (future):
// In src/lib/alerts.ts (from drop-supporting-systems-plan.md)
await sendSlackAlert({
severity: 'high',
message: `Push notification delivery rate dropped to ${rate}% (platform: ${platform})`,
link: 'http://localhost:3030/monitoring/push',
});
10. Cost Analysis
| Platform | Setup Cost | Operating Cost | Free Tier | Paid Tier |
|---|---|---|---|---|
| Web Push | 0 kr | 0 kr | Unlimited (browser-managed) | N/A |
| FCM (Android) | 0 kr | 0 kr | Unlimited | N/A |
| APNs (iOS) | 794 kr/year (Apple Developer) | 0 kr | Unlimited | N/A |
Total Annual Cost: 794 kr (Apple Developer membership only).
Infrastructure cost: Minimal — push notification sending is lightweight (HTTP requests), no message queue or worker needed for MVP.
Post-MVP (if >100k push/day): Consider message queue (BullMQ + Redis, ~200 kr/month on Railway/Fly.io).
11. Implementation Plan
Phase 1: Web Push (MVP) — 3 days
Day 1: Backend Infrastructure (6h)
- Create
device_tokens,notification_queue,notification_log,notification_preferencestables - Implement
src/lib/push.tswith Web Push support - Generate VAPID keys, add to env vars
- Create API endpoints:
- POST
/api/notifications/register-device - GET
/api/notifications/preferences - PUT
/api/notifications/preferences - GET
/api/vapid-public-key
- POST
Day 2: Frontend Integration (6h)
- Create service worker
public/sw.js(push event listener) - Register service worker in
src/app/layout.tsx - Build notification settings UI (
src/app/profile/notifications/page.tsx) - Implement permission request flow
- Test Web Push subscription registration
Day 3: Integration + Testing (6h)
- Integrate push notifications into transaction routes (remittance, QR payment)
- Integrate login alert into auth/login route
- Add
device_fingerprintcolumn tosessionstable - Test end-to-end flows:
- Register device → send test notification → receive on device
- Complete transaction → receive push notification
- Login from new device → receive security alert
- Update preferences → verify opt-out works
- Deploy to staging
Total: 18 hours (3 days @ 6h/day)
Phase 2: FCM (Android) — 1 day (FUTURE)
When: React Native Android app ready for testing.
Tasks:
- Set up Firebase project, download service account key
- Implement FCM support in
src/lib/push.ts - Add
firebase-admindependency - Test with React Native app
Phase 3: APNs (iOS) — 1 day (FUTURE)
When: React Native iOS app ready for testing.
Tasks:
- Generate APNs Auth Key (.p8 file) from Apple Developer account
- Implement APNs support in
src/lib/push.ts - Add
apndependency - Test with React Native app
Phase 4: Advanced Features — 2 days (FUTURE)
Features:
- Background queue + retry logic (BullMQ + Redis)
- Quiet hours enforcement (defer notifications to later)
- Rich notifications (images, actions, reply)
- Device token encryption at rest
- Admin dashboard for monitoring delivery rates
12. Testing Strategy
12.1 Unit Tests
Test files to create:
tests/unit/push.test.ts— TestsendPushNotification(),registerDeviceToken(), preference checkstests/unit/notification-preferences.test.ts— Test default preferences, opt-in/opt-out logic
Coverage:
- ✅ Transactional notifications always sent (ignore preferences)
- ✅ Account notifications respect opt-out
- ✅ Promotional notifications require opt-in
- ✅ Quiet hours respected (except transactional)
- ✅ Rate limiting enforced
- ✅ Device token deduplication (same token = update, not insert)
12.2 Integration Tests
Test files to create:
tests/integration/push-flow.test.ts— End-to-end push notification flow
Scenarios:
- Register device → send notification → verify log entry
- POST
/api/notifications/register-devicewith Web Push subscription - Trigger transaction → verify
notification_logentry created with status='sent'
- POST
- Opt-out → verify notification skipped
- Update preferences: account notifications OFF
- Trigger transaction → verify notification NOT sent (status='skipped' in log)
- Login alert → new device
- Login from new User-Agent → verify login alert notification sent
- Promotional consent → verify GDPR compliance
- Enable promotional notifications → verify
notification_preferences.updated_atupdated
- Enable promotional notifications → verify
12.3 Manual Testing Checklist
- Web Push permission prompt appears on first visit
- Notification received after granting permission
- Notification click opens correct URL in app
- Notification settings UI shows correct state (enabled/disabled per category)
- Quiet hours toggle works (notifications deferred)
- Device removal works (device marked inactive)
- Promotional consent dialog shows before enabling
- Rate limit enforced (send 101 notifications → last one fails)
13. Success Metrics
Week 1 (Post-Deployment)
- 0 push notification failures (delivery rate 100%)
- <5% users block notifications after permission prompt
- 0 GDPR complaints about unsolicited promotional notifications
Month 1
- >70% users with push notifications enabled
- >50% notification open rate (click-through from push to app)
- <10% opt-out rate for account notifications
- 0 support tickets about missing notifications
Quarter 1
- Push notifications enabled for all transaction types
- Login alerts sent for 100% of new device logins
- Promotional notifications available (consent flow tested)
- FCM/APNs integration ready for mobile app launch
14. Rollout Strategy
Staging (1 week)
- Deploy Web Push to staging environment
- Test with internal users (Alem, team)
- Verify DNS/HTTPS setup (Web Push requires HTTPS)
- Check spam score (should be N/A for push, unlike email)
Production (Gradual)
- Week 1: Enable Web Push for NEW users only (flag in
userstable:push_enabled) - Week 2: Monitor delivery rate, opt-out rate, open rate
- Week 3: Enable for ALL users (remove flag, make default)
- Week 4: Enable transactional notifications (transaction receipts, login alerts)
- Month 2: Enable account notifications (summaries, low balance)
- Month 3: Enable promotional notifications (with consent flow)
15. Acceptance Criteria
Push Service Layer:
-
sendPushNotification()sends via Web Push in production -
sendPushNotification()logs to console in demo mode -
registerDeviceToken()stores device token in DB - Device token deduplication works (UPSERT on endpoint/token)
- Stale token cleanup marks inactive tokens (>90 days)
Database:
- All tables created on
initDb() - Indexes exist for performance queries
- Default preferences created on first app launch
API Endpoints:
- POST
/api/notifications/register-deviceregisters Web Push subscription - GET
/api/notifications/preferencesreturns user preferences - PUT
/api/notifications/preferencesupdates preferences - GET
/api/vapid-public-keyexposes public key
Integration:
- Transaction completion sends push notification to sender
- Transfer received sends push notification to recipient (if Drop user)
- Login from new device sends security alert
- Promotional notifications require consent dialog
Compliance:
- Transactional notifications always sent (no opt-out)
- Promotional notifications OFF by default
- Consent timestamp logged in DB
- No sensitive data in push payload
UI:
- Notification settings screen shows category toggles
- Quiet hours UI functional
- Device list shows registered devices
- Permission prompt appears on first visit
16. Dependencies
Add to package.json:
{
"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
drop-rebrand-plan
Plan: Drop Full Rebrand
Objective
Rebrand all Drop screens (12 app pages + 13 landing pages) to match Alem's approved Stitch design. Plus logo, branding assets, and email templates.
Source of Truth
- Stitch reference:
/Users/makinja/ALAI/products/Drop/design/stitch-login-reference.png - Design system:
/Users/makinja/ALAI/products/Drop/design/design-system-reference.md - Login page (already rebranded):
src/drop-app/src/app/login/page.tsx
Team Orchestration
Team Members
| ID | Name | Role | Agent Type |
|---|---|---|---|
| B1 | logo-builder | Export/create logo, generate all asset sizes | builder |
| V1 | logo-validator | Verify all logo assets exist and are correct | validator |
| B2 | app-auth-builder | Rebrand Login + Onboarding pages | builder |
| B3 | app-core-builder | Rebrand Dashboard + Home + Send + Scan pages | builder |
| B4 | app-util-builder | Rebrand Cards + Accounts + Profile + History + Merchant pages | builder |
| V2 | app-validator | Verify all 12 app pages match design system | validator |
| B5 | landing-main-builder | Rebrand landing index + product pages | builder |
| B6 | landing-info-builder | Rebrand company + legal landing pages | builder |
| V3 | landing-validator | Verify all 13 landing pages match design system | validator |
| B7 | branding-builder | Email templates + OG images + favicon | builder |
| V4 | branding-validator | Verify email templates and brand assets | validator |
Step-by-Step Tasks
Phase 1: Logo & Brand Foundation
Task 1: Create/export Drop logo and all brand assets
- Owner: B1
- BlockedBy: none
- Files:
src/drop-app/public/drop-icon.png,src/drop-app/public/favicon.svg,brand/logo-icon.svg,brand/app-icon.svg,brand/favicon.svg,src/drop-app/src/components/drop-logo.tsx - Instructions:
- Read the Stitch reference image at
design/stitch-login-reference.png - The logo is: green rounded square (#0B6E35) with white $ symbol and circular transfer arrows, plus gold (#D4A017) accent dot
- Create a clean SVG version of this logo
- Generate PNG versions: drop-icon.png (128x128, 256x256), icon-200.png, icon-48.png
- Create favicon.svg using Drop green (#0B6E35) not ALAI green
- Update drop-logo.tsx: DropAppIcon should render the new logo SVG inline
- Update brand/ directory SVG files
- Read the Stitch reference image at
- Acceptance:
- drop-icon.png exists in public/ at 128x128 minimum
- favicon.svg uses #0B6E35
- drop-logo.tsx DropAppIcon renders new logo
- All brand/ SVGs updated
Task 2: Validate logo and brand assets
- Owner: V1
- BlockedBy: 1
- Acceptance: All logo files exist, correct colors, no broken references
Phase 2: App Pages (parallel)
Task 3: Rebrand Login + Onboarding
- Owner: B2
- BlockedBy: none
- Files:
src/drop-app/src/app/login/page.tsx,src/drop-app/src/app/onboarding/page.tsx - Instructions:
- Read design-system-reference.md
- Login is already close to target — refine if needed
- Onboarding: apply same design language (gray bg, white cards, green buttons, Fraunces headings)
- Keep all existing logic (form validation, API calls, state management)
- Acceptance:
- Login matches Stitch reference
- Onboarding uses same design language
- All form logic preserved
Task 4: Rebrand Dashboard + Home + Send + Scan
- Owner: B3
- BlockedBy: none
- Files:
src/drop-app/src/app/page.tsx,src/drop-app/src/app/dashboard/page.tsx,src/drop-app/src/app/send/page.tsx,src/drop-app/src/app/scan/page.tsx - Instructions:
- Read design-system-reference.md AND current login/page.tsx as reference
- These pages use BottomNav — use the "app pages WITH bottom nav" layout pattern
- Dashboard: main screen after login — balance display, quick actions, recent transactions
- Home: app entry point, likely redirect or welcome
- Send: 4-step money transfer flow — preserve all step logic
- Scan: QR scanner — preserve camera/scan logic
- Keep ALL business logic, only change visual styling
- Acceptance:
- All 4 pages use consistent design system
- BottomNav present on all pages
- All existing functionality preserved
Task 5: Rebrand Cards + Accounts + Profile + History + Merchant
- Owner: B4
- BlockedBy: none
- Files:
src/drop-app/src/app/cards/page.tsx,src/drop-app/src/app/accounts/page.tsx,src/drop-app/src/app/profile/page.tsx,src/drop-app/src/app/history/page.tsx,src/drop-app/src/app/merchant/page.tsx - Instructions:
- Read design-system-reference.md AND current login/page.tsx as reference
- All pages use BottomNav layout pattern
- Cards: virtual/physical card management
- Accounts: linked bank accounts list
- Profile: user settings and preferences
- History: transaction list with filters
- Merchant: merchant onboarding flow
- logo-preview/page.tsx can be deleted or simplified
- Keep ALL business logic, only change visual styling
- Acceptance:
- All 5 pages use consistent design system
- BottomNav on all pages
- All existing functionality preserved
Task 6: Validate all app pages
- Owner: V2
- BlockedBy: 3, 4, 5
- Acceptance:
- All 12 pages load without errors
- Consistent color palette (#0B6E35, #D4A017, #EEEEEE, #1A1A1A)
- Consistent typography (Fraunces headings, DM Sans body)
- BottomNav on all pages except login/onboarding
- No hardcoded wrong colors or fonts
Phase 3: Landing Pages (parallel)
Task 7: Rebrand landing index + product pages
- Owner: B5
- BlockedBy: none
- Files:
landing/index.html,landing/pages/send-penger.html,landing/pages/qr-betaling.html,landing/pages/priser.html,landing/pages/sikkerhet.html - Instructions:
- Read design-system-reference.md (Landing Page section)
- These are static HTML — use CSS variables + Google Fonts CDN
- Main index: hero section, features, CTA, footer
- Product pages: detailed feature descriptions
- Consistent navbar and footer across all pages
- Colors: same as app (#0B6E35, #D4A017, #EEEEEE, white)
- Desktop-responsive (max-width 1200px, mobile-friendly)
- Use favicon.svg with Drop green
- Acceptance:
- All 5 pages share consistent navbar/footer
- Same color palette as app
- Responsive (375px to 1440px)
- No broken links between pages
Task 8: Rebrand landing company + legal pages
- Owner: B6
- BlockedBy: none
- Files:
landing/pages/om-drop.html,landing/pages/karriere.html,landing/pages/presse.html,landing/pages/kontakt.html,landing/pages/personvern.html,landing/pages/vilkar.html,landing/pages/lisenser.html,landing/pages/cookies.html - Instructions:
- Read design-system-reference.md (Landing Page section)
- Read landing/index.html for navbar/footer pattern (use same)
- Company pages (om-drop, karriere, presse, kontakt): content + design
- Legal pages (personvern, vilkar, lisenser, cookies): clean readable text layout
- Consistent with the main landing page style
- Acceptance:
- All 8 pages share same navbar/footer as index
- Consistent colors and typography
- Legal pages readable and well-structured
- All internal links work
Task 9: Validate all landing pages
- Owner: V3
- BlockedBy: 7, 8
- Acceptance:
- All 13 pages render correctly
- Consistent design across all pages
- All links work
- Mobile responsive
Phase 4: Email & Remaining Branding
Task 10: Create email templates + OG images
- Owner: B7
- BlockedBy: 1
- Files: new
src/drop-app/src/email-templates/directory,brand/og-image.html - Instructions:
- Create HTML email templates (inline CSS, 600px max-width):
- welcome.html — Welcome to Drop
- transaction-receipt.html — Payment confirmation
- password-reset.html — Reset password link
- Use Drop brand colors, logo, "Send money. Simply." tagline
- Update og-image.html in brand/ to match new design
- All emails must work in Gmail, Outlook, Apple Mail
- Create HTML email templates (inline CSS, 600px max-width):
- Acceptance:
- 3 email templates created
- Valid HTML email (inline CSS, table layout)
- Drop branding consistent
- OG image updated
Task 11: Validate email templates and branding
- Owner: V4
- BlockedBy: 10
- Acceptance:
- Email templates have inline CSS
- No external CSS/JS dependencies
- Drop logo and colors present
- OG image renders correctly
Validation Commands
# App — dev server
cd /Users/makinja/ALAI/products/Drop/src/drop-app && npm run build
# Landing — open in browser
open /Users/makinja/ALAI/products/Drop/landing/index.html
# Check all files exist
ls -la /Users/makinja/ALAI/products/Drop/src/drop-app/public/drop-icon.png
ls -la /Users/makinja/ALAI/products/Drop/src/drop-app/public/favicon.svg
ls -la /Users/makinja/ALAI/products/Drop/brand/logo-icon.svg
drop-sms-otp-spec
Drop SMS/OTP 2FA Implementation Specification
Version: 1.0 Date: 2026-02-17 Author: John (AI Director) Status: DRAFT — AWAITING APPROVAL MC Task: #1189
Executive Summary
This specification defines the implementation of SMS-based Two-Factor Authentication (2FA) for Drop's high-value financial transactions. Drop already uses BankID for primary authentication — this adds SMS OTP as a second factor for critical operations (remittance, QR payments, account changes).
Key Points:
- Scope: Transaction verification ONLY (not login replacement)
- Target: Norwegian users (+47 validation)
- Provider: SMS gateway integration (Twilio recommended, see comparison)
- Storage: SQLite
otp_tokenstable with 5-min expiry - Rate limiting: Anti-abuse protection per user/IP
- Fallback: BankID re-auth if SMS fails
1. Requirements Analysis
1.1 Functional Requirements
| ID | Requirement | Priority | Rationale |
|---|---|---|---|
| FR-1 | Send 6-digit OTP via SMS on remittance initiation | HIGH | Primary security control |
| FR-2 | Verify OTP before processing transaction | HIGH | Prevents unauthorized transfers |
| FR-3 | Rate limit OTP requests (3/hour per user) | HIGH | Anti-abuse, anti-spam |
| FR-4 | Expire OTP after 5 minutes | HIGH | Security best practice |
| FR-5 | Support Norwegian phone numbers (+47) | HIGH | Target market |
| FR-6 | Block reused/expired OTPs | HIGH | Prevent replay attacks |
| FR-7 | Fallback to BankID re-auth if SMS fails | MEDIUM | Accessibility, reliability |
| FR-8 | Audit log all OTP operations | MEDIUM | Compliance, debugging |
| FR-9 | Allow user to resend OTP (1x per transaction) | MEDIUM | UX improvement |
| FR-10 | Support OTP for QR payments | MEDIUM | Future feature parity |
1.2 Non-Functional Requirements
| ID | Requirement | Target | Measurement |
|---|---|---|---|
| NFR-1 | SMS delivery time | < 30 seconds | Provider SLA |
| NFR-2 | OTP generation time | < 100ms | Server-side perf |
| NFR-3 | Verification latency | < 200ms | Database lookup + validation |
| NFR-4 | Availability | 99.9% | Provider uptime |
| NFR-5 | SMS delivery rate | > 95% | Provider metrics |
| NFR-6 | Cost per OTP | < 0.10 NOK | Budget constraint |
1.3 User Stories
US-1: Remittance with OTP AS a Drop user WHEN I initiate a remittance transfer THEN I receive an SMS with a 6-digit code AND I enter the code within 5 minutes AND the transaction proceeds only if the code is correct
US-2: Failed SMS fallback AS a Drop user WHEN I don't receive the OTP SMS within 30 seconds THEN I can request a resend (1x) OR re-authenticate with BankID AND the transaction completes via the fallback method
US-3: Rate limiting protection AS a Drop user WHEN I request more than 3 OTPs in 1 hour THEN I receive an error message AND must wait or use BankID fallback
2. SMS Provider Comparison
2.1 Provider Research
| Provider | Pricing (Norway) | Delivery Time | Reliability | Integration Complexity | Notes |
|---|---|---|---|---|---|
| Twilio | ~$0.065 (~0.70 NOK) | < 10s | 99.95% | Low (REST API) | Industry standard, good docs, auto-scaling |
| MessageBird (Bird) | ~$0.008-0.065 | < 15s | 99.9% | Low (REST API) | 90% cheaper than Twilio on bulk, Norway-specific pricing unclear |
| Vonage | €0.0057-0.0642 (~0.06-0.70 NOK) | < 20s | 99.5% | Medium (complex API) | Voice fallback available |
| BudgetSMS | €0.048 (~0.52 NOK) | < 30s | 95% | Low (HTTP/XML) | Lower cost, lower reliability |
| PSWinCom (DASH) | Not public | Unknown | Unknown | Unknown | Norwegian provider, requires quote |
| Intelecom | Not public | Unknown | Unknown | Unknown | Norwegian provider, requires quote |
Recommendation: Twilio
Rationale:
- Proven reliability — 99.95% uptime, < 10s delivery globally
- Developer-friendly — Node.js SDK, webhook support, excellent docs
- Scalability — Auto-volume pricing, no manual negotiation
- Norway coverage — Tier 1 country, consistent delivery
- Cost acceptable — ~0.70 NOK per SMS, budget allows < 0.10 NOK per OTP (TBD: verify with Alem)
- Fallback options — Voice OTP available if SMS fails
Alternative: MessageBird (if cost is critical)
- 90% cheaper on bulk volume
- Need to verify Norway-specific pricing
- Slightly longer delivery time (< 15s vs < 10s)
Decision: Use Twilio for MVP. Re-evaluate cost after 1000 transactions.
2.2 Cost Analysis
Assumptions:
- 500 remittance transactions/month (MVP target)
- 1.2 OTP per transaction (20% resend rate)
- Twilio pricing: $0.065/SMS (~0.70 NOK)
Monthly Cost:
- 500 tx × 1.2 OTP = 600 SMS
- 600 × 0.70 NOK = 420 NOK/month (~€35)
Annual Cost: 5,000 NOK (€420)
Cost per transaction: 0.84 NOK (acceptable for fintech)
3. Architecture Design
3.1 System Flow
┌─────────────┐
│ User │
│ (Browser) │
└─────┬───────┘
│ 1. POST /api/transactions/remittance
▼
┌─────────────────────────────────────┐
│ Next.js API Route │
│ /api/transactions/remittance │
│ │
│ 1. Validate transaction data │
│ 2. Check user KYC status │
│ 3. Generate 6-digit OTP │
│ 4. Store OTP in DB (expires 5min) │
│ 5. Call Twilio API → send SMS │
│ 6. Return transaction_pending │
└─────┬───────────────────────────────┘
│ 2. SMS sent
▼
┌─────────────┐
│ Twilio │
│ SMS API │
└─────┬───────┘
│ 3. Deliver SMS to +47 number
▼
┌─────────────┐
│ User │
│ (Phone) │
└─────┬───────┘
│ 4. User enters OTP code
▼
┌─────────────────────────────────────┐
│ Next.js API Route │
│ /api/otp/verify │
│ │
│ 1. Lookup OTP in DB │
│ 2. Validate: not expired, correct │
│ 3. Mark OTP as used │
│ 4. Process transaction via PISP │
│ 5. Return transaction_completed │
└─────────────────────────────────────┘
3.2 Database Schema
New Table: otp_tokens
CREATE TABLE IF NOT EXISTS otp_tokens (
id TEXT PRIMARY KEY, -- otp_xxxxx
user_id TEXT NOT NULL, -- FK to users.id
phone_number TEXT NOT NULL, -- E.164 format (+47...)
code TEXT NOT NULL, -- 6-digit numeric code (plain, NOT hashed — short-lived)
purpose TEXT NOT NULL, -- 'remittance', 'qr_payment', 'account_change'
transaction_id TEXT, -- FK to transactions.id (nullable)
status TEXT NOT NULL, -- 'pending', 'verified', 'expired', 'failed'
attempts INTEGER DEFAULT 0, -- Verification attempts (max 3)
resend_count INTEGER DEFAULT 0, -- Resend attempts (max 1)
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL, -- 5 minutes from created_at
verified_at TEXT, -- Timestamp when verified
ip_address TEXT, -- Request IP for audit
user_agent TEXT, -- Request user agent
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_otp_user_status ON otp_tokens(user_id, status);
CREATE INDEX idx_otp_expires ON otp_tokens(expires_at);
CREATE INDEX idx_otp_transaction ON otp_tokens(transaction_id);
Why NOT hash the OTP code?
- Short-lived: 5-min expiry makes brute-force impractical
- Single-use: Deleted/marked used after verification
- Performance: Plain lookup faster than bcrypt compare (< 200ms target)
- Industry standard: Most OTP providers store plain (Twilio, Auth0, Firebase)
Security mitigations:
- Rate limiting (3 OTP requests/hour per user)
- Max 3 verification attempts per OTP
- SQLite file encryption at rest (OS-level)
- Audit logging all OTP operations
4. API Endpoints
4.1 POST /api/otp/send
Purpose: Generate and send OTP via SMS
Request:
{
"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:
- User must be authenticated (JWT)
- User must have KYC approved
- Purpose must be:
remittance,qr_payment,account_change - Phone number must be +47 (Norwegian)
- Rate limit: 3 requests/hour per user
4.2 POST /api/otp/verify
Request:
{
"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
- Auto-advance on digit entry
- Paste support
- Mobile numeric keyboard
- Accessibility (ARIA labels)
OtpDialog.tsx — Modal for OTP entry
- Countdown timer (5 min)
- Resend button (appears after 30s)
- BankID fallback link
- Error messages
10. Testing Strategy
Unit Tests:
- ✅ Generate OTP creates 6-digit code
- ✅ Verify OTP accepts correct code
- ✅ Verify OTP rejects wrong code
- ✅ Verify OTP rejects expired code
- ✅ Resend OTP blocks after 1 resend
Integration Tests:
- ✅ /api/otp/send requires auth + KYC
- ✅ /api/otp/verify validates code
- ✅ Rate limiting enforced
E2E Tests (Playwright):
- ✅ Complete remittance with OTP
- ✅ Resend OTP flow
- ✅ BankID fallback
11. Deployment Plan
Phase 1: Backend (Week 1)
- Database migration:
otp_tokenstable - OTP service + Twilio service
- API routes: send, verify, resend
- 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:
- User receives SMS OTP within 30s
- User can verify OTP and complete transaction
- User can resend OTP (1x)
- Rate limiting blocks after 3/hour
- OTP expires after 5 minutes
- Audit log captures all operations
Non-Functional:
- SMS delivery < 30s (95th percentile)
- Verification latency < 200ms
- Cost per OTP < 0.10 NOK
- Availability > 99.9%
Security:
- Norwegian phone validation (+47)
- Max 3 verification attempts
- Single-use OTP
- Audit log includes IP, user agent
- HTTPS enforced
13. Cost & Timeline
Cost:
- SMS: 420 NOK/month (500 tx/month)
- Annual: ~5,000 NOK
- Cost per transaction: 0.84 NOK
Timeline:
- Week 1: Backend
- Week 2: Frontend
- Week 3: Deployment
- Total: 3 weeks
14. Risks & Mitigations
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| SMS delivery failures | HIGH | LOW | BankID fallback, 99.95% SLA |
| Cost overrun | MEDIUM | LOW | Monitor, cap at 1000/month |
| SIM swap attacks | HIGH | LOW | BankID re-auth |
| UX friction | MEDIUM | MEDIUM | Clear errors, fallback |
| Twilio outage | HIGH | VERY LOW | BankID fallback |
15. Future Enhancements
- Voice OTP fallback
- Authenticator app (TOTP)
- Anomaly detection
- Trusted devices (skip OTP)
- International numbers
- Multi-language SMS
- Biometric verification
16. References
Research:
- Twilio SMS Pricing Norway
- Norway SMS Pricing 2025: Compare 11 Providers
- MessageBird SMS Pricing
- Top 8 SMS OTP Providers in 2026
- Top 7 OTP Service Providers
- Best SMS Gateway Providers
Internal Docs:
- Drop Architecture:
~/ALAI/products/Drop/project/architecture/architecture-document.md - Drop Auth:
~/ALAI/products/Drop/src/drop-app/src/lib/auth.ts - Drop Middleware:
~/ALAI/products/Drop/src/drop-app/src/lib/middleware.ts
17. Approvals
| Role | Name | Date | Status |
|---|---|---|---|
| Spec Author | John | 2026-02-17 | ✅ COMPLETE |
| Tech Review | TBD | TBD | ⏳ PENDING |
| Security Review | TBD | TBD | ⏳ PENDING |
| CEO Approval | Alem | TBD | ⏳ PENDING |
Appendix A: SMS Templates
Norwegian:
Drop: Din bekreftelseskode er {CODE}. Koden utløper om 5 minutter.
English:
Drop: Your verification code is {CODE}. Expires in 5 minutes.
Character Count: < 160 chars (1 SMS segment)
Appendix B: Error Messages
| Code | User Message | Developer Note |
|---|---|---|
otp_expired |
"Code expired. Request new code." | OTP > 5 min |
otp_invalid |
"Incorrect code. Try again." | Wrong code |
otp_failed |
"Too many attempts. Request new code." | 3+ attempts |
rate_limited |
"Too many requests. Wait {X} minutes." | 3+ in 1 hour |
sms_failed |
"SMS failed. Try resend or BankID." | Twilio error |
phone_invalid |
"Invalid phone. Use +47 number." | Not Norwegian |
END OF SPECIFICATION
MC Task #1189: Spec complete. Ready for review. Next Action: Submit to Alem for GO/NO-GO decision. Estimated Implementation: 3 weeks (backend + frontend + deployment).
drop-supporting-systems-plan
Plan: Drop Supporting Systems — Monitoring, Logging, Alerts, Backups
Research Summary
What Exists
- Health check endpoint (
GET /api/health) — DB ping, latency, uptime, version - Container health checks (Docker Compose 30s, Fly.io 30s)
- Auto-restart on failure (
restart: unless-stopped) - CI/CD pipeline (5 GitHub Actions jobs: lint, test, build, e2e, docker)
- Security basics (JWT httpOnly, bcrypt, CSRF, rate limiting, parameterized SQL)
- Manual SQLite backup/restore documented in DEPLOYMENT.md
What's Missing (from docs/infrastructure/MONITORING.md)
- External uptime monitoring
- Error tracking (Sentry)
- Structured logging (JSON + request IDs)
- Log aggregation
- Alerting (Slack/email)
- Audit logging (compliance requirement — PSD2, AML, GDPR)
- Database performance monitoring
- Automated backups
- Security scanning in CI
Tech Stack Context
- Next.js 16 + React 19, API Routes
- SQLite (better-sqlite3) for demo, PostgreSQL for prod
- Docker multi-stage builds
- Fly.io staging config ready (not deployed)
- Vitest + Playwright tests
Objective
Implement the missing supporting systems that make Drop operationally ready: structured logging, error tracking, audit logging, automated backups, alerting, and CI security scanning.
Team Orchestration
Team Members
| ID | Name | Role | Agent Type |
|---|---|---|---|
| B1 | logging-builder | Build structured logging + audit log system | builder |
| V1 | logging-validator | Validate logging implementation | validator |
| B2 | monitoring-builder | Build error tracking (Sentry) + uptime + alerting | builder |
| V2 | monitoring-validator | Validate monitoring setup | validator |
| B3 | backup-builder | Build automated backup system + CI security scanning | builder |
| V3 | backup-validator | Validate backup + CI security | validator |
Step-by-Step Tasks
Phase 1: Structured Logging + Audit Log (Foundation)
Task 1: Implement structured logging library
- Owner: B1
- BlockedBy: none
- Files:
src/drop-app/src/lib/logger.ts(new) - Acceptance:
- JSON-formatted log output with timestamp, level, requestId, message, metadata
- Request ID generation middleware (UUID per request, passed through all handlers)
- Log levels: debug, info, warn, error
- Writes to stdout (Docker-friendly, no file writes)
- All existing API routes use logger instead of console.log
- No new dependencies if possible (use built-in, or pino if needed for perf)
Task 2: Implement audit log table + middleware
- Owner: B1
- BlockedBy: 1
- Files:
src/drop-app/src/lib/db.ts(schema),src/drop-app/src/lib/audit.ts(new) - Acceptance:
-
audit_logtable: id, timestamp, user_id, action, resource, details (JSON), ip_address, user_agent, request_id - Actions logged: login_success, login_failure, logout, register, password_change, transfer_initiated, transfer_completed, qr_payment, kyc_submitted, session_created, session_revoked
- Audit entries created in same transaction as domain action where possible
- Retention: no auto-delete (5-year compliance requirement noted in comments)
- GET /api/admin/audit endpoint (admin-only, paginated) for future use
-
Task 3: Validate logging + audit log
- Owner: V1
- BlockedBy: 2
- Acceptance:
- All API routes produce structured JSON logs on request
- Request IDs are consistent across a single request's log entries
- Login success/failure both produce audit log entries
- Transfer/payment actions produce audit log entries
- Audit table schema matches spec
- Build passes, all existing tests still pass
- No console.log left in API routes (replaced with logger)
Phase 2: Error Tracking + Monitoring + Alerting
Task 4: Integrate Sentry error tracking
- Owner: B2
- BlockedBy: 1 (needs logger for context)
- Files:
src/drop-app/src/lib/sentry.ts(new),src/drop-app/src/app/layout.tsx(init),src/drop-app/next.config.ts - Acceptance:
- @sentry/nextjs installed and initialized (client + server)
- DSN configurable via
SENTRY_DSNenv var - Sentry disabled when env var not set (no crash in dev)
- Unhandled errors + rejected promises captured
- API route errors captured with request context (user_id, requestId)
- Source maps uploaded in Docker build (or skipped if no SENTRY_AUTH_TOKEN)
- Environment tag: production/staging/development
- Performance monitoring (traces sample rate configurable via env)
Task 5: Add health check monitoring + Slack alerting hook
- Owner: B2
- BlockedBy: 4
- Files:
src/drop-app/src/lib/alerts.ts(new), env vars - Acceptance:
- Slack webhook integration via
SLACK_WEBHOOK_URLenv var - Alert on: application startup, graceful shutdown, unhandled error spike (>5 in 1 min)
- Alert format: emoji + severity + message + timestamp + link to Sentry
- Cooldown: max 1 alert per type per 10 minutes (no spam)
- No-op when SLACK_WEBHOOK_URL not configured
- UptimeRobot setup documented in MONITORING.md (external, free tier, checks /api/health)
- Slack webhook integration via
Task 6: Validate monitoring + alerting
- Owner: V2
- BlockedBy: 5
- Acceptance:
- Sentry captures thrown errors in API routes (test with intentional throw)
- Sentry DSN not hardcoded (env var only)
- Sentry disabled gracefully in dev (no SENTRY_DSN = no crash)
- Slack alert function works with mock webhook (unit test)
- No sensitive data in Sentry events (no passwords, tokens, card numbers)
- MONITORING.md updated with full stack docs
- Build passes, all existing tests still pass
Phase 3: Automated Backups + CI Security
Task 7: Create automated backup script + CI security scanning
- Owner: B3
- BlockedBy: none (independent)
- Files:
src/drop-app/scripts/backup.sh(new),.github/workflows/ci.yml(edit) - Acceptance:
- backup.sh: SQLite
.backupcommand (safe, atomic), timestamped output - backup.sh: Configurable retention (default 30 days, delete older)
- backup.sh: Exit code for cron/monitoring integration
- backup.sh: Works inside Docker container (volume mount)
- Docker Compose: backup service or documented cron setup
- CI:
npm audit --audit-level=highstep added to GitHub Actions - CI: Fails build on HIGH/CRITICAL vulnerabilities
- .github/dependabot.yml created (weekly npm updates)
- DEPLOYMENT.md updated with backup schedule + restore procedure
- backup.sh: SQLite
Task 8: Validate backups + CI security
- Owner: V3
- BlockedBy: 7
- Acceptance:
- backup.sh creates valid SQLite backup (can be opened, tables exist)
- backup.sh handles missing DB gracefully (exit 1, clear message)
- Old backups cleaned up after retention period
- npm audit step present in CI workflow
- dependabot.yml valid YAML, correct config
- DEPLOYMENT.md backup section accurate
- Build passes, all existing tests still pass
Validation Commands
# Phase 1: Logging + Audit
cd ~/ALAI/products/Drop/src/drop-app
npm run build # Build passes
npm test # All tests pass
# Start app, hit /api/auth/login → check stdout for JSON log
# Check /api/health → verify request ID in logs
# SELECT * FROM audit_log → verify entries after login
# Phase 2: Monitoring
# Set SENTRY_DSN → start app → trigger error → check Sentry dashboard
# Set SLACK_WEBHOOK_URL → trigger alert → check Slack
# npm run build with SENTRY_AUTH_TOKEN → verify sourcemaps
# Phase 3: Backups + CI
bash scripts/backup.sh # Creates timestamped backup
sqlite3 backups/drop-*.db ".tables" # Verify backup integrity
# Push to GitHub → CI runs → npm audit step visible
# Check dependabot.yml in .github/
Summary
| Phase | What | Effort |
|---|---|---|
| 1 | Structured logging + audit log | ~1 day |
| 2 | Sentry + Slack alerts + uptime docs | ~1 day |
| 3 | Automated backups + CI security scanning | ~0.5 day |
Total: ~2.5 days with 3 builder/validator pairs running in parallel.
All 3 phases can run in parallel (Phase 1 and 3 are independent, Phase 2 depends on Phase 1 Task 1 for logger context).
drop-transaction-failure-spec
Drop Transaction Failure Handling & Recovery
Task: MC #1191 Created: 2026-02-17 Author: John (Software Architect Agent) Status: DRAFT — Awaiting Alem approval
Executive Summary
This specification defines comprehensive transaction failure handling for Drop's fintech payment system. Drop operates as a PSD2 PISP (Payment Initiation Service Provider) — we initiate payments from users' bank accounts but never hold customer money. This creates unique challenges:
- External dependency: Every transaction depends on user's bank and Open Banking provider
- Asynchronous flow: PISP initiation → bank processing → status callback (can take seconds to days)
- Failure modes: Network timeouts, bank declines, partial processing, provider outages
- Customer impact: Real money, real trust — failures must be handled gracefully
Core principles:
- Clear state machine — No ambiguous states
- Idempotency — Network retries never cause double-charges
- Automatic retry — Transient failures self-heal
- User communication — Always tell user what's happening
- Admin tooling — Manual intervention when automation can't resolve
1. Current State Analysis
1.1 What We Have (Good)
Idempotency keys:
- Both
/api/transactions/remittance/route.tsand/api/transactions/qr-payment/route.tsacceptidempotencyKey - Check for existing transaction:
SELECT ... WHERE idempotency_key = ? AND user_id = ? - Returns cached response for duplicate requests (prevents double-charge)
- Status: ✅ Production-ready
Basic error handling:
insufficient_balanceerror caught and returned as 402- Rate limiting: IP (10/min) + user (3/min)
- Transaction wrapped in DB transaction (atomic balance check + insert)
- Status: ✅ Good foundation
30-second timeout:
- PISP API calls have
AbortControllerwith 30s timeout - Returns specific timeout error: "Payment request timeout"
- Status: ✅ Implemented
1.2 What's Missing (Critical Gaps)
❌ State machine enforcement:
transactions.statushas CHECK constraint:'processing','completed','failed'- But no state transition validation (can jump from processing → completed without rules)
- No transition audit (who/when/why status changed)
❌ Retry logic:
- Timeout errors return failure immediately — no retry
- No exponential backoff
- No max retry counter
- No dead letter queue for permanently failed transactions
❌ Background reconciliation:
- Transactions stuck in
processingstatus stay there forever - No periodic job to check PISP provider for status updates
- No admin alert when transactions are stuck
❌ Partial failure handling:
- FX conversion success + transfer failure → no rollback/refund flow
- No compensation logic for partial state
❌ User communication:
- No transaction status page showing real-time progress
- No push notification on status change
- No email on final completion/failure
- Error messages are generic (not user-friendly)
❌ Admin tools:
- No
/api/admin/transactions/stuckendpoint to list limbo transactions - No manual retry mechanism
- No manual resolution workflow
2. Transaction State Machine
2.1 States
┌─────────────┐
│ initiated │ ──────┐
└─────────────┘ │
│ │
▼ │
┌─────────────┐ │
│ processing │ │ (timeout after 30s)
└─────────────┘ │
│ │
├───────────────┴────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ completed │ │ timeout │
└─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ failed │
└─────────────┘
┌─────────────────────────────────────────────────────┐
│ partially_completed │ (future — FX success, transfer fail)
└─────────────────────────────────────────────────────┘
2.2 State Definitions
| State | Meaning | Terminal? | User-Facing Message |
|---|---|---|---|
initiated |
API request received, validation passed, DB record created | No | "Initiating payment..." |
processing |
PISP provider accepted request, waiting for bank confirmation | No | "Your payment is being processed" |
timeout |
PISP provider didn't respond within 30s, will check status later | No | "Processing your payment — we'll notify you when complete" |
completed |
Bank confirmed payment successful | Yes | "Payment completed" |
failed |
Bank declined, or PISP returned permanent error | Yes | "Payment failed: [reason]" |
partially_completed |
FX conversion succeeded but transfer failed (future) | No | "Processing refund..." |
Terminal states: completed, failed — no further transitions allowed
2.3 Valid Transitions
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:
- Scoped to user (prevents IDOR)
- Returns exact same response (status 200, not 201)
- No expiry — idempotency keys valid forever
- Client must generate UUID or similar unique key
3.2 Best Practices
Client implementation:
// 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:
- Simple — no queue infrastructure needed
- User waits for final result (good UX for fast retries)
- Automatic cleanup (no orphan jobs)
Cons:
- Request can take up to ~40s (blocks thread)
- If server crashes mid-retry, transaction stuck
- No visibility into retry progress
Option B: Background Job Queue (Production-Grade)
Move retries to background worker using job queue:
Tech stack:
- Job queue: BullMQ (Redis-backed) or pg-boss (PostgreSQL-backed, no extra infra)
- Worker: Separate process polls queue every 5s
Flow:
- API route creates transaction with status
initiated - Enqueue job:
{ type: "pisp_call", txId: "tx_rem_123", attempt: 1 } - Return to user:
{ status: "processing", txId: "tx_rem_123" } - Worker picks job → calls PISP → updates transaction status
- On transient failure → re-enqueue with delay + increment attempt
- On success/permanent failure → mark transaction terminal
Pros:
- Non-blocking (API responds instantly)
- Survives server restarts (jobs persisted in DB)
- Can inspect queue (show pending retries in admin dashboard)
Cons:
- More complex (requires job queue setup)
- More infrastructure (Redis or pg-boss tables)
- User must poll
/api/transactions/[id]for status updates
Recommendation: Start with Option A (in-process) for MVP. Migrate to Option B when transaction volume increases.
4.4 Dead Letter Queue
After max retries exhausted:
- Mark transaction as
failedwith reason:"PISP provider unreachable after 3 attempts" - 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.'
);
-
Send Slack/email to ops team (via webhook or existing notification system)
-
Admin dashboard shows alert at
/admin/alertswith:- 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:
- Immediate response: "Processing your payment — we'll send you a notification when it's complete" (status 202)
- Push notification (1-2 min later): "Your 500 NOK payment to Mama Jasmina is complete"
- Transaction list updates: Polling
/api/transactionsor WebSocket push
What if it never completes?
- After 24 hours stuck in
timeout→ mark asfailed+ admin alert - User can contact support via
/supportpage - Admin manually investigates + refunds if needed
6. Partial Failure Handling
6.1 Scenario (Future — FX Conversion)
Remittance flow with FX conversion:
- User sends 500 NOK → 5,085 RSD
- FX conversion succeeds (NOK debited from user's bank)
- International transfer fails (recipient bank rejects)
- 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 |
|---|---|---|
processing → completed |
"Payment Complete" | "Your 500 NOK payment to Mama Jasmina is complete" |
processing → failed |
"Payment Failed" | "Your 500 NOK payment failed. Tap to view details" |
timeout → completed |
"Payment Complete" | "Your payment has been confirmed by the bank" |
partially_completed → failed |
"Refund Processed" | "Your 500 NOK has been refunded to your account" |
Implementation:
// 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:
- Validate transaction is in retryable state (
timeout,failedwith transient error) - Reset retry counter
- Call PISP provider again (with retry logic from Section 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:
-
Overview Cards:
- Stuck transactions (count)
- Failed last 24h (count)
- Average resolution time
-
Stuck Transactions Table:
- Columns: TX ID, User, Amount, Status, Hours Stuck, Actions
- Actions: "Retry", "Resolve", "View Audit Log"
-
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)
- Update
transactionstable schema (new columns +timeoutstate) - Implement state transition validation in
lib/state-machine.ts - Add transition audit logging (every status change → audit_log)
- Update API routes to use state machine validation
- Deliverable: Status changes are validated + audited
Phase 2: Retry Logic (Week 2)
- Implement exponential backoff in
lib/retry.ts - Add error classification (transient vs permanent)
- Update
payments.tsto use retry wrapper - Add retry counter tracking in DB
- Deliverable: Transient errors auto-retry up to 3 times
Phase 3: Timeout Recovery (Week 2-3)
- Change timeout behavior: return
timeoutstatus instead of failure - Create
reconciliation-worker.tsbackground job - Implement PISP status polling (every 10 min for stuck txs)
- Add timeout → completed/failed transitions
- Deliverable: Timeouts self-resolve via background reconciliation
Phase 4: User Communication (Week 3)
- Create transaction detail page (
/transactions/[id]) - Add real-time status polling (SWR with 2s refresh)
- Implement push notifications for status changes
- Add email notifications for terminal states
- Implement user-friendly error messages
- Deliverable: Users always know transaction status
Phase 5: Admin Tools (Week 4)
- Create
admin_alertstable - Implement stuck transaction detection (every 10 min sweep)
- Build admin dashboard (
/admin/transactions) - Add manual retry endpoint (
POST /api/admin/transactions/[id]/retry) - Add manual resolution endpoint (
POST /api/admin/transactions/[id]/resolve) - Deliverable: Admins can intervene on stuck transactions
Phase 6: Partial Failure Handling (Future — After FX Provider Integration)
- Add
compensation_statusfield - Implement refund flow for partial failures
- Add FX provider status checks
- Test compensation scenarios
- Deliverable: Partial failures trigger automatic refunds
11. Testing Strategy
11.1 Unit Tests
Retry logic:
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
- Mock PISP to timeout on first call
- Initiate transaction → verify status =
timeout - Run reconciliation worker
- Mock PISP to return
completed - Verify transaction status =
completed - Verify push notification sent
Scenario: Retry exhaustion
- Mock PISP to return 503 three times
- Initiate transaction
- Verify transaction status =
failed - Verify admin alert created
- Verify user notified
11.3 End-to-End Tests
User journey:
- User initiates remittance (500 NOK → RSD)
- PISP times out after 30s
- User sees "Processing — we'll notify you"
- 2 minutes later: background worker checks status
- PISP returns
completed - User receives push notification
- User opens transaction detail page → sees "Completed"
- User receives email confirmation
Admin journey:
- Transaction stuck in
timeoutfor 2 hours - Admin opens
/admin/transactionsdashboard - Sees transaction in "Stuck" list
- Clicks "Retry" → transaction re-attempted
- PISP succeeds → status =
completed - Admin marks alert as "Resolved"
12. Acceptance Criteria
12.1 State Machine
- All status transitions validated against whitelist
- Invalid transitions blocked at DB + app level
- Every status change logged in
audit_logwith timestamp + reason - Terminal states (
completed,failed) cannot transition
12.2 Idempotency
- Duplicate requests with same
idempotencyKeyreturn cached response (already implemented) - Idempotency keys scoped to user (prevents IDOR) (already implemented)
- Response includes identical payload + status 200 (already implemented)
12.3 Retry Logic
- Transient errors (5xx, timeout) trigger automatic retry
- Exponential backoff: 2s → 8s → 32s (with jitter)
- Max 3 retry attempts
- Permanent errors (4xx, bank decline) fail immediately (no retry)
- After max retries: mark as
failed+ create admin alert
12.4 Timeout Recovery
- Timeout returns
timeoutstatus (notfailed) - Background worker checks PISP status every 10 min
- Stuck transactions (> 10 min) swept periodically
- Timeout → completed/failed based on PISP response
- User notified when status resolves
12.5 Partial Failure
-
compensation_statusfield added (for future FX refunds) - Refund flow triggers on transfer failure (when FX provider added)
- Compensation failures escalate to admin alert
- User sees "Processing refund" status
12.6 User Communication
- Transaction detail page (
/transactions/[id]) shows:- Real-time status
- Timeline of events
- User-friendly error messages
- Push notifications sent on status change
- Email sent on terminal status (
completed,failed) - Error messages in Norwegian (primary) + English
12.7 Admin Tools
-
/api/admin/transactions/stuckreturns all stuck transactions -
/api/admin/transactions/[id]/retrymanually retries transaction -
/api/admin/transactions/[id]/resolvemanually marks completed/failed - Admin dashboard shows stuck transactions with action buttons
- Admin alerts created for:
- Max retries exhausted
- Compensation failure
- Transaction stuck > 24 hours
13. Monitoring & Alerting
13.1 Metrics to Track
| Metric | Threshold | Alert If |
|---|---|---|
| Stuck transactions (count) | 5 | > 10 |
| Average resolution time (hours) | 1 | > 4 |
| Failed transactions (last 24h) | 50 | > 100 |
| PISP timeout rate (%) | 5% | > 15% |
| Retry success rate (%) | 80% | < 60% |
| Compensation failures (count) | 0 | > 0 |
13.2 Dashboard Queries
Stuck transactions:
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:
- All admin endpoints require
user.role === 'admin'(checked via JWT) - Audit every admin action (
ADMIN_TRANSACTION_RETRY,ADMIN_MARK_COMPLETED, etc.) - Log IP address + user agent for all admin operations
Rate limiting:
- Admin endpoints: 60 requests/min (higher than user endpoints)
- Admin dashboard: no rate limit (internal tool)
14.2 Idempotency Key Security
No vulnerability: Idempotency keys scoped to user → can't replay another user's transaction
Best practice: Client generates key = ${userId}_${timestamp}_${random} (prevents guessing)
14.3 Transaction Status Leaks
Risk: User A checks /api/transactions/tx_rem_123 → sees User B's transaction
Mitigation (already implemented):
const tx = await getOne(
"SELECT * FROM transactions WHERE id = ? AND user_id = ?",
[txId, user.id] // ← Scoped to logged-in user
);
Admin endpoints: Bypass user_id check (admin sees all transactions)
15. Cost Analysis
15.1 Infrastructure
| Component | Cost | Notes |
|---|---|---|
| Background worker | $0 | Same server process (cron or setInterval) |
| Job queue (pg-boss) | $0 | Uses existing PostgreSQL (when migrated from SQLite) |
| Job queue (BullMQ) | ~$20/mo | Redis hosting (if chosen over pg-boss) |
| Push notifications (FCM) | Free | Up to unlimited (Firebase Cloud Messaging) |
| Email (SendGrid) | $15/mo | 50k emails/month (transactional tier) |
Total: $15-35/mo (depending on job queue choice)
15.2 PISP API Costs
Retry costs:
- 3 retries per failed transaction
- If 5% of transactions fail → 5% * 3 = 15% extra PISP API calls
- Assuming 10,000 txs/month, 5% fail = 500 failed → 1,500 retry calls
- Cost: depends on PISP provider (typically $0.01-0.05 per API call)
- Estimated: $15-75/mo in extra API fees
Reconciliation costs:
- Background worker checks status every 10 min for stuck txs
- If 1% stuck (100 txs/month) → 100 * 6 status checks/hour * 24h = 14,400 API calls
- Estimated: $144-720/mo
Optimization: Only check status for transactions stuck > 10 min (reduces unnecessary calls)
16. Open Questions (For Alem)
16.1 Retry Strategy
Q1: Should we do in-process retry (Option A) or background job queue (Option B)?
Recommendation: Start with Option A (simpler, no extra infra). Migrate to Option B when transaction volume > 10k/month.
16.2 Notification Channels
Q2: Email + push notifications both? Or only one?
Recommendation: Both. Email is fallback (if user disabled push). Send email only for terminal states.
16.3 Admin Alert Delivery
Q3: How should admin alerts be delivered?
- Option A: Dashboard only (admin must check
/admin/alerts) - Option B: Email to ops team
- Option C: Slack webhook
- Option D: SMS for critical alerts
Recommendation: Option C (Slack) for high/critical alerts. Dashboard for all.
16.4 Stuck Transaction Threshold
Q4: When should we mark a transaction as "stuck"?
- Current spec: 10 minutes
- Alternative: 1 hour (less aggressive)
Recommendation: 10 min for reconciliation sweep, 24h for admin alert (gives time to self-resolve)
16.5 Partial Failure Compensation SLA
Q5: What's acceptable refund time for partial failures?
- PSD2 requires 24h for refunds
- Faster = better UX
Recommendation: Initiate refund immediately, complete within 24h (meet regulatory minimum)
17. Next Steps
- Review this spec with Alem
- Approve/reject each section (or request changes)
- Prioritize phases (which to implement first?)
- Assign to builder agent (one phase at a time)
- Validation after each phase (validator agent checks implementation)
Estimated timeline: 4 weeks for Phases 1-5, Phase 6 (partial failure) deferred until FX provider integrated
Appendix A: State Diagram (ASCII)
┌─────────────┐
│ initiated │──────────────┐
└──────┬──────┘ │
│ │ (immediate fail: validation error)
▼ ▼
┌─────────────┐ ┌─────────────┐
│ processing │ │ failed │ (terminal)
└──────┬──────┘ └─────────────┘
│
├──────────────────┬──────────────────┐
│ │ │
│ (success) │ (timeout) │ (permanent error)
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ completed │ │ timeout │ │ failed │
│ (terminal) │ └──────┬──────┘ └─────────────┘
└─────────────┘ │
│ (reconciliation)
├───────────┬───────────┐
│ │ │
▼ ▼ │
┌─────────────┐ ┌─────────────┐│ (retry)
│ completed │ │ failed ││
└─────────────┘ └─────────────┘▼
┌─────────────┐
│ processing │
└─────────────┘
Future:
┌─────────────────────────────┐
│ partially_completed │
└──────────┬──────────────────┘
│ (refund)
├───────────┬───────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐
│ completed │ │ failed │
└─────────────┘ └─────────────┘
Appendix B: Error Code Reference
| Code | Type | Retry? | User Message (NO) | User Message (EN) |
|---|---|---|---|---|
insufficient_balance |
Permanent | No | "Ikke nok dekning på bankkontoen" | "Insufficient funds" |
bank_declined |
Permanent | No | "Banken din avslo betalingen" | "Your bank declined the payment" |
invalid_iban |
Permanent | No | "Ugyldig kontonummer" | "Invalid account number" |
kyc_required |
Permanent | No | "Identitetsverifisering kreves" | "Identity verification required" |
pisp_timeout |
Transient | Yes | "Betalingen tar lengre tid enn vanlig" | "Payment taking longer than usual" |
pisp_unavailable |
Transient | Yes | "Betalingsleverandør midlertidig utilgjengelig" | "Payment provider temporarily unavailable" |
network_error |
Transient | Yes | "Nettverksfeil — prøver igjen automatisk" | "Network error — retrying automatically" |
pisp_5xx |
Transient | Yes | "Betalingsleverandør har tekniske problemer" | "Payment provider experiencing technical issues" |
max_retries_exceeded |
Permanent | No | "Betalingen feilet etter flere forsøk" | "Payment failed after multiple attempts" |
validation_error |
Permanent | No | "Ugyldig forespørsel" | "Invalid request" |
End of Specification
drop-validation-hardening-plan
Plan: Drop Validation Hardening
Research Summary
What the documentation says (spec vs reality)
Sources reviewed:
project/architecture/api-specification.md— API contract, field examples, error codesproject/architecture/architecture-document.md— User requirements from vilkår.html (legally binding)project/docs/security-qa-audit.md— Issue #14 (email), #19 (password), #9 (amounts)project/docs/drop-qa-rapport.md— QA findings C-1 through L-10src/lib/middleware/validation.ts— Existing validators (UNUSED by any route)src/app/api/auth/register/route.ts— Current registration validationsrc/app/onboarding/page.tsx— Current frontend validation
Gap Analysis
| Field | Spec / Audit Requirement | Current Implementation | Gap |
|---|---|---|---|
Regex /^[^\s@]+@[^\s@]+\.[^\s@]+$/ (audit #14) |
email.includes("@") |
YES — accepts @, a@, @ @ |
|
| Password | 12+ chars, uppercase, lowercase, digit (audit #19) | length >= 8, no complexity |
YES — "12345678" passes |
| First/Last Name | validateName() exists in validation.ts — 1-100 chars, no script tags, sanitized |
typeof === "string" only |
YES — "123", XSS, 100K chars pass |
| Phone | +47 Norwegian format (architecture doc 1.4) |
No validation at all | YES — any string passes |
| Age | 18+ from vilkår.html (architecture doc 1.4) | Not implemented | YES — but deferred (needs BankID) |
| Amount (remittance) | 100-50,000 NOK, 2 decimal places, finite (audit #9) | 100-50,000 check, isFinite | PARTIAL — no decimal precision |
| Amount (QR) | 1-100,000 NOK, 2 decimals | 1-100,000 check, isFinite | PARTIAL — no decimal precision |
| Bank account | IBAN validation (validation.ts has validateIBAN) |
No validation | YES — but separate scope |
| Currency | NOK, RSD, BAM, PLN, PKR, TRY, EUR | Missing RSD, TRY, PKR, NOK | YES — but unused validator |
Existing assets (ready to wire up)
src/lib/middleware/validation.ts already has:
validateEmail()— proper regexvalidatePhone()— international+format, 8-15 digitsvalidateAmount()— positive, max 2 decimalsvalidateIBAN()— format + mod-97 checksumvalidatePIN()— exactly 4 digitsvalidateName()— 1-100 chars, no script tagssanitizeText()— strips HTML, control chars, max lengthvalidateCurrency()— needs RSD, TRY, PKR, NOK added
Key insight: The validators EXIST but are NEVER IMPORTED. The middleware/ directory is entirely dead code (QA rapport C-1). The fix is to wire existing validators into the actual routes.
Objective
Wire existing validation utilities into all API routes and frontend forms. Close the gap between documented requirements and actual implementation. Do NOT create new validators — use what exists in validation.ts, extend where needed.
Scope
In scope:
- Registration API — email, password, name, phone validation
- Registration frontend — matching client-side checks
- Login frontend — email format check
- Amount validation — add decimal precision check to remittance + QR payment routes
- Update existing tests to match new validation rules
- Currency validator — add missing currencies
Out of scope (separate tasks):
- Age verification (needs BankID integration)
- IBAN validation for recipients (separate feature)
- CSRF protection (separate security task)
- Audit logging (separate task)
- Password complexity upgrade to 12+ (BREAKING CHANGE — needs migration plan, keep 8 for now but add complexity)
Team Orchestration
Team Members
| ID | Name | Role | Agent Type |
|---|---|---|---|
| B1 | validation-builder | Implement validation wiring | builder |
| V1 | validation-validator | Verify all validation works | validator |
Step-by-Step Tasks
Phase 1: Backend Validation (API Routes)
Task 1: Wire validators into registration API
- Owner: B1
- BlockedBy: none
- Files:
src/app/api/auth/register/route.ts,src/lib/middleware/validation.ts - Changes:
- Import
validateEmail,validateName,sanitizeText,validatePhonefrom@/lib/middleware/validation - Replace
!email.includes("@")with!validateEmail(email) - Replace
!firstName || typeof firstName !== "string"with!validateName(firstName) - Replace
!lastName || typeof lastName !== "string"with!validateName(lastName) - Add phone validation:
if (phone && !validatePhone(phone))error - Sanitize names before storing:
sanitizeText(firstName, 100),sanitizeText(lastName, 100) - Add password complexity: min 8 chars + at least 1 letter + at least 1 digit (soft upgrade, not full 12-char yet)
- Add
validateCurrencyupdate: add missing currencies (RSD, TRY, PKR, NOK)
- Import
- Acceptance:
-
user@rejected (no domain) -
@domain.comrejected (no local part) -
user space@domain.comrejected (spaces) -
valid@email.comaccepted -
123as firstName rejected (contains only digits? No — validateName allows digits, just checks length + no script tags. This is acceptable.) -
<script>alert(1)</script>as name rejected - Empty firstName rejected
- 200+ char firstName truncated to 100
- Phone without
+prefix rejected (if provided) - Phone
+4712345678accepted - Password
12345678rejected (no letter) - Password
abcdefghrejected (no digit) - Password
abc12345accepted (letter + digit + 8 chars)
-
Task 2: Wire validators into amount routes
- Owner: B1
- BlockedBy: none
- Files:
src/app/api/transactions/remittance/route.ts,src/app/api/transactions/qr-payment/route.ts - Changes:
- Import
validateAmountfrom@/lib/middleware/validation - Add decimal precision check before range check:
if (Math.round(amount * 100) !== amount * 100)→ 400 error - Existing range checks stay (100-50K for remittance, 1-100K for QR)
- Import
- Acceptance:
-
100.999rejected (3 decimals) -
100.99accepted (2 decimals) -
100accepted (0 decimals) - Existing range tests still pass
-
Task 3: Validate Task 1 + Task 2
- Owner: V1
- BlockedBy: 1, 2
- Acceptance:
- Read all modified files, verify imports are correct
- Verify no breaking changes to existing passing tests
- Run
npm test— all 120 Vitest tests pass - Run
npx playwright test— all 75 e2e tests pass (may need test updates)
Phase 2: Frontend Validation (Client-side)
Task 4: Add proper validation to registration form
- Owner: B1
- BlockedBy: 3 (backend must be validated first)
- Files:
src/app/onboarding/page.tsx - Changes:
- Replace
!email.includes("@")with proper regex check matching backend - Add inline error for email format: "Ugyldig e-postformat"
- Add password complexity check: show "Passord må inneholde bokstaver og tall"
- Add name format hint if script tags detected: "Ugyldig tegn i navn"
- Add phone format hint: "Norsk telefonnummer påkrevd (+47...)"
- Keep button disabled logic, but add per-field inline errors
- Replace
- Acceptance:
-
user@shows email format error immediately on blur -
12345678password shows complexity error - Valid form submits normally
- Error messages in Norwegian
-
Task 5: Add email validation to login form
- Owner: B1
- BlockedBy: 3
- Files:
src/app/login/page.tsx - Changes:
- Add email format check matching backend regex
- Show "Ugyldig e-postformat" if email doesn't match on submit
- Acceptance:
- Invalid email format shows Norwegian error
- Valid email + wrong password shows credential error (not format error)
Task 6: Validate Task 4 + Task 5
- Owner: V1
- BlockedBy: 4, 5
- Acceptance:
- Read all modified frontend files
- Verify error messages are in Norwegian
- Run
npx playwright test— all 75 e2e tests pass - Verify no regressions in user-flows or input-chaos tests
Phase 3: Test Updates
Task 7: Update e2e tests for new validation rules
- Owner: B1
- BlockedBy: 6
- Files:
tests/e2e/input-chaos.spec.ts,tests/e2e/user-flows.spec.ts - Changes:
- Update password boundary tests — "12345678" now fails (no letter), use "pass1234" instead
- Update any test assertions that depend on old weak validation
- Add new positive test: valid registration with proper fields
- Verify all 75 tests pass with the new validation
- Acceptance:
-
npx playwright test— 75/75 pass -
npm test— 120/120 pass - No test uses weak input that now gets rejected without updating the assertion
-
Task 8: Final validation — full test suite
- Owner: V1
- BlockedBy: 7
- Acceptance:
-
npm test— 120/120 pass (5 consecutive runs) -
npx playwright test— 75/75 pass - Manual check: registration form rejects
<script>in name - Manual check: registration form rejects
user@email - Manual check: registration accepts valid Norwegian data
-
Files Modified
Backend (API)
src/app/api/auth/register/route.ts— wire validatorssrc/app/api/transactions/remittance/route.ts— decimal precisionsrc/app/api/transactions/qr-payment/route.ts— decimal precisionsrc/lib/middleware/validation.ts— add missing currencies
Frontend
src/app/onboarding/page.tsx— proper client-side validationsrc/app/login/page.tsx— email format check
Tests
tests/e2e/input-chaos.spec.ts— update for new rulestests/e2e/user-flows.spec.ts— update if needed
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
- Low risk: Wiring existing validators — they're already written and tested in isolation
- Medium risk: Frontend changes may break e2e test assertions that depend on old behavior
- Mitigation: Phase 3 explicitly updates tests after backend + frontend are done
- NOT breaking existing users: Password min stays at 8 (just adds letter+digit requirement). Demo user "demo1234" has both letters and digits — still works.