This commit is contained in:
Jakob Wennberg
2026-02-19 14:32:54 +01:00
parent cdf1dcc4c8
commit e8743e6e03
104 changed files with 4551 additions and 4172 deletions
+15
View File
@@ -239,6 +239,21 @@ create table extension_data (
The base never imports from `extensions/`. Dependency flows one direction: extensions import from `lib/core/`, `lib/events/`, `lib/extensions/`.
### Type ownership
Extension-specific types live in their extension directory (e.g. `extensions/ne-bilaga/types.ts`). For convenience, `types/index.ts` re-exports them so existing importers continue to work with `import type { NEDeclaration } from '@/types'`. The canonical source is always the extension file.
| Extension | Type file | Types |
|-----------|-----------|-------|
| `push-notifications` | `extensions/push-notifications/types.ts` | `PushSubscription`, `NotificationSettings`, `NotificationType`, `NotificationLog` |
| `receipt-ocr` | `extensions/receipt-ocr/types.ts` | `Receipt`, `ReceiptLineItem`, `ReceiptExtractionResult`, `ExtractedLineItem`, `ReceiptMatchCandidate`, `ReceiptQueueSummary`, `CameraQualityFeedback`, etc. |
| `ne-bilaga` | `extensions/ne-bilaga/types.ts` | `NEDeclaration`, `NEDeclarationRutor`, `NEAccountMapping`, `SRURecord`, `SRUFile`, `NE_RUTA_LABELS` |
| `sru-export` | `extensions/sru-export/types.ts` | `SRUExportResult`, `SRUCoverageStats` |
### API route convention
All extension API routes live under `app/api/extensions/<extension-name>/`. No extension routes should exist outside this namespace.
---
## Add-ons
@@ -0,0 +1,260 @@
# NE-bilaga Extension
## Overview
The NE-bilaga extension generates the NE appendix (Näringsverksamhet) for income tax reporting of enskild firma (sole proprietorship) to Skatteverket. It maps BAS account balances to NE declaration rutor R1-R11 and optionally produces downloadable SRU files.
Only relevant for **enskild firma** (`entity_type = 'enskild_firma'`). The tab is hidden for AB users.
Previously embedded as core code in `lib/reports/ne-declaration.ts` and `app/api/reports/ne-declaration/route.ts`, the logic was extracted into a proper extension following the same pattern as `extensions/sru-export/`.
## Architecture
```
extensions/ne-bilaga/
├── index.ts Extension definition (registered in loader)
├── ne-engine.ts Account-to-ruta mapping and balance calculation
├── types.ts NE-specific type definitions (canonical source)
└── NEDeclarationView.tsx UI component for the NE-bilaga reports tab
app/api/extensions/ne-bilaga/
└── route.ts GET /api/extensions/ne-bilaga (json + sru)
```
### Relationship to SRU Export
The NE-bilaga extension and the SRU Export extension serve different purposes but share SRU file generation utilities:
```
NE-bilaga path (EF only):
ne-engine.ts (hard-coded R1-R11 account mappings) → lib/reports/sru-generator.ts
SRU Export path (EF + AB):
sru-engine.ts (reads sru_code from DB) → sru-generator.ts (any form type)
```
The NE-bilaga engine uses hard-coded account ranges to map balances to NE rutor. The SRU Export engine reads `sru_code` from `chart_of_accounts` for a more generic approach. Both use the shared `sruFileToString()` and `generateSRUFile()` from `lib/reports/sru-generator.ts`.
### Dead code removed
The backward-compatibility shim `lib/reports/ne-declaration.ts` (which re-exported from the extension) has been deleted. It had zero importers after the API route `app/api/reports/ne-declaration/route.ts` was removed earlier. All consumers now import directly from the extension or from `@/types`.
## NE Declaration Rutor
### Revenue (R1-R4)
| Ruta | Account Range | Description |
|---|---|---|
| R1 | 3000-3499 (excl 3100) | Forsaljning med moms (25%) |
| R2 | 3100, 3900, 3970-3980 | Momsfria intakter |
| R3 | 3200-3299 | Bil/bostadsforman |
| R4 | 8310-8330 | Ranteintakter |
### Expenses (R5-R10)
| Ruta | Account Range | Description |
|---|---|---|
| R5 | 4000-4990 | Varuinkop |
| R6 | 5000-6990, 7970 | Ovriga kostnader |
| R7 | 7000-7699 | Lonekostnader |
| R8 | 8400-8499 | Rantekostnader |
| R9 | 7820 | Avskrivningar fastighet |
| R10 | 7700-7899 (excl 7820) | Avskrivningar ovrigt |
### Result
| Ruta | Calculation | Description |
|---|---|---|
| R11 | (R1+R2+R3+R4) - (R5+R6+R7+R8+R9+R10) | Arets resultat |
### Gift handling
- Gifts **with** consideration: R1 (VAT-liable exchange transaction)
- Gifts **without** consideration: R2 via account 3900
- Deductible gifts: R6 via account 5460
## API Reference
### GET /api/extensions/ne-bilaga
Generate NE declaration for a fiscal period.
**Query parameters:**
| Parameter | Required | Description |
|---|---|---|
| `period_id` | Yes | Fiscal period UUID |
| `format` | No | `json` (default) or `sru` |
**Response (format=json):**
```json
{
"data": {
"fiscalYear": {
"id": "uuid",
"name": "2025",
"start": "2025-01-01",
"end": "2025-12-31",
"isClosed": false
},
"rutor": {
"R1": 150000,
"R2": 0,
"R3": 0,
"R4": 500,
"R5": 45000,
"R6": 30000,
"R7": 0,
"R8": 1200,
"R9": 0,
"R10": 5000,
"R11": 69300
},
"breakdown": {
"R1": {
"accounts": [
{ "accountNumber": "3001", "accountName": "Forsaljning tjanster 25%", "amount": 150000 }
],
"total": 150000
}
},
"companyInfo": {
"companyName": "Mitt Foretag",
"orgNumber": "801234-5678"
},
"warnings": ["Rakenskapsaret ar inte stangt. Siffrorna kan andras."]
}
}
```
**Response (format=sru):** Downloads a `.sru` file with `Content-Disposition: attachment`.
**Error responses:**
| Status | Condition |
|---|---|
| 400 | Missing `period_id` parameter |
| 401 | Not authenticated |
| 500 | Entity type is not `enskild_firma`, period not found, or database error |
## Engine Details
### Balance calculation
1. Fetch all **posted** journal entries for the given fiscal period
2. Sum `debit_amount - credit_amount` per account number
3. Match each account to an NE ruta using `NE_ACCOUNT_MAPPINGS`
4. For revenue accounts (credit-normal): negate the balance so positive = income
5. For expense accounts (debit-normal): use as-is
6. Round each ruta to whole kronor
7. Calculate R11 as total revenue minus total expenses
### Entity type guard
The engine throws if `company_settings.entity_type !== 'enskild_firma'`. This prevents accidental NE generation for AB entities.
### Warnings
The engine emits warnings when:
- The fiscal period is not closed (balances may change)
- No revenue or expenses were found (empty period)
## UI Integration
### NEDeclarationView component
The NE-bilaga UI lives in `extensions/ne-bilaga/NEDeclarationView.tsx` and is imported by the reports page:
```typescript
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
```
### Reports Page
The **NE-bilaga** tab in `/reports` is conditionally rendered based on `entity_type`:
- **Visible** when `entity_type === 'enskild_firma'`
- **Hidden** for all other entity types (e.g. `aktiebolag`)
The entity type is fetched from `GET /api/settings` on page load. The tab shows:
1. **Info card** with "Hamta NE-bilaga" and "Ladda ner SRU-fil" buttons
2. **Warnings card** (orange) if the period is open or no data was found
3. **Company info card** with company name, org number, and fiscal year badge
4. **Revenue table** (R1-R4) with expandable account-level detail
5. **Expenses table** (R5-R10) with expandable account-level detail
6. **Result card** (R11) showing net result with green/red color coding
Each ruta row is clickable to expand and show contributing accounts with individual amounts.
## Extension Registration
The extension is registered in `lib/extensions/loader.ts`:
```typescript
import { neBilagaExtension } from '@/extensions/ne-bilaga'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension,
neBilagaExtension, // ← added
]
```
The extension declares a single report type (`ne-bilaga`) for discovery via `extensionRegistry.getByCapability('reportTypes')`. It has no event handlers, no settings panel, and no sidebar items.
## Type Definitions
Canonical source: `extensions/ne-bilaga/types.ts` (re-exported from `types/index.ts` for convenience):
- **`NEDeclaration`** — Top-level response shape with fiscal year, rutor, breakdown, company info, warnings
- **`NEDeclarationRutor`** — Record of R1-R11 number values
- **`NEAccountMapping`** — Mapping config: ruta, account ranges (with exclusions), isExpense flag
- **`NE_RUTA_LABELS`** — Display labels for each ruta
- **`SRURecord`** — Single SRU field code + value pair
- **`SRUFile`** — Collection of SRU records with generation timestamp
## Files Changed
### Created
| File | Purpose |
|---|---|
| `extensions/ne-bilaga/index.ts` | Extension definition with report type registration |
| `extensions/ne-bilaga/ne-engine.ts` | Account mapping + balance calculation (moved from `lib/reports/`) |
| `extensions/ne-bilaga/types.ts` | Canonical type definitions (`NEDeclaration`, `NEDeclarationRutor`, etc.) |
| `extensions/ne-bilaga/NEDeclarationView.tsx` | UI component (extracted from reports page) |
| `app/api/extensions/ne-bilaga/route.ts` | API endpoint with json/sru format support |
### Modified
| File | Change |
|---|---|
| `lib/extensions/loader.ts` | Added `neBilagaExtension` to `FIRST_PARTY_EXTENSIONS` |
| `app/(dashboard)/reports/page.tsx` | Imports `NEDeclarationView` from extension instead of inlining it |
| `types/index.ts` | NE types replaced with re-exports from `extensions/ne-bilaga/types.ts` |
### Deleted
| File | Reason |
|---|---|
| `app/api/reports/ne-declaration/route.ts` | Replaced by `app/api/extensions/ne-bilaga/route.ts` |
| `lib/reports/ne-declaration.ts` | Dead backward-compat shim with zero importers |
### Reused (not modified)
| File | What was reused |
|---|---|
| `lib/reports/sru-generator.ts` | `generateSRUFile()`, `sruFileToString()`, `getSRUFilename()` |
## Verification
- `npx tsc --noEmit` — zero errors
- `GET /api/extensions/ne-bilaga?period_id=X&format=json` returns NE declaration data
- `GET /api/extensions/ne-bilaga?period_id=X&format=sru` downloads `.sru` file
- NE-bilaga tab visible for EF users, hidden for AB users
- Reports page renders identically to before for EF users
- Extension appears in `extensionRegistry.getAll()` and `extensionRegistry.getByCapability('reportTypes')`
- `next build` succeeds with zero errors
@@ -0,0 +1,312 @@
# Push Notifications Extension
## Overview
The `push-notifications` extension converts system events into instant push notifications delivered via the Web Push API. It is the third first-party extension, following `receipt-ocr` and `ai-categorization`.
The extension operates in two modes:
1. **Event-driven** — Instant notifications triggered by the event bus (`period.locked`, `receipt.matched`, etc.)
2. **Cron-based** — Scheduled checks for time-dependent conditions (upcoming tax deadlines, overdue invoices)
Both modes share a single send pipeline that handles settings checks, quiet hours, duplicate prevention, delivery, logging, and subscription cleanup.
---
## Architecture
```
Event Bus
|
+---------+---------+
| |
period.locked receipt.matched
invoice.sent receipt.extracted
period.year_closed
| |
v v
+-----------------------------+
| Event Handlers (index.ts) |
| gate: check setting |
| build: payload-builders |
+-------------+---------------+
|
v
+-----------------------------+ +---------------------------+
| sendNotificationToUser() | <---- | Cron Scheduler |
| (notification-sender.ts) | | (notification-scheduler) |
| | | tax deadlines, invoices |
| 1. push_enabled? | +---------------------------+
| 2. quiet hours? |
| 3. duplicate? |
| 4. get subscriptions |
| 5. web-push send |
| 6. log to notification_log |
| 7. disable 410 subs |
+-----------------------------+
```
### File Structure
```
extensions/push-notifications/
index.ts # Extension object, settings, 5 event handlers
notification-sender.ts # Unified send pipeline, VAPID config, helpers
payload-builders.ts # All notification payload constructors
notification-scheduler.ts # Cron-based tax deadline & invoice scheduling
app/api/extensions/push-notifications/
settings/route.ts # GET/PATCH settings API
cron/route.ts # Daily cron (imports from extension)
subscribe/route.ts # Subscription management (imports VAPID from extension)
```
---
## Event Subscriptions
| Event | Handler | Default Enabled | Notification Content |
|-------|---------|-----------------|---------------------|
| `period.locked` | `handlePeriodLocked` | Yes | "{period.name} har lasts" |
| `period.year_closed` | `handleYearClosed` | Yes | "Arsbokslut klart for {period.name}" |
| `invoice.sent` | `handleInvoiceSent` | No | "Faktura {number} skickad" |
| `receipt.extracted` | `handleReceiptExtracted` | Yes | "Kvitto analyserat: {merchant}" |
| `receipt.matched` | `handleReceiptMatched` | Yes | "Kvitto matchat mot transaktion" |
Each event handler follows the **gate pattern**:
1. Extract `userId` from event payload
2. Load extension settings from `extension_data`
3. Check if the specific notification type is enabled
4. Build the payload via `payload-builders.ts`
5. Call `sendNotificationToUser()` from the sender module
---
## Cron Scheduling
The cron endpoint (`GET /api/extensions/push-notifications/cron`) runs daily at 09:00 via Vercel Cron and handles two time-dependent notification types:
### Tax Deadlines
Queries the `deadlines` table for uncompleted tax deadlines due in 7 days, 1 day, or today. Checks the user-level `tax_deadlines_enabled` setting in `notification_settings` before sending.
### Invoice Reminders
Queries the `invoices` table for sent/overdue invoices due in 3 days, today, or overdue by 3/7 days. Checks the user-level `invoice_reminders_enabled` setting before sending.
Both call `sendNotificationToUser()` which handles the full pipeline (quiet hours, duplicate check, send, log).
---
## Settings
### Extension Settings (event-driven toggles)
Stored in the `extension_data` table under `extension_id = 'push-notifications'`, `key = 'settings'`.
```typescript
interface PushNotificationSettings {
periodLockedEnabled: boolean // default: true
periodYearClosedEnabled: boolean // default: true
invoiceSentEnabled: boolean // default: false
receiptExtractedEnabled: boolean // default: true
receiptMatchedEnabled: boolean // default: true
}
```
### First-Party Settings (existing tables)
The `notification_settings` table controls transport-level and category-level preferences:
| Column | Type | Purpose |
|--------|------|---------|
| `push_enabled` | boolean | Master push toggle |
| `tax_deadlines_enabled` | boolean | Cron: tax deadline notifications |
| `invoice_reminders_enabled` | boolean | Cron: invoice due/overdue |
| `quiet_start` | text | Quiet hours start (e.g., "21:00") |
| `quiet_end` | text | Quiet hours end (e.g., "08:00") |
Both layers are checked before sending. Extension settings gate event-driven notifications; `notification_settings` gates the transport and cron-based categories.
---
## API
### GET /api/extensions/push-notifications/settings
Returns the current user's event-driven notification toggles.
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": false,
"receiptExtractedEnabled": true,
"receiptMatchedEnabled": true
}
}
```
**Response 401:** `{ "error": "Unauthorized" }`
### PATCH /api/extensions/push-notifications/settings
Updates one or more event-driven notification toggles. Only the provided keys are updated; others remain unchanged.
**Request:**
```json
{
"receiptExtractedEnabled": false,
"invoiceSentEnabled": true
}
```
**Response 200:**
```json
{
"data": {
"periodLockedEnabled": true,
"periodYearClosedEnabled": true,
"invoiceSentEnabled": true,
"receiptExtractedEnabled": false,
"receiptMatchedEnabled": true
}
}
```
**Response 400:** `{ "error": "No valid settings provided" }`
**Response 401:** `{ "error": "Unauthorized" }`
**Allowed keys:** `periodLockedEnabled`, `periodYearClosedEnabled`, `invoiceSentEnabled`, `receiptExtractedEnabled`, `receiptMatchedEnabled`
---
## Notification Types
The `NotificationType` union is defined in `extensions/push-notifications/types.ts` (re-exported from `types/index.ts` for convenience):
| Type | Source | Description |
|------|--------|-------------|
| `tax_deadline` | Cron | Upcoming tax deadline |
| `invoice_due` | Cron | Invoice approaching due date |
| `invoice_overdue` | Cron | Invoice past due date |
| `period_locked` | Event | Fiscal period was locked |
| `period_year_closed` | Event | Year-end closing completed |
| `receipt_extracted` | Event | Receipt OCR completed |
| `receipt_matched` | Event | Receipt matched to transaction |
| `invoice_sent` | Event | Invoice was sent |
These are stored as text in `notification_log.notification_type` (no migration needed).
---
## Database Tables Used
No new tables or migrations were required. The extension uses existing tables:
| Table | Usage |
|-------|-------|
| `push_subscriptions` | User's active Web Push subscriptions |
| `notification_settings` | Transport-level and category-level preferences |
| `notification_log` | Sent notification history (duplicate prevention) |
| `extension_data` | Event-driven toggle settings |
| `deadlines` | Tax deadline queries (cron) |
| `invoices` | Invoice due/overdue queries (cron) |
---
## Send Pipeline
`sendNotificationToUser(supabase, userId, payload, notificationType, referenceId, daysBefore?)` executes the following steps:
1. **Settings check** — Load `notification_settings` for the user. If `push_enabled` is false, skip.
2. **Quiet hours** — Convert current time to Sweden timezone (`Europe/Stockholm`). If within the user's quiet hours window, skip.
3. **Duplicate check** — Query `notification_log` for a matching `(user_id, notification_type, reference_id, days_before)` tuple. If found, skip.
4. **Get subscriptions** — Fetch all active push subscriptions from `push_subscriptions`. If none, skip.
5. **Send** — Deliver via the `web-push` library to all subscriptions using `Promise.allSettled`.
6. **Log** — Insert a record into `notification_log` with `delivery_status: 'sent'`.
7. **Cleanup** — Any subscription returning HTTP 410 (Gone) is marked `is_active: false`.
Returns `{ sent: boolean, reason?: string }` where reason can be: `push_disabled`, `quiet_hours`, `duplicate`, `no_subscriptions`, `send_failed`, or `error`.
---
## Registration
The extension is registered in `lib/extensions/loader.ts` alongside the other first-party extensions:
```typescript
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
]
```
When `loadExtensions()` is called, the registry wires up all 5 event handlers to the event bus.
---
## Refactoring Notes
### Phase 1: Logic extraction (original)
The following files were deleted. All logic was moved into the extension:
| Deleted File | Logic Moved To |
|-------------|----------------|
| `lib/push/web-push.ts` | `extensions/push-notifications/notification-sender.ts` (VAPID config, send functions, types) + `extensions/push-notifications/payload-builders.ts` (payload constructors) |
| `lib/push/notification-scheduler.ts` | `extensions/push-notifications/notification-scheduler.ts` (cron scheduling) + `extensions/push-notifications/notification-sender.ts` (quiet hours, duplicate check, logging, subscription management) |
### Phase 2: Route relocation
The API routes were moved from `app/api/push/` into the extension namespace to enforce the architecture rule that the core never depends on extension code:
| Old Path | New Path |
|----------|----------|
| `app/api/push/cron/route.ts` | `app/api/extensions/push-notifications/cron/route.ts` |
| `app/api/push/subscribe/route.ts` | `app/api/extensions/push-notifications/subscribe/route.ts` |
Updated consumers:
- `components/push/PushPrompt.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `components/settings/NotificationSettings.tsx` — fetch URLs updated to `/api/extensions/push-notifications/subscribe`
- `vercel.json` — cron path updated to `/api/extensions/push-notifications/cron`
### Phase 3: Type extraction
Push notification types (`PushSubscription`, `NotificationSettings`, `NotificationType`, `NotificationLog`) were moved from `types/index.ts` to `extensions/push-notifications/types.ts`. Re-exports in `types/index.ts` preserve backward compatibility.
---
## Environment Variables
The extension requires the following environment variables (unchanged from before):
| Variable | Purpose |
|----------|---------|
| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | VAPID public key for client-side subscription |
| `VAPID_PRIVATE_KEY` | VAPID private key for server-side sending |
| `VAPID_SUBJECT` | VAPID subject (defaults to `mailto:support@erp-base.se`) |
| `CRON_SECRET` | Secret for authenticating cron requests |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (used by cron service client) |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (used by cron service client) |
---
## Testing
### Automated
- `npx tsc --noEmit` — Zero TypeScript errors
- `npx vitest run` — All 78 existing tests pass
### Manual Verification
1. **Period locked** — Lock a period via the UI. If the user has an active push subscription and `periodLockedEnabled` is true, they receive a push notification.
2. **Receipt extracted** — Upload a receipt image. After OCR completes, the `receipt.extracted` event fires and triggers a push notification.
3. **Receipt matched** — When a receipt is auto-matched to a transaction, the `receipt.matched` event triggers a push notification.
4. **Invoice sent** — Send an invoice. If `invoiceSentEnabled` is true (default: false), a push notification is sent.
5. **Year closed** — Complete a year-end closing. The `period.year_closed` event triggers a push notification.
6. **Settings API** — `GET /api/extensions/push-notifications/settings` returns default settings. `PATCH` with `{ "receiptExtractedEnabled": false }` disables that specific notification type.
7. **Cron** — `GET /api/extensions/push-notifications/cron` (with Bearer token) runs the tax deadline and invoice reminder checks using the extension's scheduler module.
8. **Disable toggle** — Set `receiptExtractedEnabled: false` via PATCH, then upload a receipt. No push notification should be sent for the extraction event.
@@ -0,0 +1,257 @@
# SRU Export Extension
## Overview
The SRU Export extension generates SRU (Standardiserat Räkenskapsutdrag) files for electronic tax filing with Skatteverket. It reads `sru_code` from `chart_of_accounts`, aggregates balances by SRU code, and produces downloadable `.sru` files.
Supports both:
- **NE** (Enskild firma) — field codes 7310-7350
- **INK2** (Aktiebolag) — field codes 7200-7499
The form type is determined automatically from the user's `entity_type` in `company_settings`.
## Architecture
```
extensions/sru-export/
├── index.ts Extension definition (registered in loader)
├── sru-engine.ts Balance aggregation by SRU code
├── sru-generator.ts Generic SRU file generation
├── types.ts SRU-specific type definitions (canonical source)
└── SRUExportView.tsx UI component for the SRU-export reports tab
app/api/extensions/sru-export/
├── route.ts GET /api/extensions/sru-export (export endpoint)
└── coverage/
└── route.ts GET /api/extensions/sru-export/coverage (stats)
```
### Relationship to NE-bilaga extension
The NE-bilaga extension (`extensions/ne-bilaga/`) handles the NE-specific declaration path with hard-coded R1-R11 account mappings, served via `/api/extensions/ne-bilaga`. See `dev_docs/base_architecture/NE-BILAGA-EXTENSION.md`.
The generic SRU export is an alternative path that works from raw `account → sru_code` mappings stored in `chart_of_accounts`, rather than hard-coded NE ruta mappings. The generic generator reuses `sruFileToString()` and `validateSRUFile()` from the shared module.
```
NE-bilaga path (EF only):
extensions/ne-bilaga/ne-engine.ts → lib/reports/sru-generator.ts (NE field codes)
Generic path (NE + INK2):
extensions/sru-export/sru-engine.ts (reads sru_code from DB) → sru-generator.ts (any form type)
```
## Database Changes
### Migration: `20240101000021_sru_codes.sql`
Applied as three remote migrations:
1. `add_sru_code_column` — `ALTER TABLE chart_of_accounts ADD COLUMN sru_code text`
2. `sru_codes_backfill` — Updates existing accounts with SRU codes based on account number ranges
3. `sru_codes_seed_function` — Updates `seed_chart_of_accounts()` to include `sru_code` for new users
The backfill only updates accounts where `sru_code IS NULL`, preserving any manual assignments.
### SRU Code Mappings
#### NE form (EF) — Revenue & Expense accounts
| Account Range | SRU Code | NE Ruta | Description |
|---|---|---|---|
| 3000-3499 (excl 3100) | 7310 | R1 | Försäljning med moms |
| 3100, 3900, 3970-3980 | 7311 | R2 | Momsfria intäkter |
| 3200-3299 | 7312 | R3 | Bil/bostadsförmån |
| 8310-8330 | 7313 | R4 | Ränteintäkter |
| 4000-4990 | 7320 | R5 | Varuinköp |
| 5000-6990, 7970 | 7321 | R6 | Övriga kostnader |
| 7000-7699 | 7322 | R7 | Lönekostnader |
| 8400-8499 | 7323 | R8 | Räntekostnader |
| 7820 | 7324 | R9 | Avskrivningar fastighet |
| 7700-7899 (excl 7820) | 7325 | R10 | Avskrivningar övrigt |
#### INK2 form (AB) — Balance Sheet accounts
| Account Range | SRU Code | Description |
|---|---|---|
| 1000-1099 | 7201 | Immateriella anläggningstillgångar |
| 1100-1299 | 7202 | Materiella anläggningstillgångar |
| 1300-1399 | 7203 | Finansiella anläggningstillgångar |
| 1400-1499 | 7210 | Varulager |
| 1500-1599 | 7211 | Kundfordringar |
| 1600-1999 | 7212 | Övriga omsättningstillgångar |
| 2081 | 7220 | Aktiekapital |
| 2085-2098 | 7221 | Övrigt eget kapital |
| 2099 | 7222 | Årets resultat |
| 2100-2499 | 7230 | Skulder |
| 2500-2999 | 7231 | Övriga skulder |
#### INK2 form (AB) — Income Statement accounts
| Account Range | SRU Code | Description |
|---|---|---|
| 3000-3999 | 7310 | Nettoomsättning |
| 4000-4999 | 7320 | Varuinköp |
| 5000-6999 | 7330 | Övriga externa kostnader |
| 7000-7699 | 7340 | Personalkostnader |
| 7700-7899 | 7350 | Avskrivningar |
| 7900-7999 | 7360 | Övriga rörelsekostnader |
| 8000-8499 | 7370 | Finansiella poster |
| 8500-8999 | 7380 | Extraordinära poster |
## API Reference
### GET /api/extensions/sru-export
Generate SRU export for a fiscal period.
**Query parameters:**
| Parameter | Required | Description |
|---|---|---|
| `period_id` | Yes | Fiscal period UUID |
| `format` | No | `json` (default) or `sru` |
**Response (format=json):**
```json
{
"data": {
"formType": "NE",
"entityType": "enskild_firma",
"companyName": "Mitt Företag AB",
"orgNumber": "556123-4567",
"fiscalYear": {
"id": "uuid",
"name": "2025",
"start": "2025-01-01",
"end": "2025-12-31"
},
"balances": [
{
"sruCode": "7310",
"description": "Försäljning med moms",
"amount": 150000,
"accounts": [
{ "accountNumber": "3001", "accountName": "Försäljning tjänster 25%", "amount": 150000 }
]
}
],
"warnings": ["Räkenskapsåret är inte stängt. Siffrorna kan ändras."]
}
}
```
**Response (format=sru):** Downloads a `.sru` file with `Content-Disposition: attachment`.
### GET /api/extensions/sru-export/coverage
Returns SRU code coverage statistics for the authenticated user's chart of accounts.
**Response:**
```json
{
"data": {
"totalAccounts": 30,
"accountsWithSRU": 28,
"accountsWithoutSRU": 2,
"coveragePercent": 93,
"missingAccounts": [
{ "accountNumber": "1220", "accountName": "Inventarier" }
]
}
}
```
## SRU File Format
The generated `.sru` file follows the Skatteverket standard:
```
#PRODUKT KONTROLLUPPGIFTER
#SESSION 1
#PROGRAMNAMN ERPBase
#PROGRAMVERSION 1.0
#SKAPAT 20260219
#BLANKETT NE
#IDENTITET 5561234567
#UPPGIFT 7000 20250101-20251231
#UPPGIFT 7310 150000
#UPPGIFT 7320 -45000
#UPPGIFT 7321 -30000
#BLANKETTSLUT
```
Each `#UPPGIFT` line contains an SRU field code and the rounded (whole kronor) amount. Zero-amount entries are omitted.
## UI Integration
### SRUExportView component
The SRU export UI lives in `extensions/sru-export/SRUExportView.tsx` and is imported by the reports page:
```typescript
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
```
### Reports Page
The **SRU-export** tab in `/reports` shows:
1. **Info card** with "Förhandsgranska" and "Ladda ner SRU-fil" buttons
2. **Coverage warning** if accounts lack SRU codes (fetched from `/coverage` endpoint)
3. **Company info card** showing entity type badge (NE/INK2) and fiscal year
4. **SRU balances table** with expandable rows showing per-account detail
### Chart of Accounts
A new **SRU** column is added to the accounts table in the Kontoplan view. The column is inline-editable: click a cell to type a new SRU code, press Enter to save, Escape to cancel. Updates go directly to `chart_of_accounts.sru_code` via the Supabase client.
## Extension Registration
The extension is registered in `lib/extensions/loader.ts`:
```typescript
import { sruExportExtension } from '@/extensions/sru-export'
const FIRST_PARTY_EXTENSIONS: Extension[] = [
receiptOcrExtension,
aiCategorizationExtension,
pushNotificationsExtension,
sruExportExtension, // ← added
]
```
The extension has no event handlers, no settings panel, and no sidebar items. It only declares a report type for discovery purposes.
## Type Definitions
Canonical source: `extensions/sru-export/types.ts` (re-exported from `types/index.ts` for convenience):
- **`SRUExportResult`** — Response shape for the JSON format export endpoint
- **`SRUCoverageStats`** — Response shape for the coverage endpoint
## Files Changed
### Created
| File | Purpose |
|---|---|
| `supabase/migrations/20240101000021_sru_codes.sql` | Add column, backfill SRU codes, update seed function |
| `extensions/sru-export/index.ts` | Extension definition |
| `extensions/sru-export/sru-engine.ts` | Balance aggregation by SRU code |
| `extensions/sru-export/sru-generator.ts` | Generic SRU file generation |
| `extensions/sru-export/types.ts` | Canonical type definitions (`SRUExportResult`, `SRUCoverageStats`) |
| `extensions/sru-export/SRUExportView.tsx` | UI component (extracted from reports page) |
| `app/api/extensions/sru-export/route.ts` | Export API endpoint |
| `app/api/extensions/sru-export/coverage/route.ts` | Coverage stats endpoint |
### Modified
| File | Change |
|---|---|
| `lib/extensions/loader.ts` | Added `sruExportExtension` to `FIRST_PARTY_EXTENSIONS` |
| `app/(dashboard)/reports/page.tsx` | Imports `SRUExportView` from extension instead of inlining it |
| `types/index.ts` | SRU types replaced with re-exports from `extensions/sru-export/types.ts` |
| `components/bookkeeping/ChartOfAccounts.tsx` | Added inline-editable SRU code column |
### Reused (not modified)
| File | What was reused |
|---|---|
| `lib/reports/sru-generator.ts` | `sruFileToString()`, `validateSRUFile()` |
| `extensions/ne-bilaga/ne-engine.ts` | Balance calculation pattern |
| `lib/reports/sie-export.ts` | `calculateBalances()` pattern |