feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)

Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-05 14:47:08 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent bd3592cb99
commit 398c734b93
33 changed files with 3825 additions and 11 deletions
+16
View File
@@ -56,6 +56,22 @@ CRON_SECRET=generate-a-random-secret
# User-Key is entered by the user in the migration wizard)
# BJORN_LUNDEN_CLIENT_ID=
# BJORN_LUNDEN_CLIENT_SECRET=
# WhatsApp receipt intake (whatsapp-inbox extension, Meta Cloud API).
# ACCESS_TOKEN: system-user permanent token with whatsapp_business_messaging
# scope only. PHONE_NUMBER_ID: the Graph object id of the sending number.
# APP_SECRET verifies X-Hub-Signature-256 on the webhook; VERIFY_TOKEN is the
# GET-handshake shared secret you also enter in the Meta app dashboard.
# PHONE_HASH_KEY: random pepper for phone lookup hashes (openssl rand -hex 32).
# PHONE_ENCRYPTION_KEY: 32-byte hex AES-256-GCM key (openssl rand -hex 32).
# WHATSAPP_ACCESS_TOKEN=
# WHATSAPP_PHONE_NUMBER_ID=
# WHATSAPP_APP_SECRET=
# WHATSAPP_VERIFY_TOKEN=
# WHATSAPP_PHONE_HASH_KEY=
# WHATSAPP_PHONE_ENCRYPTION_KEY=
# Optional: the public number as E.164 digits (e.g. 46766867041) for the
# wa.me deep link in settings. Unset = resolved from the Graph API instead.
# WHATSAPP_PUBLIC_NUMBER=
# Bolagsverket: digital inlämning av årsredovisning (bolagsverket extension).
# BOLAGSVERKET_ENV is test | accept | prod (default test) and also caps which
# environment a company may select in settings (test < accept < prod).
+2
View File
@@ -773,3 +773,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-01] Page-count gate (issue #553) changed from skip-extraction to slice-first-3-pages via pdf-lib: invoice data sits on page 1, and a >3-page supplier invoice getting ZERO fields was the worse failure mode. too_many_pages skip remains only for unsliceable (encrypted/malformed) PDFs; truncation recorded in extracted_data.pages. Side effect: client_opt_out now outranks too_many_pages in skip-reason priority (an opted-out caller never extracts regardless of length).
[2026-08-01] Extraction classification fields (documentKind/payment/merchantCategory/legibility) validate with .catch(null) instead of strict enums: a hallucinated label must degrade to unknown, not sink the whole document parse; amounts keep strict parsing on purpose. HEIC handling = attempt sharp transcode at runtime, fall through to today's empty-extraction when libvips lacks HEIF (prebuilt binaries exclude it for patent reasons), with a UI hint replacing the silence.
[2026-08-02] WhatsApp channel tables: whatsapp_messages/conversations/link_codes are RLS-enabled with NO policies (service-role only): rows hold third-party PII (phone hashes, chat text) with no company scope and no v1 UI reader; whatsapp_phone_links is USER-scoped (auth.uid()), not company-scoped, because a phone binding belongs to a person. whatsapp_conversations triaged into ARCHIVE_EXCLUDED_TABLES (bot state, company_id is only a pin; receipts live in document_attachments). check_and_increment_whatsapp_sender_quota is EXECUTE-granted to service_role only (quota-drain lesson from 20260726090000).
[2026-08-02] whatsapp-inbox link codes: a bare 6-char body without the AC prefix must contain a digit to count as a code; the ambiguity-free alphabet makes ordinary words ("hejhej") valid code shapes, and greeting text must earn M1, not a confusing "wrong code" M2. Prefixed codes (what the panel and wa.me prefill always send) are never rejected.
[2026-08-02] whatsapp-inbox webhook: truly unknown inbound types (reactions, ephemeral, future Meta additions) get silence + a skipped row instead of the M15 unsupported-content reply; answering a thumbs-up reaction with "I only take images and PDF" is noise. M15 stays for explicit content types (video/sticker/location/contacts).
@@ -0,0 +1,5 @@
import { WhatsAppSettingsContent } from '@/components/settings/sections/WhatsAppSettingsContent'
export default function WhatsAppSettingsPage() {
return <WhatsAppSettingsContent />
}
@@ -0,0 +1,240 @@
'use client'
/**
* Settings panel for the whatsapp-inbox extension (Inställningar -> WhatsApp).
*
* Unlinked: mint a one-time code (10 min TTL) + wa.me deep link; the user
* sends the code from their phone and the webhook binds the number.
* Linked: masked phone, default-company select (multi-company routing),
* revoke. Muted (user sent *stopp* in chat) shows a hint: unmuting happens
* in the chat with *start*, not here.
*/
import { useCallback, useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Loader2, MessageCircle, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { useCompany } from '@/contexts/CompanyContext'
import {
SettingsGroup,
SettingsRow,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
const BASE = '/api/extensions/ext/whatsapp-inbox'
interface LinkStatus {
linked: boolean
phoneMasked?: string
defaultCompanyId?: string | null
muted?: boolean
}
interface MintedCode {
code: string
expiresAt: string
waLink: string | null
}
export function WhatsAppLinkPanel() {
const t = useTranslations('settings_whatsapp')
const { toast } = useToast()
const { companies } = useCompany()
const [isLoading, setIsLoading] = useState(true)
const [loadFailed, setLoadFailed] = useState(false)
const [status, setStatus] = useState<LinkStatus | null>(null)
const [minted, setMinted] = useState<MintedCode | null>(null)
const [isMinting, setIsMinting] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [minutesLeft, setMinutesLeft] = useState<number | null>(null)
const fetchStatus = useCallback(async () => {
setIsLoading(true)
setLoadFailed(false)
try {
const response = await fetch(`${BASE}/link`)
if (!response.ok) throw new Error('load failed')
const { data } = (await response.json()) as { data: LinkStatus }
setStatus(data)
} catch {
setLoadFailed(true)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
void fetchStatus()
}, [fetchStatus])
// Countdown hint for the minted code (10 min TTL server-side).
useEffect(() => {
if (!minted) {
setMinutesLeft(null)
return
}
const tick = () => {
const msLeft = new Date(minted.expiresAt).getTime() - Date.now()
setMinutesLeft(msLeft > 0 ? Math.ceil(msLeft / 60_000) : 0)
}
tick()
const interval = setInterval(tick, 15_000)
return () => clearInterval(interval)
}, [minted])
const startLinking = async () => {
setIsMinting(true)
try {
const response = await fetch(`${BASE}/link/start`, { method: 'POST' })
if (!response.ok) throw new Error('mint failed')
const { data } = (await response.json()) as { data: MintedCode }
setMinted(data)
} catch {
toast({ title: t('mint_failed'), variant: 'destructive' })
} finally {
setIsMinting(false)
}
}
const saveDefaultCompany = async (companyId: string) => {
setIsSaving(true)
try {
const response = await fetch(`${BASE}/link/default-company`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ companyId: companyId || null }),
})
if (!response.ok) throw new Error('save failed')
setStatus((prev) => (prev ? { ...prev, defaultCompanyId: companyId || null } : prev))
toast({ title: t('default_company_saved') })
} catch {
toast({ title: t('default_company_save_failed'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
}
const revoke = async () => {
if (!window.confirm(t('revoke_confirm'))) return
setIsSaving(true)
try {
const response = await fetch(`${BASE}/link/revoke`, { method: 'POST' })
if (!response.ok) throw new Error('revoke failed')
setStatus({ linked: false })
setMinted(null)
toast({ title: t('revoked_toast') })
} catch {
toast({ title: t('revoke_failed'), variant: 'destructive' })
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="flex items-center gap-2 px-1 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
{t('loading')}
</div>
)
}
if (loadFailed) {
return (
<div className="px-1 py-6 text-sm text-muted-foreground">
{t('load_failed')}{' '}
<button type="button" className="underline" onClick={() => void fetchStatus()}>
{t('retry')}
</button>
</div>
)
}
if (status?.linked) {
return (
<SettingsGroup label={t('group_label')}>
<SettingsRow label={t('linked_number_label')}>
<span className="font-mono text-sm">{status.phoneMasked}</span>
</SettingsRow>
{status.muted ? (
<SettingsRow label={t('muted_label')}>
<SettingsRowNote>{t('muted_hint')}</SettingsRowNote>
</SettingsRow>
) : null}
<SettingsRow label={t('default_company_label')} help={t('default_company_help')}>
<SettingsSelect
aria-label={t('default_company_label')}
value={status.defaultCompanyId ?? ''}
disabled={isSaving}
onChange={(event) => void saveDefaultCompany(event.target.value)}
>
<option value="">{t('default_company_none')}</option>
{companies.map(({ company }) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</SettingsSelect>
</SettingsRow>
<SettingsRow label={t('disconnect_label')} borderless>
<Button type="button" variant="outline" size="sm" disabled={isSaving} onClick={() => void revoke()}>
{t('revoke_button')}
</Button>
<SettingsRowNote>{t('revoke_note')}</SettingsRowNote>
</SettingsRow>
</SettingsGroup>
)
}
return (
<SettingsGroup label={t('group_label')}>
<div className="space-y-4 px-1 py-4">
<p className="max-w-prose text-sm text-muted-foreground">{t('unlinked_intro')}</p>
{minted ? (
<div className="space-y-3">
<div className="flex items-center gap-3">
<span className="rounded-md border border-border bg-muted/50 px-3 py-1.5 font-mono text-base tracking-wider">
{minted.code}
</span>
{minted.waLink ? (
<Button asChild type="button" size="sm">
<a href={minted.waLink} target="_blank" rel="noreferrer">
<MessageCircle className="mr-1.5 h-4 w-4" aria-hidden="true" />
{t('open_whatsapp')}
<ExternalLink className="ml-1.5 h-3.5 w-3.5" aria-hidden="true" />
</a>
</Button>
) : null}
</div>
<ol className="ml-4 list-decimal space-y-1 text-sm text-muted-foreground">
<li>{t('step_open')}</li>
<li>{t('step_send_code')}</li>
<li>{t('step_confirm')}</li>
</ol>
<p className="text-xs text-muted-foreground">
{minutesLeft != null && minutesLeft > 0
? t('expires_in', { minutes: minutesLeft })
: minutesLeft === 0
? t('code_expired')
: t('expires_hint')}
</p>
</div>
) : (
<Button type="button" disabled={isMinting} onClick={() => void startLinking()}>
{isMinting ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
{t('connect_button')}
</Button>
)}
</div>
</SettingsGroup>
)
}
export default WhatsAppLinkPanel
@@ -0,0 +1,17 @@
'use client'
import { useTranslations } from 'next-intl'
import { WhatsAppLinkPanel } from '@/components/extensions/general/WhatsAppLinkPanel'
import { SettingsSectionHeader } from '@/components/settings/SettingsRows'
export function WhatsAppSettingsContent() {
const tNav = useTranslations('settings_nav')
const tIntro = useTranslations('settings_intro')
return (
<div>
<SettingsSectionHeader title={tNav('whatsapp')} intro={tIntro('whatsapp')} />
<WhatsAppLinkPanel />
</div>
)
}
+5
View File
@@ -46,6 +46,10 @@ const BillingSettingsContent = dynamic(() =>
import('./BillingSettingsContent').then((module) => ({ default: module.BillingSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
const WhatsAppSettingsContent = dynamic(() =>
import('./WhatsAppSettingsContent').then((module) => ({ default: module.WhatsAppSettingsContent })),
{ loading: SettingsLoadingSkeleton },
)
/**
* Single source of truth mapping a settings section id to the component that
@@ -66,6 +70,7 @@ export const SETTINGS_SECTIONS: Record<string, ComponentType> = {
assistant: AssistantSettingsContent,
api: ApiSettingsContent,
billing: BillingSettingsContent,
whatsapp: WhatsAppSettingsContent,
}
export type SettingsSectionId = keyof typeof SETTINGS_SECTIONS
@@ -41,6 +41,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
const hasCompany = !!company
const hasBankingExtension = ENABLED_EXTENSION_IDS.has('enable-banking')
const hasMcpExtension = ENABLED_EXTENSION_IDS.has('mcp-server')
const hasWhatsAppExtension = ENABLED_EXTENSION_IDS.has('whatsapp-inbox')
// Företagsprofil (TIC-snapshot) lives under Företag; Skatteverket under Skatt;
// assistentens minne + kunskap under Assistenten; säkerhetsbackup under
@@ -58,6 +59,7 @@ export function useSettingsNavItems(): { items: SettingsNavItem[]; groups: Setti
{ id: 'invoicing', href: '/settings/invoicing', label: t('invoicing'), group: 'sales', show: hasCompany },
{ id: 'templates', href: '/settings/templates', label: t('templates'), group: 'sales', show: hasCompany },
{ id: 'banking', href: '/settings/banking', label: t('banking'), group: 'tools', show: hasCompany && !isSandbox && hasBankingExtension },
{ id: 'whatsapp', href: '/settings/whatsapp', label: t('whatsapp'), group: 'tools', show: hasCompany && !isSandbox && hasWhatsAppExtension },
{ id: 'assistant', href: '/settings/assistant', label: t('assistant'), group: 'tools', show: hasCompany && identity.isVerified },
{ id: 'api', href: '/settings/api', label: t('api'), group: 'tools', show: hasCompany && hasMcpExtension },
]
+12
View File
@@ -66,6 +66,18 @@ Resolution order (last wins): **defaults → env vars → extension override**.
| `RESEND_INBOUND_WEBHOOK_SECRET` | Verifies the `/inbound` webhook signature from Resend |
| `RESEND_DELIVERY_WEBHOOK_SECRET` | Verifies the `/delivery-status` webhook signature from Resend. Optional: without it, invoice delivery history shows "sent" but never the delivery outcome |
### WhatsApp (when the `whatsapp-inbox` extension is enabled)
| Env var | Purpose |
|---|---|
| `WHATSAPP_ACCESS_TOKEN` | Meta system-user permanent token (`whatsapp_business_messaging` scope only) |
| `WHATSAPP_PHONE_NUMBER_ID` | Graph object id of your WhatsApp Business number |
| `WHATSAPP_APP_SECRET` | Verifies `X-Hub-Signature-256` on the `/webhook` POST |
| `WHATSAPP_VERIFY_TOKEN` | Shared secret for the GET subscription handshake (also entered in the Meta app dashboard) |
| `WHATSAPP_PHONE_HASH_KEY` | Random pepper for phone lookup hashes (`openssl rand -hex 32`) |
| `WHATSAPP_PHONE_ENCRYPTION_KEY` | 32-byte hex AES-256-GCM key for phone numbers at rest (`openssl rand -hex 32`) |
| `WHATSAPP_PUBLIC_NUMBER` | Optional: the public number as E.164 digits (e.g. `46766867041`) for the wa.me deep link in settings; unset = resolved from the Graph API |
## Things you MUST NOT change
These are stable contracts. Renaming them breaks existing data, sessions, or external clients (npm package consumers, MCP connectors, browser sessions, invite links). Leave them alone in your fork:
+1 -1
View File
@@ -1 +1 @@
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe"]}
{"$schema":"./extensions.schema.json","extensions":["enable-banking","email","arcim-migration","tic","mcp-server","cloud-backup","skatteverket","invoice-inbox","document-extraction","stripe","whatsapp-inbox"]}
+2 -1
View File
@@ -30,7 +30,8 @@
"mcp-server",
"skatteverket",
"cloud-backup",
"document-extraction"
"document-extraction",
"whatsapp-inbox"
]
},
"description": "Extension IDs to enable. Each ID must match a manifest.json in the extensions/ directory."
@@ -109,6 +109,24 @@ export interface EmailMeta {
resendAttachmentId?: string | null
}
// Chat-channel provenance (whatsapp-inbox extension). When present, the inbox
// row links back to the delivering whatsapp_messages row and seeds
// channel_context (kept OUT of extracted_data: retry-extraction overwrites
// that container wholesale, and chat context must survive it).
export interface ChannelMeta {
whatsappMessageId?: string
caption?: string | null
}
// Captions are attacker-adjacent free text from a chat client: strip control
// characters and cap length before they land in a jsonb column read by the UI.
function sanitiseCaption(raw: string | null | undefined): string | null {
if (!raw) return null
// eslint-disable-next-line no-control-regex
const cleaned = raw.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, 500)
return cleaned || null
}
// ── Shared helper: upload + extract + create inbox item ──────
export async function uploadAndExtract(
@@ -116,14 +134,20 @@ export async function uploadAndExtract(
userId: string,
companyId: string,
file: { name: string; buffer: ArrayBuffer; type: string },
source: 'upload' | 'email',
source: 'upload' | 'email' | 'whatsapp',
emailMeta?: EmailMeta,
// Pre-match the new inbox item to a bank transaction. Set when the caller
// already knows which transaction this receipt belongs to (e.g. the
// VerifyAndBookOverlay opened from a transaction row's paperclip or from
// a transaction-anchored chat). Skipped silently if missing.
matchedTransactionId?: string | null,
opts: { skipExtraction?: boolean } = {},
opts: {
skipExtraction?: boolean
channelMeta?: ChannelMeta
/** Overrides the system actor id on the DocumentIngested history event.
* Omitted = today's behavior (resend-inbound for email, user otherwise). */
actorId?: string
} = {},
) {
const correlationId = crypto.randomUUID()
@@ -132,7 +156,7 @@ export async function uploadAndExtract(
buffer: file.buffer,
type: file.type,
}, {
upload_source: source === 'email' ? 'email' : 'file_upload',
upload_source: source === 'email' ? 'email' : source === 'whatsapp' ? 'whatsapp' : 'file_upload',
})
try {
@@ -148,7 +172,11 @@ export async function uploadAndExtract(
mime_type: file.type,
size_bytes: file.buffer.byteLength,
},
actor: source === 'email' ? { type: 'system', id: 'resend-inbound' } : { type: 'user', id: userId },
actor: opts.actorId
? { type: 'system', id: opts.actorId }
: source === 'email'
? { type: 'system', id: 'resend-inbound' }
: { type: 'user', id: userId },
occurredAt: new Date(),
})
} catch (err) {
@@ -256,6 +284,13 @@ export async function uploadAndExtract(
: null,
correlation_id: correlationId,
matched_transaction_id: matchedTransactionId ?? null,
// Chat-channel provenance. Explicit nulls (not a conditional spread)
// keep the payload statically checkable; a null insert is identical to
// omitting the column, so the email/upload behavior is unchanged.
whatsapp_message_id: opts.channelMeta?.whatsappMessageId ?? null,
channel_context: opts.channelMeta
? { channel: 'whatsapp', caption: sanitiseCaption(opts.channelMeta.caption) }
: null,
})
.select('*')
.single()
@@ -0,0 +1,201 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TimeoutError } from '@/lib/http/fetch-with-timeout'
import {
sendText,
downloadMedia,
getDisplayPhoneNumber,
resetDisplayNumberCacheForTests,
GraphApiError,
MAX_MEDIA_BYTES,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
import type { SupabaseClient } from '@supabase/supabase-js'
const fetchMock = vi.fn()
describe('graph-api', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('fetch', fetchMock)
resetDisplayNumberCacheForTests()
process.env.WHATSAPP_ACCESS_TOKEN = 'token-1'
process.env.WHATSAPP_PHONE_NUMBER_ID = '111222333'
})
afterEach(() => {
vi.unstubAllGlobals()
process.env = { ...originalEnv }
})
describe('sendText', () => {
it('sends and persists an outbound row with the response wamid', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ messages: [{ id: 'wamid.OUT1' }] }), { status: 200 }),
)
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m16Fallback,
senderPhoneHash: 'hash-1',
})
expect(result).toEqual({ ok: true, wamid: 'wamid.OUT1' })
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.direction).toBe('outbound')
expect(row.wamid).toBe('wamid.OUT1')
expect(row.delivery_status).toBe('sent')
expect(row.processing_status).toBe('done')
expect(row.raw_payload).toEqual({ template: TEMPLATE.m16Fallback })
expect(row.sender_phone_hash).toBe('hash-1')
// The Graph call itself
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]
expect(url).toContain('/111222333/messages')
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer token-1')
expect(JSON.parse(init.body as string).text.body).toBe('Hej!')
})
it('never throws on non-2xx and records a failed row', async () => {
fetchMock.mockResolvedValueOnce(new Response('{"error":{}}', { status: 500 }))
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m18Error,
})
expect(result).toEqual({ ok: false, wamid: null })
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.delivery_status).toBe('failed')
expect(row.wamid).toBeNull()
})
it('never throws on a network error', async () => {
fetchMock.mockRejectedValueOnce(new Error('ECONNRESET'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const result = await sendText(supabase as unknown as SupabaseClient, {
to: '46701234567',
body: 'Hej!',
template: TEMPLATE.m18Error,
})
expect(result.ok).toBe(false)
})
})
describe('downloadMedia', () => {
it('resolves the media id, downloads with Bearer auth, and returns the bytes', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
fetchMock
.mockResolvedValueOnce(
new Response(
JSON.stringify({ url: 'https://lookaside.example/m1', mime_type: 'image/jpeg' }),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(bytes, { status: 200 }))
const media = await downloadMedia('media-1')
expect(media.mime).toBe('image/jpeg')
expect(media.fileSize).toBe(4)
expect(new Uint8Array(media.buffer)).toEqual(bytes)
const [downloadUrl, downloadInit] = fetchMock.mock.calls[1] as [string, RequestInit]
expect(downloadUrl).toBe('https://lookaside.example/m1')
expect((downloadInit.headers as Record<string, string>).Authorization).toBe('Bearer token-1')
})
it('rejects a non-2xx lookup', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 404 }))
await expect(downloadMedia('media-1')).rejects.toThrow(GraphApiError)
})
it('rejects a non-2xx download', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://lookaside.example/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(new Response('gone', { status: 410 }))
await expect(downloadMedia('media-1')).rejects.toThrow(/download failed/)
})
it('rejects when the declared file size exceeds the cap', async () => {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ url: 'https://x/m1', file_size: MAX_MEDIA_BYTES + 1 }),
{ status: 200 },
),
)
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('rejects via the content-length header before reading the body', async () => {
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://x/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(
new Response('x', {
status: 200,
headers: { 'content-length': String(MAX_MEDIA_BYTES + 1) },
}),
)
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
})
it('rejects an oversized stream even without a content-length header', async () => {
const chunk = new Uint8Array(6 * 1024 * 1024)
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(chunk)
controller.enqueue(chunk) // 12 MB total > 10 MB cap
controller.close()
},
})
fetchMock
.mockResolvedValueOnce(
new Response(JSON.stringify({ url: 'https://x/m1' }), { status: 200 }),
)
.mockResolvedValueOnce(new Response(stream, { status: 200 }))
await expect(downloadMedia('media-1')).rejects.toThrow(/size limit/)
})
it('propagates timeouts as TimeoutError', async () => {
fetchMock.mockRejectedValueOnce(new TimeoutError('WhatsApp media lookup timed out'))
await expect(downloadMedia('media-1')).rejects.toThrow(TimeoutError)
})
it('throws when the access token is missing', async () => {
delete process.env.WHATSAPP_ACCESS_TOKEN
await expect(downloadMedia('media-1')).rejects.toThrow(/WHATSAPP_ACCESS_TOKEN/)
})
})
describe('getDisplayPhoneNumber', () => {
it('resolves and caches the display number as digits', async () => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ display_phone_number: '+46 10 123 45 67' }), { status: 200 }),
)
expect(await getDisplayPhoneNumber()).toBe('46101234567')
// Cached: second call issues no fetch.
expect(await getDisplayPhoneNumber()).toBe('46101234567')
expect(fetchMock).toHaveBeenCalledTimes(1)
})
it('returns null on failure instead of throwing', async () => {
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 500 }))
expect(await getDisplayPhoneNumber()).toBeNull()
})
})
})
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
import {
CODE_ALPHABET,
normalizeLinkCode,
looksLikeLinkCode,
hashLinkCode,
mintLinkCode,
consumeLinkCode,
} from '@/extensions/general/whatsapp-inbox/lib/linking'
import type { SupabaseClient } from '@supabase/supabase-js'
describe('linking', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
})
afterEach(() => {
process.env = { ...originalEnv }
})
describe('normalizeLinkCode', () => {
it('accepts the canonical form', () => {
expect(normalizeLinkCode('AC-7KP4QF')).toBe('AC-7KP4QF')
})
it('uppercases and trims', () => {
expect(normalizeLinkCode(' ac-7kp4qf ')).toBe('AC-7KP4QF')
})
it('tolerates a missing prefix (when digits disambiguate) and inner whitespace', () => {
expect(normalizeLinkCode('7KP4QF')).toBe('AC-7KP4QF')
expect(normalizeLinkCode('AC 7KP4QF')).toBe('AC-7KP4QF')
// All-letter bodies are only accepted WITH the prefix: bare ones would
// collide with ordinary words.
expect(normalizeLinkCode('AC-QSTVWX')).toBe('AC-QSTVWX')
expect(normalizeLinkCode('QSTVWX')).toBeNull()
})
it('rejects ambiguous characters outside the alphabet', () => {
// 1, 0, I, O, L, U are excluded from the alphabet.
expect(normalizeLinkCode('AC-7KP4Q1')).toBeNull()
expect(normalizeLinkCode('AC-7KP4QO')).toBeNull()
})
it('rejects free text and wrong lengths', () => {
expect(normalizeLinkCode('hej hej')).toBeNull()
expect(normalizeLinkCode('hejhej')).toBeNull()
expect(normalizeLinkCode('AC-7KP4Q')).toBeNull()
expect(normalizeLinkCode('AC-7KP4QFF')).toBeNull()
expect(normalizeLinkCode('')).toBeNull()
expect(normalizeLinkCode(null)).toBeNull()
})
it('looksLikeLinkCode mirrors normalizeLinkCode', () => {
expect(looksLikeLinkCode('ac-7kp4qf')).toBe(true)
expect(looksLikeLinkCode('lunch med anna')).toBe(false)
})
})
describe('hashLinkCode', () => {
it('is sha256 hex of the code', () => {
const expected = crypto.createHash('sha256').update('AC-7KP4QF').digest('hex')
expect(hashLinkCode('AC-7KP4QF')).toBe(expected)
})
})
describe('mintLinkCode', () => {
it('mints an AC- prefixed code from the ambiguity-free alphabet with a 10 min TTL', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: null, error: null })
const before = Date.now()
const minted = await mintLinkCode(supabase as unknown as SupabaseClient, 'user-1')
expect(minted.code).toMatch(/^AC-[A-Z2-9]{6}$/)
for (const ch of minted.code.slice(3)) {
expect(CODE_ALPHABET).toContain(ch)
}
const ttlMs = new Date(minted.expiresAt).getTime() - before
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000)
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5000)
const insertArgs = findCall('whatsapp_link_codes', 'insert') as [Record<string, unknown>]
expect(insertArgs[0].user_id).toBe('user-1')
expect(insertArgs[0].code_hash).toBe(hashLinkCode(minted.code))
})
it('throws when the insert fails', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'boom' } })
await expect(
mintLinkCode(supabase as unknown as SupabaseClient, 'user-1'),
).rejects.toThrow(/boom/)
})
})
describe('consumeLinkCode', () => {
const futureExpiry = () => new Date(Date.now() + 5 * 60 * 1000).toISOString()
it('consumes a valid unused code', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
enqueue({ data: { id: 'code-1' } })
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'ac-7kp4qf')
expect(result).toEqual({ userId: 'user-1' })
const updateArgs = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
expect(updateArgs[0].used_at).toBeTruthy()
})
it('rejects an expired code', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() - 1000).toISOString(),
used_at: null,
},
})
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('rejects an already-used code (single use)', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: futureExpiry(),
used_at: new Date().toISOString(),
},
})
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('loses the claim race gracefully', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'code-1', user_id: 'user-1', expires_at: futureExpiry(), used_at: null } })
enqueue({ data: null }) // guarded update matched no row: someone else won
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
})
it('rejects unknown codes without touching anything', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: null })
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'AC-7KP4QF')
expect(result).toBeNull()
expect(findCalls('whatsapp_link_codes', 'update')).toHaveLength(0)
})
it('short-circuits on non-code text without any DB call', async () => {
const { supabase, calls } = createQueuedMockSupabase()
const result = await consumeLinkCode(supabase as unknown as SupabaseClient, 'hej!')
expect(result).toBeNull()
expect(calls).toHaveLength(0)
})
})
})
@@ -0,0 +1,64 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import {
normalizePhone,
hashPhone,
encryptPhone,
decryptPhone,
maskPhone,
} from '@/extensions/general/whatsapp-inbox/lib/phone-crypto'
const HEX_KEY = 'a'.repeat(64)
describe('phone-crypto', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = HEX_KEY
})
afterEach(() => {
process.env = { ...originalEnv }
})
it('normalizes to digits only', () => {
expect(normalizePhone('+46 70-123 45 67')).toBe('46701234567')
expect(normalizePhone('46701234567')).toBe('46701234567')
})
it('hashes deterministically and collides across formatting variants', () => {
const a = hashPhone('46701234567')
expect(a).toMatch(/^[0-9a-f]{64}$/)
expect(hashPhone('+46 70 123 45 67')).toBe(a)
expect(hashPhone('46701234568')).not.toBe(a)
})
it('changes the hash when the pepper changes', () => {
const a = hashPhone('46701234567')
process.env.WHATSAPP_PHONE_HASH_KEY = 'other-pepper'
expect(hashPhone('46701234567')).not.toBe(a)
})
it('throws without the hash key', () => {
delete process.env.WHATSAPP_PHONE_HASH_KEY
expect(() => hashPhone('46701234567')).toThrow(/WHATSAPP_PHONE_HASH_KEY/)
})
it('encrypts and decrypts roundtrip', () => {
const stored = encryptPhone('+46 70 123 45 67')
expect(stored).toMatch(/^[0-9a-f]+$/)
expect(decryptPhone(stored)).toBe('46701234567')
})
it('produces a fresh iv per encryption', () => {
expect(encryptPhone('46701234567')).not.toBe(encryptPhone('46701234567'))
})
it('masks in the +46 70 *** ** 67 shape', () => {
expect(maskPhone('46701234567')).toBe('+46 70 *** ** 67')
})
it('fully masks too-short values', () => {
expect(maskPhone('12345')).toBe('+** *** ** **')
})
})
@@ -0,0 +1,349 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { SupabaseClient } from '@supabase/supabase-js'
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }),
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
downloadMedia: vi.fn(),
}
})
vi.mock('@/extensions/general/invoice-inbox/lib/upload-and-extract', () => ({
uploadAndExtract: vi.fn(),
}))
vi.mock('@/lib/rate-limits/inbox', () => ({
checkInboxUploadRateLimit: vi.fn().mockResolvedValue({ ok: true }),
}))
vi.mock('@/lib/processing-history/append', () => ({
appendProcessingHistory: vi.fn().mockResolvedValue('event-1'),
}))
vi.mock('@/lib/core/documents/document-service', () => ({
computeSHA256: vi.fn().mockResolvedValue('sha-abc'),
}))
import {
sendText,
markReadWithTyping,
downloadMedia,
GraphApiError,
} from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { processInboundMessage } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
const sendTextMock = vi.mocked(sendText)
const downloadMediaMock = vi.mocked(downloadMedia)
const uploadAndExtractMock = vi.mocked(uploadAndExtract)
const rateLimitMock = vi.mocked(checkInboxUploadRateLimit)
const appendHistoryMock = vi.mocked(appendProcessingHistory)
function makeRow(overrides: Record<string, unknown> = {}) {
return {
id: 'msg-1',
direction: 'inbound',
wamid: 'wamid.IN1',
sender_phone_hash: 'hash-1',
phone_link_id: 'link-1',
conversation_id: 'conv-1',
message_type: 'image',
body_text: 'lunch med kund',
media_id: 'media-1',
media_mime: 'image/jpeg',
media_sha256: null,
media_filename: null,
raw_payload: { from: '46701234567' },
processing_status: 'received',
attempts: 0,
error_message: null,
inbox_item_id: null,
delivery_status: null,
correlation_id: 'corr-1',
created_at: '2026-08-01T10:00:00Z',
updated_at: '2026-08-01T10:00:00Z',
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-1',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
wa_profile_name: null,
default_company_id: null,
last_company_id: null,
verified_at: '2026-08-01T09:00:00Z',
revoked_at: null,
muted_at: null,
last_message_at: null,
created_at: '2026-08-01T09:00:00Z',
updated_at: '2026-08-01T09:00:00Z',
...overrides,
}
}
function lastUpdate(findCalls: (table: string, method: string) => unknown[][]): Record<string, unknown> {
const updates = findCalls('whatsapp_messages', 'update')
return updates[updates.length - 1][0] as Record<string, unknown>
}
describe('processInboundMessage', () => {
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
rateLimitMock.mockResolvedValue({ ok: true })
downloadMediaMock.mockResolvedValue({
buffer: new Uint8Array([1, 2, 3, 4]).buffer,
mime: 'image/jpeg',
fileSize: 4,
})
uploadAndExtractMock.mockResolvedValue({
document_id: 'doc-1',
inbox_item_id: 'item-1',
status: 'received',
extracted_data: {
supplier: { name: 'Espresso House' },
totals: { total: 450 },
invoice: { invoiceDate: '2026-07-30' },
},
matched_supplier_id: null,
matched_transaction_id: null,
extraction_skipped: false,
skip_reason: null,
page_count: null,
} as never)
})
afterEach(() => {
vi.clearAllMocks()
})
it('happy path: claims, downloads, funnels through uploadAndExtract and acks with M4', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() }) // load row
enqueue({ data: { id: 'msg-1' } }) // claim
enqueue({ data: makeLink() }) // load link
enqueue({ data: [{ company_id: 'company-1' }] }) // sole membership
enqueue({ data: null }) // sha256 dup check: none
enqueue({ data: null }) // final markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(markReadWithTyping).toHaveBeenCalledWith('wamid.IN1')
expect(downloadMediaMock).toHaveBeenCalledWith('media-1')
expect(uploadAndExtractMock).toHaveBeenCalledTimes(1)
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-1',
expect.objectContaining({ type: 'image/jpeg' }),
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: 'msg-1', caption: 'lunch med kund' },
actorId: 'whatsapp-inbound',
},
)
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('done')
expect(finalUpdate.inbox_item_id).toBe('item-1')
expect(sendTextMock).toHaveBeenCalledTimes(1)
const ack = sendTextMock.mock.calls[0][1]
expect(ack.template).toBe(TEMPLATE.m4Ack)
expect(ack.to).toBe('46701234567')
expect(ack.body).toContain('Espresso House')
expect(ack.body).toContain('450 kr')
expect(ack.body).toContain('2026-07-30')
})
it('sends the empty-extraction M4 variant when no total was read', async () => {
uploadAndExtractMock.mockResolvedValue({
document_id: 'doc-1',
inbox_item_id: 'item-1',
extracted_data: { supplier: { name: null }, totals: { total: null }, invoice: {} },
} as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null })
enqueue({ data: null })
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m4AckEmpty)
})
it('does nothing when the claim is lost (already processing)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: null }) // claim matched no row
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).not.toHaveBeenCalled()
})
it('rejects disallowed MIME types with M15, skipped, and no download', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ media_mime: 'video/mp4', message_type: 'document' }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('multi-company sender without a default gets M6 fallback and no item', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }, { company_id: 'company-2' }] })
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m6NoDefaultCompany)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('uses the default company when set and still a member', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink({ default_company_id: 'company-7' }) })
enqueue({ data: { company_id: 'company-7' } }) // membership check for default
enqueue({ data: null }) // dup check
enqueue({ data: null }) // markStatus done
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).toHaveBeenCalledWith(
supabase,
'user-1',
'company-7',
expect.anything(),
'whatsapp',
undefined,
undefined,
expect.anything(),
)
})
it('rate limit: drops with M17 once, records RateLimitedDropped, never a retryable status', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // M17 notice check: none sent yet
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(appendHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({ eventType: 'RateLimitedDropped', companyId: 'company-1' }),
)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m17RateLimited)
expect(downloadMediaMock).not.toHaveBeenCalled()
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('rate limit: stays silent when an M17 already went out inside the window', async () => {
rateLimitMock.mockResolvedValue({ ok: false, scope: 'minute', retryAfterSec: 60 })
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'earlier-m17' } }) // notice already sent
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(sendTextMock).not.toHaveBeenCalled()
})
it('exact sha256 duplicate: M4-duplicate ack and no item created', async () => {
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: { id: 'existing-doc' } }) // dup found
enqueue({ data: null }) // markStatus skipped
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(uploadAndExtractMock).not.toHaveBeenCalled()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m4Duplicate)
expect(lastUpdate(findCalls).processing_status).toBe('skipped')
})
it('wraps failures: error status + error_message + a single M18', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('Media download failed (500)', 500))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow() })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus error
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
const finalUpdate = lastUpdate(findCalls)
expect(finalUpdate.processing_status).toBe('error')
expect(String(finalUpdate.error_message)).toContain('download failed')
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m18Error)
})
it('suppresses M18 on re-claims (attempts > 1)', async () => {
downloadMediaMock.mockRejectedValue(new GraphApiError('still failing'))
const { supabase, enqueue, findCalls } = createQueuedMockSupabase()
enqueue({ data: makeRow({ attempts: 1 }) })
enqueue({ data: { id: 'msg-1' } })
enqueue({ data: makeLink() })
enqueue({ data: [{ company_id: 'company-1' }] })
enqueue({ data: null }) // markStatus error
await processInboundMessage(supabase as unknown as SupabaseClient, 'msg-1')
expect(lastUpdate(findCalls).processing_status).toBe('error')
expect(sendTextMock).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,499 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createQueuedMockSupabase } from '@/tests/helpers'
vi.mock('@supabase/supabase-js', () => ({
createClient: vi.fn(),
}))
vi.mock('@/extensions/general/whatsapp-inbox/lib/graph-api', async () => {
const actual = await vi.importActual<
typeof import('@/extensions/general/whatsapp-inbox/lib/graph-api')
>('@/extensions/general/whatsapp-inbox/lib/graph-api')
return {
...actual,
sendText: vi.fn().mockResolvedValue({ ok: true, wamid: 'wamid.OUT' }),
markReadWithTyping: vi.fn().mockResolvedValue(undefined),
downloadMedia: vi.fn(),
getDisplayPhoneNumber: vi.fn().mockResolvedValue(null),
}
})
vi.mock('@/extensions/general/whatsapp-inbox/lib/process-inbound', () => ({
kickInboundProcessing: vi.fn(),
}))
import { createClient } from '@supabase/supabase-js'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
import { sendText, downloadMedia } from '@/extensions/general/whatsapp-inbox/lib/graph-api'
import { kickInboundProcessing } from '@/extensions/general/whatsapp-inbox/lib/process-inbound'
import { TEMPLATE } from '@/extensions/general/whatsapp-inbox/lib/messages'
import { hashLinkCode } from '@/extensions/general/whatsapp-inbox/lib/linking'
const SECRET = 'meta-app-secret'
const createClientMock = vi.mocked(createClient)
const sendTextMock = vi.mocked(sendText)
const kickMock = vi.mocked(kickInboundProcessing)
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
const route = findRoute('POST', '/webhook')
function signedRequest(body: unknown, secret = SECRET): Request {
const raw = JSON.stringify(body)
const signature =
'sha256=' + crypto.createHmac('sha256', secret).update(raw, 'utf8').digest('hex')
return new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Hub-Signature-256': signature },
body: raw,
})
}
function envelope(value: Record<string, unknown>) {
return {
object: 'whatsapp_business_account',
entry: [
{
id: 'waba-1',
changes: [
{
field: 'messages',
value: { messaging_product: 'whatsapp', ...value },
},
],
},
],
}
}
function textMessage(body: string, overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'text',
text: { body },
...overrides,
}
}
function imageMessage(overrides: Record<string, unknown> = {}) {
return {
from: '46701234567',
id: 'wamid.IN1',
timestamp: '1754000000',
type: 'image',
image: { id: 'media-1', mime_type: 'image/jpeg', sha256: 'abc', caption: 'kvitto' },
...overrides,
}
}
function makeLink(overrides: Record<string, unknown> = {}) {
return {
id: 'link-1',
user_id: 'user-1',
phone_hash: 'hash-x',
phone_enc: 'enc',
phone_masked: '+46 70 *** ** 67',
default_company_id: null,
revoked_at: null,
muted_at: null,
...overrides,
}
}
describe('POST /webhook', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
vi.clearAllMocks()
sendTextMock.mockResolvedValue({ ok: true, wamid: 'wamid.OUT' })
process.env.WHATSAPP_APP_SECRET = SECRET
process.env.WHATSAPP_PHONE_HASH_KEY = 'test-pepper'
process.env.WHATSAPP_PHONE_ENCRYPTION_KEY = 'a'.repeat(64)
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
})
afterEach(() => {
process.env = { ...originalEnv }
})
function mockSupabase() {
const mock = createQueuedMockSupabase()
createClientMock.mockReturnValue(mock.supabase as never)
return mock
}
it('503s when the app secret is not configured', async () => {
delete process.env.WHATSAPP_APP_SECRET
const response = await route.handler(signedRequest(envelope({ messages: [] })))
expect(response.status).toBe(503)
})
it('401s on an invalid signature before touching anything', async () => {
mockSupabase()
const response = await route.handler(
signedRequest(envelope({ messages: [textMessage('hej')] }), 'wrong-secret'),
)
expect(response.status).toBe(401)
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).not.toHaveBeenCalled()
})
it('updates outbound delivery status from statuses[]', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // update chain
const response = await route.handler(
signedRequest(envelope({ statuses: [{ id: 'wamid.OUT9', status: 'delivered' }] })),
)
expect(response.status).toBe(200)
const updateArgs = findCall('whatsapp_messages', 'update') as [Record<string, unknown>]
expect(updateArgs[0]).toEqual({ delivery_status: 'delivered' })
})
it('persists a linked media message and defers processing', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: makeLink() }) // active link lookup
enqueue({ data: { id: 'conv-1' } }) // conversation lookup
enqueue({ data: { id: 'msg-row-1' } }) // message insert
enqueue({ data: null }) // phone_links last_message_at update
enqueue({ data: null }) // conversation window update
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
const [row] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.direction).toBe('inbound')
expect(row.wamid).toBe('wamid.IN1')
expect(row.processing_status).toBe('received')
expect(row.media_id).toBe('media-1')
expect(row.media_mime).toBe('image/jpeg')
expect(row.body_text).toBe('kvitto') // caption travels in body_text
expect(kickMock).toHaveBeenCalledWith(['msg-row-1'])
expect(sendTextMock).not.toHaveBeenCalled() // per-receipt ack comes from the worker
})
it('dedupes a redelivered wamid via the unique index (23505): no reply, no processing', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: makeLink() })
enqueue({ data: { id: 'conv-1' } })
enqueue({ data: null, error: { code: '23505', message: 'duplicate key' } })
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
expect(kickMock).toHaveBeenCalledWith([])
expect(sendTextMock).not.toHaveBeenCalled()
})
describe('unknown senders', () => {
it('greets once with M1: no media download, no message persistence', async () => {
const { enqueue, findCalls } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // sender quota RPC
enqueue({ data: [] }) // greeting throttle: nothing sent before
const response = await route.handler(
signedRequest(envelope({ messages: [imageMessage()] })),
)
expect(response.status).toBe(200)
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m1Unlinked)
expect(vi.mocked(downloadMedia)).not.toHaveBeenCalled()
expect(findCalls('whatsapp_messages', 'insert')).toHaveLength(0)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stays silent when the M1 throttle window is exhausted', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: [{ created_at: new Date().toISOString() }] }) // greeted within the hour
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
})
it('stays silent when the pre-binding sender quota is exhausted', async () => {
const mock = mockSupabase()
mock.enqueue({ data: null })
mock.enqueue({ data: { ok: false, scope: 'minute', retry_after_sec: 60 } })
await route.handler(signedRequest(envelope({ messages: [textMessage('hej')] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(mock.supabase.rpc).toHaveBeenCalledWith(
'check_and_increment_whatsapp_sender_quota',
expect.objectContaining({ p_phone_hash: expect.any(String) }),
)
// Only the link lookup ever touched a table.
const tables = [...new Set(mock.calls.map((c) => c.table))]
expect(tables).toEqual(['whatsapp_phone_links'])
})
})
describe('link codes', () => {
it('binds a valid code: creates link + conversation, replies M3 with the company name', async () => {
const { enqueue, findCall } = mockSupabase()
enqueue({ data: null }) // no active link
enqueue({ data: { ok: true } }) // quota
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: null,
},
}) // code lookup
enqueue({ data: { id: 'code-1' } }) // code claim
enqueue({ data: null }) // revoke by phone hash
enqueue({ data: null }) // revoke by user
enqueue({ data: { id: 'link-9', user_id: 'user-1' } }) // link insert
enqueue({ data: { id: 'conv-9' } }) // conversation insert
enqueue({ data: null }) // content-free code-message row
enqueue({ data: [{ company_id: 'company-1' }] }) // memberships
enqueue({ data: { name: 'Bolaget AB' } }) // company name
const response = await route.handler(
signedRequest(
envelope({
contacts: [{ wa_id: '46701234567', profile: { name: 'Jakob' } }],
messages: [textMessage('ac-7kp4qf')],
}),
),
)
expect(response.status).toBe(200)
const [linkRow] = findCall('whatsapp_phone_links', 'insert') as [Record<string, unknown>]
expect(linkRow.user_id).toBe('user-1')
expect(linkRow.wa_profile_name).toBe('Jakob')
expect(linkRow.phone_masked).toBe('+46 70 *** ** 67')
// The code message row is persisted content-free (dedupe only).
const [codeRow] = findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(codeRow.wamid).toBe('wamid.IN1')
expect(codeRow.body_text).toBeUndefined()
expect(codeRow.raw_payload).toBeUndefined()
// The code was claimed single-use.
const [claim] = findCall('whatsapp_link_codes', 'update') as [Record<string, unknown>]
expect(claim.used_at).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
const reply = sendTextMock.mock.calls[0][1]
expect(reply.template).toBe(TEMPLATE.m3Linked)
expect(reply.body).toContain('Bolaget AB')
})
it('replies M2 to an unknown code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({ data: null }) // code lookup: nothing
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to an expired code', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() - 1000).toISOString(),
used_at: null,
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('replies M2 to a reused code (single use)', async () => {
const { enqueue } = mockSupabase()
enqueue({ data: null })
enqueue({ data: { ok: true } })
enqueue({
data: {
id: 'code-1',
user_id: 'user-1',
expires_at: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
used_at: new Date().toISOString(),
},
})
await route.handler(signedRequest(envelope({ messages: [textMessage('AC-7KP4QF')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m2BadCode)
})
it('hashLinkCode matches what the webhook looks up', () => {
// Regression guard: panel mints, webhook consumes; both must hash alike.
expect(hashLinkCode('AC-7KP4QF')).toMatch(/^[0-9a-f]{64}$/)
})
})
describe('keywords', () => {
function enqueueLinkedTextPreamble(
mock: ReturnType<typeof mockSupabase>,
link: Record<string, unknown>,
) {
mock.enqueue({ data: link }) // link lookup
mock.enqueue({ data: { id: 'conv-1' } }) // conversation
mock.enqueue({ data: { id: 'msg-row-1' } }) // insert
mock.enqueue({ data: null }) // link last_message_at
mock.enqueue({ data: null }) // conversation window
}
it('stopp mutes the link and confirms with M11', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
mock.enqueue({ data: null }) // muted_at update
await route.handler(signedRequest(envelope({ messages: [textMessage('Stopp')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const mutedUpdate = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at != null,
)
expect(mutedUpdate).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m11Stop)
})
it('while muted everything except start is silence', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
await route.handler(signedRequest(envelope({ messages: [textMessage('hej, är du där?')] })))
expect(sendTextMock).not.toHaveBeenCalled()
const [row] = mock.findCall('whatsapp_messages', 'insert') as [Record<string, unknown>]
expect(row.processing_status).toBe('skipped')
})
it('while muted media is also silence', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink({ muted_at: '2026-08-01T10:00:00Z' }) })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(signedRequest(envelope({ messages: [imageMessage()] })))
expect(sendTextMock).not.toHaveBeenCalled()
expect(kickMock).toHaveBeenCalledWith([])
})
it('start unmutes and welcomes back with M12', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink({ muted_at: '2026-08-01T10:00:00Z' }))
mock.enqueue({ data: null }) // muted_at cleared
await route.handler(signedRequest(envelope({ messages: [textMessage('start')] })))
const linkUpdates = mock.findCalls('whatsapp_phone_links', 'update')
const unmute = linkUpdates.find(
(args) => (args[0] as Record<string, unknown>).muted_at === null,
)
expect(unmute).toBeTruthy()
expect(sendTextMock).toHaveBeenCalledTimes(1)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m12Start)
})
it('hjälp escalates to a human with M13', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(signedRequest(envelope({ messages: [textMessage('Hjälp')] })))
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m13Help)
expect(sendTextMock.mock.calls[0][1].body).toContain('support@accounted.se')
})
it('other free text gets the M16 fallback', async () => {
const mock = mockSupabase()
enqueueLinkedTextPreamble(mock, makeLink())
await route.handler(
signedRequest(envelope({ messages: [textMessage('kan du bokföra allt åt mig?')] })),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m16Fallback)
})
it('voice notes get M14', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'audio',
audio: { id: 'media-2', mime_type: 'audio/ogg', voice: true },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m14Voice)
expect(kickMock).toHaveBeenCalledWith([])
})
it('stickers get M15', async () => {
const mock = mockSupabase()
mock.enqueue({ data: makeLink() })
mock.enqueue({ data: { id: 'conv-1' } })
mock.enqueue({ data: { id: 'msg-row-1' } })
mock.enqueue({ data: null })
mock.enqueue({ data: null })
await route.handler(
signedRequest(
envelope({
messages: [
{
from: '46701234567',
id: 'wamid.IN1',
type: 'sticker',
sticker: { id: 'media-3', mime_type: 'image/webp' },
},
],
}),
),
)
expect(sendTextMock.mock.calls[0][1].template).toBe(TEMPLATE.m15Unsupported)
})
})
it('acks signed-but-unparseable bodies without redelivery bait', async () => {
mockSupabase()
const raw = 'not json'
const signature =
'sha256=' + crypto.createHmac('sha256', SECRET).update(raw, 'utf8').digest('hex')
const response = await route.handler(
new Request('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook', {
method: 'POST',
headers: { 'X-Hub-Signature-256': signature },
body: raw,
}),
)
expect(response.status).toBe(200)
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import {
verifyMetaSignature,
verifyChallengeToken,
} from '@/extensions/general/whatsapp-inbox/lib/webhook-verify'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
const SECRET = 'meta-app-secret'
function sign(body: string, secret = SECRET): string {
return 'sha256=' + crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
}
function findRoute(method: string, path: string) {
return whatsappInboxExtension.apiRoutes!.find((r) => r.method === method && r.path === path)!
}
describe('verifyMetaSignature', () => {
it('accepts a valid signature', () => {
const body = '{"object":"whatsapp_business_account"}'
expect(verifyMetaSignature(body, sign(body), SECRET)).toBe(true)
})
it('rejects a signature made with the wrong secret', () => {
const body = '{"a":1}'
expect(verifyMetaSignature(body, sign(body, 'forged-secret'), SECRET)).toBe(false)
})
it('rejects a signature over a different body', () => {
expect(verifyMetaSignature('{"a":2}', sign('{"a":1}'), SECRET)).toBe(false)
})
it('rejects a malformed (non-hex) header without throwing', () => {
expect(verifyMetaSignature('{}', 'sha256=zzzz-not-hex', SECRET)).toBe(false)
expect(verifyMetaSignature('{}', 'sha256=', SECRET)).toBe(false)
expect(verifyMetaSignature('{}', 'garbage', SECRET)).toBe(false)
})
it('rejects a missing header', () => {
expect(verifyMetaSignature('{}', null, SECRET)).toBe(false)
expect(verifyMetaSignature('{}', undefined, SECRET)).toBe(false)
})
it('rejects when the secret is empty', () => {
const body = '{}'
expect(verifyMetaSignature(body, sign(body), '')).toBe(false)
})
})
describe('verifyChallengeToken', () => {
it('accepts an exact match', () => {
expect(verifyChallengeToken('tok-123', 'tok-123')).toBe(true)
})
it('rejects mismatches and length differences', () => {
expect(verifyChallengeToken('tok-124', 'tok-123')).toBe(false)
expect(verifyChallengeToken('tok-1234', 'tok-123')).toBe(false)
})
it('rejects missing values', () => {
expect(verifyChallengeToken(null, 'tok-123')).toBe(false)
expect(verifyChallengeToken('tok-123', undefined)).toBe(false)
})
})
describe('GET /webhook (subscription handshake)', () => {
const originalEnv = { ...process.env }
const route = findRoute('GET', '/webhook')
beforeEach(() => {
process.env.WHATSAPP_VERIFY_TOKEN = 'verify-token-1'
})
afterEach(() => {
process.env = { ...originalEnv }
})
function handshakeRequest(params: Record<string, string>): Request {
const url = new URL('http://localhost:3000/api/extensions/ext/whatsapp-inbox/webhook')
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
return new Request(url.toString(), { method: 'GET' })
}
it('echoes hub.challenge as text/plain on a valid handshake', async () => {
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'verify-token-1',
'hub.challenge': '1158201444',
}),
)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('text/plain')
expect(await response.text()).toBe('1158201444')
})
it('403s on a wrong verify token', async () => {
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'wrong-token',
'hub.challenge': '1158201444',
}),
)
expect(response.status).toBe(403)
})
it('403s on a missing mode', async () => {
const response = await route.handler(
handshakeRequest({ 'hub.verify_token': 'verify-token-1', 'hub.challenge': 'x' }),
)
expect(response.status).toBe(403)
})
it('503s when the verify token env is not configured', async () => {
delete process.env.WHATSAPP_VERIFY_TOKEN
const response = await route.handler(
handshakeRequest({
'hub.mode': 'subscribe',
'hub.verify_token': 'verify-token-1',
'hub.challenge': 'x',
}),
)
expect(response.status).toBe(503)
})
})
+580
View File
@@ -0,0 +1,580 @@
/**
* WhatsApp intake extension (PR3 of the WhatsApp track).
*
* Receives receipts sent to the shared Accounted WhatsApp number and lands
* them in the document inbox (Underlag) through the same uploadAndExtract
* funnel as email intake. Phone numbers bind to Accounted users via one-time
* codes; unknown senders get one canned, throttled greeting and are never
* processed further (no LLM, no media download, no content persistence).
*
* Webhook lifecycle: persist-first. POST verifies the Meta signature over the
* RAW body, Zod-parses, persists inbound rows (wamid partial-unique index =
* the dedupe key against Meta's up-to-7-day redelivery), 200s fast, and
* defers media processing via the after() idiom (lib/process-inbound.ts).
* Rejected/rate-limited content always acks 200: a retryable status would
* only buy a redelivery of something we already decided to drop.
*
* Deferred to PR4: burst debounce + combined ack, company choice buttons,
* clarifying questions (representation/quality/context), interpret-answer LLM
* call, sweep + retention crons.
*/
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import type { SupabaseClient } from '@supabase/supabase-js'
import { z } from 'zod'
import { createServiceClient } from '@/lib/supabase/server'
import { createLogger } from '@/lib/logger'
import type { WhatsAppPhoneLink } from '@/types'
import { verifyMetaSignature, verifyChallengeToken } from './lib/webhook-verify'
import { parseWebhookEnvelope, type ParsedInboundMessage } from './lib/webhook-parse'
import { hashPhone } from './lib/phone-crypto'
import {
consumeLinkCode,
createPhoneLink,
looksLikeLinkCode,
lookupActiveLink,
mintLinkCode,
} from './lib/linking'
import { sendText, getDisplayPhoneNumber } from './lib/graph-api'
import { botCopy, TEMPLATE } from './lib/messages'
import { kickInboundProcessing } from './lib/process-inbound'
const log = createLogger('whatsapp-inbox')
// ── Unknown-sender budgets ───────────────────────────────────
// Pre-binding limiter (check_and_increment_whatsapp_sender_quota): caps how
// much handling an unbound phone can consume at all. Beyond it: silence.
const UNKNOWN_SENDER_MINUTE_MAX = 15
const UNKNOWN_SENDER_DAY_MAX = 200
// The M1 greeting itself is throttled much harder: 1/hour, 3/day, then silence.
const GREETING_HOUR_MS = 60 * 60 * 1000
const GREETING_DAY_MS = 24 * 60 * 60 * 1000
const GREETING_DAY_MAX = 3
const SERVICE_WINDOW_MS = 24 * 60 * 60 * 1000
// Exact whole-message keyword sets (normalized lowercase + trim). Text
// messages only, never captions, per the conversation spec.
const STOP_KEYWORDS = new Set(['stopp', 'stop', 'avsluta'])
const HELP_KEYWORDS = new Set(['hjälp', 'hjalp', 'help', 'support', 'människa', 'manniska'])
const START_KEYWORD = 'start'
const DefaultCompanySchema = z.object({
companyId: z.string().uuid().nullable(),
})
function buildServiceClient(): SupabaseClient {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
)
}
// ── Unknown senders ──────────────────────────────────────────
async function greetingThrottled(
supabase: SupabaseClient,
phoneHash: string,
): Promise<boolean> {
const since = new Date(Date.now() - GREETING_DAY_MS).toISOString()
const { data } = await supabase
.from('whatsapp_messages')
.select('created_at')
.eq('direction', 'outbound')
.eq('sender_phone_hash', phoneHash)
.eq('raw_payload->>template', TEMPLATE.m1Unlinked)
.gte('created_at', since)
.order('created_at', { ascending: false })
.limit(GREETING_DAY_MAX)
const rows = (data ?? []) as Array<{ created_at: string }>
if (rows.length >= GREETING_DAY_MAX) return true
const hourAgo = Date.now() - GREETING_HOUR_MS
return rows.some((r) => new Date(r.created_at).getTime() > hourAgo)
}
/**
* Unknown/unlinked sender. Hard rules: never download media, never persist
* message content or raw payloads, never touch any LLM. The only DB writes
* are the quota counters, a consumed link code, and outbound reply rows.
*/
async function handleUnknownSender(
supabase: SupabaseClient,
msg: ParsedInboundMessage,
phoneHash: string,
): Promise<void> {
const { data: quota, error: quotaError } = await supabase.rpc(
'check_and_increment_whatsapp_sender_quota',
{
p_phone_hash: phoneHash,
p_minute_max: UNKNOWN_SENDER_MINUTE_MAX,
p_day_max: UNKNOWN_SENDER_DAY_MAX,
},
)
if (quotaError) {
// Fail closed for unknown senders: without the limiter we send nothing.
log.warn('sender quota RPC failed; staying silent', { error: quotaError.message })
return
}
if ((quota as { ok?: boolean } | null)?.ok === false) return
const copy = botCopy('sv')
if (msg.type === 'text' && looksLikeLinkCode(msg.text)) {
const consumed = await consumeLinkCode(supabase, msg.text ?? '')
if (!consumed) {
await sendText(supabase, {
to: msg.from,
body: copy.m2BadCode(),
template: TEMPLATE.m2BadCode,
senderPhoneHash: phoneHash,
})
return
}
const { link, conversationId } = await createPhoneLink(supabase, {
userId: consumed.userId,
phone: msg.from,
profileName: msg.profileName,
})
// Persist a content-free row for the code message so a Meta redelivery
// of the same wamid dedupes instead of falling into the keyword path.
await supabase.from('whatsapp_messages').insert({
direction: 'inbound',
wamid: msg.wamid,
sender_phone_hash: phoneHash,
phone_link_id: link.id,
conversation_id: conversationId,
message_type: 'text',
processing_status: 'done',
})
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', consumed.userId)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
let companyName: string | null = null
if (companyIds.length === 1) {
const { data: company } = await supabase
.from('companies')
.select('name')
.eq('id', companyIds[0])
.maybeSingle()
companyName = (company as { name?: string } | null)?.name ?? null
}
await sendText(supabase, {
to: msg.from,
body: copy.m3Linked({ companyName, companyCount: Math.max(companyIds.length, 1) }),
template: TEMPLATE.m3Linked,
senderPhoneHash: phoneHash,
phoneLinkId: link.id,
conversationId,
})
return
}
// Anything else from an unknown number: the AI-disclosure greeting, hard
// throttled per phone hash (EU AI Act Art 50 disclosure lives in M1).
if (await greetingThrottled(supabase, phoneHash)) return
await sendText(supabase, {
to: msg.from,
body: copy.m1Unlinked(),
template: TEMPLATE.m1Unlinked,
senderPhoneHash: phoneHash,
})
}
// ── Linked senders ───────────────────────────────────────────
type Disposition =
| { kind: 'media' }
| { kind: 'stop' }
| { kind: 'start' }
| { kind: 'help' }
| { kind: 'voice' }
| { kind: 'unsupported' }
| { kind: 'fallback' }
| { kind: 'silence' }
function classify(msg: ParsedInboundMessage, muted: boolean): Disposition {
if (msg.type === 'text') {
const normalized = (msg.text ?? '').trim().toLowerCase()
if (muted) {
// While muted only `start` is recognized; everything else is silence.
return normalized === START_KEYWORD ? { kind: 'start' } : { kind: 'silence' }
}
if (STOP_KEYWORDS.has(normalized)) return { kind: 'stop' }
if (HELP_KEYWORDS.has(normalized)) return { kind: 'help' }
if (normalized === START_KEYWORD) return { kind: 'start' }
return { kind: 'fallback' }
}
if (muted) return { kind: 'silence' }
if (msg.type === 'image' || msg.type === 'document') {
return msg.media ? { kind: 'media' } : { kind: 'unsupported' }
}
if (msg.type === 'audio') return { kind: 'voice' }
if (msg.type === 'video' || msg.type === 'sticker' || msg.type === 'location' || msg.type === 'contacts') {
return { kind: 'unsupported' }
}
// Truly unknown types (reactions, ephemeral, future additions): stay
// silent rather than lecture someone for sending a thumbs-up.
return { kind: 'silence' }
}
async function resolveConversationId(
supabase: SupabaseClient,
phoneLinkId: string,
): Promise<string | null> {
const { data: existing } = await supabase
.from('whatsapp_conversations')
.select('id')
.eq('phone_link_id', phoneLinkId)
.maybeSingle()
if (existing) return (existing as { id: string }).id
const { data: created } = await supabase
.from('whatsapp_conversations')
.insert({ phone_link_id: phoneLinkId })
.select('id')
.maybeSingle()
return (created as { id: string } | null)?.id ?? null
}
async function handleLinkedSender(
supabase: SupabaseClient,
msg: ParsedInboundMessage,
phoneHash: string,
link: WhatsAppPhoneLink,
mediaMessageIds: string[],
): Promise<void> {
const disposition = classify(msg, link.muted_at != null)
const conversationId = await resolveConversationId(supabase, link.id)
const correlationId = crypto.randomUUID()
const copy = botCopy('sv')
// Persist-first, dedupe on the inbound-wamid partial unique index. A
// redelivered wamid violates it (23505): already handled, stop entirely.
const initialStatus =
disposition.kind === 'media' ? 'received' : disposition.kind === 'silence' ? 'skipped' : 'done'
const { data: inserted, error: insertError } = await supabase
.from('whatsapp_messages')
.insert({
direction: 'inbound',
wamid: msg.wamid,
sender_phone_hash: phoneHash,
phone_link_id: link.id,
conversation_id: conversationId,
message_type: msg.type,
body_text: msg.type === 'text' ? msg.text : (msg.caption ?? null),
media_id: msg.media?.id ?? null,
media_mime: msg.media?.mime ?? null,
media_sha256: msg.media?.sha256 ?? null,
media_filename: msg.media?.filename ?? null,
raw_payload: msg.raw as Record<string, unknown>,
processing_status: initialStatus,
correlation_id: correlationId,
})
.select('id')
.maybeSingle()
if (insertError) {
if (insertError.code === '23505') return // wamid dedupe: Meta redelivery
log.error('Failed to persist inbound WhatsApp message', insertError)
return
}
const messageId = (inserted as { id: string } | null)?.id ?? null
const now = new Date()
await supabase
.from('whatsapp_phone_links')
.update({ last_message_at: now.toISOString() })
.eq('id', link.id)
if (conversationId) {
await supabase
.from('whatsapp_conversations')
.update({
last_inbound_at: now.toISOString(),
service_window_expires_at: new Date(now.getTime() + SERVICE_WINDOW_MS).toISOString(),
})
.eq('id', conversationId)
}
const replyBase = {
senderPhoneHash: phoneHash,
phoneLinkId: link.id,
conversationId,
correlationId,
}
switch (disposition.kind) {
case 'media':
if (messageId) mediaMessageIds.push(messageId)
return
case 'stop':
await supabase
.from('whatsapp_phone_links')
.update({ muted_at: now.toISOString() })
.eq('id', link.id)
await sendText(supabase, { to: msg.from, body: copy.m11Stop(), template: TEMPLATE.m11Stop, ...replyBase })
return
case 'start':
if (link.muted_at != null) {
await supabase
.from('whatsapp_phone_links')
.update({ muted_at: null })
.eq('id', link.id)
}
await sendText(supabase, { to: msg.from, body: copy.m12Start(), template: TEMPLATE.m12Start, ...replyBase })
return
case 'help':
await sendText(supabase, { to: msg.from, body: copy.m13Help(), template: TEMPLATE.m13Help, ...replyBase })
return
case 'voice':
await sendText(supabase, { to: msg.from, body: copy.m14Voice(), template: TEMPLATE.m14Voice, ...replyBase })
return
case 'unsupported':
await sendText(supabase, { to: msg.from, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
return
case 'fallback':
await sendText(supabase, { to: msg.from, body: copy.m16Fallback(), template: TEMPLATE.m16Fallback, ...replyBase })
return
case 'silence':
return
}
}
// ── Extension definition ─────────────────────────────────────
export const whatsappInboxExtension: Extension = {
id: 'whatsapp-inbox',
name: 'WhatsApp-inkorg',
version: '1.0.0',
sector: 'general',
settingsPanel: {
label: 'WhatsApp',
path: '/settings/whatsapp',
},
apiRoutes: [
// ── Meta webhook: subscription handshake ────────────────
{
method: 'GET',
path: '/webhook',
skipAuth: true,
handler: async (request: Request) => {
const expected = process.env.WHATSAPP_VERIFY_TOKEN
if (!expected) {
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
}
const url = new URL(request.url)
const mode = url.searchParams.get('hub.mode')
const token = url.searchParams.get('hub.verify_token')
const challenge = url.searchParams.get('hub.challenge')
if (mode === 'subscribe' && verifyChallengeToken(token, expected) && challenge != null) {
return new Response(challenge, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
})
}
return NextResponse.json({ error: 'Verification failed' }, { status: 403 })
},
},
// ── Meta webhook: inbound events ────────────────────────
{
method: 'POST',
path: '/webhook',
skipAuth: true,
handler: async (request: Request) => {
const appSecret = process.env.WHATSAPP_APP_SECRET
if (!appSecret) {
log.error('WHATSAPP_APP_SECRET not configured', undefined)
return NextResponse.json({ error: 'Webhook not configured' }, { status: 503 })
}
// Signature over the RAW body, before any parsing.
const rawBody = await request.text()
const signature = request.headers.get('x-hub-signature-256')
if (!verifyMetaSignature(rawBody, signature, appSecret)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
let body: unknown
try {
body = JSON.parse(rawBody)
} catch {
// Signed but unparseable: ack so Meta does not redeliver garbage.
return NextResponse.json({ data: { ignored: 'unparseable' } })
}
const parsed = parseWebhookEnvelope(body)
const supabase = buildServiceClient()
// Outbound delivery lifecycle updates (sent -> delivered -> read).
for (const status of parsed.statuses) {
await supabase
.from('whatsapp_messages')
.update({ delivery_status: status.status })
.eq('wamid', status.wamid)
.eq('direction', 'outbound')
}
const mediaMessageIds: string[] = []
for (const msg of parsed.messages) {
try {
const phoneHash = hashPhone(msg.from)
const link = await lookupActiveLink(supabase, phoneHash)
if (link) {
await handleLinkedSender(supabase, msg, phoneHash, link, mediaMessageIds)
} else {
await handleUnknownSender(supabase, msg, phoneHash)
}
} catch (err) {
// One bad message must not take down the batch or trigger a
// Meta redelivery of messages we already handled.
log.error('WhatsApp message handling failed', err, { wamid: msg.wamid })
}
}
// 200 first, processing after: extraction takes 10-60s and Meta
// expects the ack within seconds.
kickInboundProcessing(mediaMessageIds)
return NextResponse.json({
data: {
received: parsed.messages.length,
statuses: parsed.statuses.length,
queued: mediaMessageIds.length,
},
})
},
},
// ── Phone linking (authenticated settings panel) ────────
{
method: 'POST',
path: '/link/start',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!process.env.WHATSAPP_ACCESS_TOKEN || !process.env.WHATSAPP_PHONE_NUMBER_ID) {
return NextResponse.json(
{ error: 'WhatsApp-kanalen är inte konfigurerad på den här installationen.' },
{ status: 503 },
)
}
// whatsapp_link_codes is service-role only (RLS with no policies).
const serviceClient = createServiceClient()
const minted = await mintLinkCode(serviceClient, ctx.userId)
// The wa.me link needs the real number, not the Graph object id.
// WHATSAPP_PUBLIC_NUMBER (E.164 digits) is authoritative when set;
// otherwise resolve display_phone_number from the Graph API (cached).
const publicNumber = (process.env.WHATSAPP_PUBLIC_NUMBER ?? '').replace(/\D/g, '')
const displayNumber = publicNumber || (await getDisplayPhoneNumber())
const waLink = displayNumber
? `https://wa.me/${displayNumber}?text=${encodeURIComponent(minted.code)}`
: null
return NextResponse.json({
data: { code: minted.code, expiresAt: minted.expiresAt, waLink },
})
},
},
{
method: 'GET',
path: '/link',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data } = await ctx.supabase
.from('whatsapp_phone_links')
.select('phone_masked, default_company_id, muted_at, verified_at')
.eq('user_id', ctx.userId)
.is('revoked_at', null)
.maybeSingle()
if (!data) return NextResponse.json({ data: { linked: false } })
const row = data as {
phone_masked: string
default_company_id: string | null
muted_at: string | null
verified_at: string
}
return NextResponse.json({
data: {
linked: true,
phoneMasked: row.phone_masked,
defaultCompanyId: row.default_company_id,
muted: row.muted_at != null,
verifiedAt: row.verified_at,
},
})
},
},
{
method: 'POST',
path: '/link/revoke',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
await ctx.supabase
.from('whatsapp_phone_links')
.update({ revoked_at: new Date().toISOString() })
.eq('user_id', ctx.userId)
.is('revoked_at', null)
return NextResponse.json({ data: { revoked: true } })
},
},
{
method: 'POST',
path: '/link/default-company',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let parsedBody: z.infer<typeof DefaultCompanySchema>
try {
parsedBody = DefaultCompanySchema.parse(await request.json())
} catch {
return NextResponse.json({ error: 'Ogiltig förfrågan.' }, { status: 400 })
}
// The caller must be a member of the company receipts are routed to:
// otherwise a user could point their intake at someone else's books.
if (parsedBody.companyId) {
const { data: membership } = await ctx.supabase
.from('company_members')
.select('id')
.eq('company_id', parsedBody.companyId)
.eq('user_id', ctx.userId)
.maybeSingle()
if (!membership) {
return NextResponse.json(
{ error: 'Du är inte medlem i det företaget.' },
{ status: 403 },
)
}
}
const { error } = await ctx.supabase
.from('whatsapp_phone_links')
.update({ default_company_id: parsedBody.companyId })
.eq('user_id', ctx.userId)
.is('revoked_at', null)
if (error) {
return NextResponse.json(
{ error: 'Kunde inte spara standardföretaget.' },
{ status: 500 },
)
}
return NextResponse.json({ data: { defaultCompanyId: parsedBody.companyId } })
},
},
],
}
@@ -0,0 +1,303 @@
/**
* Meta Cloud API (Graph v26.0) client for the WhatsApp channel.
*
* Plain HTTPS via fetchWithTimeout, deliberately no SDK: the surface we need
* is four endpoints, and the AGPL dependency budget is audited (CLAUDE.md).
*
* Every message send persists an outbound whatsapp_messages row through the
* caller's service client (wamid from the send response). Sends are
* best-effort: a failed send logs + records a failed row but never throws,
* because a reply must never take down webhook acking or intake processing.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchWithTimeout, TimeoutError } from '@/lib/http/fetch-with-timeout'
import { createLogger } from '@/lib/logger'
import type { TemplateId } from './messages'
const log = createLogger('whatsapp-inbox/graph-api')
const GRAPH_BASE = 'https://graph.facebook.com/v26.0'
const SEND_TIMEOUT_MS = 10_000
const MEDIA_LOOKUP_TIMEOUT_MS = 10_000
const MEDIA_DOWNLOAD_TIMEOUT_MS = 30_000
/** WhatsApp caps inbound images at 5 MB and documents at 100 MB; our inbox
* pipeline caps everything at 10 MB (matches invoice-inbox MAX_FILE_SIZE). */
export const MAX_MEDIA_BYTES = 10 * 1024 * 1024
export class GraphApiError extends Error {
readonly name = 'GraphApiError'
constructor(
message: string,
readonly status?: number,
) {
super(message)
}
}
function getAccessToken(): string {
const token = process.env.WHATSAPP_ACCESS_TOKEN
if (!token) throw new GraphApiError('WHATSAPP_ACCESS_TOKEN is not configured')
return token
}
function getPhoneNumberId(): string {
const id = process.env.WHATSAPP_PHONE_NUMBER_ID
if (!id) throw new GraphApiError('WHATSAPP_PHONE_NUMBER_ID is not configured')
return id
}
export interface SendTextArgs {
/** Recipient: E.164 digits, no '+' (Meta's wa_id format). */
to: string
body: string
/** Template id stamped into raw_payload for throttle checks and audit. */
template: TemplateId
senderPhoneHash?: string | null
phoneLinkId?: string | null
conversationId?: string | null
correlationId?: string | null
}
export interface SendTextResult {
ok: boolean
wamid: string | null
}
/**
* Send a plain text message and persist the outbound row. Never throws.
*/
export async function sendText(
supabase: SupabaseClient,
args: SendTextArgs,
): Promise<SendTextResult> {
let wamid: string | null = null
let ok = false
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: args.to,
type: 'text',
text: { body: args.body },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp send' },
)
if (response.ok) {
const payload = (await response.json().catch(() => null)) as {
messages?: Array<{ id?: string }>
} | null
wamid = payload?.messages?.[0]?.id ?? null
ok = true
} else {
const detail = await response.text().catch(() => '')
log.warn('WhatsApp send failed', {
status: response.status,
template: args.template,
detail: detail.slice(0, 300),
})
}
} catch (err) {
log.warn('WhatsApp send errored', {
template: args.template,
error: err instanceof Error ? err.message : String(err),
})
}
try {
await supabase.from('whatsapp_messages').insert({
direction: 'outbound',
wamid,
sender_phone_hash: args.senderPhoneHash ?? null,
phone_link_id: args.phoneLinkId ?? null,
conversation_id: args.conversationId ?? null,
message_type: 'text',
body_text: args.body,
raw_payload: { template: args.template },
// Outbound rows are not jobs: mark done so the PR4 sweep never claims them.
processing_status: 'done',
delivery_status: ok ? 'sent' : 'failed',
correlation_id: args.correlationId ?? null,
})
} catch (err) {
log.error('Failed to persist outbound WhatsApp message row', err)
}
return { ok, wamid }
}
/**
* Mark an inbound message read and show the typing indicator. Best-effort:
* cosmetic, so failures are logged and swallowed.
*/
export async function markReadWithTyping(wamid: string): Promise<void> {
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${getAccessToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messaging_product: 'whatsapp',
status: 'read',
message_id: wamid,
typing_indicator: { type: 'text' },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp mark-read' },
)
if (!response.ok) {
log.warn('WhatsApp mark-read failed', { status: response.status })
}
} catch (err) {
log.warn('WhatsApp mark-read errored', {
error: err instanceof Error ? err.message : String(err),
})
}
}
export interface DownloadedMedia {
buffer: ArrayBuffer
mime: string | null
fileSize: number
}
/**
* Download a media item: resolve the media id to a fresh short-lived URL
* (TTL ~5 min, re-resolvable for days, so retries work), then fetch the bytes
* with the same Bearer token. Enforces MAX_MEDIA_BYTES twice: via the
* content-length header before reading, and while streaming the body (a
* missing or lying header must not let an oversized file through).
*
* Throws GraphApiError / TimeoutError: the caller owns error handling here,
* unlike sends, because a failed download IS a failed intake.
*/
export async function downloadMedia(mediaId: string): Promise<DownloadedMedia> {
const token = getAccessToken()
const lookupResponse = await fetchWithTimeout(
`${GRAPH_BASE}/${encodeURIComponent(mediaId)}?phone_number_id=${encodeURIComponent(getPhoneNumberId())}`,
{ method: 'GET', headers: { Authorization: `Bearer ${token}` } },
{ timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp media lookup' },
)
if (!lookupResponse.ok) {
throw new GraphApiError(`Media lookup failed (${lookupResponse.status})`, lookupResponse.status)
}
const lookup = (await lookupResponse.json().catch(() => null)) as {
url?: string
mime_type?: string
file_size?: number
} | null
if (!lookup?.url) {
throw new GraphApiError('Media lookup returned no download URL')
}
if (typeof lookup.file_size === 'number' && lookup.file_size > MAX_MEDIA_BYTES) {
throw new GraphApiError('Media exceeds the size limit')
}
const download = await fetchWithTimeout(
lookup.url,
{ method: 'GET', headers: { Authorization: `Bearer ${token}` } },
{ timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS, description: 'WhatsApp media download' },
)
if (!download.ok) {
throw new GraphApiError(`Media download failed (${download.status})`, download.status)
}
const declaredLength = Number.parseInt(download.headers.get('content-length') ?? '', 10)
if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) {
throw new GraphApiError('Media exceeds the size limit')
}
// Stream with a running byte count so a body larger than its content-length
// header is rejected without buffering the whole thing first.
const chunks: Uint8Array[] = []
let total = 0
if (download.body) {
const reader = download.body.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value) {
total += value.byteLength
if (total > MAX_MEDIA_BYTES) {
await reader.cancel().catch(() => undefined)
throw new GraphApiError('Media exceeds the size limit')
}
chunks.push(value)
}
}
}
const buffer = new ArrayBuffer(total)
const view = new Uint8Array(buffer)
let offset = 0
for (const chunk of chunks) {
view.set(chunk, offset)
offset += chunk.byteLength
}
return {
buffer,
mime: lookup.mime_type ?? download.headers.get('content-type'),
fileSize: total,
}
}
// ── Display number (for the wa.me deep link) ─────────────────
//
// WHATSAPP_PHONE_NUMBER_ID is a Graph object id, not the phone number, and the
// env contract for this extension is fixed at six vars. The wa.me link needs
// the real number, so resolve it once from the Graph API and cache in module
// scope. On failure the panel simply gets no deep link (code still works).
let cachedDisplayNumber: { value: string; fetchedAt: number } | null = null
const DISPLAY_NUMBER_TTL_MS = 60 * 60 * 1000
export async function getDisplayPhoneNumber(): Promise<string | null> {
if (cachedDisplayNumber && Date.now() - cachedDisplayNumber.fetchedAt < DISPLAY_NUMBER_TTL_MS) {
return cachedDisplayNumber.value
}
try {
const response = await fetchWithTimeout(
`${GRAPH_BASE}/${getPhoneNumberId()}?fields=display_phone_number`,
{ method: 'GET', headers: { Authorization: `Bearer ${getAccessToken()}` } },
{ timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp number lookup' },
)
if (!response.ok) return null
const payload = (await response.json().catch(() => null)) as {
display_phone_number?: string
} | null
const digits = payload?.display_phone_number?.replace(/\D/g, '') ?? ''
if (!digits) return null
cachedDisplayNumber = { value: digits, fetchedAt: Date.now() }
return digits
} catch (err) {
if (!(err instanceof TimeoutError)) {
log.warn('WhatsApp display number lookup errored', {
error: err instanceof Error ? err.message : String(err),
})
}
return null
}
}
/** Test-only: reset the module-level display-number cache. */
export function resetDisplayNumberCacheForTests(): void {
cachedDisplayNumber = null
}
@@ -0,0 +1,189 @@
/**
* One-time link codes + phone-link lifecycle.
*
* The code proves control of an Accounted account (minted in an authenticated
* settings panel) and sending it proves possession of the phone; the webhook
* binds the two. Invite-token pattern (lib/auth/invite-tokens.ts): the raw
* code exists only in the user's chat, the DB stores its sha256.
*
* All functions here take a SERVICE-ROLE client: whatsapp_link_codes has RLS
* enabled with no policies, and link INSERTs are service-role only by design
* (see migration 20260802090000).
*/
import crypto from 'crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type { WhatsAppPhoneLink } from '@/types'
import { encryptPhone, hashPhone, maskPhone } from './phone-crypto'
/** Uppercased twin of generate_inbox_local_part's ambiguity-free alphabet
* (no I/L/O/U, no 0/1): codes survive being read aloud or retyped. */
export const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789'
export const CODE_PREFIX = 'AC-'
export const CODE_LENGTH = 6
export const CODE_TTL_MS = 10 * 60 * 1000
export function hashLinkCode(code: string): string {
return crypto.createHash('sha256').update(code).digest('hex')
}
/**
* Normalize a chat message into a canonical code ('AC-7KP4QF') or null when
* the text does not look like a code at all. Forgiving on formatting (trims,
* uppercases, tolerates a missing hyphen), but a BARE 6-char body without the
* AC prefix must contain at least one digit: otherwise ordinary greetings
* built from alphabet letters ('hej hej' -> HEJHEJ) would read as codes and
* earn a confusing M2 instead of the M1 greeting. The panel and the wa.me
* prefill always carry the prefix, so prefixed codes are never rejected.
*/
export function normalizeLinkCode(text: string | null | undefined): string | null {
if (!text) return null
const compact = text.trim().toUpperCase().replace(/\s+/g, '')
const match = compact.match(/^(AC-?)?([A-Z2-9]{6})$/)
if (!match) return null
const hasPrefix = match[1] != null
const body = match[2]
for (const ch of body) {
if (!CODE_ALPHABET.includes(ch)) return null
}
if (!hasPrefix && !/[2-9]/.test(body)) return null
return `${CODE_PREFIX}${body}`
}
export function looksLikeLinkCode(text: string | null | undefined): boolean {
return normalizeLinkCode(text) !== null
}
export interface MintedCode {
code: string
expiresAt: string
}
/** Mint a fresh code for the settings panel. Earlier unused codes stay valid
* until their own 10-minute expiry; the webhook consumes whichever arrives. */
export async function mintLinkCode(
serviceClient: SupabaseClient,
userId: string,
): Promise<MintedCode> {
let body = ''
for (let i = 0; i < CODE_LENGTH; i++) {
body += CODE_ALPHABET[crypto.randomInt(CODE_ALPHABET.length)]
}
const code = `${CODE_PREFIX}${body}`
const expiresAt = new Date(Date.now() + CODE_TTL_MS).toISOString()
const { error } = await serviceClient.from('whatsapp_link_codes').insert({
user_id: userId,
code_hash: hashLinkCode(code),
expires_at: expiresAt,
})
if (error) throw new Error(`Failed to mint link code: ${error.message}`)
return { code, expiresAt }
}
/**
* Verify + consume a code from an inbound chat message. Returns the owning
* user id, or null for unknown/expired/already-used codes. Single-use is
* enforced by the guarded UPDATE (used_at IS NULL): a concurrent redelivery
* loses the race and gets null.
*/
export async function consumeLinkCode(
serviceClient: SupabaseClient,
rawText: string,
): Promise<{ userId: string } | null> {
const code = normalizeLinkCode(rawText)
if (!code) return null
const { data: row } = await serviceClient
.from('whatsapp_link_codes')
.select('id, user_id, expires_at, used_at')
.eq('code_hash', hashLinkCode(code))
.maybeSingle()
if (!row || row.used_at) return null
if (new Date(row.expires_at).getTime() < Date.now()) return null
const { data: claimed } = await serviceClient
.from('whatsapp_link_codes')
.update({ used_at: new Date().toISOString() })
.eq('id', row.id)
.is('used_at', null)
.select('id')
.maybeSingle()
if (!claimed) return null
return { userId: row.user_id }
}
export interface CreatedPhoneLink {
link: WhatsAppPhoneLink
conversationId: string | null
}
/**
* Bind a verified phone to a user: revoke whatever active links stand in the
* way of the two partial-unique indexes (same phone bound elsewhere, or the
* user re-linking from a new phone), then insert the link + its conversation
* row. Revocation-not-deletion keeps the trail auditable.
*/
export async function createPhoneLink(
serviceClient: SupabaseClient,
args: { userId: string; phone: string; profileName?: string | null },
): Promise<CreatedPhoneLink> {
const phoneHash = hashPhone(args.phone)
const now = new Date().toISOString()
await serviceClient
.from('whatsapp_phone_links')
.update({ revoked_at: now })
.eq('phone_hash', phoneHash)
.is('revoked_at', null)
await serviceClient
.from('whatsapp_phone_links')
.update({ revoked_at: now })
.eq('user_id', args.userId)
.is('revoked_at', null)
const { data: link, error } = await serviceClient
.from('whatsapp_phone_links')
.insert({
user_id: args.userId,
phone_hash: phoneHash,
phone_enc: encryptPhone(args.phone),
phone_masked: maskPhone(args.phone),
wa_profile_name: args.profileName?.slice(0, 200) ?? null,
last_message_at: now,
})
.select('*')
.single()
if (error || !link) {
throw new Error(`Failed to create phone link: ${error?.message ?? 'no row returned'}`)
}
const { data: conversation } = await serviceClient
.from('whatsapp_conversations')
.insert({
phone_link_id: link.id,
last_inbound_at: now,
service_window_expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
})
.select('id')
.maybeSingle()
return { link: link as WhatsAppPhoneLink, conversationId: conversation?.id ?? null }
}
/** Active (non-revoked) link for a phone hash, or null. */
export async function lookupActiveLink(
serviceClient: SupabaseClient,
phoneHash: string,
): Promise<WhatsAppPhoneLink | null> {
const { data } = await serviceClient
.from('whatsapp_phone_links')
.select('*')
.eq('phone_hash', phoneHash)
.is('revoked_at', null)
.maybeSingle()
return (data as WhatsAppPhoneLink | null) ?? null
}
@@ -0,0 +1,181 @@
/**
* Outbound bot copy for the WhatsApp channel (PR3 subset of the approved
* conversation spec M1-M18; M5-M10 belong to the PR4 conversation layer).
*
* Server-side constants, NOT messages/*.json: these strings are sent from
* webhook/worker contexts where no next-intl session exists (same pattern as
* lib/email/reminder-templates.ts). Domain terms (Underlag, verifikation) stay
* Swedish in both locales per the i18n rules. WhatsApp formatting subset only
* (*bold*, _italic_); never em/en dashes, per repo hard rule.
*
* Locale: the chat has no session, and no per-user locale is stored server-side
* today, so dispatch always sends 'sv'. The 'en' variants exist so the wiring
* is a one-line change once a stored locale exists.
*/
export type BotLocale = 'sv' | 'en'
export interface LinkedTemplateArgs {
companyName?: string | null
companyCount?: number
merchant?: string | null
amount?: string | null
date?: string | null
minutes?: number
}
/** Stable ids stamped into whatsapp_messages.raw_payload.template on every send
* (throttle checks and tests key on these, never on the copy). */
export const TEMPLATE = {
m1Unlinked: 'm1_unlinked',
m2BadCode: 'm2_bad_code',
m3Linked: 'm3_linked',
m4Ack: 'm4_ack',
m4AckEmpty: 'm4_ack_empty',
m4Duplicate: 'm4_duplicate',
m6NoDefaultCompany: 'm6_no_default_company',
m11Stop: 'm11_stop',
m12Start: 'm12_start',
m13Help: 'm13_help',
m14Voice: 'm14_voice',
m15Unsupported: 'm15_unsupported',
m16Fallback: 'm16_fallback',
m17RateLimited: 'm17_rate_limited',
m18Error: 'm18_error',
} as const
export type TemplateId = (typeof TEMPLATE)[keyof typeof TEMPLATE]
const SV = {
m1Unlinked: () =>
'Hej! Det här är *Accounteds kvittomottagning*, en automatisk AI-tjänst.\n\n' +
'För att koppla ditt nummer: öppna Accounted, gå till *Inställningar -> WhatsApp* och skicka den 6-siffriga koden här.\n\n' +
'Skriv *hjälp* för att nå en människa.',
m2BadCode: () =>
'Koden stämmer inte eller har gått ut. Hämta en ny under *Inställningar -> WhatsApp* i Accounted och skicka den här inom 10 minuter.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Klart! Ditt nummer är kopplat till *${companyName}*.`
: 'Klart! Ditt nummer är kopplat till *Accounted*.'
const tips =
'Skicka kvitton hit som foto eller PDF så lägger jag dem i *Underlag* i Accounted. ' +
'Två tips: skicka som _dokument_ (gem-ikonen) för bästa skärpa, och en fil per kvitto. Flersidiga kvitton skickas som PDF.'
const outro =
'Jag är en AI-assistent. Skriv *hjälp* för mänsklig support, *stopp* för att koppla från.'
const multi =
companyCount > 1
? `Du är med i ${companyCount} företag i Accounted. Välj standardföretag i panelen så hamnar kvitton rätt. `
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Kvitto mottaget:* ${who}${amount}${when}. Ligger i Underlag, granska och bokför i appen.`
},
m4AckEmpty: () =>
'*Kvitto mottaget.* Jag kunde inte läsa av beloppet, komplettera i appen under Underlag.',
m4Duplicate: () => 'Det kvittot finns redan i Underlag.',
m6NoDefaultCompany: () =>
'Du är med i flera företag. Välj standardföretag under *Inställningar -> WhatsApp* i Accounted, så tar jag emot kvitton här. Kvittot sparades inte.',
m11Stop: () =>
'Okej, jag slutar svara här och ditt nummer kopplas från. Dina underlag i Accounted påverkas inte. Skicka *start* om du vill aktivera igen.',
m12Start: () => 'Välkommen tillbaka! Ditt nummer är aktivt igen, skicka kvitton när du vill.',
m13Help: () =>
'Du når en människa på *support@accounted.se*, vi svarar vardagar, oftast samma dag. Skriv gärna vilket företag det gäller.\n\n' +
'Här i chatten tar jag annars emot kvitton och underlag (foto eller PDF).',
m14Voice: () =>
'Jag kan inte lyssna på röstmeddelanden ännu. Skicka kvittot som foto eller PDF, eller skriv en kort rad text.',
m15Unsupported: () =>
'Jag kan bara ta emot bilder och PDF-dokument (bild max 5 MB). Skicka kvittot som foto eller PDF.',
m16Fallback: () =>
'Jag är en automatisk kvittomottagning och kan inte svara på frågor här i chatten. Skicka ett kvitto (foto/PDF) så tar jag hand om det, eller skriv *hjälp* för mänsklig support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`Det blev många filer på kort tid, jag pausar mottagningen en stund. Försök igen om cirka ${minutes} minuter.`,
m18Error: () =>
'Något gick fel när jag tog emot filen. Försök igen om en stund, eller ladda upp kvittot direkt i appen under *Underlag*. Skriv *hjälp* om det fortsätter.',
}
const EN: typeof SV = {
m1Unlinked: () =>
'Hi! This is *Accounted receipt intake*, an automated AI service.\n\n' +
'To link your number: open Accounted, go to *Settings -> WhatsApp* and send the 6-character code here.\n\n' +
'Type *hjälp* to reach a human.',
m2BadCode: () =>
'That code is wrong or has expired. Get a new one under *Settings -> WhatsApp* in Accounted and send it here within 10 minutes.',
m3Linked: ({ companyName, companyCount = 1 }: LinkedTemplateArgs) => {
const intro = companyName
? `Done! Your number is linked to *${companyName}*.`
: 'Done! Your number is linked to *Accounted*.'
const tips =
'Send receipts here as a photo or PDF and I will file them under *Underlag* in Accounted. ' +
'Two tips: send as a _document_ (the paperclip icon) for full sharpness, and one file per receipt. Multi-page receipts go as PDF.'
const outro =
'I am an AI assistant. Type *hjälp* for human support, *stopp* to disconnect.'
const multi =
companyCount > 1
? `You belong to ${companyCount} companies in Accounted. Pick a default company in the panel so receipts land in the right one. `
: ''
return `${intro}\n\n${tips}\n\n${multi}${outro}`
},
m4Ack: ({ merchant, amount, date }: LinkedTemplateArgs) => {
const who = merchant ? `${merchant}, ` : ''
const when = date ? ` (${date})` : ''
return `*Receipt received:* ${who}${amount}${when}. It is in Underlag, review and book it in the app.`
},
m4AckEmpty: () =>
'*Receipt received.* I could not read the amount, complete it in the app under Underlag.',
m4Duplicate: () => 'That receipt is already in Underlag.',
m6NoDefaultCompany: () =>
'You belong to several companies. Pick a default company under *Settings -> WhatsApp* in Accounted, then I can receive receipts here. The receipt was not saved.',
m11Stop: () =>
'Okay, I will stop replying here and your number is disconnected. Your documents in Accounted are not affected. Send *start* to activate again.',
m12Start: () => 'Welcome back! Your number is active again, send receipts whenever you like.',
m13Help: () =>
'You reach a human at *support@accounted.se*, we reply on weekdays, usually the same day. Please mention which company it concerns.\n\n' +
'Here in the chat I otherwise receive receipts and documents (photo or PDF).',
m14Voice: () =>
'I cannot listen to voice messages yet. Send the receipt as a photo or PDF, or write a short line of text.',
m15Unsupported: () =>
'I can only receive images and PDF documents (image max 5 MB). Send the receipt as a photo or PDF.',
m16Fallback: () =>
'I am an automated receipt intake and cannot answer questions here in the chat. Send a receipt (photo/PDF) and I will handle it, or type *hjälp* for human support.',
m17RateLimited: ({ minutes = 10 }: LinkedTemplateArgs) =>
`That was a lot of files in a short time, I am pausing intake for a bit. Try again in about ${minutes} minutes.`,
m18Error: () =>
'Something went wrong receiving the file. Try again in a moment, or upload the receipt directly in the app under *Underlag*. Type *hjälp* if it keeps happening.',
}
const COPY: Record<BotLocale, typeof SV> = { sv: SV, en: EN }
export function botCopy(locale: BotLocale = 'sv'): typeof SV {
return COPY[locale] ?? SV
}
@@ -0,0 +1,77 @@
/**
* Phone-number PII primitives for the WhatsApp channel.
*
* Three representations, three purposes:
* - hashPhone: HMAC-SHA256 with a server-side pepper (WHATSAPP_PHONE_HASH_KEY).
* The DB lookup key. A plain sha256 would be brute-forceable
* over the ~10^9 phone-number space, hence the pepper.
* - encryptPhone: AES-256-GCM (WHATSAPP_PHONE_ENCRYPTION_KEY), mirrors the
* lib/auth/bankid.ts codec (iv 12 | tag 16 | ciphertext) but
* with its own key: self-hosters without BankID must be able
* to run this channel. Stored hex-encoded in a text column.
* - maskPhone: display-only shape for the settings panel.
*
* Keys are deliberately NOT shared with the BankID key material.
*/
import crypto from 'crypto'
const ALGORITHM = 'aes-256-gcm'
/** Meta sends wa_id / from as E.164 digits without '+'. Normalize to digits only. */
export function normalizePhone(raw: string): string {
return String(raw ?? '').replace(/\D/g, '')
}
function getHashKey(): Buffer {
const key = process.env.WHATSAPP_PHONE_HASH_KEY
if (!key) throw new Error('WHATSAPP_PHONE_HASH_KEY is required for WhatsApp operations')
return Buffer.from(key, 'utf8')
}
function getEncryptionKey(): Buffer {
const key = process.env.WHATSAPP_PHONE_ENCRYPTION_KEY
if (!key) throw new Error('WHATSAPP_PHONE_ENCRYPTION_KEY is required for WhatsApp operations')
return Buffer.from(key, 'hex')
}
/** Peppered lookup hash (hex). Input is normalized first so '+46 70...' and '4670...' collide. */
export function hashPhone(rawPhone: string): string {
return crypto.createHmac('sha256', getHashKey()).update(normalizePhone(rawPhone)).digest('hex')
}
/** AES-256-GCM encrypt a phone number. Returns hex(iv | tag | ciphertext) for a text column. */
export function encryptPhone(rawPhone: string): string {
const key = getEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(normalizePhone(rawPhone), 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('hex')
}
/** Decrypt a stored hex(iv | tag | ciphertext) value back to the digit string. */
export function decryptPhone(stored: string): string {
const key = getEncryptionKey()
const raw = Buffer.from(stored, 'hex')
const iv = raw.subarray(0, 12)
const tag = raw.subarray(12, 28)
const encrypted = raw.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8')
}
/**
* Mask for display: '+46 70 *** ** 67'. Keeps country code + two leading
* digits + two trailing digits; everything in between is starred. Works for
* any E.164 length; very short inputs degrade to a fully starred value.
*/
export function maskPhone(rawPhone: string): string {
const digits = normalizePhone(rawPhone)
if (digits.length < 8) return '+** *** ** **'
const cc = digits.slice(0, 2)
const lead = digits.slice(2, 4)
const tail = digits.slice(-2)
return `+${cc} ${lead} *** ** ${tail}`
}
@@ -0,0 +1,358 @@
/**
* Deferred intake worker: one inbound media message -> one Underlag item.
*
* The webhook persists the message row and 200s fast; this worker runs via
* the after() idiom (lib/webhooks/dispatch-kick.ts) on the same instance.
* The whatsapp_messages row IS the durable job record: the atomic claim
* (UPDATE ... WHERE processing_status='received' RETURNING) makes redelivered
* webhooks and the PR4 sweep cron safe to race.
*
* Failure policy: NOTHING here returns a retryable status to Meta. Rejected
* and rate-limited content acks in chat and lands as 'skipped'; real failures
* land as 'error' + error_message with a single M18 to the user.
*/
import { after } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { computeSHA256 } from '@/lib/core/documents/document-service'
import { createLogger } from '@/lib/logger'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import type { InvoiceExtractionResult, WhatsAppMessage, WhatsAppPhoneLink } from '@/types'
import { sendText, markReadWithTyping, downloadMedia, GraphApiError } from './graph-api'
import { botCopy, TEMPLATE } from './messages'
const log = createLogger('whatsapp-inbox/process-inbound')
/** Chat intake accepts what phones actually produce. Narrower than the upload
* allowlist on purpose: WhatsApp transcodes photos to JPEG, so HEIC never
* arrives, and everything else gets the M15 nudge. */
export const CHAT_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
])
/** M17 is sent at most once per this window per sender, not once per file. */
const RATE_LIMIT_NOTICE_WINDOW_MS = 10 * 60 * 1000
const EXTENSION_FOR_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
}
function fallbackFilename(mime: string): string {
const ext = EXTENSION_FOR_MIME[mime] ?? 'bin'
return `whatsapp-${new Date().toISOString().slice(0, 10)}.${ext}`
}
function formatSek(amount: number): string {
return `${new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)} kr`
}
async function loadRow(
supabase: SupabaseClient,
messageId: string,
): Promise<WhatsAppMessage | null> {
const { data } = await supabase
.from('whatsapp_messages')
.select('*')
.eq('id', messageId)
.maybeSingle()
return (data as WhatsAppMessage | null) ?? null
}
async function loadLink(
supabase: SupabaseClient,
phoneLinkId: string,
): Promise<WhatsAppPhoneLink | null> {
const { data } = await supabase
.from('whatsapp_phone_links')
.select('*')
.eq('id', phoneLinkId)
.maybeSingle()
return (data as WhatsAppPhoneLink | null) ?? null
}
/**
* Resolve which company the receipt lands in (PR3 ladder: default company if
* still a member -> sole membership -> null). The PR4 conversation pin slots
* in above default when it exists.
*/
async function resolveCompanyId(
supabase: SupabaseClient,
link: WhatsAppPhoneLink,
): Promise<string | null> {
if (link.default_company_id) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
.eq('company_id', link.default_company_id)
.maybeSingle()
if (membership) return link.default_company_id
}
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 1) return companyIds[0]
return null
}
/** True when an M17 notice already went to this sender inside the window. */
async function rateLimitNoticeAlreadySent(
supabase: SupabaseClient,
senderPhoneHash: string,
): Promise<boolean> {
const since = new Date(Date.now() - RATE_LIMIT_NOTICE_WINDOW_MS).toISOString()
const { data } = await supabase
.from('whatsapp_messages')
.select('id')
.eq('direction', 'outbound')
.eq('sender_phone_hash', senderPhoneHash)
.eq('raw_payload->>template', TEMPLATE.m17RateLimited)
.gte('created_at', since)
.limit(1)
.maybeSingle()
return data != null
}
async function markStatus(
supabase: SupabaseClient,
messageId: string,
status: 'skipped' | 'error' | 'done',
extra: { errorMessage?: string | null; inboxItemId?: string | null } = {},
): Promise<void> {
// Literal payload (no spread) so the phantom-column scanner can verify it.
// Writing null where the caller passed nothing matches the columns' actual
// state on every path that reaches here.
await supabase
.from('whatsapp_messages')
.update({
processing_status: status,
error_message: extra.errorMessage ?? null,
inbox_item_id: extra.inboxItemId ?? null,
})
.eq('id', messageId)
}
/**
* Process one inbound media message end to end. Never throws.
*/
export async function processInboundMessage(
supabase: SupabaseClient,
messageId: string,
): Promise<void> {
const row = await loadRow(supabase, messageId)
if (!row) return
// Atomic claim: only a row still in 'received' can be taken, so a webhook
// redelivery racing this worker (or the PR4 sweep) claims exactly once.
const { data: claimed } = await supabase
.from('whatsapp_messages')
.update({ processing_status: 'processing', attempts: row.attempts + 1 })
.eq('id', messageId)
.eq('processing_status', 'received')
.select('id')
.maybeSingle()
if (!claimed) return
const attempt = row.attempts + 1
const copy = botCopy('sv')
const to = extractRecipient(row)
const replyBase = {
senderPhoneHash: row.sender_phone_hash,
phoneLinkId: row.phone_link_id,
conversationId: row.conversation_id,
correlationId: row.correlation_id,
}
try {
if (!row.phone_link_id || !row.media_id || !to) {
await markStatus(supabase, messageId, 'error', {
errorMessage: 'Message row is missing link or media reference',
})
return
}
if (row.wamid) await markReadWithTyping(row.wamid)
const link = await loadLink(supabase, row.phone_link_id)
if (!link || link.revoked_at) {
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'Phone link revoked before processing',
})
return
}
// ── Company resolution ─────────────────────────────────
const companyId = await resolveCompanyId(supabase, link)
if (!companyId) {
await sendText(supabase, { to, body: copy.m6NoDefaultCompany(), template: TEMPLATE.m6NoDefaultCompany, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'No default company set for multi-company sender',
})
return
}
// ── Per-company intake quota (ack-and-drop, never retryable) ──
const limit = await checkInboxUploadRateLimit(supabase, companyId)
if (!limit.ok) {
try {
await appendProcessingHistory({
companyId,
correlationId: row.correlation_id ?? messageId,
aggregateType: 'System',
aggregateId: messageId,
eventType: 'RateLimitedDropped',
payload: {
channel: 'whatsapp',
scope: limit.scope,
retry_after_sec: limit.retryAfterSec,
whatsapp_message_id: messageId,
},
actor: { type: 'system', id: 'whatsapp-inbound' },
occurredAt: new Date(),
})
} catch (err) {
log.error('RateLimitedDropped append failed', err)
}
if (row.sender_phone_hash && !(await rateLimitNoticeAlreadySent(supabase, row.sender_phone_hash))) {
await sendText(supabase, { to, body: copy.m17RateLimited({}), template: TEMPLATE.m17RateLimited, ...replyBase })
}
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Rate limited' })
return
}
// ── MIME allowlist ─────────────────────────────────────
const mime = (row.media_mime ?? '').split(';')[0].trim().toLowerCase()
if (!CHAT_ALLOWED_MIME_TYPES.has(mime)) {
await sendText(supabase, { to, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: `Unsupported media type: ${mime || 'unknown'}`,
})
return
}
// ── Download (fresh URL per media id; 10 MB stream-checked cap) ──
const media = await downloadMedia(row.media_id)
// ── Exact duplicate check within the company ───────────
const sha256 = await computeSHA256(media.buffer)
const { data: duplicate } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('sha256_hash', sha256)
.limit(1)
.maybeSingle()
if (duplicate) {
await sendText(supabase, { to, body: copy.m4Duplicate(), template: TEMPLATE.m4Duplicate, ...replyBase })
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Duplicate document (sha256)' })
return
}
// ── Upload + extract (the shared invoice-inbox funnel) ──
const result = await uploadAndExtract(
supabase,
link.user_id,
companyId,
{ name: row.media_filename || fallbackFilename(mime), buffer: media.buffer, type: mime },
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: messageId, caption: row.body_text ?? null },
actorId: 'whatsapp-inbound',
},
)
await markStatus(supabase, messageId, 'done', {
inboxItemId: result.inbox_item_id,
})
// ── Receipt ack ────────────────────────────────────────
const extracted = result.extracted_data as InvoiceExtractionResult | undefined
const total = extracted?.totals?.total ?? null
if (total != null) {
await sendText(supabase, {
to,
body: copy.m4Ack({
merchant: extracted?.supplier?.name ?? null,
amount: formatSek(total),
date: extracted?.invoice?.invoiceDate ?? null,
}),
template: TEMPLATE.m4Ack,
...replyBase,
})
} else {
await sendText(supabase, { to, body: copy.m4AckEmpty(), template: TEMPLATE.m4AckEmpty, ...replyBase })
}
} catch (err) {
const message =
err instanceof GraphApiError || err instanceof Error ? err.message : String(err)
log.error('WhatsApp intake processing failed', err, { messageId })
try {
await markStatus(supabase, messageId, 'error', { errorMessage: message.slice(0, 500) })
// M18 once per message: only on the first attempt, so a PR4 sweep
// re-claim of the same row never spams the sender.
if (to && attempt <= 1) {
await sendText(supabase, { to, body: botCopy('sv').m18Error(), template: TEMPLATE.m18Error, ...replyBase })
}
} catch (innerErr) {
log.error('Failed to record WhatsApp processing error', innerErr, { messageId })
}
}
}
/**
* The recipient phone (E.164 digits) for replies. The raw inbound payload is
* persisted verbatim for linked senders, so `from` is read back from it: the
* row itself never stores the raw number outside raw_payload/phone_enc.
*/
function extractRecipient(row: WhatsAppMessage): string | null {
const raw = row.raw_payload as { from?: unknown } | null
return raw && typeof raw.from === 'string' && raw.from.length > 0 ? raw.from : null
}
/**
* Schedule processing of freshly persisted message rows after the webhook
* response is sent. Exact dispatch-kick idiom: never awaited by the caller,
* never throws, falls back to a microtask outside a request scope (tests).
*/
export function kickInboundProcessing(messageIds: string[]): void {
if (messageIds.length === 0) return
const run = async (): Promise<void> => {
try {
const supabase = createServiceClientNoCookies()
for (const id of messageIds) {
await processInboundMessage(supabase, id)
}
} catch (err) {
// The PR4 sweep cron re-claims 'received' rows: a failed kick is a
// latency regression, never a lost message.
log.warn('deferred WhatsApp processing failed; sweep will retry', {
error: err instanceof Error ? err.message : String(err),
})
}
}
try {
after(() => run())
} catch {
queueMicrotask(() => void run())
}
}
@@ -0,0 +1,214 @@
/**
* Zod parser for the Meta Cloud API webhook envelope (already signature
* verified). Extracts messages[] and statuses[] from
* entry[].changes[].value and flattens them for the dispatcher.
*
* Contract: NEVER throws. Anything that does not match the envelope parses to
* an empty result; any single message that does not match a known shape
* becomes type 'unknown' (a safe skip) rather than failing the batch. Meta
* adds message types over time and a new type must not take down intake for
* the whole webhook delivery.
*/
import { z } from 'zod'
const MediaSchema = z.object({
id: z.string().min(1),
mime_type: z.string().max(120).optional(),
sha256: z.string().max(200).optional(),
filename: z.string().max(500).optional(),
caption: z.string().max(4096).optional(),
voice: z.boolean().optional(),
})
// Permissive per-message schema: `type` is an open string and per-type payloads
// are all optional, so an unexpected combination degrades instead of failing.
const MessageSchema = z.object({
from: z.string().min(1).max(30),
id: z.string().min(1).max(200),
timestamp: z.string().max(30).optional(),
type: z.string().max(40),
text: z.object({ body: z.string().max(65536) }).optional(),
image: MediaSchema.optional(),
document: MediaSchema.optional(),
audio: MediaSchema.optional(),
video: MediaSchema.optional(),
sticker: MediaSchema.optional(),
context: z.object({ id: z.string().max(200).optional() }).optional(),
})
const StatusSchema = z.object({
id: z.string().min(1).max(200),
status: z.string().max(40),
})
const ContactSchema = z.object({
wa_id: z.string().max(30).optional(),
profile: z.object({ name: z.string().max(200).optional() }).optional(),
})
const EnvelopeSchema = z.object({
object: z.string().optional(),
entry: z
.array(
z.object({
changes: z
.array(
z.object({
field: z.string().optional(),
value: z
.object({
messages: z.array(z.unknown()).optional(),
statuses: z.array(z.unknown()).optional(),
contacts: z.array(z.unknown()).optional(),
})
.optional(),
}),
)
.optional(),
}),
)
.optional(),
})
export type ParsedMessageType =
| 'text'
| 'image'
| 'document'
| 'audio'
| 'video'
| 'sticker'
| 'location'
| 'contacts'
| 'unknown'
export interface ParsedMedia {
id: string
mime: string | null
sha256: string | null
filename: string | null
voice: boolean
}
export interface ParsedInboundMessage {
wamid: string
from: string
timestamp: string | null
type: ParsedMessageType
/** Body for text messages. */
text: string | null
/** Caption for media messages. */
caption: string | null
media: ParsedMedia | null
/** Quoted-reply target (context.id), when present. */
contextWamid: string | null
profileName: string | null
/** The raw value.messages[i] object, persisted verbatim for known senders. */
raw: unknown
}
export interface ParsedStatus {
wamid: string
status: string
}
export interface ParsedWebhook {
messages: ParsedInboundMessage[]
statuses: ParsedStatus[]
}
const KNOWN_TYPES: ReadonlySet<string> = new Set([
'text',
'image',
'document',
'audio',
'video',
'sticker',
'location',
'contacts',
])
function toParsedMessage(
raw: unknown,
profileNames: Map<string, string>,
): ParsedInboundMessage | null {
const parsed = MessageSchema.safeParse(raw)
if (!parsed.success) return null
const msg = parsed.data
const type: ParsedMessageType = KNOWN_TYPES.has(msg.type)
? (msg.type as ParsedMessageType)
: 'unknown'
const mediaSource =
type === 'image'
? msg.image
: type === 'document'
? msg.document
: type === 'audio'
? msg.audio
: type === 'video'
? msg.video
: type === 'sticker'
? msg.sticker
: undefined
return {
wamid: msg.id,
from: msg.from,
timestamp: msg.timestamp ?? null,
type,
text: type === 'text' ? (msg.text?.body ?? null) : null,
caption: mediaSource?.caption ?? null,
media: mediaSource
? {
id: mediaSource.id,
mime: mediaSource.mime_type ?? null,
sha256: mediaSource.sha256 ?? null,
filename: mediaSource.filename ?? null,
voice: mediaSource.voice === true,
}
: null,
contextWamid: msg.context?.id ?? null,
profileName: profileNames.get(msg.from) ?? null,
raw,
}
}
/**
* Parse a verified webhook body. Returns flattened messages and statuses in
* arrival order. Never throws; unparseable input yields empty arrays.
*/
export function parseWebhookEnvelope(body: unknown): ParsedWebhook {
const result: ParsedWebhook = { messages: [], statuses: [] }
const envelope = EnvelopeSchema.safeParse(body)
if (!envelope.success) return result
for (const entry of envelope.data.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
if (!value) continue
const profileNames = new Map<string, string>()
for (const rawContact of value.contacts ?? []) {
const contact = ContactSchema.safeParse(rawContact)
if (contact.success && contact.data.wa_id && contact.data.profile?.name) {
profileNames.set(contact.data.wa_id, contact.data.profile.name)
}
}
for (const rawMessage of value.messages ?? []) {
const parsed = toParsedMessage(rawMessage, profileNames)
if (parsed) result.messages.push(parsed)
}
for (const rawStatus of value.statuses ?? []) {
const parsed = StatusSchema.safeParse(rawStatus)
if (parsed.success) result.statuses.push({ wamid: parsed.data.id, status: parsed.data.status })
}
}
}
return result
}
@@ -0,0 +1,47 @@
/**
* Meta webhook authenticity checks.
*
* POST: X-Hub-Signature-256 is 'sha256=' + hex(HMAC-SHA256(rawBody, app secret)),
* computed over the RAW request body. Verify BEFORE any JSON parse: a body that
* fails the HMAC is untrusted input and must never reach a parser.
*
* GET: the one-time subscription handshake sends hub.verify_token; echo
* hub.challenge only when the token matches.
*
* Both compares are length-guarded timingSafeEqual (same shape as
* lib/webhooks/signing.ts): Buffer.from(x, 'hex') silently drops invalid hex,
* so lengths are compared AFTER decoding to keep a malformed header from
* throwing RangeError instead of returning false.
*/
import crypto from 'crypto'
const SIGNATURE_PREFIX = 'sha256='
export function verifyMetaSignature(
rawBody: string,
header: string | null | undefined,
appSecret: string,
): boolean {
if (!header || !appSecret) return false
const provided = header.startsWith(SIGNATURE_PREFIX)
? header.slice(SIGNATURE_PREFIX.length)
: header
const expected = crypto.createHmac('sha256', appSecret).update(rawBody, 'utf8').digest('hex')
const expectedBuf = Buffer.from(expected, 'hex')
const providedBuf = Buffer.from(provided, 'hex')
if (expectedBuf.length !== providedBuf.length) return false
return crypto.timingSafeEqual(expectedBuf, providedBuf)
}
/** Constant-time compare of the GET handshake's hub.verify_token. */
export function verifyChallengeToken(
token: string | null | undefined,
expected: string | null | undefined,
): boolean {
if (!token || !expected) return false
const a = Buffer.from(token, 'utf8')
const b = Buffer.from(expected, 'utf8')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
@@ -0,0 +1,27 @@
{
"id": "whatsapp-inbox",
"sector": "general",
"exportName": "whatsappInboxExtension",
"entryPoint": "@/extensions/general/whatsapp-inbox",
"workspace": null,
"requiredEnvVars": [
"WHATSAPP_ACCESS_TOKEN",
"WHATSAPP_PHONE_NUMBER_ID",
"WHATSAPP_APP_SECRET",
"WHATSAPP_VERIFY_TOKEN",
"WHATSAPP_PHONE_HASH_KEY",
"WHATSAPP_PHONE_ENCRYPTION_KEY"
],
"optionalEnvVars": ["WHATSAPP_PUBLIC_NUMBER"],
"npmDependencies": [],
"definition": {
"name": "WhatsApp-inkorg",
"category": "import",
"icon": "MessageCircle",
"dataPattern": "both",
"hasOwnData": true,
"readsCoreTables": ["company_members", "document_attachments", "invoice_inbox_items"],
"description": "Skicka kvitton som foto eller PDF till Accounteds WhatsApp-nummer: de landar i Underlag med avlästa fält",
"longDescription": "Koppla ditt mobilnummer med en engångskod och skicka sedan kvitton direkt i WhatsApp. Varje kvitto laddas upp till dokumentarkivet, fält som belopp och datum läses av med AI, och du får en bekräftelse i chatten. Bokföringen sker som vanligt i appen."
}
}
+3 -3
View File
@@ -48,8 +48,8 @@ describe('sectors registry', () => {
expect(SECTORS.length).toBe(1)
})
it('should have 14 total extensions', () => {
expect(getAllExtensions().length).toBe(14)
it('should have 15 total extensions', () => {
expect(getAllExtensions().length).toBe(15)
})
it('should have unique slugs within each sector', () => {
@@ -94,7 +94,7 @@ describe('sectors registry', () => {
it('getExtensionsBySector returns extensions for a sector', () => {
const extensions = getExtensionsBySector('general')
expect(extensions.length).toBe(14)
expect(extensions.length).toBe(15)
})
it('all extensions have required fields', () => {
+1
View File
@@ -11,4 +11,5 @@ export const ENABLED_EXTENSION_IDS: ReadonlySet<string> = new Set([
'invoice-inbox',
'document-extraction',
'stripe',
'whatsapp-inbox',
])
+2
View File
@@ -10,6 +10,7 @@ import { skatteverketExtension } from '@/extensions/general/skatteverket'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import { documentExtractionExtension } from '@/extensions/general/document-extraction'
import { stripeExtension } from '@/extensions/general/stripe'
import { whatsappInboxExtension } from '@/extensions/general/whatsapp-inbox'
export const FIRST_PARTY_EXTENSIONS: Extension[] = [
enableBankingExtension,
@@ -22,4 +23,5 @@ export const FIRST_PARTY_EXTENSIONS: Extension[] = [
invoiceInboxExtension,
documentExtractionExtension,
stripeExtension,
whatsappInboxExtension,
]
+16
View File
@@ -131,5 +131,21 @@ export const EXTENSION_DEFINITIONS: Record<string, ExtensionDefinition[]> = {
"hasOwnData": true,
"subscriptionNotice": "Denna integration kräver ett eget Stripe-konto. Stripes transaktionsavgifter tillkommer enligt ditt avtal med Stripe."
},
{
"slug": "whatsapp-inbox",
"name": "WhatsApp-inkorg",
"sector": "general",
"category": "import",
"icon": "MessageCircle",
"dataPattern": "both",
"description": "Skicka kvitton som foto eller PDF till Accounteds WhatsApp-nummer: de landar i Underlag med avlästa fält",
"longDescription": "Koppla ditt mobilnummer med en engångskod och skicka sedan kvitton direkt i WhatsApp. Varje kvitto laddas upp till dokumentarkivet, fält som belopp och datum läses av med AI, och du får en bekräftelse i chatten. Bokföringen sker som vanligt i appen.",
"readsCoreTables": [
"company_members",
"document_attachments",
"invoice_inbox_items"
],
"hasOwnData": true
},
],
}
+7
View File
@@ -37,6 +37,13 @@ const REQUIRED_EXTENSION_VARS: ReadonlyArray<readonly string[]> = [
['ENABLE_BANKING_PRIVATE_KEY_PRODUCTION', 'ENABLE_BANKING_PRIVATE_KEY'],
['AWS_ACCESS_KEY_ID'],
['AWS_SECRET_ACCESS_KEY'],
// whatsapp-inbox extension (Meta Cloud API + phone PII at rest)
['WHATSAPP_ACCESS_TOKEN'],
['WHATSAPP_PHONE_NUMBER_ID'],
['WHATSAPP_APP_SECRET'],
['WHATSAPP_VERIFY_TOKEN'],
['WHATSAPP_PHONE_HASH_KEY'],
['WHATSAPP_PHONE_ENCRYPTION_KEY'],
] as const
function validateEnvironment(): void {
+33 -1
View File
@@ -322,7 +322,8 @@
"templates": "Reusable postings and patterns learned from your bookkeeping.",
"banking": "Transactions are fetched automatically. Accounted can only read, never move money.",
"assistant": "What the assistant knows, remembers and can do.",
"api": "Keys for MCP clients like Claude and Cursor, and for your own integrations."
"api": "Keys for MCP clients like Claude and Cursor, and for your own integrations.",
"whatsapp": "Link your mobile number and send receipts as photos or PDFs straight from WhatsApp."
},
"settings_nav": {
"aria_label": "Settings",
@@ -343,6 +344,7 @@
"account": "Account",
"api": "API",
"billing": "Subscription",
"whatsapp": "WhatsApp",
"group_account": "Account",
"group_company": "Company",
"group_accounting": "Accounting & tax",
@@ -2783,6 +2785,36 @@
"cancel": "Cancel",
"confirm": "Confirm booking"
},
"settings_whatsapp": {
"group_label": "WhatsApp receipts",
"loading": "Loading status …",
"load_failed": "Could not read the WhatsApp link status.",
"retry": "Try again",
"unlinked_intro": "Link your mobile number to send receipts as photos or PDFs straight from WhatsApp. They land in Underlag, ready to review and book.",
"connect_button": "Link WhatsApp",
"mint_failed": "Could not create a code. Try again in a moment.",
"open_whatsapp": "Open WhatsApp",
"step_open": "Open WhatsApp on your phone (the button above prefills the code).",
"step_send_code": "Send the code to the Accounted number from the mobile number you want to link.",
"step_confirm": "You get a confirmation in the chat once the number is linked.",
"expires_hint": "The code is valid for 10 minutes.",
"expires_in": "The code is valid for {minutes} more min.",
"code_expired": "The code has expired. Create a new one.",
"linked_number_label": "Linked number",
"muted_label": "Paused",
"muted_hint": "You paused the chat with stopp. Send start in WhatsApp to activate it again.",
"default_company_label": "Default company",
"default_company_help": "If you belong to several companies, this controls which company's Underlag your receipts land in.",
"default_company_none": "None selected",
"default_company_saved": "Default company saved.",
"default_company_save_failed": "Could not save the default company.",
"disconnect_label": "Disconnect",
"revoke_button": "Disconnect number",
"revoke_note": "Your documents in Accounted are not affected.",
"revoke_confirm": "Disconnect your WhatsApp number? You can link it again at any time with a new code.",
"revoked_toast": "The number is disconnected.",
"revoke_failed": "Could not disconnect the number."
},
"inbox_bulk_book": {
"total_label": "Total for the documents: {amount}",
"mixed_currency_totals_label": "Total per currency",
+33 -1
View File
@@ -322,7 +322,8 @@
"templates": "Återanvändbara konteringar och mönster som lärts in från din bokföring.",
"banking": "Transaktioner hämtas automatiskt. Accounted kan bara läsa, aldrig flytta pengar.",
"assistant": "Vad assistenten vet, minns och kan.",
"api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer."
"api": "Nycklar för MCP-klienter som Claude och Cursor, och för egna integrationer.",
"whatsapp": "Koppla ditt mobilnummer och skicka kvitton som foto eller PDF direkt i WhatsApp."
},
"settings_nav": {
"aria_label": "Inställningar",
@@ -343,6 +344,7 @@
"account": "Konto",
"api": "API",
"billing": "Abonnemang",
"whatsapp": "WhatsApp",
"group_account": "Konto",
"group_company": "Företag",
"group_accounting": "Bokföring & skatt",
@@ -2783,6 +2785,36 @@
"cancel": "Avbryt",
"confirm": "Bekräfta bokföring"
},
"settings_whatsapp": {
"group_label": "WhatsApp-kvitton",
"loading": "Hämtar status …",
"load_failed": "Kunde inte läsa av WhatsApp-kopplingen.",
"retry": "Försök igen",
"unlinked_intro": "Koppla ditt mobilnummer så kan du skicka kvitton som foto eller PDF direkt i WhatsApp. De landar i Underlag, färdiga att granska och bokföra.",
"connect_button": "Koppla WhatsApp",
"mint_failed": "Kunde inte skapa någon kod. Försök igen om en stund.",
"open_whatsapp": "Öppna WhatsApp",
"step_open": "Öppna WhatsApp på din telefon (knappen ovan fyller i koden åt dig).",
"step_send_code": "Skicka koden till Accounteds nummer från det mobilnummer du vill koppla.",
"step_confirm": "Du får en bekräftelse i chatten när numret är kopplat.",
"expires_hint": "Koden gäller i 10 minuter.",
"expires_in": "Koden gäller i {minutes} min till.",
"code_expired": "Koden har gått ut. Skapa en ny.",
"linked_number_label": "Kopplat nummer",
"muted_label": "Pausad",
"muted_hint": "Du har pausat chatten med stopp. Skicka start i WhatsApp för att aktivera den igen.",
"default_company_label": "Standardföretag",
"default_company_help": "Är du med i flera företag styr valet vilket företags Underlag dina kvitton hamnar i.",
"default_company_none": "Inget valt",
"default_company_saved": "Standardföretag sparat.",
"default_company_save_failed": "Kunde inte spara standardföretaget.",
"disconnect_label": "Koppla från",
"revoke_button": "Koppla från numret",
"revoke_note": "Dina underlag i Accounted påverkas inte.",
"revoke_confirm": "Vill du koppla från ditt WhatsApp-nummer? Du kan koppla det igen när som helst med en ny kod.",
"revoked_toast": "Numret är frånkopplat.",
"revoke_failed": "Kunde inte koppla från numret."
},
"inbox_bulk_book": {
"total_label": "Underlagens summa: {amount}",
"mixed_currency_totals_label": "Summa per valuta",