refactor: clean up codebase, remove dead code and obsolete docs
Remove influencer-era documentation, unused components, boilerplate assets, and ghost tiktok cron job. Add supplier invoice management, document API routes, and PWA icons. Replace boilerplate README. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -5,7 +5,18 @@
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"WebFetch(domain:www.skatteverket.se)",
|
||||
"WebSearch",
|
||||
"mcp__plugin_supabase_supabase__apply_migration",
|
||||
"Bash(cat:*)",
|
||||
"Bash(wc:*)",
|
||||
"Bash(git -C /Users/jakobwennberg/erp-base log --oneline)",
|
||||
"mcp__plugin_supabase_supabase__list_projects",
|
||||
"Bash(python3:*)",
|
||||
"Bash(git -C /Users/jakobwennberg/erp-base status --porcelain)",
|
||||
"Bash(__NEW_LINE_421a23f44da9923d__ echo '=== Checking which index exports are used ===' grep -r @/components/calendar /Users/jakobwennberg/erp-base/app --include=*.tsx --include=*.ts 2)",
|
||||
"Bash(/dev/null __NEW_LINE_421a23f44da9923d__ echo -e '\\\\n=== Checking which calendar deadline components are directly imported ===' grep -rE \"DeadlineList|DeadlineCard|DeadlineForm|DeadlineFilters|PaymentSummaryCard|TaxTodoWidget|UpcomingDeadlinesWidget\" /Users/jakobwennberg/erp-base --include=*.tsx --include=*.ts)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
# CLAUDE.md — erp-base
|
||||
|
||||
## Project Overview
|
||||
|
||||
erp-base is a Swedish-focused accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). It implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention.
|
||||
|
||||
**Tech stack**: Next.js 16 (App Router), React 19, TypeScript (strict), Supabase (PostgreSQL + RLS + magic link auth), Tailwind CSS 4 + shadcn/ui, Vercel hosting.
|
||||
|
||||
**Integrations**: Enable Banking (PSD2), Anthropic SDK, OpenAI (embeddings), Resend (email), web-push (VAPID).
|
||||
|
||||
**Path alias**: `@/*` maps to the project root (tsconfig.json).
|
||||
|
||||
**Language**: All code, comments, and commit messages must be in English.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start development server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint
|
||||
npm test # Run all Vitest tests
|
||||
npx vitest run <dir> # Run tests in a specific directory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
(auth)/ Login, auth callback
|
||||
(onboarding)/ 6-step setup wizard
|
||||
(dashboard)/ Authenticated routes (invoices, customers, transactions,
|
||||
bookkeeping, reports, suppliers, supplier-invoices,
|
||||
receipts, settings, calendar, help, import)
|
||||
(public)/ Public invoice action links (no auth)
|
||||
api/ API routes organized by domain
|
||||
|
||||
components/
|
||||
ui/ shadcn/ui primitives (button, card, dialog, table, etc.)
|
||||
[feature]/ Feature-organized components (banking, invoices, suppliers, etc.)
|
||||
|
||||
extensions/ First-party extension implementations
|
||||
receipt-ocr/ Receipt image OCR processing
|
||||
ai-categorization/ AI-powered transaction categorization
|
||||
ai-chat/ Claude-based chat assistant
|
||||
ne-bilaga/ NE tax form attachment generation
|
||||
sru-export/ SRU file export
|
||||
push-notifications/ Web push notification system
|
||||
example-logger/ Minimal reference extension (not loaded by default)
|
||||
|
||||
lib/
|
||||
bookkeeping/ Core journal entry engine and all entry generators
|
||||
engine.ts Draft/commit workflow, balance validation, voucher numbering
|
||||
invoice-entries.ts Sales invoice journal entries
|
||||
transaction-entries.ts Bank transaction journal entries
|
||||
supplier-invoice-entries.ts Purchase invoice journal entries
|
||||
category-mapping.ts Category-to-BAS-account mapping
|
||||
mapping-engine.ts Rule-based auto-categorization (MCC codes, merchant patterns)
|
||||
vat-entries.ts VAT line generation
|
||||
core/
|
||||
bookkeeping/ Period service, storno reversal, year-end closing
|
||||
documents/ Document archive (upload, versioning, SHA-256 integrity)
|
||||
audit/ Audit trail service
|
||||
tax/ Tax code service
|
||||
banking/ PSD2 integration via Enable Banking
|
||||
reports/ Financial reports (trial-balance, income-statement,
|
||||
balance-sheet, vat-declaration, sie-export,
|
||||
supplier-ledger, supplier-reconciliation)
|
||||
events/ Event bus (bus.ts, types.ts)
|
||||
extensions/ Extension registry, loader, types
|
||||
tax/ Tax calculations, deadlines, Swedish holidays
|
||||
import/ SIE file parser
|
||||
invoice/ VAT rules for invoicing
|
||||
currency/ Riksbanken exchange rates
|
||||
supabase/ Client setup (client.ts = browser, server.ts = server/admin)
|
||||
init.ts Extension loader (idempotent, called by API routes)
|
||||
|
||||
types/index.ts Canonical type definitions (70+ types, single source of truth)
|
||||
tests/helpers.ts Mock factories and fixture builders
|
||||
supabase/migrations/ SQL migration files
|
||||
dev_docs/ Extensive project documentation (PRD, architecture, BAS guide, etc.)
|
||||
```
|
||||
|
||||
### Key Relationships
|
||||
|
||||
- **All journal entry creation** routes through `lib/bookkeeping/engine.ts`. The entry generators (`invoice-entries.ts`, `transaction-entries.ts`, `supplier-invoice-entries.ts`) call `createJournalEntry()` from the engine.
|
||||
- **API routes** that emit events must call `ensureInitialized()` (from `lib/init.ts`) at module level to load extensions.
|
||||
- **Event bus** (`lib/events/bus.ts`) is a module-level singleton. Core services emit, extensions subscribe.
|
||||
- **Supabase clients**: browser (`lib/supabase/client.ts`), server with user cookies (`createClient()` from `lib/supabase/server.ts`), and service role (`createServiceClient()`).
|
||||
|
||||
---
|
||||
|
||||
## Core Bookkeeping Engine
|
||||
|
||||
The bookkeeping engine (`lib/bookkeeping/engine.ts`) is the most critical system. All accounting flows route through it.
|
||||
|
||||
### Journal Entry Lifecycle
|
||||
|
||||
1. **`createDraftEntry(userId, input)`** — Creates entry with `status: 'draft'`, `voucher_number: 0`. Validates balance. Emits `journal_entry.drafted`.
|
||||
2. **`commitEntry(userId, entryId)`** — Assigns voucher number via DB RPC (`next_voucher_number`, concurrent-safe). Sets `status: 'posted'`. DB trigger sets `committed_at`. Emits `journal_entry.committed`.
|
||||
3. **`createJournalEntry(userId, input)`** — Convenience: draft + commit in one call. This is what all entry generators use.
|
||||
4. **`reverseEntry(userId, entryId)`** — Storno reversal: swaps debit/credit, links via `reverses_id`/`reversed_by_id`. Original marked `'reversed'`.
|
||||
|
||||
### Entry Generators
|
||||
|
||||
| Function | File | Purpose |
|
||||
|----------|------|---------|
|
||||
| `createInvoiceJournalEntry()` | `invoice-entries.ts` | Debit 1510, Credit 30xx + 26xx VAT |
|
||||
| `createInvoicePaymentJournalEntry()` | `invoice-entries.ts` | Debit 1930, Credit 1510 |
|
||||
| `createCreditNoteJournalEntry()` | `invoice-entries.ts` | Reverses original invoice entry |
|
||||
| `createInvoiceCashEntry()` | `invoice-entries.ts` | Cash method: revenue + VAT at payment |
|
||||
| `createTransactionJournalEntry()` | `transaction-entries.ts` | Maps bank transactions via MappingResult |
|
||||
| `createSupplierInvoiceRegistrationEntry()` | `supplier-invoice-entries.ts` | Debit expense + 2641, Credit 2440 |
|
||||
| `createSupplierInvoicePaymentEntry()` | `supplier-invoice-entries.ts` | Debit 2440, Credit 1930 |
|
||||
|
||||
### Key BAS Accounts
|
||||
|
||||
| Account | Description |
|
||||
|---------|-------------|
|
||||
| `1510` | Accounts receivable |
|
||||
| `1930` | Business bank account |
|
||||
| `2013` | Private withdrawals (enskild firma) |
|
||||
| `2440` | Accounts payable |
|
||||
| `2611` / `2621` / `2631` | Output VAT 25% / 12% / 6% |
|
||||
| `2614` | Output VAT reverse charge |
|
||||
| `2641` | Input VAT (deductible) |
|
||||
| `2645` | Calculated input VAT (EU reverse charge) |
|
||||
| `2893` | Loan from shareholders (aktiebolag) |
|
||||
| `3001` / `3002` / `3003` | Revenue by VAT rate (25% / 12% / 6%) |
|
||||
| `3305` / `3308` | Export / EU service revenue |
|
||||
| `3960` / `7960` | Exchange rate gains / losses |
|
||||
|
||||
### VAT Treatments
|
||||
|
||||
`standard_25`, `reduced_12`, `reduced_6`, `reverse_charge`, `export`, `exempt`
|
||||
|
||||
---
|
||||
|
||||
## Accounting Guard Rails
|
||||
|
||||
These rules exist for legal compliance and are enforced by database triggers. **Never violate them.**
|
||||
|
||||
1. **Committed entries are immutable.** Once `status: 'posted'`, an entry cannot be edited or deleted. Enforced by DB trigger `enforce_journal_entry_immutability`.
|
||||
2. **Never delete posted entries.** Use `reverseEntry()` (storno) to cancel. The reversal creates a new entry with swapped debit/credit and bidirectional linking.
|
||||
3. **Every entry must balance.** `sum(debits) === sum(credits)`, both must be `> 0`. Checked by `validateBalance()` before insert.
|
||||
4. **Voucher numbers are sequential.** Assigned via DB RPC `next_voucher_number` (concurrent-safe). Never set manually.
|
||||
5. **Period lock enforcement.** DB trigger `enforce_period_lock` blocks writes to closed/locked fiscal periods. Check period status before creating entries.
|
||||
6. **7-year document retention.** DB triggers prevent deletion of documents linked to posted entries. `retention_expires_at` is auto-calculated as `period_end + 7 years`.
|
||||
7. **Storno, never edit.** To correct an error: reverse the wrong entry, then create a new correct one. Use `correctEntry()` from `lib/core/bookkeeping/storno-service.ts` for atomic correction.
|
||||
8. **Use `Math.round(x * 100) / 100` for all monetary calculations.** Never use `toFixed()` (returns strings, has rounding edge cases).
|
||||
9. **Always use engine functions.** Never insert directly into `journal_entries` or `journal_entry_lines` tables. Always go through `lib/bookkeeping/engine.ts`.
|
||||
10. **Account numbers are strings.** BAS account numbers like `'1930'` are always strings, never numbers.
|
||||
|
||||
---
|
||||
|
||||
## Extension Development
|
||||
|
||||
Extensions are first-party plugins in the `/extensions/` directory, loaded statically at startup.
|
||||
|
||||
### Creating a New Extension
|
||||
|
||||
1. Create `extensions/<name>/index.ts`
|
||||
2. Export an object implementing the `Extension` interface from `lib/extensions/types.ts`
|
||||
3. Add a static import to the `FIRST_PARTY_EXTENSIONS` array in `lib/extensions/loader.ts`
|
||||
4. Extensions **cannot** use dynamic imports (Next.js bundling constraint)
|
||||
|
||||
### Extension Interface
|
||||
|
||||
```typescript
|
||||
interface Extension {
|
||||
id: string // Unique identifier (e.g. 'receipt-ocr')
|
||||
name: string // Display name
|
||||
version: string // Semver
|
||||
|
||||
// Surfaces (all optional)
|
||||
routes?: RouteDefinition[]
|
||||
apiRoutes?: ApiRouteDefinition[]
|
||||
sidebarItems?: SidebarItem[]
|
||||
eventHandlers?: ExtensionEventHandler[]
|
||||
mappingRuleTypes?: MappingRuleTypeDefinition[]
|
||||
reportTypes?: ReportDefinition[]
|
||||
settingsPanel?: SettingsPanelDefinition
|
||||
taxCodes?: TaxCodeDefinition[]
|
||||
dimensionTypes?: DimensionDefinition[]
|
||||
|
||||
// Lifecycle hooks
|
||||
onInstall?(ctx: ExtensionContext): Promise<void>
|
||||
onUninstall?(ctx: ExtensionContext): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
### Minimal Example
|
||||
|
||||
See `extensions/example-logger/index.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
export const myExtension: Extension = {
|
||||
id: 'my-extension',
|
||||
name: 'My Extension',
|
||||
version: '0.1.0',
|
||||
eventHandlers: [
|
||||
{
|
||||
eventType: 'journal_entry.committed',
|
||||
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
|
||||
// React to committed journal entries
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Available Event Types
|
||||
|
||||
All defined in `lib/events/types.ts`:
|
||||
|
||||
| Event | Payload |
|
||||
|-------|---------|
|
||||
| `journal_entry.drafted` | `{ entry, userId }` |
|
||||
| `journal_entry.committed` | `{ entry, userId }` |
|
||||
| `journal_entry.corrected` | `{ original, storno, corrected, userId }` |
|
||||
| `document.uploaded` | `{ document, userId }` |
|
||||
| `invoice.created` | `{ invoice, userId }` |
|
||||
| `invoice.sent` | `{ invoice, userId }` |
|
||||
| `invoice.paid` | `{ invoice, transaction, kursdifferens?, userId }` |
|
||||
| `invoice.overdue` | `{ invoice, days, userId }` |
|
||||
| `credit_note.created` | `{ creditNote, userId }` |
|
||||
| `transaction.synced` | `{ transactions[], userId }` |
|
||||
| `transaction.categorized` | `{ transaction, account, taxCode, userId }` |
|
||||
| `bank.statement_received` | `{ statement, userId }` |
|
||||
| `bank.payment_notification` | `{ notification, userId }` |
|
||||
| `period.locked` | `{ period, userId }` |
|
||||
| `period.year_closed` | `{ period, userId }` |
|
||||
| `customer.created` | `{ customer, userId }` |
|
||||
| `customer.pseudonymized` | `{ customerId, userId }` |
|
||||
| `receipt.extracted` | `{ receipt, documentId, confidence, userId }` |
|
||||
| `receipt.matched` | `{ receipt, transaction, confidence, autoMatched, userId }` |
|
||||
| `receipt.confirmed` | `{ receipt, businessTotal, privateTotal, userId }` |
|
||||
| `audit.security_event` | `{ event, userId }` |
|
||||
|
||||
### Event Bus Behavior
|
||||
|
||||
- Handlers run concurrently via `Promise.allSettled` — a failing handler never crashes the emitter
|
||||
- Module-level singleton, persists across requests in the same process
|
||||
- One-way: core services emit, extensions subscribe
|
||||
- Call `eventBus.clear()` in tests to reset state
|
||||
|
||||
---
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
### Scope
|
||||
|
||||
Only test business logic in `lib/`. No component tests, no API route tests, no E2E tests.
|
||||
|
||||
### Framework
|
||||
|
||||
Vitest 4 with `globals: true` and `environment: 'node'`. Config in `vitest.config.ts`.
|
||||
|
||||
### Test Location
|
||||
|
||||
Colocated `__tests__/` directories alongside source files in `lib/`.
|
||||
|
||||
### Test Helpers (`tests/helpers.ts`)
|
||||
|
||||
**Supabase mock**:
|
||||
```typescript
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: makeTransaction(), error: null })
|
||||
// supabase.from('x').select('*').eq('id', '1') resolves to the mocked result
|
||||
```
|
||||
|
||||
**Fixture factories** (all accept `Partial<T>` overrides):
|
||||
- `makeReceipt()` — Receipt with default merchant, amounts, dates
|
||||
- `makeTransaction()` — Transaction with default category, amount
|
||||
- `makeFiscalPeriod()` — FiscalPeriod with default date range
|
||||
- `makeJournalEntry()` — Posted JournalEntry with voucher number
|
||||
- `makeJournalEntryLine()` — Line with account number, zero amounts
|
||||
- `makeDocumentAttachment()` — Document with hash, storage path
|
||||
- `makeTaxCode()` — TaxCode with default output VAT 25%
|
||||
|
||||
### Patterns
|
||||
|
||||
- Always mock `@/lib/supabase/server` to avoid real DB calls
|
||||
- Use `vi.clearAllMocks()` and `eventBus.clear()` in `beforeEach`
|
||||
- Test balance validation edge cases (floating point precision, zero amounts)
|
||||
- Test error paths (missing fiscal period, unbalanced entries)
|
||||
- Verify events are emitted correctly
|
||||
|
||||
### Reference Tests
|
||||
|
||||
- `lib/bookkeeping/__tests__/engine.test.ts` — Balance validation
|
||||
- `lib/core/bookkeeping/__tests__/storno-service.test.ts` — Complex mock queues
|
||||
- `lib/core/documents/__tests__/document-service.test.ts` — Storage mocking
|
||||
- `lib/events/__tests__/bus.test.ts` — Event bus behavior
|
||||
- `lib/extensions/__tests__/registry.test.ts` — Extension registration
|
||||
|
||||
---
|
||||
|
||||
## Database & Migrations
|
||||
|
||||
### Location
|
||||
|
||||
`supabase/migrations/` — currently 27 files numbered `20240101000001` through `20240101000027`.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
`YYYYMMDD00NNNN_descriptive_name.sql` — next migration: `20240101000028_*.sql`
|
||||
|
||||
### Migration Rules
|
||||
|
||||
1. **Always enable RLS** on new tables: `ALTER TABLE public.tablename ENABLE ROW LEVEL SECURITY;`
|
||||
2. **Always create RLS policies** using `auth.uid() = user_id`:
|
||||
```sql
|
||||
CREATE POLICY "tablename_select" ON public.tablename FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "tablename_insert" ON public.tablename FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
CREATE POLICY "tablename_update" ON public.tablename FOR UPDATE USING (auth.uid() = user_id);
|
||||
```
|
||||
3. **Always add `updated_at` trigger**:
|
||||
```sql
|
||||
CREATE TRIGGER tablename_updated_at BEFORE UPDATE ON public.tablename
|
||||
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
|
||||
```
|
||||
4. **UUID primary keys**: `id UUID PRIMARY KEY DEFAULT uuid_generate_v4()`
|
||||
5. **User ownership**: `user_id UUID REFERENCES auth.users ON DELETE CASCADE NOT NULL`
|
||||
6. **Never modify existing migrations** — create new ones instead
|
||||
7. **Never modify enforcement triggers** (migration `20240101000017`) — these are legally required
|
||||
8. **Never hardcode IDs** in data migrations — use subqueries or variables
|
||||
9. **Apply via Supabase MCP tool**: `mcp__plugin_supabase_supabase__apply_migration`
|
||||
10. **Test on a branch database** before applying to production (use `create_branch` MCP tool)
|
||||
|
||||
### Key Enforcement Triggers (migration 017)
|
||||
|
||||
- `enforce_journal_entry_immutability` — Blocks edits/deletes on posted/reversed entries
|
||||
- `enforce_journal_entry_line_immutability` — Blocks line modifications on committed entries
|
||||
- `enforce_period_lock` — Blocks writes to closed/locked fiscal periods
|
||||
- `block_document_deletion` — Prevents deletion of documents linked to committed entries
|
||||
- `enforce_retention_journal_entries` — 7-year retention enforcement
|
||||
- `set_committed_at` — Auto-sets timestamp on draft-to-posted transition
|
||||
- `calculate_retention_expiry` — Auto-sets `retention_expires_at = period_end + 7 years`
|
||||
|
||||
---
|
||||
|
||||
## Type System
|
||||
|
||||
- All shared types live in `types/index.ts` — this is the single source of truth
|
||||
- Import via `import type { TypeName } from '@/types'`
|
||||
- When adding new domain types, add them to `types/index.ts`
|
||||
- Event types are the exception — they live in `lib/events/types.ts` (since they reference domain types)
|
||||
|
||||
---
|
||||
|
||||
## API Route Patterns
|
||||
|
||||
Standard pattern for new API routes:
|
||||
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized() // Module-level — loads extensions for event emission
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Business logic...
|
||||
// Always filter by user_id (defense in depth alongside RLS)
|
||||
// Wrap journal entry creation in try/catch (non-blocking side effect)
|
||||
// Emit events after successful operations
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
}
|
||||
```
|
||||
|
||||
**Key conventions**:
|
||||
- Call `ensureInitialized()` at module level in any route that emits events
|
||||
- Dynamic route params use `{ params }: { params: Promise<{ id: string }> }` (Next.js 16)
|
||||
- Response shapes: `{ data }` for success, `{ error }` for failures
|
||||
- Journal entry creation is non-blocking: catch errors and continue
|
||||
|
||||
---
|
||||
|
||||
## Skills Usage
|
||||
|
||||
- **Always** use the `/frontend-design` skill when creating new UI pages or significant components. This ensures consistent use of shadcn/ui, Tailwind CSS 4, and the existing component library.
|
||||
- Use the `langchain` skill when building or modifying AI features (ai-chat, ai-categorization extensions).
|
||||
- Use `vercel:deploy` for deployment tasks.
|
||||
|
||||
---
|
||||
|
||||
## Git Conventions
|
||||
|
||||
Use **conventional commits**:
|
||||
|
||||
```
|
||||
feat: add supplier invoice payment tracking
|
||||
fix: correct VAT calculation for reduced 12% rate
|
||||
refactor: extract VAT line generation into vat-entries.ts
|
||||
test: add balance validation edge cases
|
||||
docs: update CLAUDE.md with extension guide
|
||||
```
|
||||
|
||||
- Atomic commits — one logical change per commit
|
||||
- Branch from `main` for new work
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
Hosted on **Vercel** with cron jobs defined in `vercel.json`:
|
||||
|
||||
| Cron Job | Schedule |
|
||||
|----------|----------|
|
||||
| `/api/banking/sync/cron` | Daily 05:00 |
|
||||
| `/api/deadlines/status/cron` | Daily 06:00 |
|
||||
| `/api/invoices/reminders/cron` | Daily 08:00 |
|
||||
| `/api/extensions/push-notifications/cron` | Daily 09:00 |
|
||||
| `/api/tax-deadlines/cron` | Monthly, 1st at 00:00 |
|
||||
| `/api/documents/verify/cron` | Weekly, Sunday 03:00 |
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_SUPABASE_URL # Supabase project URL
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY # Supabase anonymous key
|
||||
SUPABASE_SERVICE_ROLE_KEY # Supabase service role key
|
||||
ENABLE_BANKING_APP_ID # Enable Banking app ID
|
||||
ENABLE_BANKING_APP_SECRET # Enable Banking app secret
|
||||
ENABLE_BANKING_SANDBOX # true for sandbox mode
|
||||
ANTHROPIC_API_KEY # Claude API key (ai-chat)
|
||||
OPENAI_API_KEY # OpenAI API key (embeddings)
|
||||
NEXT_PUBLIC_APP_URL # App base URL
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY # Web push public key
|
||||
VAPID_PRIVATE_KEY # Web push private key
|
||||
```
|
||||
@@ -1,878 +0,0 @@
|
||||
# Product Requirements Document
|
||||
## Modular Swedish ERP & Accounting Platform
|
||||
|
||||
**Version 2.0 | February 2026 | CONFIDENTIAL**
|
||||
|
||||
*A next-generation bokföringssystem for Swedish aktiebolag*
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Product Vision & Positioning](#2-product-vision--positioning)
|
||||
3. [Target Users & Market](#3-target-users--market)
|
||||
4. [Core Platform Architecture](#4-core-platform-architecture)
|
||||
5. [Module System Design](#5-module-system-design)
|
||||
6. [Core Accounting Engine (Always Active)](#6-core-accounting-engine-always-active)
|
||||
7. [Module Catalog: Categories & Modules](#7-module-catalog-categories--modules)
|
||||
8. [Industry Packs & Business Type Recommendations](#8-industry-packs--business-type-recommendations)
|
||||
9. [User Experience & Onboarding Flow](#9-user-experience--onboarding-flow)
|
||||
10. [Technical Architecture](#10-technical-architecture)
|
||||
11. [Database Design](#11-database-design)
|
||||
12. [Event System](#12-event-system)
|
||||
13. [Security & Compliance](#13-security--compliance)
|
||||
14. [Integration Strategy](#14-integration-strategy)
|
||||
15. [Development Roadmap](#15-development-roadmap)
|
||||
16. [Business Model & Pricing](#16-business-model--pricing)
|
||||
17. [Success Metrics & KPIs](#17-success-metrics--kpis)
|
||||
18. [Risks & Mitigations](#18-risks--mitigations)
|
||||
19. [Appendices](#19-appendices)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This document defines the product requirements for a modular Swedish ERP and accounting platform designed to compete directly with Fortnox and Visma. The platform takes a fundamentally different architectural approach: rather than offering a rigid, one-size-fits-all product, it provides a rock-solid core accounting engine with a composable module system that lets every business tailor their experience to their specific industry and needs.
|
||||
|
||||
The core insight driving this product is that a restaurant, a hair salon, and a consulting firm all need correct Swedish accounting (BAS-kontoplan, momsredovisning, SIE export), but their day-to-day operational needs are completely different. Today, they all use the same generic Fortnox interface. This platform changes that by letting each business activate industry-specific modules that add relevant functionality, terminology, dashboards, and workflows on top of a shared, audited accounting foundation.
|
||||
|
||||
The platform targets Swedish aktiebolag (limited companies) with 5 to 50 employees, starting with the Food & Beverage industry cluster before expanding to Beauty & Wellness, Fitness & Health, and beyond.
|
||||
|
||||
> **Key Differentiator:** Every business gets a complete Swedish bokföringssystem out of the box. The module system lets them shape their platform to their industry without custom development, code generation, or AI-driven modifications. Modules are pre-built, tested, and audited by the platform team. The user simply activates what they need.
|
||||
|
||||
### 1.1 Document Scope
|
||||
|
||||
This PRD covers the full product vision, technical architecture, module system design, core accounting engine specification, initial module catalog, onboarding flow, development roadmap, and business model. It is intended for the founding engineering team and serves as the authoritative reference for building the MVP and subsequent releases.
|
||||
|
||||
---
|
||||
|
||||
## 2. Product Vision & Positioning
|
||||
|
||||
### 2.1 Vision Statement
|
||||
|
||||
Build the most adaptable accounting platform in Sweden, where every business gets a system that feels like it was built specifically for their industry — powered by a modular architecture that makes this economically viable at scale.
|
||||
|
||||
### 2.2 Positioning
|
||||
|
||||
The platform sits in a unique position in the Swedish market:
|
||||
|
||||
| Competitor | Strength | Weakness | Our Advantage |
|
||||
|---|---|---|---|
|
||||
| **Fortnox** | Market leader, trusted, integrations | Rigid, same UI for everyone, slow to innovate | Industry-tailored experience from day one |
|
||||
| **Visma** | Enterprise-grade, comprehensive | Complex, expensive, overwhelming for SMBs | Simple to start, grows with you via modules |
|
||||
| **Bokio** | Simple, modern UI, free tier | Too basic for growing businesses | Professional-grade with modular depth |
|
||||
| **Odoo** | Highly modular, open source | Complex setup, requires developer, not Swedish-first | Swedish-native, no developer needed, curated modules |
|
||||
|
||||
### 2.3 Core Principles
|
||||
|
||||
- **Correctness first:** The accounting engine must be bulletproof. Swedish accounting standards (BAS, K2/K3, BFL, BFN) are non-negotiable. Every module that touches financial data must maintain the integrity of the general ledger.
|
||||
- **Composition over configuration:** Users don't configure complex settings. They activate pre-built modules. The platform composes their experience from tested building blocks.
|
||||
- **Industry-native experience:** When a restaurant owner uses the platform, it should feel like it was built for restaurants. The terminology, the dashboards, the workflows, the reports — all should speak their language.
|
||||
- **Progressive complexity:** Start simple. A sole-owner café might only use core accounting and invoicing. A multi-location restaurant group might activate 15 modules. Both are well-served.
|
||||
- **Open data, no lock-in:** SIE4 export is always available. Users own their data. The platform competes on value, not on trapping users.
|
||||
|
||||
---
|
||||
|
||||
## 3. Target Users & Market
|
||||
|
||||
### 3.1 Primary Target
|
||||
|
||||
Swedish aktiebolag (AB) with 5–50 employees. This segment is large enough to need real accounting but small enough to be underserved by enterprise ERP systems. They typically use Fortnox or Visma today and find these tools either too rigid or too complex.
|
||||
|
||||
### 3.2 User Personas
|
||||
|
||||
#### Persona 1: The Restaurant Owner
|
||||
|
||||
- **Name:** Maria, 38, owns a mid-range restaurant in Göteborg with 12 employees
|
||||
- **Current tools:** Fortnox for accounting, Excel for food cost tracking, paper for daily cash reconciliation
|
||||
- **Pain points:** Can't see real-time food cost percentage in her accounting system. Spends hours reconciling Z-rapporter manually. Her accountant (redovisningskonsult) has to explain the financial reports because they're too abstract.
|
||||
- **What she wants:** Open the app in the morning, see yesterday's omsättning, matkostnadsprocent, and personalkostnad. Import Z-rapport with one click. Have her bokföringsbyrå access the same system.
|
||||
|
||||
#### Persona 2: The Redovisningskonsult
|
||||
|
||||
- **Name:** Erik, 45, runs a small bokföringsbyrå with 8 clients across different industries
|
||||
- **Current tools:** Fortnox for all clients, same interface regardless of industry
|
||||
- **Pain points:** Has to manually set up chart of accounts differently for each client. Wastes time explaining generic reports. Wants to standardize his process per industry.
|
||||
- **What he wants:** Set up a new restaurant client in 10 minutes with the right modules pre-activated. See all his clients in one view. Access industry-relevant reports without custom configuration.
|
||||
|
||||
#### Persona 3: The Growing Business
|
||||
|
||||
- **Name:** Ahmed, 32, started a food truck, now expanding to a second location and considering a café
|
||||
- **Current tools:** Bokio (outgrowing it), spreadsheets for everything else
|
||||
- **Pain points:** Bokio is too basic. Fortnox feels like it's built for accountants, not business owners. Needs to track multiple locations but doesn't want enterprise pricing.
|
||||
- **What he wants:** A system that grows with him. Start with food truck modules, add café modules when he expands. One platform, one subscription, one view of his whole business.
|
||||
|
||||
### 3.3 Market Size
|
||||
|
||||
There are approximately 430,000 active aktiebolag in Sweden. The addressable market of businesses with 5–50 employees in service industries represents roughly 80,000–120,000 companies. The Swedish cloud accounting market is estimated at 8–10 billion SEK annually.
|
||||
|
||||
---
|
||||
|
||||
## 4. Core Platform Architecture
|
||||
|
||||
### 4.1 Architecture Overview
|
||||
|
||||
The platform follows a three-layer architecture where each layer has clear responsibilities and boundaries:
|
||||
|
||||
| Layer | Description | Who Controls It | Examples |
|
||||
|---|---|---|---|
|
||||
| **Layer 1: Core Engine** | Accounting foundation. Always active. Handles double-entry bookkeeping, BAS-kontoplan, moms, SIE, financial statements. | Platform team only. Never modified by modules. | Huvudbok, verifikationer, momsredovisning, resultaträkning, balansräkning |
|
||||
| **Layer 2: Feature Modules** | Self-contained functionality packages. Activated per tenant. Hook into the core via events and APIs. | Platform team builds all modules. Users activate/deactivate. | Fakturering, kassaintegration, matkostnadsanalys, kvittoskanning |
|
||||
| **Layer 3: Business Type Config** | Metadata layer. Maps business types to recommended modules. Controls UI terminology and dashboard defaults. | Platform team maintains mappings. Users select their type. | Restaurant pack, Salon pack, Gym pack |
|
||||
|
||||
### 4.2 Multi-Tenancy Model
|
||||
|
||||
The platform is a single-codebase, multi-tenant SaaS application. All tenants share the same deployment, the same database server (Supabase PostgreSQL), and the same module code. Tenant isolation is achieved through a `tenant_id` column on every database table, enforced at both the application level and through PostgreSQL Row Level Security (RLS) policies — a natural fit since Supabase has first-class RLS support.
|
||||
|
||||
Each tenant has a configuration record that tracks which modules are active, which business type they've selected, and any module-specific settings. This configuration drives the entire user experience: which navigation items appear, which dashboard widgets are shown, which pages are accessible, and which API endpoints are available.
|
||||
|
||||
### 4.3 Module Lifecycle
|
||||
|
||||
Modules go through the following states:
|
||||
|
||||
- **Available:** The module exists in the catalog but is not active for this tenant. Its pages, widgets, and routes are hidden.
|
||||
- **Activating:** The user has chosen to activate the module. The platform runs the module's `onActivate` hook, which may seed default data and register event subscriptions.
|
||||
- **Active:** The module is fully operational. Its navigation items, pages, widgets, API routes, and event hooks are all live.
|
||||
- **Deactivating:** The user has chosen to deactivate. The platform runs `onDeactivate`, which hides UI elements and unregisters event hooks. Data is preserved.
|
||||
- **Inactive:** Same as Available but retains any data from when it was active. Reactivating restores the module with its previous data intact.
|
||||
|
||||
> **Design Decision — Data Preservation:** When a module is deactivated, its data is never deleted. This is critical for accounting integrity. If a user activated kassaintegration for 6 months and then deactivated it, those 6 months of dagskassa records must remain in the database and be accessible for auditing and reporting. Deactivation only hides the UI and stops event processing.
|
||||
|
||||
---
|
||||
|
||||
## 5. Module System Design
|
||||
|
||||
### 5.1 Module Contract
|
||||
|
||||
Every module is a feature folder in the codebase that exports a standardized definition. This contract is the foundation of the entire system. A module can register the following capabilities:
|
||||
|
||||
| Capability | Description | Example |
|
||||
|---|---|---|
|
||||
| **Database Schema** | New tables required by this module (defined in Supabase migrations) | `mod_kassa_dagskassa` table for daily cash register data |
|
||||
| **API Routes** | Next.js API route handlers under `app/api/modules/{moduleId}/` | `GET /api/modules/kassaintegration/dagskassa/:date` |
|
||||
| **Supabase Edge Functions** | Server-side logic for external integrations requiring secrets | Tink bank API calls, Stripe webhook processing |
|
||||
| **Event Hooks** | Subscriptions to core events or other module events | On `leverantorsfaktura.added`, check if food supplier and update food cost |
|
||||
| **Navigation Items** | Sidebar menu entries that appear when module is active | "Dagskassa" and "Z-rapporter" menu items |
|
||||
| **Pages** | Full page views accessible via Next.js routing | DagskassaPage showing daily sales breakdown |
|
||||
| **Dashboard Widgets** | Cards/charts that appear on the user's dashboard | Today's sales widget showing revenue vs yesterday |
|
||||
| **Settings Panel** | Module-specific configuration UI | Select POS system type, configure moms split defaults |
|
||||
| **Seed Data** | Default data created when module is activated | Default BAS-konto mappings for food cost categories |
|
||||
| **Scheduled Jobs** | Recurring background tasks (via Supabase pg_cron or Edge Functions + cron) | Nightly food cost percentage rollup calculation |
|
||||
| **Dependencies** | Other modules that must be active for this one to work | Matkostnadsanalys depends on varuinkop module |
|
||||
|
||||
### 5.2 Module Registration Pattern
|
||||
|
||||
Modules are statically imported and registered at build time. There is no dynamic plugin loading. This is a deliberate choice for a small team: it keeps the system simple, fully type-safe, and easy to debug. Every module is part of the same codebase, the same test suite, and the same deployment.
|
||||
|
||||
The module registry maintains a master list of all available modules. At runtime, it consults the tenant's configuration (stored in the `tenant_modules` table in Supabase) to determine which modules are active and should have their routes, hooks, and UI elements registered for that tenant's requests.
|
||||
|
||||
### 5.3 Module Isolation Rules
|
||||
|
||||
- **Modules may read from core tables:** Any module can query the general ledger, chart of accounts, invoices, and other core data via Supabase client or server-side queries.
|
||||
- **Modules must not write to core tables directly:** Instead, modules use core services (e.g., `verifikation.create()`) to interact with the accounting engine. This ensures all writes go through validation and double-entry enforcement.
|
||||
- **Modules own their own tables:** Each module prefixes its tables (e.g., `mod_kassa_`, `mod_food_cost_`) and has full read/write access to them. All module tables have RLS policies scoped to `tenant_id`.
|
||||
- **Modules communicate via events:** If module A needs to notify module B of something, it emits an event. Module B subscribes. There are no direct function calls between modules.
|
||||
- **Modules may extend core UI:** Modules can add widgets to the dashboard, items to the navigation, and columns to existing list views via extension points defined by the core.
|
||||
|
||||
### 5.4 Module Dependency Management
|
||||
|
||||
Some modules depend on other modules. For example, matkostnadsanalys (food cost analysis) requires varuinkop (purchasing) to be active because it analyzes purchasing data. When a user tries to activate a module with unmet dependencies, the platform clearly shows what else needs to be activated and offers to activate the dependencies together.
|
||||
|
||||
Circular dependencies are not allowed and are enforced at build time through static analysis of the module definitions.
|
||||
|
||||
---
|
||||
|
||||
## 6. Core Accounting Engine (Always Active)
|
||||
|
||||
### 6.1 Overview
|
||||
|
||||
The core accounting engine is the non-negotiable foundation of the platform. It is always active for every tenant and cannot be deactivated or modified by modules. It implements Swedish accounting standards as defined by BFN (Bokföringsnämnden), BFL (Bokföringslagen), and ÅRL (Årsredovisningslagen) for K2 companies.
|
||||
|
||||
### 6.2 Chart of Accounts (BAS-kontoplan)
|
||||
|
||||
The platform ships with the standard BAS-kontoplan pre-configured. On tenant creation, the full BAS 2024 chart of accounts is seeded into the tenant's account table. The platform supports all standard BAS account classes:
|
||||
|
||||
| Class | Range | Description | Examples |
|
||||
|---|---|---|---|
|
||||
| 1 | 1000–1999 | Tillgångar (Assets) | 1930 Bankkonto, 1910 Kassa, 1510 Kundfordringar |
|
||||
| 2 | 2000–2999 | Eget kapital & skulder | 2440 Leverantörsskulder, 2610 Utgående moms 25% |
|
||||
| 3 | 3000–3999 | Intäkter (Revenue) | 3001 Försäljning varor 25%, 3002 Försäljning varor 12% |
|
||||
| 4 | 4000–4999 | Varuinköp & direkta kostnader | 4010 Inköp varor, 4011 Inköp livsmedel |
|
||||
| 5–6 | 5000–6999 | Övriga externa kostnader | 5010 Lokalhyra, 6110 Kontorsförnödenheter |
|
||||
| 7 | 7000–7999 | Personalkostnader | 7010 Löner, 7510 Arbetsgivaravgifter |
|
||||
| 8 | 8000–8999 | Finansiella poster & bokslut | 8310 Ränteintäkter, 8999 Årets resultat |
|
||||
|
||||
Users can customize account names and add sub-accounts but cannot modify the fundamental structure. Modules can suggest additional accounts during their activation (e.g., the food cost module suggests adding 4011 Inköp livsmedel if not present).
|
||||
|
||||
### 6.3 Double-Entry Bookkeeping Engine
|
||||
|
||||
Every financial transaction in the system is recorded as a verifikation (journal entry) with one or more debit and credit rows that must balance to zero. The engine enforces the following invariants:
|
||||
|
||||
- **Balance requirement:** Sum of all debit amounts must equal sum of all credit amounts for every verifikation. Unbalanced entries are rejected.
|
||||
- **Immutability:** Posted verifikationer cannot be modified. Corrections are made by creating new correcting entries. This complies with BFL 5 kap. 5§.
|
||||
- **Sequential numbering:** Verifikationer are numbered sequentially within each räkenskapsår (fiscal year) with no gaps. This complies with BFL 5 kap. 6§.
|
||||
- **Audit trail:** Every verifikation records who created it, when, and from which source (manual entry, module, import, integration).
|
||||
- **Period control:** Entries can only be posted to open periods. Closed periods are locked and require explicit re-opening with audit logging.
|
||||
|
||||
### 6.4 Momsredovisning (VAT Reporting)
|
||||
|
||||
The platform handles Swedish moms (VAT) at all standard rates:
|
||||
|
||||
| Rate | Usage | BAS Account (Output) | BAS Account (Input) |
|
||||
|---|---|---|---|
|
||||
| 25% | Standard rate (most goods and services) | 2610 Utgående moms 25% | 2640 Ingående moms |
|
||||
| 12% | Food, restaurant services, hotel | 2620 Utgående moms 12% | 2640 Ingående moms |
|
||||
| 6% | Books, newspapers, culture, transport | 2630 Utgående moms 6% | 2640 Ingående moms |
|
||||
| 0% | Healthcare, education, financial services | N/A | N/A |
|
||||
|
||||
The system automatically determines the momsredovisningsperiod based on the company's annual turnover: monthly (omsättning > 40M SEK), quarterly (> 1M SEK), or annually. It generates the momsdeklaration (VAT return) data in the format required by Skatteverket.
|
||||
|
||||
### 6.5 Financial Statements
|
||||
|
||||
The core engine generates the following reports, always available to every tenant:
|
||||
|
||||
- **Resultaträkning (Income Statement):** Follows the K2 format with revenue, cost of goods sold, gross profit, operating expenses, operating result, financial items, and net result.
|
||||
- **Balansräkning (Balance Sheet):** Assets (tillgångar), equity and liabilities (eget kapital och skulder), following K2 structure.
|
||||
- **Huvudbok (General Ledger):** Complete listing of all transactions per account for any selected period.
|
||||
- **Verifikationslista (Journal Entry List):** Sequential list of all verifikationer with full details.
|
||||
- **Saldobalans (Trial Balance):** All accounts with their debit/credit balances for a selected period.
|
||||
- **Momsrapport (VAT Report):** Summary of output and input VAT for the declaration period.
|
||||
|
||||
### 6.6 SIE4 Import & Export
|
||||
|
||||
SIE (Standard Import Export) version 4 is the standard data exchange format for Swedish accounting. The platform supports full SIE4 import (for migrating from another system) and export (for sharing with accountants or switching systems). The import process validates data integrity, maps accounts to the BAS-kontoplan, and flags any discrepancies for manual review.
|
||||
|
||||
### 6.7 Räkenskapsår (Fiscal Year) Management
|
||||
|
||||
The core supports standard calendar-year fiscal years (January–December) and brutet räkenskapsår (non-calendar fiscal years). It handles årsbokslut (year-end closing) by transferring the result to retained earnings and opening balances for the new year. The first räkenskapsår may be shorter or longer than 12 months as permitted by ÅRL.
|
||||
|
||||
---
|
||||
|
||||
## 7. Module Catalog: Categories & Modules
|
||||
|
||||
### 7.1 Catalog Structure
|
||||
|
||||
The module catalog is organized into categories. Each category groups related functionality. Users browse the catalog to discover and activate modules. When a user selects their business type, relevant categories and modules are highlighted with recommendation badges.
|
||||
|
||||
### 7.2 Category: Fakturering & Betalning
|
||||
|
||||
Modules related to invoicing, payments, and accounts receivable.
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Kundfakturering** | Create, send, and manage customer invoices with Swedish compliance (Mervärdeskattelagen) | Core | Invoice creation, PDF generation, email sending, OCR-nummer, payment status tracking, kundreskontra |
|
||||
| **Autofakturering** | Recurring invoice automation for subscription-based services | Kundfakturering | Recurring schedules, automatic generation and sending, pause/resume |
|
||||
| **ROT/RUT-hantering** | Handle ROT and RUT tax deductions on invoices per Skatteverket rules | Kundfakturering | Automatic calculation of deduction amount, Skatteverket formatting, buyer personnummer handling |
|
||||
| **Påminnelser & Inkasso** | Automated payment reminders and debt collection integration | Kundfakturering | Configurable reminder schedules, late fees (dröjsmålsränta), inkasso handoff |
|
||||
|
||||
### 7.3 Category: Bank & Likviditet
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Bankintegration** | Auto-import bank transactions via Tink or Open Banking APIs | Core | Daily auto-import, multi-bank support, transaction categorization |
|
||||
| **Automatisk matchning** | AI-assisted matching of bank transactions to invoices and expenses | Bankintegration | Rule-based matching, learning from corrections, unmatched queue |
|
||||
| **Likviditetsprognos** | Cash flow forecasting based on receivables, payables, and recurring costs | Bankintegration, Kundfakturering | 30/60/90-day forecasts, scenario modeling, alert thresholds |
|
||||
|
||||
### 7.4 Category: Leverantörer & Inköp
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Leverantörsreskontra** | Manage supplier invoices, payment tracking, and accounts payable | Core | Invoice registration, due dates, payment scheduling, OCR import |
|
||||
| **Inköpsorder** | Create and track purchase orders to suppliers | Leverantörsreskontra | PO creation, supplier management, delivery tracking, PO-to-invoice matching |
|
||||
| **Kvittoskanning** | OCR-based receipt scanning that auto-creates bokföring entries | Core | Photo capture, OCR extraction, auto-kontosättning, manual review queue |
|
||||
|
||||
### 7.5 Category: Rapporter & Analys
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Budgetmodul** | Set budgets per account/category and track actual vs budget | Core | Annual budget setup, monthly breakdown, variance analysis, alerts |
|
||||
| **KPI-dashboard** | Customizable dashboard with business-relevant key metrics | Core | Configurable widgets, period comparison, export to PDF |
|
||||
| **Periodjämförelse** | Compare financial performance across months, quarters, or years | Core | Side-by-side comparison, trend visualization, anomaly highlighting |
|
||||
|
||||
### 7.6 Category: Personal & Lön
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Löneintegration** | Import salary data from external payroll systems (Hogia, Visma Lön, etc.) | Core | File import, auto-bokföring of payroll entries, arbetsgivaravgifter |
|
||||
| **Personalkostnadsöversikt** | Track and analyze total staff costs vs revenue | Löneintegration | Cost per employee, ratio to omsättning, trend analysis |
|
||||
| **Traktamente & Utlägg** | Employee expense and travel allowance management | Core | Expense submission, approval workflow, Skatteverket traktamente rates |
|
||||
| **Semesterskuldberäkning** | Calculate and track vacation pay liability per employee | Löneintegration | Automatic calculation per ÅL, liability tracking, period accruals |
|
||||
|
||||
### 7.7 Category: Restaurang & Mat (F&B Cluster)
|
||||
|
||||
These modules are recommended for businesses in the Food & Beverage industry cluster. This is the first industry-specific category to be built.
|
||||
|
||||
| Module | Description | Dependencies | Key Features |
|
||||
|---|---|---|---|
|
||||
| **Kassaintegration** | Import Z-rapporter from POS systems and auto-bokför daily sales | Core | CSV/file import, iZettle/Trivec support, automatic moms split (12%/25%), dagskassaavstämning |
|
||||
| **Matkostnadsanalys** | Track and analyze food cost as percentage of revenue | Inköpsorder | Daily/weekly/monthly food cost %, target vs actual, trend charts, alerts when over target |
|
||||
| **Svinnhantering** | Record and analyze food waste by category | Matkostnadsanalys | Waste logging, cost calculation, waste-to-revenue ratio, category breakdown |
|
||||
| **Tipshantering** | Track, allocate, and report tips received by staff | Core | Tip pool management, per-employee allocation, tax reporting, bokföring integration |
|
||||
| **Receptkalkyl** | Calculate cost per recipe/menu item based on ingredient prices | Inköpsorder | Recipe builder, cost per portion, margin calculation, price change impact analysis |
|
||||
| **Alkohol & Accis** | Handle alcohol excise tax (punktskatt) for breweries and bars | Core | Accis calculation, Tullverket reporting format, inventory tracking for excise goods |
|
||||
|
||||
### 7.8 Future Categories (Post-MVP)
|
||||
|
||||
The following categories are planned for future development and listed here to inform architectural decisions:
|
||||
|
||||
- **Salong & Skönhet:** Bokningsintegration, provisionsberäkning per anställd, produktförsäljning (retail vs tjänst moms split).
|
||||
- **Fitness & Hälsa:** Medlemshantering, autogiroförsäljning, drop-in vs abonnemang tracking.
|
||||
- **Bygg & Hantverkare:** ROT-avdragshantering, projektbudget vs utfall, ÄTA-hantering, underentreprenörsspårning.
|
||||
- **E-handel:** Shopify/WooCommerce-integration, automatbokföring av ordrar, lagervärdering, returhantering, multi-currency.
|
||||
- **Konsultbolag:** Projektredovisning, timregistrering, milstolpsfakturering, resekostnader.
|
||||
|
||||
---
|
||||
|
||||
## 8. Industry Packs & Business Type Recommendations
|
||||
|
||||
### 8.1 How Industry Packs Work
|
||||
|
||||
An industry pack is not a separate product or a pre-configured instance. It is a metadata layer that maps a business type to a set of recommended modules. When a user selects their business type during onboarding (or changes it later in settings), the platform uses this mapping to surface relevant modules in the catalog with recommendation badges.
|
||||
|
||||
The user is never restricted to their industry pack's recommendations. They can browse the entire catalog and activate any module. The business type selection simply provides a curated starting point.
|
||||
|
||||
### 8.2 F&B Cluster: Business Types and Recommended Modules
|
||||
|
||||
| Module | Restaurang | Café | Bageri | Bar/Pub | Bryggeri | Food Truck | Catering |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Kundfakturering | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓✓ |
|
||||
| Leverantörsreskontra | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Kassaintegration | ✓✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ | — |
|
||||
| Matkostnadsanalys | ✓✓ | ✓ | ✓✓ | — | ✓ | ✓ | ✓ |
|
||||
| Svinnhantering | ✓✓ | ✓ | ✓✓ | — | — | ✓ | ✓ |
|
||||
| Tipshantering | ✓✓ | ✓ | — | ✓✓ | — | — | — |
|
||||
| Receptkalkyl | ✓ | ✓ | ✓✓ | — | ✓✓ | ✓ | ✓ |
|
||||
| Alkohol & Accis | ✓ | — | — | ✓✓ | ✓✓ | — | ✓? |
|
||||
| Inköpsorder | ✓ | ✓ | ✓✓ | ✓ | ✓✓ | ✓ | ✓ |
|
||||
| Bankintegration | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Personalkostnadsöversikt | ✓ | ✓ | ✓ | ✓ | ✓ | — | ✓ |
|
||||
| KPI-dashboard | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
**Legend:** ✓✓ = Highly recommended (pre-checked during onboarding), ✓ = Recommended (shown prominently), — = Not typically relevant (available but not highlighted).
|
||||
|
||||
### 8.3 Onboarding Module Suggestion UX
|
||||
|
||||
After the user selects their business type, the platform presents a module suggestion screen organized into three tiers:
|
||||
|
||||
- **Included with your plan:** Core accounting modules that are always active (huvudbok, moms, resultaträkning, etc.)
|
||||
- **Recommended for your business:** Modules flagged as ✓✓ for their business type, pre-checked. The user can uncheck any they don't want.
|
||||
- **Also popular in your industry:** Modules flagged as ✓, shown but not pre-checked.
|
||||
|
||||
Below these, a link to "Browse all modules" opens the full catalog. This onboarding step is skippable and all choices are changeable later.
|
||||
|
||||
---
|
||||
|
||||
## 9. User Experience & Onboarding Flow
|
||||
|
||||
### 9.1 Onboarding Flow
|
||||
|
||||
The onboarding is designed to get the user to a working, personalized accounting platform in under 5 minutes:
|
||||
|
||||
1. **Create Account:** Email/password or BankID login via Supabase Auth. Enter company org.nr.
|
||||
2. **Company Setup:** Platform pulls company data from Bolagsverket via org.nr (company name, address, F-skatt status, momsregistrering, SNI-kod). User confirms or corrects.
|
||||
3. **Import or Start Fresh:** Option to import SIE4 file from existing system (Fortnox, Visma) or start with a clean räkenskapsår. Import includes validation and discrepancy flagging.
|
||||
4. **Select Business Type:** Visual grid of business types organized by industry cluster. SNI-kod from Bolagsverket can auto-suggest. User picks one (or skips for generic setup).
|
||||
5. **Module Suggestions:** Based on business type, platform suggests recommended modules (as described in section 8.3). User reviews and confirms.
|
||||
6. **Connect Bank:** Optional step to connect their bank account via Tink/Open Banking for automatic transaction import.
|
||||
7. **Dashboard:** User lands on their personalized dashboard with active module widgets, quick-start guide, and contextual help.
|
||||
|
||||
### 9.2 Main Application Layout
|
||||
|
||||
The application follows a standard SaaS layout with module-driven navigation:
|
||||
|
||||
- **Left sidebar:** Primary navigation. Shows core items (Dashboard, Bokföring, Rapporter) at the top, followed by active module navigation items grouped by category. Inactive modules are not shown.
|
||||
- **Top bar:** Company name, räkenskapsår selector, notification bell, user menu, and a search bar that searches across all active modules.
|
||||
- **Main content area:** Renders the active page from either core or an active module via Next.js App Router.
|
||||
- **Dashboard:** The home page. Shows widgets from active modules arranged in a grid. Users can rearrange widgets but the initial layout is determined by the business type configuration.
|
||||
|
||||
### 9.3 Module Catalog UI
|
||||
|
||||
Accessible from settings, the module catalog is where users discover and manage modules:
|
||||
|
||||
- **Category browsing:** Modules organized by category with counts showing how many are active vs available.
|
||||
- **Recommendation badges:** Modules recommended for the user's business type are badged with "Rekommenderas för [business type]."
|
||||
- **Module detail page:** Each module has a detail page showing description, screenshots, what it adds (pages, widgets, reports), dependencies, and an activate/deactivate button.
|
||||
- **Active modules view:** A filtered view showing only currently active modules with quick deactivation options.
|
||||
|
||||
### 9.4 Redovisningskonsult (Accountant) Access
|
||||
|
||||
The platform supports a multi-user model where a business owner can invite their bokföringsbyrå (accounting firm) to access their account. The accountant gets a separate role with access to all accounting data and reports, the ability to create and edit verifikationer, perform årsbokslut, and generate SIE exports. The accountant may have access to multiple client tenants and can switch between them from a single login.
|
||||
|
||||
---
|
||||
|
||||
## 10. Technical Architecture
|
||||
|
||||
### 10.1 Tech Stack
|
||||
|
||||
| Component | Technology | Rationale |
|
||||
|---|---|---|
|
||||
| **Language** | TypeScript (everywhere) | Team's strongest language. Shared types between frontend and backend. Single language reduces context switching. |
|
||||
| **Framework** | Next.js (App Router) | Full-stack React framework. API routes, Server Components, Server Actions, SSR/SSG — all in one. One deploy, one mental model. Eliminates need for separate backend. |
|
||||
| **Styling** | Tailwind CSS + shadcn/ui | Utility-first for rapid development. shadcn/ui provides professional, accessible components out of the box. |
|
||||
| **Database** | PostgreSQL (via Supabase) | Rock-solid, ACID-compliant, perfect for financial data. Supabase provides managed Postgres with built-in RLS, auth, storage, edge functions, and real-time. |
|
||||
| **Backend-as-a-Service** | Supabase | Handles auth, database, storage, edge functions, real-time subscriptions, and cron jobs (pg_cron). Reduces infrastructure work dramatically for a small team. |
|
||||
| **ORM / Query** | Supabase JS Client + Drizzle ORM | Supabase client for standard CRUD and RLS-enforced queries. Drizzle for complex queries, migrations, and type-safe schema definitions. |
|
||||
| **Authentication** | Supabase Auth | Built-in email/password, magic link, OAuth providers, and potential for BankID via custom provider. Row-level security integrates directly with auth. |
|
||||
| **File Storage** | Supabase Storage | Receipt images, invoice PDFs, SIE file uploads. Scoped per tenant with storage policies. |
|
||||
| **Edge Functions** | Supabase Edge Functions (Deno) | Server-side logic requiring secrets: bank API calls (Tink), payment webhooks (Stripe), OCR processing, Skatteverket integration. |
|
||||
| **Scheduled Jobs** | Supabase pg_cron + Edge Functions | Nightly food cost rollups, bank transaction sync, overdue invoice checks. pg_cron triggers Edge Functions on schedule. |
|
||||
| **Hosting** | Vercel (frontend) + Supabase (backend) | Vercel for Next.js with EU region deployment. Supabase for all backend services with EU region (Frankfurt or Stockholm). Both support EU data residency. |
|
||||
| **Testing** | Vitest + Playwright | Vitest for unit/integration (fast, TypeScript-native). Playwright for E2E. |
|
||||
| **CI/CD** | GitHub Actions | Standard, integrates with Vercel deployments and Supabase migrations. |
|
||||
|
||||
### 10.2 Project Structure
|
||||
|
||||
The entire application lives in a single Next.js project:
|
||||
|
||||
```
|
||||
├── app/ ← Next.js App Router
|
||||
│ ├── (auth)/ ← Auth pages (login, signup, onboarding)
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ ├── signup/page.tsx
|
||||
│ │ └── onboarding/
|
||||
│ │ ├── company/page.tsx
|
||||
│ │ ├── business-type/page.tsx
|
||||
│ │ └── modules/page.tsx
|
||||
│ │
|
||||
│ ├── (app)/ ← Authenticated app shell
|
||||
│ │ ├── layout.tsx ← Sidebar + topbar (reads active modules)
|
||||
│ │ ├── dashboard/page.tsx ← Dynamic dashboard with module widgets
|
||||
│ │ │
|
||||
│ │ ├── bokforing/ ← Core accounting pages
|
||||
│ │ │ ├── huvudbok/page.tsx
|
||||
│ │ │ ├── verifikationer/page.tsx
|
||||
│ │ │ ├── kontoplan/page.tsx
|
||||
│ │ │ └── perioder/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── rapporter/ ← Core report pages
|
||||
│ │ │ ├── resultatrakning/page.tsx
|
||||
│ │ │ ├── balansrakning/page.tsx
|
||||
│ │ │ ├── momsrapport/page.tsx
|
||||
│ │ │ └── saldobalans/page.tsx
|
||||
│ │ │
|
||||
│ │ ├── moduler/ ← Module catalog
|
||||
│ │ │ ├── page.tsx ← Browse all modules
|
||||
│ │ │ └── [moduleId]/page.tsx ← Module detail page
|
||||
│ │ │
|
||||
│ │ ├── installningar/ ← Settings
|
||||
│ │ │ ├── foretag/page.tsx
|
||||
│ │ │ ├── anvandare/page.tsx
|
||||
│ │ │ └── moduler/page.tsx
|
||||
│ │ │
|
||||
│ │ └── m/ ← Module pages (namespaced)
|
||||
│ │ ├── fakturering/
|
||||
│ │ │ ├── page.tsx ← Invoice list
|
||||
│ │ │ └── ny/page.tsx ← New invoice
|
||||
│ │ ├── kassaintegration/
|
||||
│ │ │ ├── dagskassa/page.tsx
|
||||
│ │ │ └── z-rapporter/page.tsx
|
||||
│ │ ├── matkostnadsanalys/
|
||||
│ │ │ └── page.tsx
|
||||
│ │ └── ... more module pages
|
||||
│ │
|
||||
│ └── api/ ← API routes
|
||||
│ ├── core/ ← Core accounting API
|
||||
│ │ ├── verifikationer/route.ts
|
||||
│ │ ├── konton/route.ts
|
||||
│ │ └── rapporter/route.ts
|
||||
│ ├── modules/ ← Module API routes
|
||||
│ │ ├── fakturering/route.ts
|
||||
│ │ ├── kassaintegration/route.ts
|
||||
│ │ └── ... more module routes
|
||||
│ └── webhooks/ ← External webhooks
|
||||
│ ├── tink/route.ts
|
||||
│ └── stripe/route.ts
|
||||
│
|
||||
├── modules/ ← Module definitions & business logic
|
||||
│ ├── _registry.ts ← Master module registry
|
||||
│ ├── _types.ts ← Module contract types
|
||||
│ │
|
||||
│ ├── core-bokforing/ ← Core accounting (always active)
|
||||
│ │ ├── module.ts ← Module definition
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── huvudbok.service.ts
|
||||
│ │ │ ├── verifikation.service.ts
|
||||
│ │ │ ├── moms.service.ts
|
||||
│ │ │ └── sie.service.ts
|
||||
│ │ ├── validators/
|
||||
│ │ │ └── verifikation.validator.ts
|
||||
│ │ └── events.ts ← Core events emitted
|
||||
│ │
|
||||
│ ├── fakturering/
|
||||
│ │ ├── module.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── faktura.service.ts
|
||||
│ │ │ └── paminnelse.service.ts
|
||||
│ │ ├── components/ ← Module-specific UI components
|
||||
│ │ │ ├── FakturaForm.tsx
|
||||
│ │ │ └── FakturaPreview.tsx
|
||||
│ │ ├── widgets/
|
||||
│ │ │ └── ObetaladaFakturorWidget.tsx
|
||||
│ │ └── hooks.ts ← Event subscriptions
|
||||
│ │
|
||||
│ ├── kassaintegration/
|
||||
│ │ ├── module.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── zrapport.parser.ts
|
||||
│ │ │ └── dagskassa.service.ts
|
||||
│ │ ├── components/
|
||||
│ │ │ └── ZRapportImport.tsx
|
||||
│ │ ├── widgets/
|
||||
│ │ │ └── DagensForsaljningWidget.tsx
|
||||
│ │ └── hooks.ts
|
||||
│ │
|
||||
│ ├── matkostnadsanalys/
|
||||
│ │ ├── module.ts
|
||||
│ │ ├── services/
|
||||
│ │ │ └── food-cost.service.ts
|
||||
│ │ ├── components/
|
||||
│ │ │ └── FoodCostChart.tsx
|
||||
│ │ ├── widgets/
|
||||
│ │ │ └── MatkostnadWidget.tsx
|
||||
│ │ └── hooks.ts
|
||||
│ │
|
||||
│ └── ... more modules
|
||||
│
|
||||
├── lib/ ← Shared utilities
|
||||
│ ├── supabase/
|
||||
│ │ ├── client.ts ← Browser Supabase client
|
||||
│ │ ├── server.ts ← Server-side Supabase client
|
||||
│ │ └── admin.ts ← Service role client (for migrations/admin)
|
||||
│ ├── events/
|
||||
│ │ ├── emitter.ts ← Typed event emitter
|
||||
│ │ └── types.ts ← Event type definitions
|
||||
│ ├── tenant/
|
||||
│ │ ├── context.ts ← Tenant context provider
|
||||
│ │ └── middleware.ts ← Tenant resolution middleware
|
||||
│ └── modules/
|
||||
│ ├── guard.ts ← Module activation check middleware
|
||||
│ └── loader.ts ← Active module loader for UI
|
||||
│
|
||||
├── components/ ← Shared UI components
|
||||
│ ├── ui/ ← shadcn/ui components
|
||||
│ ├── layout/
|
||||
│ │ ├── Sidebar.tsx ← Module-aware sidebar
|
||||
│ │ ├── Topbar.tsx
|
||||
│ │ └── DashboardGrid.tsx ← Widget grid renderer
|
||||
│ └── shared/
|
||||
│ ├── DataTable.tsx
|
||||
│ ├── KontoSelector.tsx
|
||||
│ └── PeriodPicker.tsx
|
||||
│
|
||||
├── supabase/ ← Supabase configuration
|
||||
│ ├── migrations/ ← Database migrations (Drizzle-generated)
|
||||
│ │ ├── 0001_core_tables.sql
|
||||
│ │ ├── 0002_bas_kontoplan_seed.sql
|
||||
│ │ ├── 0003_mod_fakturering.sql
|
||||
│ │ ├── 0004_mod_kassaintegration.sql
|
||||
│ │ └── ...
|
||||
│ ├── functions/ ← Supabase Edge Functions
|
||||
│ │ ├── tink-sync/index.ts
|
||||
│ │ ├── ocr-receipt/index.ts
|
||||
│ │ └── nightly-rollup/index.ts
|
||||
│ └── seed.sql ← Development seed data
|
||||
│
|
||||
├── types/ ← Global TypeScript types
|
||||
│ ├── database.ts ← Generated Supabase types
|
||||
│ ├── modules.ts ← Module system types
|
||||
│ └── accounting.ts ← Accounting domain types
|
||||
│
|
||||
├── middleware.ts ← Next.js middleware (auth + tenant resolution)
|
||||
├── next.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── tsconfig.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 10.3 Backend Architecture
|
||||
|
||||
The backend uses a hybrid approach leveraging both Next.js and Supabase:
|
||||
|
||||
- **Next.js API Routes** handle application logic: module activation/deactivation, complex business operations, report generation, SIE import/export, and any operation that requires orchestrating multiple database queries or service calls.
|
||||
- **Next.js Server Actions** handle form mutations: creating verifikationer, sending invoices, updating settings — anywhere a form submits data. This eliminates boilerplate API route code for simple CRUD operations.
|
||||
- **Supabase Direct Access** from the client handles simple reads: fetching lists, loading dashboard data, real-time subscriptions for collaborative editing. RLS policies ensure tenant isolation even for direct client queries.
|
||||
- **Supabase Edge Functions** handle external integrations: Tink bank API calls, OCR processing, Stripe webhooks — anything requiring secrets that shouldn't be in the Next.js bundle or that benefits from running close to the data.
|
||||
|
||||
Request flow for a module operation: incoming request hits Next.js middleware (auth check, tenant resolution) → API route or Server Action checks module activation status → calls module service → service uses Supabase client for database operations → core events are emitted → subscribed module hooks process side effects.
|
||||
|
||||
### 10.4 Frontend Architecture
|
||||
|
||||
The frontend uses Next.js App Router with Server Components as the default. Module pages are regular Next.js pages under the `/m/` namespace. The app shell (`layout.tsx`) fetches the tenant's active modules and dynamically renders the sidebar navigation and dashboard widgets.
|
||||
|
||||
Module UI code is organized within each module's feature folder (`modules/{name}/components/` and `modules/{name}/widgets/`). Module pages in `app/(app)/m/` import from these folders. This keeps the routing in Next.js's domain while the business logic and UI components live with the module definition.
|
||||
|
||||
A module guard middleware wraps all `/m/{moduleId}/` routes and `/api/modules/{moduleId}/` routes, checking the tenant's active modules before allowing access. If the module is not active, the user sees a "Module not activated" page with a link to the catalog.
|
||||
|
||||
---
|
||||
|
||||
## 11. Database Design
|
||||
|
||||
### 11.1 Multi-Tenancy Strategy
|
||||
|
||||
The platform uses a shared-database, shared-schema multi-tenancy model with row-level tenant isolation. Every table includes a `tenant_id` column. Supabase's built-in Row Level Security (RLS) is the primary isolation mechanism:
|
||||
|
||||
```sql
|
||||
-- Example RLS policy (applied to every table)
|
||||
CREATE POLICY "Tenant isolation" ON verifikationer
|
||||
USING (tenant_id = (SELECT current_setting('app.current_tenant_id')::uuid))
|
||||
WITH CHECK (tenant_id = (SELECT current_setting('app.current_tenant_id')::uuid));
|
||||
```
|
||||
|
||||
The tenant ID is set in the Supabase session context via the JWT claims from Supabase Auth, ensuring that every query — whether from the Next.js server, the client, or an Edge Function — is automatically scoped to the correct tenant.
|
||||
|
||||
### 11.2 Core Tables
|
||||
|
||||
| Table | Purpose | Key Columns |
|
||||
|---|---|---|
|
||||
| `tenants` | Company/organization records | id, org_nr, name, address, f_skatt, moms_registered, moms_period, fiscal_year_start, business_type |
|
||||
| `users` | User accounts with tenant membership | id (Supabase Auth UID), tenant_id, email, name, role (owner, admin, staff, accountant) |
|
||||
| `accounts` | Chart of accounts (BAS-kontoplan) | tenant_id, account_number, name, type, is_active, parent_account |
|
||||
| `fiscal_years` | Räkenskapsår definitions | tenant_id, start_date, end_date, status (open, closed), opening_balances_posted |
|
||||
| `verifikationer` | Journal entries (the core of everything) | tenant_id, nummer, date, description, fiscal_year_id, created_by, source_module, is_posted |
|
||||
| `verifikation_rows` | Individual debit/credit lines | verifikation_id, account_number, debit, credit, moms_code, description |
|
||||
| `tenant_modules` | Which modules are active per tenant | tenant_id, module_id, activated_at, deactivated_at, status, config (JSONB) |
|
||||
| `business_types` | Reference table of business types | id, name, cluster, icon, sni_codes[], recommended_modules[] |
|
||||
|
||||
### 11.3 Module Table Convention
|
||||
|
||||
Every module that needs database storage follows the naming convention `mod_{module_short_name}_{table_name}`. For example, the kassaintegration module uses `mod_kassa_dagskassa` and `mod_kassa_z_rapporter`. All module tables include `tenant_id` and have RLS policies identical to core tables.
|
||||
|
||||
Module tables are created via Supabase migrations stored in `supabase/migrations/`. The tables exist for all tenants (they're part of the shared schema), but only contain data for tenants that have activated the module. When a module is activated for a tenant, the `onActivate` hook may seed tenant-specific default data.
|
||||
|
||||
### 11.4 Configuration Storage
|
||||
|
||||
Module-specific configuration is stored as JSONB in the `tenant_modules` table's `config` column. This allows each module to store arbitrary settings (e.g., the kassaintegration module stores the POS system type and default moms split rules) without requiring schema changes. The JSONB schema is validated at the application level using Zod schemas defined in each module's definition.
|
||||
|
||||
---
|
||||
|
||||
## 12. Event System
|
||||
|
||||
### 12.1 Purpose
|
||||
|
||||
The event system is the primary mechanism for inter-module communication and the way modules react to changes in the core accounting engine. It ensures loose coupling: the core and individual modules never call each other's functions directly. Instead, they emit typed events that any module can subscribe to.
|
||||
|
||||
### 12.2 Core Events
|
||||
|
||||
| Event | Emitted When | Payload |
|
||||
|---|---|---|
|
||||
| `verifikation.created` | A new journal entry is posted | tenantId, verifikationId, date, rows[], sourceModule |
|
||||
| `verifikation.reversed` | A correction entry reverses a previous one | tenantId, originalId, reversalId |
|
||||
| `faktura.created` | A new customer invoice is created | tenantId, fakturaId, customerId, totalAmount, momsAmount |
|
||||
| `faktura.sent` | An invoice is sent to the customer | tenantId, fakturaId, sentVia (email/post) |
|
||||
| `faktura.paid` | An invoice is marked as paid | tenantId, fakturaId, paymentDate, paymentMethod |
|
||||
| `faktura.overdue` | An invoice passes its due date unpaid | tenantId, fakturaId, dueDate, daysPastDue |
|
||||
| `leverantorsfaktura.added` | A new supplier invoice is registered | tenantId, fakturaId, supplierId, totalAmount, momsAmount |
|
||||
| `leverantorsfaktura.paid` | A supplier invoice is paid | tenantId, fakturaId, paymentDate |
|
||||
| `bank_transaction.imported` | A new bank transaction arrives | tenantId, transactionId, amount, date, description, bankAccount |
|
||||
| `bank_transaction.matched` | A bank transaction is matched to a record | tenantId, transactionId, matchedType, matchedId |
|
||||
| `period.closed` | A month/quarter is closed | tenantId, periodStart, periodEnd |
|
||||
| `period.reopened` | A previously closed period is reopened | tenantId, periodStart, periodEnd, reopenedBy |
|
||||
| `fiscal_year.closed` | A fiscal year is closed (årsbokslut) | tenantId, fiscalYearId |
|
||||
|
||||
### 12.3 Implementation
|
||||
|
||||
For the Next.js + Supabase architecture, the event system has two layers:
|
||||
|
||||
**In-request events:** A simple typed event emitter (same pattern as before) handles synchronous events within a single API route or Server Action. When a verifikation is created, the emitter fires `verifikation.created` and all subscribed module hooks run within the same request. This covers the majority of use cases.
|
||||
|
||||
**Async/cross-request events:** For events that need to trigger background processing (e.g., nightly rollups, or events that should fire even if no user is currently making a request), Supabase provides two mechanisms:
|
||||
|
||||
- **Database triggers + pg_notify:** A PostgreSQL trigger fires on INSERT to `verifikationer`, sending a notification that a Supabase Edge Function can pick up.
|
||||
- **Supabase Realtime:** For real-time UI updates when another user or background process makes changes (e.g., accountant creates a verifikation, business owner's dashboard updates live).
|
||||
|
||||
If a module's event handler fails, the failure is logged but does not roll back the core operation that emitted the event. Module handlers are expected to be idempotent and to handle their own error recovery.
|
||||
|
||||
---
|
||||
|
||||
## 13. Security & Compliance
|
||||
|
||||
### 13.1 Data Residency
|
||||
|
||||
All data is stored within the EU. Supabase supports EU region deployment (Frankfurt). The Next.js frontend is deployed to Vercel's EU edge. This is a non-negotiable requirement for Swedish financial data. Both services must guarantee EU data residency and GDPR compliance.
|
||||
|
||||
### 13.2 Authentication & Authorization
|
||||
|
||||
Authentication is handled by Supabase Auth with support for email/password, magic links, and Google OAuth. BankID integration is a future consideration via a custom Supabase Auth provider.
|
||||
|
||||
Authorization follows a role-based model:
|
||||
|
||||
- **Owner:** Full access including billing and tenant settings.
|
||||
- **Admin:** Full access except billing.
|
||||
- **Staff:** Access to active modules, no admin functions.
|
||||
- **Accountant:** Read access to all financial data, write access to verifikationer and reports, access to SIE export and årsbokslut.
|
||||
|
||||
Module-level authorization is enforced by middleware that checks whether the requested module is active for the tenant before processing the request. RLS policies in Supabase provide defense-in-depth at the database level.
|
||||
|
||||
### 13.3 Financial Data Integrity
|
||||
|
||||
- **Immutable verifikationer:** Once posted, journal entries cannot be modified. This is enforced at the database level with a PostgreSQL trigger that prevents UPDATE on posted verifikationer.
|
||||
- **Audit logging:** All state changes to financial data are logged with timestamp, user, and before/after values. Supabase's `auth.uid()` function automatically captures the acting user.
|
||||
- **Backup strategy:** Supabase provides automated daily backups with point-in-time recovery (PITR) on Pro plan. Minimum 7-day retention (30-day on Team plan).
|
||||
- **Encryption:** Data encrypted at rest (Supabase uses AES-256) and in transit (TLS 1.3). Sensitive fields (personnummer, bank account numbers) are additionally encrypted at the application level before storage.
|
||||
|
||||
---
|
||||
|
||||
## 14. Integration Strategy
|
||||
|
||||
### 14.1 Banking
|
||||
|
||||
Bank transaction import via Tink (now part of Visa) or similar Open Banking aggregator. Tink supports all major Swedish banks (Swedbank, SEB, Nordea, Handelsbanken, Danske Bank, Länsförsäkringar). Implemented as the Bankintegration module, with the Tink API calls running in a Supabase Edge Function (to keep API secrets secure).
|
||||
|
||||
### 14.2 POS Systems
|
||||
|
||||
The kassaintegration module supports importing daily sales data from point-of-sale systems. Initial support focuses on file-based import (CSV, Z-rapport format) for maximum compatibility. Future versions will add direct API integrations with major Swedish POS providers: Zettle (iZettle), Trivec, Lightspeed, and Square.
|
||||
|
||||
### 14.3 Payroll
|
||||
|
||||
The löneintegration module imports salary data from external payroll systems. Initial support via SIE-L (the payroll subset of SIE) and CSV import. Future API integrations with Hogia Lön, Visma Lön, and Fortnox Lön.
|
||||
|
||||
### 14.4 Skatteverket
|
||||
|
||||
Electronic filing integration with Skatteverket for momsdeklaration and arbetsgivardeklaration. This is a high-value feature that requires certification. Planned for post-MVP when the core is stable.
|
||||
|
||||
### 14.5 Third-Party API Strategy
|
||||
|
||||
All external integrations follow a consistent adapter pattern within their respective modules. Each integration defines an adapter interface, and specific providers implement that interface. This allows swapping providers (e.g., switching from Tink to another bank aggregator) without modifying the module's business logic. External API calls always run in Supabase Edge Functions to keep secrets out of the Next.js client bundle.
|
||||
|
||||
---
|
||||
|
||||
## 15. Development Roadmap
|
||||
|
||||
### 15.1 Phase 1: Foundation (Weeks 1–5)
|
||||
|
||||
Build the core infrastructure and accounting engine. No modules beyond the always-active core.
|
||||
|
||||
- **Week 1–2:** Next.js project setup with App Router, Supabase project creation (EU region), Supabase Auth integration, multi-tenancy with RLS policies on all tables, basic module registry with one dummy module to validate the pattern, event system implementation, Tailwind + shadcn/ui component library setup.
|
||||
- **Week 3–4:** Core accounting engine: BAS-kontoplan seeding on tenant creation, verifikation creation with double-entry enforcement, huvudbok, saldobalans, period management. All core pages built with Server Components.
|
||||
- **Week 5:** Financial statements: resultaträkning and balansräkning generation, momsrapport, SIE4 export.
|
||||
|
||||
> **Milestone 1:** At the end of Phase 1, a user can sign up, get a working bokföringssystem with BAS-kontoplan, create verifikationer, view huvudbok, generate resultaträkning, balansräkning, momsrapport, and export SIE4. No industry-specific features yet, but the core is rock-solid.
|
||||
|
||||
### 15.2 Phase 2: First Modules (Weeks 6–10)
|
||||
|
||||
Build the first set of universally useful modules.
|
||||
|
||||
- **Week 6–7:** Kundfakturering module: invoice creation, PDF generation (via Supabase Edge Function or server-side), email sending, kundreskontra, payment tracking.
|
||||
- **Week 8:** SIE4 import (migration from other systems), leverantörsreskontra module.
|
||||
- **Week 9–10:** Bankintegration module (via Tink Edge Function): auto-import, transaction list, basic matching UI. Module catalog UI where users can browse and activate modules.
|
||||
|
||||
> **Milestone 2:** At the end of Phase 2, the platform is a functional Fortnox alternative. Users can do daily bokföring, send invoices, track supplier bills, import bank transactions, and migrate from their old system via SIE4. The module system is live.
|
||||
|
||||
### 15.3 Phase 3: F&B Cluster (Weeks 11–16)
|
||||
|
||||
Build the first industry-specific modules and the business type selection/recommendation system.
|
||||
|
||||
- **Week 11–12:** Kassaintegration module (Z-rapport import, dagskassa, auto-bokföring). Business type selection UI in onboarding.
|
||||
- **Week 13–14:** Matkostnadsanalys and svinnhantering modules.
|
||||
- **Week 15–16:** Tipshantering, receptkalkyl modules. KPI-dashboard module with customizable widgets. Industry pack recommendation engine.
|
||||
|
||||
> **Milestone 3:** At the end of Phase 3, a restaurant owner can sign up, select "Restaurang," get recommended modules, activate them, and have a daily workflow that includes importing Z-rapporter, tracking food costs, managing tips, and seeing industry-specific KPIs. This is the differentiation point from Fortnox.
|
||||
|
||||
### 15.4 Phase 4: Growth & Polish (Weeks 17–24)
|
||||
|
||||
- **Weeks 17–18:** Kvittoskanning module (OCR via Supabase Edge Function), automatisk matchning (AI-assisted bank matching).
|
||||
- **Weeks 19–20:** Budgetmodul, periodjämförelse, advanced reporting.
|
||||
- **Weeks 21–22:** Accountant access (multi-client view), redovisningskonsult portal.
|
||||
- **Weeks 23–24:** Performance optimization, security audit, onboarding polish, preparation for public launch.
|
||||
|
||||
---
|
||||
|
||||
## 16. Business Model & Pricing
|
||||
|
||||
### 16.1 Pricing Strategy
|
||||
|
||||
The pricing model follows a base-plus-modules approach:
|
||||
|
||||
| Tier | Price (SEK/month) | Includes | Target |
|
||||
|---|---|---|---|
|
||||
| **Starter** | 199 | Core accounting, SIE export, 1 user, up to 3 modules | Solo founders, very small AB |
|
||||
| **Business** | 499 | Core accounting, SIE, invoicing, bank integration, 5 users, up to 10 modules | Small businesses, 5–15 employees |
|
||||
| **Professional** | 999 | Everything in Business, unlimited modules, unlimited users, accountant access, priority support | Growing businesses, 15–50 employees, businesses with accountant |
|
||||
| **Enterprise** | Custom | Custom pricing, SSO, dedicated support, SLA, custom integrations | Larger organizations, bokföringsbyråer managing many clients |
|
||||
|
||||
### 16.2 Revenue Expansion
|
||||
|
||||
The module system creates natural upsell paths. As users grow and need more functionality, they activate more modules, which may push them to higher tiers. Industry-specific modules provide clear value that justifies the Business or Professional tier. Accountant access is gated to the Professional tier, creating incentive for businesses whose bokföringsbyrå recommends the platform.
|
||||
|
||||
### 16.3 Cost Structure
|
||||
|
||||
Supabase pricing is usage-based (database size, edge function invocations, auth users, storage). The Pro plan ($25/month per project) covers most needs for early growth. Vercel Pro ($20/month per team member) handles the frontend. Total infrastructure cost at launch is estimated at $50–100/month, scaling with usage.
|
||||
|
||||
---
|
||||
|
||||
## 17. Success Metrics & KPIs
|
||||
|
||||
### 17.1 Product Metrics
|
||||
|
||||
- **Activation rate:** Percentage of signups that complete onboarding and create at least one verifikation within 7 days. Target: > 40%.
|
||||
- **Module adoption:** Average number of active modules per tenant. Target: > 4 within first month.
|
||||
- **Daily active usage:** Percentage of paying tenants that log in at least once per business day. Target: > 60%.
|
||||
- **Churn rate:** Monthly churn of paying tenants. Target: < 3%.
|
||||
- **Net Promoter Score:** Measured quarterly. Target: > 50 (Fortnox averages ~30).
|
||||
|
||||
### 17.2 Technical Metrics
|
||||
|
||||
- **Page load time:** Largest Contentful Paint < 1.5s (leveraging Next.js Server Components and edge caching).
|
||||
- **API response time:** p95 < 200ms for all read operations, < 500ms for writes.
|
||||
- **Uptime:** 99.9% availability (< 8.7 hours downtime per year). Critical for accounting software.
|
||||
- **Module activation time:** < 2 seconds from click to fully active module with UI visible.
|
||||
- **SIE4 import speed:** < 30 seconds for a typical 3-year import with 10,000+ verifikationer.
|
||||
|
||||
---
|
||||
|
||||
## 18. Risks & Mitigations
|
||||
|
||||
| Risk | Impact | Likelihood | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Accounting engine bugs causing incorrect financial data | Critical | Medium | Extensive test suite with known-good BAS scenarios. Double-entry balance check on every write. Automated reconciliation tests. External auditor review before launch. |
|
||||
| Module activation/deactivation causing data inconsistency | High | Medium | Modules never delete core data. Activation/deactivation wrapped in transactions. Comprehensive integration tests for module lifecycle. |
|
||||
| Supabase service outage affecting all tenants | High | Low | Supabase has 99.9% SLA on Pro. Implement graceful degradation for non-critical features. Daily backups with PITR. Document manual failover procedures. |
|
||||
| Tink/bank integration downtime | Medium | Medium | Graceful degradation: manual import always available. Queue failed syncs for retry. Clear user communication of sync status. |
|
||||
| Swedish accounting regulation changes (BAS update, moms rate change) | Medium | Low (annual) | Abstract regulatory values into configuration. BAS-kontoplan versioning. Regression tests for regulation-dependent calculations. |
|
||||
| Difficulty competing with Fortnox's ecosystem and integrations | High | High | Focus on differentiation (industry-specific experience) rather than matching Fortnox feature-for-feature. Target underserved segments first (restaurants, salons). |
|
||||
| Supabase vendor lock-in | Medium | Low | Use Drizzle ORM for schema/migrations (portable). Core business logic in Next.js, not Edge Functions. Supabase is open source — self-hosting is a fallback option. |
|
||||
| Performance degradation as data grows | Medium | Medium | Proper indexing (tenant_id + date on all queries). Supabase connection pooling (Supavisor). Read replicas for heavy reporting. Load testing at 10x expected volume. |
|
||||
|
||||
---
|
||||
|
||||
## 19. Appendices
|
||||
|
||||
### 19.1 Appendix A: Swedish Accounting Standards Reference
|
||||
|
||||
- **BFL (Bokföringslagen 1999:1078):** The Swedish Bookkeeping Act. Defines who must keep books, requirements for verifikationer, arkivering (7 years), and årsbokslut obligations.
|
||||
- **BFN (Bokföringsnämnden):** The Swedish Accounting Standards Board. Issues BFNAR (Bokföringsnämndens allmänna råd) including the K2 and K3 frameworks.
|
||||
- **ÅRL (Årsredovisningslagen 1995:1554):** The Annual Reports Act. Defines the format and content of annual financial statements.
|
||||
- **K2 (BFNAR 2016:10):** Simplified annual report rules for smaller companies. Most AB in our target segment use K2. Defines fixed formats for resultaträkning and balansräkning.
|
||||
- **BAS-kontoplanen:** The standard chart of accounts used by virtually all Swedish companies. Maintained by BAS-kontogruppen. Updated annually.
|
||||
- **SIE (Standard Import Export):** The standard file format for exchanging accounting data between Swedish accounting systems. Version 4 (SIE4) includes full transaction-level data.
|
||||
- **Mervärdeskattelagen (ML):** The Swedish VAT Act. Defines moms rates, reporting periods, and compliance requirements.
|
||||
|
||||
### 19.2 Appendix B: Glossary
|
||||
|
||||
| Swedish Term | English Translation | Description |
|
||||
|---|---|---|
|
||||
| Bokföring | Bookkeeping | The recording of financial transactions |
|
||||
| Verifikation | Journal entry / voucher | A record of a financial transaction with debit and credit rows |
|
||||
| Huvudbok | General ledger | Complete record of all transactions organized by account |
|
||||
| Kontoplan | Chart of accounts | The structured list of accounts used for bookkeeping |
|
||||
| Räkenskapsår | Fiscal year | The 12-month period for financial reporting |
|
||||
| Resultaträkning | Income statement | Report showing revenue, costs, and profit/loss |
|
||||
| Balansräkning | Balance sheet | Report showing assets, equity, and liabilities |
|
||||
| Momsredovisning | VAT reporting | Periodic reporting of output and input VAT to Skatteverket |
|
||||
| Redovisningskonsult | Accounting consultant | External accountant managing a company's books |
|
||||
| Bokföringsbyrå | Accounting firm | Firm providing bookkeeping and accounting services |
|
||||
| Årsbokslut | Year-end closing | The process of closing a fiscal year and preparing annual reports |
|
||||
| Dagskassa | Daily cash register | Daily reconciliation of cash register / POS system sales |
|
||||
| Z-rapport | Z-report | End-of-day summary report from a POS system |
|
||||
| Kundreskontra | Accounts receivable | Tracking of outstanding customer invoices |
|
||||
| Leverantörsreskontra | Accounts payable | Tracking of outstanding supplier invoices |
|
||||
| Arbetsgivaravgifter | Employer contributions | Social security contributions paid by the employer |
|
||||
| Traktamente | Travel allowance | Per diem allowance for business travel |
|
||||
| Omsättning | Turnover / revenue | Total sales revenue for a period |
|
||||
| Matkostnadsprocent | Food cost percentage | Food cost as a percentage of food revenue (key F&B metric) |
|
||||
| Svinn | Waste / shrinkage | Food or inventory lost to waste, spoilage, or theft |
|
||||
|
||||
---
|
||||
|
||||
*End of Document*
|
||||
@@ -1,36 +1,31 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# erp-base
|
||||
|
||||
Swedish accounting SaaS for sole traders (enskild firma) and limited companies (aktiebolag). Implements double-entry bookkeeping compliant with Swedish accounting law (Bokforingslagen), including VAT handling, tax reporting, and 7-year document retention.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 16 (App Router), React 19, TypeScript (strict)
|
||||
- **Database**: Supabase (PostgreSQL + RLS + magic link auth)
|
||||
- **Styling**: Tailwind CSS 4 + shadcn/ui
|
||||
- **Hosting**: Vercel
|
||||
- **Integrations**: Enable Banking (PSD2), Anthropic SDK, OpenAI (embeddings), Resend (email), web-push (VAPID)
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Commands
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
npm run dev # Start development server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint
|
||||
npm test # Run all Vitest tests
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Documentation
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
See `CLAUDE.md` for comprehensive project documentation including architecture, bookkeeping engine, extension development, and database conventions.
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function CustomersPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyCustomers />
|
||||
<EmptyCustomers onAction={() => setIsDialogOpen(true)} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -147,29 +147,45 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
|
||||
|
||||
setIsUpdating(true)
|
||||
|
||||
const updates: Partial<Invoice> = { status }
|
||||
if (status === 'paid') {
|
||||
updates.paid_at = new Date().toISOString()
|
||||
updates.paid_amount = invoice.total
|
||||
}
|
||||
try {
|
||||
if (status === 'sent') {
|
||||
// Use mark-sent API for proper bookkeeping
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/mark-sent`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte markera som skickad')
|
||||
}
|
||||
} else if (status === 'paid') {
|
||||
// Use mark-paid API for proper bookkeeping
|
||||
const response = await fetch(`/api/invoices/${invoice.id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error || 'Kunde inte markera som betald')
|
||||
}
|
||||
} else {
|
||||
// Other status changes (cancelled) — direct update is fine
|
||||
const { error } = await supabase
|
||||
.from('invoices')
|
||||
.update({ status })
|
||||
.eq('id', invoice.id)
|
||||
if (error) throw new Error(error.message)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('invoices')
|
||||
.update(updates)
|
||||
.eq('id', invoice.id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Uppdaterad',
|
||||
description: `Fakturan är nu markerad som ${statusConfig[status].label.toLowerCase()}`,
|
||||
})
|
||||
fetchInvoice()
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: error instanceof Error ? error.message : 'Kunde inte uppdatera status',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
|
||||
setIsUpdating(false)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase } from 'lucide-react'
|
||||
import { Download, FileText, FileDown, TrendingUp, Scale, AlertCircle, Receipt, Briefcase, Building2 } from 'lucide-react'
|
||||
import { NEDeclarationView } from '@/extensions/ne-bilaga/NEDeclarationView'
|
||||
import { SRUExportView } from '@/extensions/sru-export/SRUExportView'
|
||||
import type {
|
||||
@@ -124,6 +124,10 @@ export default function ReportsPage() {
|
||||
<FileDown className="h-4 w-4 mr-1" />
|
||||
SRU-export
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="supplier-ledger">
|
||||
<Building2 className="h-4 w-4 mr-1" />
|
||||
Lev.reskontra
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="trial-balance">
|
||||
@@ -146,6 +150,9 @@ export default function ReportsPage() {
|
||||
<TabsContent value="sru-export">
|
||||
<SRUExportView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
<TabsContent value="supplier-ledger">
|
||||
<SupplierLedgerView periodId={selectedPeriod} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Card>
|
||||
@@ -898,3 +905,204 @@ function VatRutaRow({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface SupplierLedgerData {
|
||||
ledger: {
|
||||
entries: {
|
||||
supplier_id: string
|
||||
supplier_name: string
|
||||
current: number
|
||||
days_1_30: number
|
||||
days_31_60: number
|
||||
days_61_90: number
|
||||
days_90_plus: number
|
||||
total_outstanding: number
|
||||
}[]
|
||||
total_outstanding: number
|
||||
total_current: number
|
||||
total_overdue: number
|
||||
unpaid_count: number
|
||||
}
|
||||
reconciliation: {
|
||||
supplier_ledger_total: number
|
||||
account_2440_balance: number
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
} | null
|
||||
}
|
||||
|
||||
function SupplierLedgerView({ periodId }: { periodId: string }) {
|
||||
const [data, setData] = useState<SupplierLedgerData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/reports/supplier-ledger?period_id=${periodId}`)
|
||||
const result = await res.json()
|
||||
if (result.error) {
|
||||
setError(result.error)
|
||||
} else {
|
||||
setData(result.data)
|
||||
}
|
||||
} catch {
|
||||
setError('Kunde inte hämta leverantörsreskontra')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (periodId) fetchData()
|
||||
}, [periodId])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar leverantörsreskontra...
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
<AlertCircle className="h-6 w-6 mx-auto mb-2" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || !data.ledger) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Ingen data tillgänglig.
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const { ledger, reconciliation } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Totalt utestående</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{formatAmount(ledger.total_outstanding)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">{ledger.unpaid_count} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Ej förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-green-600">{formatAmount(ledger.total_current)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Förfallet</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{formatAmount(ledger.total_overdue)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Aging table */}
|
||||
{ledger.entries.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Ålderfördelning per leverantör</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2">Leverantör</th>
|
||||
<th className="py-2 text-right">Ej förfallet</th>
|
||||
<th className="py-2 text-right">1-30 dagar</th>
|
||||
<th className="py-2 text-right">31-60 dagar</th>
|
||||
<th className="py-2 text-right">61-90 dagar</th>
|
||||
<th className="py-2 text-right">90+ dagar</th>
|
||||
<th className="py-2 text-right font-semibold">Totalt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ledger.entries.map((entry) => (
|
||||
<tr key={entry.supplier_id} className="border-b last:border-0">
|
||||
<td className="py-2">{entry.supplier_name}</td>
|
||||
<td className="py-2 text-right">{entry.current > 0 ? formatAmount(entry.current) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_1_30 > 0 ? formatAmount(entry.days_1_30) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_31_60 > 0 ? formatAmount(entry.days_31_60) : ''}</td>
|
||||
<td className="py-2 text-right">{entry.days_61_90 > 0 ? formatAmount(entry.days_61_90) : ''}</td>
|
||||
<td className="py-2 text-right text-red-600">{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}</td>
|
||||
<td className="py-2 text-right font-semibold">{formatAmount(entry.total_outstanding)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="font-semibold border-t-2">
|
||||
<td className="py-2">Summa</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.current, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_1_30, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_31_60, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_61_90, 0))}</td>
|
||||
<td className="py-2 text-right text-red-600">{formatAmount(ledger.entries.reduce((s, e) => s + e.days_90_plus, 0))}</td>
|
||||
<td className="py-2 text-right">{formatAmount(ledger.total_outstanding)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reconciliation */}
|
||||
{reconciliation && (
|
||||
<Card className="border-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Avstämning mot konto 2440</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>Leverantörsreskontra (summa utestående)</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.supplier_ledger_total)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Konto 2440 saldo (huvudbok)</span>
|
||||
<span className="font-mono">{formatAmount(reconciliation.account_2440_balance)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t font-semibold">
|
||||
<span>Differens</span>
|
||||
<span className={reconciliation.is_reconciled ? 'text-green-600' : 'text-red-600'}>
|
||||
{formatAmount(reconciliation.difference)} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
{reconciliation.is_reconciled ? (
|
||||
<Badge className="bg-green-100 text-green-800">Avstämd</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Ej avstämd - kontrollera bokföring</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ export default function SettingsPage() {
|
||||
clearing_number: formData.get('clearing_number') as string,
|
||||
account_number: formData.get('account_number') as string,
|
||||
preliminary_tax_monthly: parseFloat(formData.get('preliminary_tax_monthly') as string) || null,
|
||||
invoice_prefix: formData.get('invoice_prefix') as string || null,
|
||||
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
|
||||
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
|
||||
accounting_method: formData.get('accounting_method') as string || 'accrual',
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -364,6 +368,75 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Fakturainställningar</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_prefix">Fakturaprefix</Label>
|
||||
<Input
|
||||
id="invoice_prefix"
|
||||
name="invoice_prefix"
|
||||
placeholder="t.ex. F-"
|
||||
defaultValue={settings?.invoice_prefix || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="next_invoice_number">Nästa fakturanummer</Label>
|
||||
<Input
|
||||
id="next_invoice_number"
|
||||
name="next_invoice_number"
|
||||
type="number"
|
||||
min="1"
|
||||
defaultValue={settings?.next_invoice_number || 1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="invoice_default_days">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="invoice_default_days"
|
||||
name="invoice_default_days"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={settings?.invoice_default_days || 30}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="accounting_method">Bokföringsmetod</Label>
|
||||
{settings?.entity_type === 'aktiebolag' ? (
|
||||
<>
|
||||
<input type="hidden" name="accounting_method" value="accrual" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value="Faktureringsmetoden"
|
||||
disabled
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Obligatorisk för aktiebolag
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id="accounting_method"
|
||||
name="accounting_method"
|
||||
defaultValue={settings?.accounting_method || 'accrual'}
|
||||
className="flex h-10 w-full max-w-xs rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="accrual">Faktureringsmetoden</option>
|
||||
<option value="cash">Kontantmetoden</option>
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{settings?.entity_type === 'aktiebolag'
|
||||
? 'Aktiebolag måste använda faktureringsmetoden enligt BFL.'
|
||||
: 'Kontantmetoden är tillgänglig för enskild firma med omsättning under 3 MSEK.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h3 className="font-medium mb-4">Skatteinställningar</h3>
|
||||
<div className="space-y-2">
|
||||
@@ -406,8 +479,8 @@ export default function SettingsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{activeConnections.map((connection) => {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment } from '@/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
disputed: 'bg-purple-100 text-purple-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
registered: 'Registrerad',
|
||||
approved: 'Godkänd',
|
||||
paid: 'Betald',
|
||||
partially_paid: 'Delbetald',
|
||||
overdue: 'Förfallen',
|
||||
disputed: 'Tvist',
|
||||
credited: 'Krediterad',
|
||||
}
|
||||
|
||||
export default function SupplierInvoiceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
|
||||
const [payAmount, setPayAmount] = useState('')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoice()
|
||||
}, [params.id])
|
||||
|
||||
async function fetchInvoice() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}`)
|
||||
const { data, error } = await res.json()
|
||||
if (error) {
|
||||
toast({ title: 'Fel', description: error, variant: 'destructive' })
|
||||
} else {
|
||||
setInvoice(data)
|
||||
setPayAmount(String(data.remaining_amount))
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleApprove() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Godkänd', description: 'Fakturan har godkänts' })
|
||||
fetchInvoice()
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
async function handleMarkPaid() {
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: parseFloat(payAmount) }),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({
|
||||
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
|
||||
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
|
||||
})
|
||||
setIsPayDialogOpen(false)
|
||||
fetchInvoice()
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
async function handleCredit() {
|
||||
if (!confirm('Vill du registrera en kreditfaktura för denna faktura?')) return
|
||||
setIsProcessing(true)
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Kreditfaktura registrerad' })
|
||||
fetchInvoice()
|
||||
}
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Vill du ta bort denna faktura?')) return
|
||||
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Borttagen' })
|
||||
router.push('/supplier-invoices')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Fakturan hittades inte</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.push('/supplier-invoices')}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const items = (invoice.items || []) as SupplierInvoiceItem[]
|
||||
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
Ankomst #{invoice.arrival_number}
|
||||
</h1>
|
||||
<Badge className={statusColors[invoice.status] || ''}>
|
||||
{statusLabels[invoice.status] || invoice.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{invoice.supplier?.name} | Faktura {invoice.supplier_invoice_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{invoice.status === 'registered' && (
|
||||
<>
|
||||
<Button onClick={handleApprove} disabled={isProcessing}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
Godkänn
|
||||
</Button>
|
||||
<Button variant="destructive" size="icon" onClick={handleDelete} disabled={isProcessing}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{['approved', 'overdue', 'partially_paid'].includes(invoice.status) && (
|
||||
<>
|
||||
<Button onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
Markera betald
|
||||
</Button>
|
||||
{invoice.status !== 'partially_paid' && (
|
||||
<Button variant="outline" onClick={handleCredit} disabled={isProcessing}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Kreditfaktura
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invoice details */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Fakturainformation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Ankomstnummer</span>
|
||||
<span className="font-mono">{invoice.arrival_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fakturanummer</span>
|
||||
<span>{invoice.supplier_invoice_number}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Fakturadatum</span>
|
||||
<span>{invoice.invoice_date}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Förfallodatum</span>
|
||||
<span>{invoice.due_date}</span>
|
||||
</div>
|
||||
{invoice.delivery_date && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Leveransdatum</span>
|
||||
<span>{invoice.delivery_date}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.payment_reference && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">OCR/referens</span>
|
||||
<span className="font-mono">{invoice.payment_reference}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoice.reverse_charge && (
|
||||
<div className="mt-2">
|
||||
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Belopp</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Netto (exkl. moms)</span>
|
||||
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-bold text-base pt-2 border-t">
|
||||
<span>Totalt</span>
|
||||
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<span className="text-muted-foreground">Betalt</span>
|
||||
<span className="font-mono text-green-600">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
<div className="flex justify-between font-semibold">
|
||||
<span>Kvar att betala</span>
|
||||
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Supplier info */}
|
||||
{invoice.supplier && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Leverantör</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline font-medium">
|
||||
{invoice.supplier.name}
|
||||
</Link>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
{invoice.supplier.org_number && <span>Org.nr: {invoice.supplier.org_number} | </span>}
|
||||
{invoice.supplier.email}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Line items */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Rader</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2">Beskrivning</th>
|
||||
<th className="pb-2 w-16 text-right">Antal</th>
|
||||
<th className="pb-2 w-16">Enhet</th>
|
||||
<th className="pb-2 w-28 text-right">À-pris</th>
|
||||
<th className="pb-2 w-20">Konto</th>
|
||||
<th className="pb-2 w-16 text-right">Moms%</th>
|
||||
<th className="pb-2 w-28 text-right">Belopp</th>
|
||||
<th className="pb-2 w-24 text-right">Moms</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b last:border-0">
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2">{item.unit}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.unit_price)}</td>
|
||||
<td className="py-2 font-mono">{item.account_number}</td>
|
||||
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Payment history */}
|
||||
{payments.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Betalningshistorik</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2">Datum</th>
|
||||
<th className="pb-2 text-right">Belopp</th>
|
||||
<th className="pb-2">Verifikation</th>
|
||||
<th className="pb-2">Anteckning</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((p) => (
|
||||
<tr key={p.id} className="border-b last:border-0">
|
||||
<td className="py-2">{p.payment_date}</td>
|
||||
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
|
||||
<td className="py-2">
|
||||
{p.journal_entry_id ? (
|
||||
<Link href={`/bookkeeping?entry=${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
|
||||
{p.journal_entry_id.substring(0, 8)}...
|
||||
</Link>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Journal entries (sambandskrav) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Verifikationer (sambandskrav)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{invoice.registration_journal_entry_id ? (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Registreringsverifikation</span>
|
||||
<Link
|
||||
href={`/bookkeeping?entry=${invoice.registration_journal_entry_id}`}
|
||||
className="text-primary hover:underline font-mono"
|
||||
>
|
||||
{invoice.registration_journal_entry_id.substring(0, 8)}...
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Ingen registreringsverifikation (kontantmetoden)</p>
|
||||
)}
|
||||
{invoice.payment_journal_entry_id && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Betalningsverifikation</span>
|
||||
<Link
|
||||
href={`/bookkeeping?entry=${invoice.payment_journal_entry_id}`}
|
||||
className="text-primary hover:underline font-mono"
|
||||
>
|
||||
{invoice.payment_journal_entry_id.substring(0, 8)}...
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
{invoice.notes && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Anteckningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pay Dialog */}
|
||||
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Markera som betald</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Belopp att betala</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleMarkPaid} disabled={isProcessing}>
|
||||
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm, Controller, useFieldArray } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft, Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import type { Supplier, VatTreatment } from '@/types'
|
||||
|
||||
interface LineItem {
|
||||
description: string
|
||||
quantity: number
|
||||
unit: string
|
||||
unit_price: number
|
||||
account_number: string
|
||||
vat_rate: number
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
supplier_id: string
|
||||
supplier_invoice_number: string
|
||||
invoice_date: string
|
||||
due_date: string
|
||||
delivery_date: string
|
||||
currency: string
|
||||
exchange_rate: string
|
||||
vat_treatment: VatTreatment
|
||||
reverse_charge: boolean
|
||||
payment_reference: string
|
||||
notes: string
|
||||
items: LineItem[]
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export default function NewSupplierInvoicePage() {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const { register, control, handleSubmit, watch, setValue, formState: { errors } } = useForm<FormData>({
|
||||
defaultValues: {
|
||||
supplier_id: '',
|
||||
supplier_invoice_number: '',
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: '',
|
||||
delivery_date: '',
|
||||
currency: 'SEK',
|
||||
exchange_rate: '',
|
||||
vat_treatment: 'standard_25',
|
||||
reverse_charge: false,
|
||||
payment_reference: '',
|
||||
notes: '',
|
||||
items: [
|
||||
{
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
account_number: '5010',
|
||||
vat_rate: 0.25,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
|
||||
const watchedItems = watch('items')
|
||||
const watchedSupplierId = watch('supplier_id')
|
||||
const watchedCurrency = watch('currency')
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
}, [])
|
||||
|
||||
// Auto-fill due date when supplier is selected
|
||||
useEffect(() => {
|
||||
if (watchedSupplierId) {
|
||||
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
|
||||
if (supplier) {
|
||||
const invoiceDate = watch('invoice_date')
|
||||
if (invoiceDate) {
|
||||
const due = new Date(invoiceDate)
|
||||
due.setDate(due.getDate() + supplier.default_payment_terms)
|
||||
setValue('due_date', due.toISOString().split('T')[0])
|
||||
}
|
||||
if (supplier.default_expense_account && fields.length > 0) {
|
||||
setValue('items.0.account_number', supplier.default_expense_account)
|
||||
}
|
||||
if (supplier.default_currency) {
|
||||
setValue('currency', supplier.default_currency)
|
||||
}
|
||||
if (supplier.supplier_type === 'eu_business') {
|
||||
setValue('reverse_charge', true)
|
||||
setValue('vat_treatment', 'reverse_charge')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [watchedSupplierId, suppliers])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
const res = await fetch('/api/suppliers')
|
||||
const { data } = await res.json()
|
||||
setSuppliers(data || [])
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
const itemTotals = (watchedItems || []).map((item) => {
|
||||
const lineTotal = Math.round((item.quantity || 0) * (item.unit_price || 0) * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
|
||||
return { lineTotal, vatAmount }
|
||||
})
|
||||
|
||||
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
|
||||
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
|
||||
const total = Math.round((subtotal + totalVat) * 100) / 100
|
||||
|
||||
async function onSubmit(data: FormData) {
|
||||
if (!data.supplier_id) {
|
||||
toast({ title: 'Fel', description: 'Välj en leverantör', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
if (!data.supplier_invoice_number) {
|
||||
toast({ title: 'Fel', description: 'Ange fakturanummer', variant: 'destructive' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
const payload = {
|
||||
supplier_id: data.supplier_id,
|
||||
supplier_invoice_number: data.supplier_invoice_number,
|
||||
invoice_date: data.invoice_date,
|
||||
due_date: data.due_date,
|
||||
delivery_date: data.delivery_date || undefined,
|
||||
currency: data.currency,
|
||||
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
|
||||
vat_treatment: data.vat_treatment,
|
||||
reverse_charge: data.reverse_charge,
|
||||
payment_reference: data.payment_reference || undefined,
|
||||
notes: data.notes || undefined,
|
||||
items: data.items.map((item) => ({
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
account_number: item.account_number,
|
||||
vat_rate: item.vat_rate,
|
||||
})),
|
||||
}
|
||||
|
||||
const res = await fetch('/api/supplier-invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error || 'Kunde inte registrera faktura', variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Faktura registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
|
||||
router.push(`/supplier-invoices/${result.data.id}`)
|
||||
}
|
||||
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/supplier-invoices')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Registrera leverantörsfaktura</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Registrera en inkommande faktura (uppfyller BFL verifikationskrav)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Supplier & Reference */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Leverantör & referens</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantör *</Label>
|
||||
<Controller
|
||||
name="supplier_id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj leverantör" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{suppliers.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantörens fakturanummer *</Label>
|
||||
<Input
|
||||
placeholder="Fakturanr från leverantören"
|
||||
{...register('supplier_invoice_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>OCR / Betalningsreferens</Label>
|
||||
<Input
|
||||
placeholder="OCR-nummer"
|
||||
{...register('payment_reference')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Dates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Datum</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Fakturadatum *</Label>
|
||||
<Input type="date" {...register('invoice_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Förfallodatum *</Label>
|
||||
<Input type="date" {...register('due_date')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Leveransdatum (ML krav)</Label>
|
||||
<Input type="date" {...register('delivery_date')} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Currency & VAT */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Valuta & moms</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Valuta</Label>
|
||||
<Controller
|
||||
name="currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SEK">SEK</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="NOK">NOK</SelectItem>
|
||||
<SelectItem value="DKK">DKK</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{watchedCurrency !== 'SEK' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Växelkurs</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="1.0000"
|
||||
{...register('exchange_rate')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Momsbehandling</Label>
|
||||
<Controller
|
||||
name="vat_treatment"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard_25">Standard 25%</SelectItem>
|
||||
<SelectItem value="reduced_12">Reducerad 12%</SelectItem>
|
||||
<SelectItem value="reduced_6">Reducerad 6%</SelectItem>
|
||||
<SelectItem value="reverse_charge">Omvänd skattskyldighet</SelectItem>
|
||||
<SelectItem value="exempt">Momsfritt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Controller
|
||||
name="reverse_charge"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Label>Omvänd skattskyldighet (reverse charge)</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Line Items */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Rader</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
append({
|
||||
description: '',
|
||||
quantity: 1,
|
||||
unit: 'st',
|
||||
unit_price: 0,
|
||||
account_number: '5010',
|
||||
vat_rate: 0.25,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2">Beskrivning</th>
|
||||
<th className="pb-2 w-16">Antal</th>
|
||||
<th className="pb-2 w-16">Enhet</th>
|
||||
<th className="pb-2 w-28">À-pris (exkl.)</th>
|
||||
<th className="pb-2 w-24">Konto</th>
|
||||
<th className="pb-2 w-24">Momssats</th>
|
||||
<th className="pb-2 w-28 text-right">Belopp</th>
|
||||
<th className="pb-2 w-24 text-right">Moms</th>
|
||||
<th className="pb-2 w-8"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((field, index) => (
|
||||
<tr key={field.id} className="border-b last:border-0">
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
placeholder="Beskrivning"
|
||||
{...register(`items.${index}.description`)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input {...register(`items.${index}.unit`)} />
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register(`items.${index}.unit_price`, { valueAsNumber: true })}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Input
|
||||
placeholder="5010"
|
||||
{...register(`items.${index}.account_number`)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2">
|
||||
<Controller
|
||||
name={`items.${index}.vat_rate`}
|
||||
control={control}
|
||||
render={({ field: f }) => (
|
||||
<Select
|
||||
value={String(f.value)}
|
||||
onValueChange={(v) => f.onChange(parseFloat(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0.25">25%</SelectItem>
|
||||
<SelectItem value="0.12">12%</SelectItem>
|
||||
<SelectItem value="0.06">6%</SelectItem>
|
||||
<SelectItem value="0">0%</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono">
|
||||
{formatAmount(itemTotals[index]?.lineTotal || 0)}
|
||||
</td>
|
||||
<td className="py-2 pr-2 text-right font-mono">
|
||||
{formatAmount(itemTotals[index]?.vatAmount || 0)}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{fields.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="mt-4 pt-4 border-t space-y-2 text-right">
|
||||
<div className="flex justify-end gap-8">
|
||||
<span className="text-muted-foreground">Netto (exkl. moms)</span>
|
||||
<span className="font-mono w-32">{formatAmount(subtotal)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-8">
|
||||
<span className="text-muted-foreground">Moms</span>
|
||||
<span className="font-mono w-32">{formatAmount(totalVat)} kr</span>
|
||||
</div>
|
||||
<div className="flex justify-end gap-8 font-bold text-lg">
|
||||
<span>Totalt</span>
|
||||
<span className="font-mono w-32">{formatAmount(total)} kr</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Anteckningar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
placeholder="Interna anteckningar om denna faktura..."
|
||||
{...register('notes')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button type="button" variant="outline" onClick={() => router.push('/supplier-invoices')}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registrerar...
|
||||
</>
|
||||
) : (
|
||||
'Registrera faktura'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Plus, FileInput, AlertCircle, Clock } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { SupplierInvoice } from '@/types'
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
disputed: 'bg-purple-100 text-purple-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
registered: 'Registrerad',
|
||||
approved: 'Godkänd',
|
||||
paid: 'Betald',
|
||||
partially_paid: 'Delbetald',
|
||||
overdue: 'Förfallen',
|
||||
disputed: 'Tvist',
|
||||
credited: 'Krediterad',
|
||||
}
|
||||
|
||||
export default function SupplierInvoicesPage() {
|
||||
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices()
|
||||
}, [])
|
||||
|
||||
async function fetchInvoices() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch('/api/supplier-invoices?status=all')
|
||||
const { data } = await res.json()
|
||||
setInvoices(data || [])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const filteredInvoices = invoices.filter((inv) => {
|
||||
switch (activeTab) {
|
||||
case 'registered': return inv.status === 'registered'
|
||||
case 'approved': return inv.status === 'approved'
|
||||
case 'to_pay': return inv.status === 'approved' || inv.status === 'overdue'
|
||||
case 'paid': return inv.status === 'paid'
|
||||
default: return true
|
||||
}
|
||||
})
|
||||
|
||||
// Summary stats
|
||||
const totalUnpaid = invoices
|
||||
.filter((i) => !['paid', 'credited'].includes(i.status))
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueAmount = invoices
|
||||
.filter((i) => i.status === 'overdue')
|
||||
.reduce((sum, i) => sum + i.remaining_amount, 0)
|
||||
const overdueCount = invoices.filter((i) => i.status === 'overdue').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Leverantörsfakturor</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Registrera och hantera inkommande fakturor
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<FileInput className="h-4 w-4" />
|
||||
Totalt obetalt
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{formatAmount(totalUnpaid)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{invoices.filter((i) => !['paid', 'credited'].includes(i.status)).length} fakturor
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
Förfallet
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold text-red-600">{formatAmount(overdueAmount)} kr</p>
|
||||
<p className="text-xs text-muted-foreground">{overdueCount} fakturor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Antal fakturor
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{invoices.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Alla</TabsTrigger>
|
||||
<TabsTrigger value="registered">Registrerade</TabsTrigger>
|
||||
<TabsTrigger value="approved">Godkända</TabsTrigger>
|
||||
<TabsTrigger value="to_pay">Att betala</TabsTrigger>
|
||||
<TabsTrigger value="paid">Betalda</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={activeTab}>
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Laddar fakturor...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredInvoices.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileInput className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga fakturor</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
{activeTab === 'all'
|
||||
? 'Registrera din första leverantörsfaktura'
|
||||
: 'Inga fakturor i denna kategori'}
|
||||
</p>
|
||||
{activeTab === 'all' && (
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button className="mt-4">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Registrera faktura
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="p-3">Ankomst</th>
|
||||
<th className="p-3">Leverantör</th>
|
||||
<th className="p-3">Fakturanr</th>
|
||||
<th className="p-3">Fakturadatum</th>
|
||||
<th className="p-3">Förfaller</th>
|
||||
<th className="p-3 text-right">Belopp</th>
|
||||
<th className="p-3 text-right">Kvar att betala</th>
|
||||
<th className="p-3">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInvoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="p-3 font-mono">{inv.arrival_number}</td>
|
||||
<td className="p-3">
|
||||
<Link href={`/suppliers/${inv.supplier_id}`} className="hover:underline">
|
||||
{inv.supplier?.name || '-'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
|
||||
{inv.supplier_invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">{inv.invoice_date}</td>
|
||||
<td className="p-3">{inv.due_date}</td>
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.total)}</td>
|
||||
<td className="p-3 text-right font-mono">{formatAmount(inv.remaining_amount)}</td>
|
||||
<td className="p-3">
|
||||
<Badge className={statusColors[inv.status] || ''}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ArrowLeft, Edit, Trash2, FileText } from 'lucide-react'
|
||||
import SupplierForm from '@/components/suppliers/SupplierForm'
|
||||
import Link from 'next/link'
|
||||
import type { Supplier, SupplierType, CreateSupplierInput, SupplierInvoice } from '@/types'
|
||||
|
||||
const supplierTypeLabels: Record<SupplierType, string> = {
|
||||
swedish_business: 'Svenskt företag',
|
||||
eu_business: 'EU-företag',
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
export default function SupplierDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
const [supplier, setSupplier] = useState<Supplier & { stats?: { total_outstanding: number; total_paid: number; invoice_count: number } } | null>(null)
|
||||
const [invoices, setInvoices] = useState<SupplierInvoice[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSupplier()
|
||||
fetchInvoices()
|
||||
}, [params.id])
|
||||
|
||||
async function fetchSupplier() {
|
||||
setIsLoading(true)
|
||||
const res = await fetch(`/api/suppliers/${params.id}`)
|
||||
const { data, error } = await res.json()
|
||||
if (error) {
|
||||
toast({ title: 'Fel', description: error, variant: 'destructive' })
|
||||
} else {
|
||||
setSupplier(data)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function fetchInvoices() {
|
||||
const res = await fetch(`/api/supplier-invoices?status=all`)
|
||||
const { data } = await res.json()
|
||||
if (data) {
|
||||
setInvoices(data.filter((inv: SupplierInvoice) => inv.supplier_id === params.id))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(data: CreateSupplierInput) {
|
||||
setIsSaving(true)
|
||||
const res = await fetch(`/api/suppliers/${params.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Sparat', description: 'Leverantören har uppdaterats' })
|
||||
setSupplier({ ...result.data, stats: supplier?.stats })
|
||||
setIsEditOpen(false)
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm('Är du säker på att du vill ta bort denna leverantör?')) return
|
||||
|
||||
const res = await fetch(`/api/suppliers/${params.id}`, { method: 'DELETE' })
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
toast({ title: 'Fel', description: result.error, variant: 'destructive' })
|
||||
} else {
|
||||
toast({ title: 'Borttagen', description: 'Leverantören har tagits bort' })
|
||||
router.push('/suppliers')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
|
||||
<Card className="animate-pulse">
|
||||
<CardContent className="h-48" />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!supplier) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Leverantören hittades inte</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.push('/suppliers')}>
|
||||
Tillbaka
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
registered: 'bg-blue-100 text-blue-800',
|
||||
approved: 'bg-yellow-100 text-yellow-800',
|
||||
paid: 'bg-green-100 text-green-800',
|
||||
partially_paid: 'bg-orange-100 text-orange-800',
|
||||
overdue: 'bg-red-100 text-red-800',
|
||||
credited: 'bg-gray-100 text-gray-800',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
registered: 'Registrerad',
|
||||
approved: 'Godkänd',
|
||||
paid: 'Betald',
|
||||
partially_paid: 'Delbetald',
|
||||
overdue: 'Förfallen',
|
||||
credited: 'Krediterad',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/suppliers')}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">{supplier.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{supplierTypeLabels[supplier.supplier_type]}
|
||||
{supplier.org_number && ` | Org.nr: ${supplier.org_number}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setIsEditOpen(true)}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Redigera
|
||||
</Button>
|
||||
<Button variant="destructive" size="icon" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Utestående</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_outstanding || 0)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Totalt betalt</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{formatAmount(supplier.stats?.total_paid || 0)} kr</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-muted-foreground">Antal fakturor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-2xl font-bold">{supplier.stats?.invoice_count || 0}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Contact & Payment Info */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Kontaktuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{supplier.email && <p>E-post: {supplier.email}</p>}
|
||||
{supplier.phone && <p>Telefon: {supplier.phone}</p>}
|
||||
{supplier.address_line1 && <p>{supplier.address_line1}</p>}
|
||||
{supplier.postal_code && <p>{supplier.postal_code} {supplier.city}</p>}
|
||||
{supplier.vat_number && <p>VAT: {supplier.vat_number}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Betalningsuppgifter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
{supplier.bankgiro && <p>Bankgiro: {supplier.bankgiro}</p>}
|
||||
{supplier.plusgiro && <p>Plusgiro: {supplier.plusgiro}</p>}
|
||||
{supplier.iban && <p>IBAN: {supplier.iban}</p>}
|
||||
{supplier.bic && <p>BIC: {supplier.bic}</p>}
|
||||
<p>Betalningsvillkor: {supplier.default_payment_terms} dagar</p>
|
||||
<p>Valuta: {supplier.default_currency}</p>
|
||||
{supplier.default_expense_account && <p>Kostnadskonto: {supplier.default_expense_account}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Invoices */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">Fakturor</CardTitle>
|
||||
<Link href="/supplier-invoices/new">
|
||||
<Button size="sm">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Ny faktura
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{invoices.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-8">
|
||||
Inga fakturor registrerade för denna leverantör
|
||||
</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-2">Ankomst</th>
|
||||
<th className="py-2">Fakturanr</th>
|
||||
<th className="py-2">Datum</th>
|
||||
<th className="py-2">Förfaller</th>
|
||||
<th className="py-2 text-right">Belopp</th>
|
||||
<th className="py-2 text-right">Kvar</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((inv) => (
|
||||
<tr key={inv.id} className="border-b last:border-0">
|
||||
<td className="py-2 font-mono">{inv.arrival_number}</td>
|
||||
<td className="py-2">
|
||||
<Link href={`/supplier-invoices/${inv.id}`} className="text-primary hover:underline">
|
||||
{inv.supplier_invoice_number}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">{inv.invoice_date}</td>
|
||||
<td className="py-2">{inv.due_date}</td>
|
||||
<td className="py-2 text-right">{formatAmount(inv.total)} kr</td>
|
||||
<td className="py-2 text-right">{formatAmount(inv.remaining_amount)} kr</td>
|
||||
<td className="py-2">
|
||||
<Badge className={statusColors[inv.status] || ''}>
|
||||
{statusLabels[inv.status] || inv.status}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Redigera leverantör</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SupplierForm
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={isSaving}
|
||||
initialData={{
|
||||
name: supplier.name,
|
||||
supplier_type: supplier.supplier_type,
|
||||
email: supplier.email || '',
|
||||
phone: supplier.phone || '',
|
||||
address_line1: supplier.address_line1 || '',
|
||||
address_line2: supplier.address_line2 || '',
|
||||
postal_code: supplier.postal_code || '',
|
||||
city: supplier.city || '',
|
||||
country: supplier.country || 'SE',
|
||||
org_number: supplier.org_number || '',
|
||||
vat_number: supplier.vat_number || '',
|
||||
bankgiro: supplier.bankgiro || '',
|
||||
plusgiro: supplier.plusgiro || '',
|
||||
iban: supplier.iban || '',
|
||||
bic: supplier.bic || '',
|
||||
default_expense_account: supplier.default_expense_account || '',
|
||||
default_payment_terms: supplier.default_payment_terms,
|
||||
default_currency: supplier.default_currency || 'SEK',
|
||||
notes: supplier.notes || '',
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Plus, Search, Building2, Globe } from 'lucide-react'
|
||||
import SupplierForm from '@/components/suppliers/SupplierForm'
|
||||
import Link from 'next/link'
|
||||
import type { Supplier, SupplierType, CreateSupplierInput } from '@/types'
|
||||
|
||||
const supplierTypeLabels: Record<SupplierType, string> = {
|
||||
swedish_business: 'Svenskt företag',
|
||||
eu_business: 'EU-företag',
|
||||
non_eu_business: 'Utanför EU',
|
||||
}
|
||||
|
||||
const supplierTypeIcons: Record<SupplierType, React.ElementType> = {
|
||||
swedish_business: Building2,
|
||||
eu_business: Globe,
|
||||
non_eu_business: Globe,
|
||||
}
|
||||
|
||||
export default function SuppliersPage() {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSuppliers()
|
||||
}, [])
|
||||
|
||||
async function fetchSuppliers() {
|
||||
setIsLoading(true)
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte hämta leverantörer',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setSuppliers(data || [])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleCreateSupplier(data: CreateSupplierInput) {
|
||||
setIsCreating(true)
|
||||
|
||||
const response = await fetch('/api/suppliers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: result.error || 'Kunde inte skapa leverantör',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: 'Leverantör skapad',
|
||||
description: `${data.name} har lagts till`,
|
||||
})
|
||||
setSuppliers([...suppliers, result.data])
|
||||
setIsDialogOpen(false)
|
||||
}
|
||||
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
const filteredSuppliers = suppliers.filter((s) =>
|
||||
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
s.org_number?.includes(searchTerm)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Leverantörer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Hantera dina leverantörer och deras betalningsuppgifter
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny leverantör
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Lägg till leverantör</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SupplierForm
|
||||
onSubmit={handleCreateSupplier}
|
||||
isLoading={isCreating}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Sök på namn, e-post eller org.nr..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supplier list */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-5 bg-muted rounded w-1/2" />
|
||||
<div className="h-4 bg-muted rounded w-1/3 mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-4 bg-muted rounded w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filteredSuppliers.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
{searchTerm ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga träffar</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Inga leverantörer matchar "{searchTerm}"
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium">Inga leverantörer</h3>
|
||||
<p className="text-muted-foreground text-center mt-1">
|
||||
Lägg till din första leverantör för att börja registrera inköpsfakturor
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Ny leverantör
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const Icon = supplierTypeIcons[supplier.supplier_type]
|
||||
return (
|
||||
<Link key={supplier.id} href={`/suppliers/${supplier.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{supplier.name}</CardTitle>
|
||||
<CardDescription>{supplier.email || 'Ingen e-post'}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{supplierTypeLabels[supplier.supplier_type]}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
{supplier.org_number && (
|
||||
<p>Org.nr: {supplier.org_number}</p>
|
||||
)}
|
||||
{supplier.bankgiro && (
|
||||
<p>Bankgiro: {supplier.bankgiro}</p>
|
||||
)}
|
||||
{supplier.city && (
|
||||
<p>{supplier.city}, {supplier.country}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSession, getAccountBalance, type AccountInfo } from '@/lib/banking/enable-banking'
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function GET(request: Request) {
|
||||
return NextResponse.redirect(`${baseUrl}/settings?bank_error=missing_parameters`)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
try {
|
||||
// Create session from authorization code
|
||||
@@ -88,16 +88,17 @@ export async function GET(request: Request) {
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: state,
|
||||
bank_id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
|
||||
provider: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-${aspsp.country.toLowerCase()}`,
|
||||
bank_name: aspsp.name,
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
accounts_data: accountsWithBalances,
|
||||
consent_expires: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('Insert error:', insertError)
|
||||
throw new Error('Failed to create connection')
|
||||
}
|
||||
} else {
|
||||
@@ -107,8 +108,8 @@ export async function GET(request: Request) {
|
||||
.update({
|
||||
session_id,
|
||||
status: 'active',
|
||||
accounts: accountsWithBalances,
|
||||
consent_expires_at: consentExpiresAt,
|
||||
accounts_data: accountsWithBalances,
|
||||
consent_expires: consentExpiresAt,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function POST(request: Request) {
|
||||
.from('bank_connections')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
bank_id: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
provider: `${aspsp_name.toLowerCase().replace(/\s+/g, '-')}-${aspsp_country.toLowerCase()}`,
|
||||
bank_name: aspsp_name,
|
||||
authorization_id,
|
||||
status: 'pending',
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function GET(request: Request) {
|
||||
for (const connection of connections) {
|
||||
try {
|
||||
// Check consent expiry
|
||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const daysLeft = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpired = daysLeft !== null && daysLeft <= 0
|
||||
|
||||
if (isExpired) {
|
||||
@@ -95,7 +95,7 @@ export async function GET(request: Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
const expiringSoon = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
// Sync last 7 days (daily cron, with overlap for safety)
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
@@ -103,7 +103,7 @@ export async function GET(request: Request) {
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
let totalImported = 0
|
||||
let totalDuplicates = 0
|
||||
@@ -128,7 +128,7 @@ export async function GET(request: Request) {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
accounts_data: accounts,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const accounts = (connection.accounts as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
const accounts = (connection.accounts_data as StoredAccount[] || []).map(a => ({ ...a }))
|
||||
|
||||
const toDate = new Date().toISOString().split('T')[0]
|
||||
const fromDate = new Date(Date.now() - days_back * 24 * 60 * 60 * 1000)
|
||||
@@ -72,7 +72,7 @@ export async function POST(request: Request) {
|
||||
await supabase
|
||||
.from('bank_connections')
|
||||
.update({
|
||||
accounts,
|
||||
accounts_data: accounts,
|
||||
last_synced_at: syncedAt,
|
||||
})
|
||||
.eq('id', connection.id)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents/:id/link
|
||||
* Link a document to a journal entry (verifikation)
|
||||
*
|
||||
* Request body:
|
||||
* - journal_entry_id: string (required)
|
||||
* - journal_entry_line_id: string (optional)
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
if (!body.journal_entry_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'journal_entry_id is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const document = await linkToJournalEntry(
|
||||
user.id,
|
||||
id,
|
||||
body.journal_entry_id,
|
||||
body.journal_entry_line_id
|
||||
)
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (error) {
|
||||
console.error('[documents/link/POST] Link failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Link failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/documents/:id
|
||||
* Fetch document metadata + signed download URL (60 min expiry)
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Fetch document record
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (docError || !doc) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Create signed download URL (60 minutes)
|
||||
const { data: signedUrl, error: signError } = await supabase.storage
|
||||
.from('documents')
|
||||
.createSignedUrl(doc.storage_path, 3600)
|
||||
|
||||
if (signError) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create download URL: ${signError.message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...doc,
|
||||
download_url: signedUrl.signedUrl,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyIntegrity } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents/:id/verify
|
||||
* Verify document integrity by re-computing SHA-256 and comparing
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const result = await verifyIntegrity(user.id, id)
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
} catch (error) {
|
||||
console.error('[documents/verify/POST] Verification failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Verification failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { createNewVersion } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents/:id/versions
|
||||
* Create a new version of an existing document (atomic via RPC)
|
||||
*
|
||||
* Accepts multipart/form-data with:
|
||||
* - file: The new version file
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
const newVersion = await createNewVersion(user.id, id, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: newVersion })
|
||||
} catch (error) {
|
||||
console.error('[documents/versions/POST] Version creation failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Version creation failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/documents/:id/versions
|
||||
* List all versions in the document chain
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// First, check if the document belongs to the user
|
||||
const { data: doc, error: docError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, original_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (docError || !doc) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// The root document is either the original_id or the document itself
|
||||
const rootId = doc.original_id || doc.id
|
||||
|
||||
// Fetch all versions in the chain
|
||||
const { data: versions, error: versionsError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.or(`id.eq.${rootId},original_id.eq.${rootId}`)
|
||||
.order('version', { ascending: true })
|
||||
|
||||
if (versionsError) {
|
||||
return NextResponse.json({ error: versionsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: versions })
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* POST /api/documents
|
||||
* Upload a document to the WORM archive
|
||||
*
|
||||
* Accepts multipart/form-data with:
|
||||
* - file: The document file
|
||||
* - upload_source (optional): 'camera' | 'file_upload' | 'email' | ...
|
||||
* - journal_entry_id (optional): Link to a journal entry
|
||||
* - journal_entry_line_id (optional): Link to a journal entry line
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = await request.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
|
||||
}
|
||||
|
||||
const uploadSource = (formData.get('upload_source') as string) || 'file_upload'
|
||||
const journalEntryId = formData.get('journal_entry_id') as string | null
|
||||
const journalEntryLineId = formData.get('journal_entry_line_id') as string | null
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
const document = await uploadDocument(user.id, {
|
||||
name: file.name,
|
||||
buffer,
|
||||
type: file.type,
|
||||
}, {
|
||||
upload_source: uploadSource as import('@/types').DocumentUploadSource,
|
||||
journal_entry_id: journalEntryId || undefined,
|
||||
journal_entry_line_id: journalEntryLineId || undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: document })
|
||||
} catch (error) {
|
||||
console.error('[documents/POST] Upload failed:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/documents
|
||||
* List documents with optional filtering
|
||||
*
|
||||
* Query params:
|
||||
* - journal_entry_id: Filter by journal entry
|
||||
* - current_only: If 'true', only return current versions (default: true)
|
||||
* - limit: Number of results (default: 50)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const journalEntryId = searchParams.get('journal_entry_id')
|
||||
const currentOnly = searchParams.get('current_only') !== 'false'
|
||||
const limit = parseInt(searchParams.get('limit') || '50')
|
||||
const offset = parseInt(searchParams.get('offset') || '0')
|
||||
|
||||
let query = supabase
|
||||
.from('document_attachments')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1)
|
||||
|
||||
if (journalEntryId) {
|
||||
query = query.eq('journal_entry_id', journalEntryId)
|
||||
}
|
||||
|
||||
if (currentOnly) {
|
||||
query = query.eq('is_current_version', true)
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data, count })
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/documents/verify/cron
|
||||
* Batch integrity verification of WORM document archive
|
||||
*
|
||||
* Runs weekly (Sunday 03:00 UTC / 05:00 Swedish time).
|
||||
* Processes up to 100 documents per run, prioritizing
|
||||
* documents never checked or least recently checked.
|
||||
*
|
||||
* Uses service role for cross-user verification (RLS bypass).
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization')
|
||||
const cronSecret = process.env.CRON_SECRET
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Supabase configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Fetch up to 100 current-version documents, prioritizing unchecked/oldest
|
||||
const { data: documents, error: fetchError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('id, user_id, storage_path, sha256_hash, file_name')
|
||||
.eq('is_current_version', true)
|
||||
.order('last_integrity_check_at', { ascending: true, nullsFirst: true })
|
||||
.limit(100)
|
||||
|
||||
if (fetchError) {
|
||||
console.error('[doc-verify-cron] Failed to fetch documents:', fetchError)
|
||||
return NextResponse.json({ error: 'Failed to fetch documents' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!documents || documents.length === 0) {
|
||||
return NextResponse.json({ message: 'No documents to verify', processed: 0 })
|
||||
}
|
||||
|
||||
let verified = 0
|
||||
let failures = 0
|
||||
let errors = 0
|
||||
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
// Download file from storage
|
||||
const { data: fileData, error: downloadError } = await supabase.storage
|
||||
.from('documents')
|
||||
.download(doc.storage_path)
|
||||
|
||||
if (downloadError || !fileData) {
|
||||
console.error(`[doc-verify-cron] Download failed for ${doc.id}:`, downloadError)
|
||||
errors++
|
||||
continue
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const buffer = await fileData.arrayBuffer()
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const computedHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
const isValid = computedHash === doc.sha256_hash
|
||||
|
||||
// Update last_integrity_check_at
|
||||
await supabase
|
||||
.from('document_attachments')
|
||||
.update({ last_integrity_check_at: new Date().toISOString() })
|
||||
.eq('id', doc.id)
|
||||
|
||||
if (!isValid) {
|
||||
// Log integrity failure to audit_log
|
||||
await supabase.from('audit_log').insert({
|
||||
user_id: doc.user_id,
|
||||
action: 'INTEGRITY_FAILURE',
|
||||
table_name: 'document_attachments',
|
||||
record_id: doc.id,
|
||||
description: `Integrity check failed for document "${doc.file_name}": stored hash ${doc.sha256_hash}, computed hash ${computedHash}`,
|
||||
old_state: { sha256_hash: doc.sha256_hash },
|
||||
new_state: { computed_hash: computedHash },
|
||||
})
|
||||
|
||||
console.error(`[doc-verify-cron] INTEGRITY FAILURE: document ${doc.id} (${doc.file_name})`)
|
||||
failures++
|
||||
} else {
|
||||
verified++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[doc-verify-cron] Error verifying document ${doc.id}:`, error)
|
||||
errors++
|
||||
// Continue with other documents
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[doc-verify-cron] Processed ${documents.length}: ${verified} verified, ${failures} failures, ${errors} errors`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: documents.length,
|
||||
verified,
|
||||
failures,
|
||||
errors,
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { analyzeReceipt } from '@/extensions/receipt-ocr/lib/receipt-analyzer'
|
||||
import { processLineItems } from '@/extensions/receipt-ocr/lib/receipt-categorizer'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -63,6 +64,17 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to upload image' }, { status: 500 })
|
||||
}
|
||||
|
||||
// WORM archive copy (non-blocking — receipt flow continues even if this fails)
|
||||
try {
|
||||
await uploadDocument(user.id, {
|
||||
name: imageFile.name,
|
||||
buffer: arrayBuffer,
|
||||
type: imageFile.type,
|
||||
}, { upload_source: 'camera' })
|
||||
} catch (archiveErr) {
|
||||
console.error('[receipt-upload] WORM archive copy failed:', archiveErr)
|
||||
}
|
||||
|
||||
// Get public URL
|
||||
const { data: urlData } = supabase.storage.from('receipts').getPublicUrl(filename)
|
||||
const imageUrl = urlData.publicUrl
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/mark-paid
|
||||
*
|
||||
* Manually marks an invoice as paid (for payments received outside bank sync).
|
||||
*
|
||||
* Faktureringsmetoden (accrual):
|
||||
* Creates payment clearing entry: Debit 1930, Credit 1510
|
||||
*
|
||||
* Kontantmetoden (cash):
|
||||
* Creates combined revenue entry: Debit 1930, Credit 30xx, Credit 26xx
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'sent' && invoice.status !== 'overdue') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const paymentDate = now.split('T')[0]
|
||||
|
||||
// Update status to paid
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: now,
|
||||
paid_amount: invoice.total,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'accrual') {
|
||||
// Faktureringsmetoden: clear receivable (Debit 1930, Credit 1510)
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
// Kontantmetoden: combined revenue entry (Debit 1930, Credit 30xx, Credit 26xx)
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
paymentDate
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry on mark-paid:', err)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'paid',
|
||||
paid_at: now,
|
||||
paid_amount: invoice.total,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Invoice } from '@/types'
|
||||
|
||||
/**
|
||||
* POST /api/invoices/[id]/mark-sent
|
||||
*
|
||||
* Manually marks a draft invoice as sent (for invoices delivered outside the system).
|
||||
* Under faktureringsmetoden (accrual): creates the journal entry (Debit 1510, Credit 30xx/26xx).
|
||||
* Under kontantmetoden (cash): no journal entry — booking happens at payment.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('invoices')
|
||||
.select('*, customer:customers(*), items:invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: 'Fakturan hittades inte' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'draft') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Endast utkast kan markeras som skickade' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update status to sent
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: 'sent',
|
||||
sent_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: 'Kunde inte uppdatera status' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Faktureringsmetoden: book at send
|
||||
let journalEntryId: string | null = null
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry on mark-sent:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: 'sent',
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
generateInvoiceEmailText,
|
||||
generateInvoiceEmailSubject
|
||||
} from '@/lib/email/invoice-templates'
|
||||
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
@@ -158,6 +159,25 @@ export async function POST(
|
||||
// Don't fail the request - the email was sent successfully
|
||||
}
|
||||
|
||||
// Faktureringsmetoden: create journal entry when invoice is issued
|
||||
if ((company as Record<string, unknown>).accounting_method === 'accrual' || !(company as Record<string, unknown>).accounting_method) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
invoice as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry on send:', err)
|
||||
// Non-blocking — don't fail the send
|
||||
}
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.sent',
|
||||
payload: { invoice: invoice as Invoice, userId: user.id },
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { CreateInvoiceInput, Invoice, CreditNote } from '@/types'
|
||||
import { getVatRules, calculateVat, calculateTotal } from '@/lib/invoice/vat-rules'
|
||||
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
|
||||
import {
|
||||
createInvoiceJournalEntry,
|
||||
createCreditNoteJournalEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
|
||||
@@ -176,24 +175,8 @@ export async function POST(request: Request) {
|
||||
.eq('id', invoice.id)
|
||||
.single()
|
||||
|
||||
// Create journal entry for the invoice (non-blocking)
|
||||
// Emit event (no journal entry at draft — booking happens at send/payment based on accounting method)
|
||||
if (completeInvoice) {
|
||||
try {
|
||||
const journalEntry = await createInvoiceJournalEntry(
|
||||
user.id,
|
||||
completeInvoice as Invoice
|
||||
)
|
||||
if (journalEntry) {
|
||||
await supabase
|
||||
.from('invoices')
|
||||
.update({ journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create invoice journal entry:', err)
|
||||
// Don't fail the invoice creation
|
||||
}
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'invoice.created',
|
||||
payload: { invoice: completeInvoice as Invoice, userId: user.id },
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
|
||||
import { generateReconciliation } from '@/lib/reports/supplier-reconciliation'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const asOfDate = searchParams.get('as_of_date') || undefined
|
||||
const periodId = searchParams.get('period_id') || undefined
|
||||
|
||||
const ledger = await generateSupplierLedger(user.id, asOfDate)
|
||||
|
||||
let reconciliation = null
|
||||
if (periodId) {
|
||||
reconciliation = await generateReconciliation(user.id, periodId)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
ledger,
|
||||
reconciliation,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data: invoice } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('status')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!invoice) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invoice.status !== 'registered') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan bara godkänna registrerade fakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ status: 'approved' })
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch original invoice with supplier and items
|
||||
const { data: original, error: fetchError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !original) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (original.status === 'credited') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Fakturan har redan krediterats' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Get next arrival number
|
||||
const { data: arrivalNum } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_user_id: user.id })
|
||||
|
||||
// Create credit note invoice (negative amounts)
|
||||
const { data: creditNote, error: creditError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
supplier_id: original.supplier_id,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: `KREDIT-${original.supplier_invoice_number}`,
|
||||
invoice_date: new Date().toISOString().split('T')[0],
|
||||
due_date: new Date().toISOString().split('T')[0],
|
||||
status: 'registered',
|
||||
currency: original.currency,
|
||||
exchange_rate: original.exchange_rate,
|
||||
vat_treatment: original.vat_treatment,
|
||||
reverse_charge: original.reverse_charge,
|
||||
subtotal: original.subtotal,
|
||||
subtotal_sek: original.subtotal_sek,
|
||||
vat_amount: original.vat_amount,
|
||||
vat_amount_sek: original.vat_amount_sek,
|
||||
total: original.total,
|
||||
total_sek: original.total_sek,
|
||||
remaining_amount: 0,
|
||||
is_credit_note: true,
|
||||
credited_invoice_id: id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (creditError || !creditNote) {
|
||||
return NextResponse.json({ error: creditError?.message || 'Failed to create credit note' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Copy items to credit note
|
||||
const creditItems = (original.items || []).map((item: SupplierInvoiceItem) => ({
|
||||
supplier_invoice_id: creditNote.id,
|
||||
sort_order: item.sort_order,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
line_total: item.line_total,
|
||||
account_number: item.account_number,
|
||||
vat_code: item.vat_code,
|
||||
vat_rate: item.vat_rate,
|
||||
vat_amount: item.vat_amount,
|
||||
}))
|
||||
|
||||
await supabase.from('supplier_invoice_items').insert(creditItems)
|
||||
|
||||
// Create credit note journal entry
|
||||
let journalEntryId: string | null = null
|
||||
try {
|
||||
const journalEntry = await createSupplierCreditNoteEntry(
|
||||
user.id,
|
||||
creditNote as SupplierInvoice,
|
||||
creditItems as SupplierInvoiceItem[],
|
||||
original.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) {
|
||||
journalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', creditNote.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create credit note journal entry:', err)
|
||||
}
|
||||
|
||||
// Update original invoice: reduce remaining_amount
|
||||
const newRemaining = Math.max(0, original.remaining_amount - original.total)
|
||||
const newStatus = newRemaining <= 0 ? 'credited' : original.status
|
||||
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
return NextResponse.json({
|
||||
data: creditNote,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
interface MarkPaidRequest {
|
||||
amount?: number
|
||||
payment_date?: string
|
||||
exchange_rate_difference?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: MarkPaidRequest = await request.json()
|
||||
|
||||
// Fetch invoice with supplier and items
|
||||
const { data: invoice, error: fetchError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchError || !invoice) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (!['registered', 'approved', 'partially_paid', 'overdue'].includes(invoice.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Fakturan kan inte markeras som betald i nuvarande status' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const paymentDate = body.payment_date || new Date().toISOString().split('T')[0]
|
||||
const paymentAmount = body.amount || invoice.remaining_amount
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Create journal entry
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
paymentDate,
|
||||
invoice.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
paymentAmount,
|
||||
paymentDate,
|
||||
body.exchange_rate_difference
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry:', err)
|
||||
}
|
||||
|
||||
// Calculate new remaining amount
|
||||
const newRemaining = Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100
|
||||
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
// Update invoice
|
||||
const { error: updateError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
remaining_amount: Math.max(0, newRemaining),
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
})
|
||||
.eq('id', id)
|
||||
|
||||
if (updateError) {
|
||||
return NextResponse.json({ error: updateError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Record payment
|
||||
const { error: paymentError } = await supabase
|
||||
.from('supplier_invoice_payments')
|
||||
.insert({
|
||||
supplier_invoice_id: id,
|
||||
payment_date: paymentDate,
|
||||
amount: paymentAmount,
|
||||
currency: invoice.currency,
|
||||
exchange_rate_difference: body.exchange_rate_difference || 0,
|
||||
journal_entry_id: journalEntryId,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
|
||||
if (paymentError) {
|
||||
console.error('Failed to record payment:', paymentError)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: Math.max(0, newRemaining),
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data: invoice, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*), payments:supplier_invoice_payments(*)')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !invoice) {
|
||||
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: invoice })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Only allow editing registered invoices
|
||||
const { data: existing } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('status')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.status !== 'registered') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan bara redigera registrerade fakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date,
|
||||
payment_reference: body.payment_reference,
|
||||
notes: body.notes,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Only allow deleting registered invoices without journal entries
|
||||
const { data: existing } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('status, registration_journal_entry_id')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (existing.status !== 'registered') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan bara ta bort registrerade fakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete items first, then invoice
|
||||
await supabase.from('supplier_invoice_items').delete().eq('supplier_invoice_id', id)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { CreateSupplierInvoiceInput, SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const status = searchParams.get('status')
|
||||
|
||||
let query = supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(id, name)')
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (status && status !== 'all') {
|
||||
if (status === 'to_pay') {
|
||||
query = query.in('status', ['approved', 'overdue'])
|
||||
} else {
|
||||
query = query.eq('status', status)
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await query.order('due_date', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateSupplierInvoiceInput = await request.json()
|
||||
|
||||
// Validate supplier exists and belongs to user
|
||||
const { data: supplier, error: supplierError } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('id', body.supplier_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (supplierError || !supplier) {
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get next arrival number
|
||||
const { data: arrivalNum, error: arrivalError } = await supabase
|
||||
.rpc('get_next_arrival_number', { p_user_id: user.id })
|
||||
|
||||
if (arrivalError) {
|
||||
return NextResponse.json({ error: 'Failed to get arrival number' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Calculate totals from items
|
||||
const items = body.items.map((item, index) => {
|
||||
const vatRate = item.vat_rate ?? 0.25
|
||||
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
|
||||
const vatAmount = Math.round(lineTotal * vatRate * 100) / 100
|
||||
return {
|
||||
sort_order: index,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit || 'st',
|
||||
unit_price: item.unit_price,
|
||||
line_total: lineTotal,
|
||||
account_number: item.account_number,
|
||||
vat_code: item.vat_code || null,
|
||||
vat_rate: vatRate,
|
||||
vat_amount: vatAmount,
|
||||
}
|
||||
})
|
||||
|
||||
const subtotal = items.reduce((sum, i) => sum + i.line_total, 0)
|
||||
const vatAmount = items.reduce((sum, i) => sum + i.vat_amount, 0)
|
||||
const total = Math.round((subtotal + vatAmount) * 100) / 100
|
||||
|
||||
const exchangeRate = body.exchange_rate || null
|
||||
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
|
||||
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
|
||||
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
|
||||
|
||||
// Insert supplier invoice
|
||||
const { data: invoice, error: invoiceError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
supplier_id: body.supplier_id,
|
||||
arrival_number: arrivalNum,
|
||||
supplier_invoice_number: body.supplier_invoice_number,
|
||||
invoice_date: body.invoice_date,
|
||||
due_date: body.due_date,
|
||||
delivery_date: body.delivery_date || null,
|
||||
status: 'registered',
|
||||
currency: body.currency || 'SEK',
|
||||
exchange_rate: exchangeRate,
|
||||
vat_treatment: body.vat_treatment || 'standard_25',
|
||||
reverse_charge: body.reverse_charge || false,
|
||||
payment_reference: body.payment_reference || null,
|
||||
subtotal: Math.round(subtotal * 100) / 100,
|
||||
subtotal_sek: subtotalSek,
|
||||
vat_amount: Math.round(vatAmount * 100) / 100,
|
||||
vat_amount_sek: vatAmountSek,
|
||||
total: Math.round(total * 100) / 100,
|
||||
total_sek: totalSek,
|
||||
remaining_amount: Math.round(total * 100) / 100,
|
||||
notes: body.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (invoiceError || !invoice) {
|
||||
return NextResponse.json({ error: invoiceError?.message || 'Failed to create invoice' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Insert line items
|
||||
const itemInserts = items.map((item) => ({
|
||||
supplier_invoice_id: invoice.id,
|
||||
...item,
|
||||
}))
|
||||
|
||||
const { error: itemsError } = await supabase
|
||||
.from('supplier_invoice_items')
|
||||
.insert(itemInserts)
|
||||
|
||||
if (itemsError) {
|
||||
// Clean up invoice on items failure
|
||||
await supabase.from('supplier_invoices').delete().eq('id', invoice.id)
|
||||
return NextResponse.json({ error: itemsError.message }, { status: 500 })
|
||||
}
|
||||
|
||||
// Accrual method: create registration journal entry
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
let registrationJournalEntryId: string | null = null
|
||||
|
||||
if (accountingMethod === 'accrual') {
|
||||
try {
|
||||
const journalEntry = await createSupplierInvoiceRegistrationEntry(
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
items as SupplierInvoiceItem[],
|
||||
supplier.supplier_type
|
||||
)
|
||||
if (journalEntry) {
|
||||
registrationJournalEntryId = journalEntry.id
|
||||
await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({ registration_journal_entry_id: journalEntry.id })
|
||||
.eq('id', invoice.id)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create registration journal entry:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
...invoice,
|
||||
items: itemInserts,
|
||||
registration_journal_entry_id: registrationJournalEntryId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateSupplierInput } from '@/types'
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Fetch supplier
|
||||
const { data: supplier, error } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (error || !supplier) {
|
||||
return NextResponse.json({ error: 'Supplier not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Fetch stats: total outstanding & total paid
|
||||
const { data: invoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('status, total, remaining_amount, paid_amount')
|
||||
.eq('supplier_id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
const stats = {
|
||||
total_outstanding: 0,
|
||||
total_paid: 0,
|
||||
invoice_count: 0,
|
||||
}
|
||||
|
||||
if (invoices) {
|
||||
stats.invoice_count = invoices.length
|
||||
for (const inv of invoices) {
|
||||
if (inv.status !== 'paid' && inv.status !== 'credited') {
|
||||
stats.total_outstanding += inv.remaining_amount || 0
|
||||
}
|
||||
stats.total_paid += inv.paid_amount || 0
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: { ...supplier, stats } })
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: Partial<CreateSupplierInput> = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.update({
|
||||
name: body.name,
|
||||
supplier_type: body.supplier_type,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address_line1: body.address_line1,
|
||||
address_line2: body.address_line2,
|
||||
postal_code: body.postal_code,
|
||||
city: body.city,
|
||||
country: body.country,
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
bankgiro: body.bankgiro,
|
||||
plusgiro: body.plusgiro,
|
||||
bank_account: body.bank_account,
|
||||
iban: body.iban,
|
||||
bic: body.bic,
|
||||
default_expense_account: body.default_expense_account,
|
||||
default_payment_terms: body.default_payment_terms,
|
||||
default_currency: body.default_currency,
|
||||
notes: body.notes,
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check for linked invoices
|
||||
const { count } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('supplier_id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (count && count > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Kan inte ta bort leverantör med kopplade fakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('suppliers')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { CreateSupplierInput, Supplier } from '@/types'
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: CreateSupplierInput = await request.json()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('suppliers')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: body.name,
|
||||
supplier_type: body.supplier_type,
|
||||
email: body.email,
|
||||
phone: body.phone,
|
||||
address_line1: body.address_line1,
|
||||
address_line2: body.address_line2,
|
||||
postal_code: body.postal_code,
|
||||
city: body.city,
|
||||
country: body.country || 'SE',
|
||||
org_number: body.org_number,
|
||||
vat_number: body.vat_number,
|
||||
bankgiro: body.bankgiro,
|
||||
plusgiro: body.plusgiro,
|
||||
bank_account: body.bank_account,
|
||||
iban: body.iban,
|
||||
bic: body.bic,
|
||||
default_expense_account: body.default_expense_account,
|
||||
default_payment_terms: body.default_payment_terms || 30,
|
||||
default_currency: body.default_currency || 'SEK',
|
||||
notes: body.notes,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import { ensureInitialized } from '@/lib/init'
|
||||
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
|
||||
import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
|
||||
import { saveUserMappingRule } from '@/lib/bookkeeping/mapping-engine'
|
||||
import type { Transaction, TransactionCategory, EntityType } from '@/types'
|
||||
import type { Transaction, TransactionCategory, EntityType, VatTreatment } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
interface CategorizeRequest {
|
||||
is_business: boolean
|
||||
category?: TransactionCategory
|
||||
vat_treatment?: VatTreatment
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,7 +21,8 @@ interface CategorizeRequest {
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
date: string,
|
||||
fiscalYearStartMonth: number = 1
|
||||
): Promise<boolean> {
|
||||
// Check if a fiscal period already covers this date
|
||||
const { data: existing } = await supabase
|
||||
@@ -36,18 +38,38 @@ async function ensureFiscalPeriod(
|
||||
return true
|
||||
}
|
||||
|
||||
// No fiscal period exists - create one for the year of the transaction
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
// Compute fiscal year period based on start month
|
||||
const txDate = new Date(date)
|
||||
const txMonth = txDate.getMonth() + 1
|
||||
const txYear = txDate.getFullYear()
|
||||
|
||||
const periodStart = `${year}-01-01`
|
||||
const periodEnd = `${year}-12-31`
|
||||
let periodStartYear: number
|
||||
if (fiscalYearStartMonth === 1) {
|
||||
periodStartYear = txYear
|
||||
} else if (txMonth >= fiscalYearStartMonth) {
|
||||
periodStartYear = txYear
|
||||
} else {
|
||||
periodStartYear = txYear - 1
|
||||
}
|
||||
|
||||
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
|
||||
const periodStart = `${periodStartYear}-${startMonth}-01`
|
||||
|
||||
// Period ends the day before the next fiscal year starts
|
||||
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
|
||||
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
|
||||
const lastDay = new Date(endYear, endMonth, 0).getDate()
|
||||
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
|
||||
|
||||
const periodName = fiscalYearStartMonth === 1
|
||||
? `Räkenskapsår ${periodStartYear}`
|
||||
: `Räkenskapsår ${periodStartYear}/${endYear}`
|
||||
|
||||
const { error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
name: periodName,
|
||||
period_start: periodStart,
|
||||
period_end: periodEnd,
|
||||
}, {
|
||||
@@ -122,14 +144,15 @@ export async function POST(
|
||||
})
|
||||
}
|
||||
|
||||
// Fetch company settings to get entity type
|
||||
// Fetch company settings to get entity type and fiscal year start
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('entity_type')
|
||||
.select('entity_type, fiscal_year_start_month')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
const fiscalYearStartMonth: number = settings?.fiscal_year_start_month ?? 1
|
||||
|
||||
// Determine the category to use
|
||||
const finalCategory: TransactionCategory = is_business
|
||||
@@ -141,11 +164,12 @@ export async function POST(
|
||||
finalCategory,
|
||||
transaction as Transaction,
|
||||
is_business,
|
||||
entityType
|
||||
entityType,
|
||||
body.vat_treatment
|
||||
)
|
||||
|
||||
// Ensure fiscal period exists for the transaction date
|
||||
await ensureFiscalPeriod(supabase, user.id, transaction.date)
|
||||
await ensureFiscalPeriod(supabase, user.id, transaction.date, fiscalYearStartMonth)
|
||||
|
||||
// Try to create journal entry
|
||||
let journalEntryCreated = false
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput } from '@/types'
|
||||
import {
|
||||
getRevenueAccount,
|
||||
getOutputVatAccount,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import type { Transaction, Invoice, CreateJournalEntryInput, VatTreatment } from '@/types'
|
||||
|
||||
interface MatchInvoiceRequest {
|
||||
invoice_id: string
|
||||
@@ -132,7 +136,16 @@ export async function POST(
|
||||
const now = new Date().toISOString()
|
||||
const paidAmount = transaction.amount
|
||||
|
||||
// Create journal entry for payment receipt
|
||||
// Fetch accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Create journal entry for payment receipt (method-aware)
|
||||
let journalEntryId: string | null = null
|
||||
let journalEntryError: string | null = null
|
||||
|
||||
@@ -140,26 +153,69 @@ export async function POST(
|
||||
const fiscalPeriodId = await ensureFiscalPeriod(supabase, user.id, transaction.date)
|
||||
|
||||
if (fiscalPeriodId) {
|
||||
const journalInput: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines: [
|
||||
let journalInput: CreateJournalEntryInput
|
||||
|
||||
if (accountingMethod === 'cash') {
|
||||
// Kontantmetoden: combined revenue entry at payment
|
||||
// Debit 1930 Företagskonto, Credit 30xx Försäljning, Credit 26xx Utgående moms
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment as VatTreatment)
|
||||
const lines: CreateJournalEntryInput['lines'] = [
|
||||
{
|
||||
account_number: '1930', // Företagskonto (Bank)
|
||||
debit_amount: paidAmount,
|
||||
account_number: '1930',
|
||||
debit_amount: invoice.total,
|
||||
credit_amount: 0,
|
||||
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: '1510', // Kundfordringar (Accounts Receivable)
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: paidAmount,
|
||||
line_description: `Faktura ${invoice.invoice_number} betald`,
|
||||
credit_amount: invoice.subtotal,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment as VatTreatment)
|
||||
lines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.vat_amount,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
}
|
||||
|
||||
journalInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
|
||||
source_type: 'invoice_cash_payment',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
} else {
|
||||
// Faktureringsmetoden: clear receivable
|
||||
// Debit 1930 Företagskonto, Credit 1510 Kundfordringar
|
||||
journalInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: transaction.date,
|
||||
description: `Betalning mottagen: Faktura ${invoice.invoice_number}`,
|
||||
source_type: 'invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines: [
|
||||
{
|
||||
account_number: '1930',
|
||||
debit_amount: paidAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Inbetalning faktura ${invoice.invoice_number}`,
|
||||
},
|
||||
{
|
||||
account_number: '1510',
|
||||
debit_amount: 0,
|
||||
credit_amount: paidAmount,
|
||||
line_description: `Faktura ${invoice.invoice_number} betald`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const journalEntry = await createJournalEntry(user.id, journalInput)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import {
|
||||
createSupplierInvoicePaymentEntry,
|
||||
createSupplierInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/supplier-invoice-entries'
|
||||
import type { SupplierInvoice, SupplierInvoiceItem } from '@/types'
|
||||
|
||||
interface MatchRequest {
|
||||
supplier_invoice_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a fiscal period exists for the given date
|
||||
*/
|
||||
async function ensureFiscalPeriod(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
userId: string,
|
||||
date: string
|
||||
): Promise<string | null> {
|
||||
const existingPeriodId = await findFiscalPeriod(userId, date)
|
||||
if (existingPeriodId) return existingPeriodId
|
||||
|
||||
const transactionDate = new Date(date)
|
||||
const year = transactionDate.getFullYear()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.upsert({
|
||||
user_id: userId,
|
||||
name: `Räkenskapsår ${year}`,
|
||||
period_start: `${year}-01-01`,
|
||||
period_end: `${year}-12-31`,
|
||||
}, {
|
||||
onConflict: 'user_id,period_start,period_end',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to create fiscal period:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return data?.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/transactions/[id]/match-supplier-invoice
|
||||
*
|
||||
* Match a negative transaction (expense) to a supplier invoice.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient()
|
||||
const { id: transactionId } = await params
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const body: MatchRequest = await request.json()
|
||||
const { supplier_invoice_id } = body
|
||||
|
||||
if (!supplier_invoice_id) {
|
||||
return NextResponse.json({ error: 'supplier_invoice_id is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Fetch the transaction
|
||||
const { data: transaction, error: fetchTxError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('id', transactionId)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchTxError || !transaction) {
|
||||
return NextResponse.json({ error: 'Transaction not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Verify transaction is an expense (amount < 0)
|
||||
if (transaction.amount >= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Bara utgiftstransaktioner kan matchas mot leverantörsfakturor' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (transaction.supplier_invoice_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Transaktionen är redan kopplad till en leverantörsfaktura' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch the invoice with supplier and items
|
||||
const { data: invoice, error: fetchInvError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(*), items:supplier_invoice_items(*)')
|
||||
.eq('id', supplier_invoice_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (fetchInvError || !invoice) {
|
||||
return NextResponse.json({ error: 'Supplier invoice not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (invoice.status === 'paid' || invoice.status === 'credited') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Leverantörsfakturan är redan betald' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const paymentAmount = Math.abs(transaction.amount)
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Get accounting method
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('accounting_method')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
|
||||
// Create journal entry
|
||||
let journalEntryId: string | null = null
|
||||
|
||||
try {
|
||||
if (accountingMethod === 'cash') {
|
||||
const journalEntry = await createSupplierInvoiceCashEntry(
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
(invoice.items || []) as SupplierInvoiceItem[],
|
||||
transaction.date,
|
||||
invoice.supplier?.supplier_type || 'swedish_business'
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
} else {
|
||||
const journalEntry = await createSupplierInvoicePaymentEntry(
|
||||
user.id,
|
||||
invoice as SupplierInvoice,
|
||||
paymentAmount,
|
||||
transaction.date
|
||||
)
|
||||
if (journalEntry) journalEntryId = journalEntry.id
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create payment journal entry:', err)
|
||||
}
|
||||
|
||||
// Update supplier invoice
|
||||
const newRemaining = Math.max(0, Math.round((invoice.remaining_amount - paymentAmount) * 100) / 100)
|
||||
const newPaidAmount = Math.round((invoice.paid_amount + paymentAmount) * 100) / 100
|
||||
const isFullyPaid = newRemaining <= 0
|
||||
const newStatus = isFullyPaid ? 'paid' : 'partially_paid'
|
||||
|
||||
const { error: updateInvError } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
remaining_amount: newRemaining,
|
||||
paid_amount: newPaidAmount,
|
||||
paid_at: isFullyPaid ? now : null,
|
||||
payment_journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
})
|
||||
.eq('id', supplier_invoice_id)
|
||||
|
||||
if (updateInvError) {
|
||||
return NextResponse.json({ error: 'Failed to update supplier invoice' }, { status: 500 })
|
||||
}
|
||||
|
||||
// Record payment
|
||||
await supabase.from('supplier_invoice_payments').insert({
|
||||
supplier_invoice_id,
|
||||
payment_date: transaction.date,
|
||||
amount: paymentAmount,
|
||||
currency: invoice.currency,
|
||||
journal_entry_id: journalEntryId,
|
||||
transaction_id: transactionId,
|
||||
})
|
||||
|
||||
// Update transaction
|
||||
const { error: updateTxError } = await supabase
|
||||
.from('transactions')
|
||||
.update({
|
||||
supplier_invoice_id,
|
||||
journal_entry_id: journalEntryId,
|
||||
is_business: true,
|
||||
})
|
||||
.eq('id', transactionId)
|
||||
|
||||
if (updateTxError) {
|
||||
return NextResponse.json({ error: 'Failed to link transaction' }, { status: 500 })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
invoice_status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
})
|
||||
}
|
||||
@@ -27,8 +27,8 @@ export function BankConnectionStatus({
|
||||
onDisconnect,
|
||||
isSyncing = false,
|
||||
}: BankConnectionStatusProps) {
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires_at)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires_at)
|
||||
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
|
||||
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
|
||||
|
||||
const statusConfig = {
|
||||
active: {
|
||||
@@ -61,7 +61,7 @@ export function BankConnectionStatus({
|
||||
const StatusIcon = status.icon
|
||||
|
||||
// Parse accounts from connection
|
||||
const accounts = (connection.accounts as Array<{
|
||||
const accounts = (connection.accounts_data as Array<{
|
||||
uid: string
|
||||
iban?: string
|
||||
name?: string
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
X,
|
||||
HelpCircle,
|
||||
ChevronDown,
|
||||
Building2,
|
||||
FileInput,
|
||||
} from 'lucide-react'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
@@ -43,6 +45,8 @@ const navItems: NavItem[] = [
|
||||
{ href: '/calendar', label: 'Kalender', icon: Calendar, group: 'main' },
|
||||
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
|
||||
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
|
||||
{ href: '/suppliers', label: 'Leverantörer', icon: Building2, group: 'finans' },
|
||||
{ href: '/supplier-invoices', label: 'Lev.fakturor', icon: FileInput, group: 'finans' },
|
||||
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'finans' },
|
||||
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'finans' },
|
||||
{ href: '/import', label: 'Importera', icon: Upload, group: 'övrigt' },
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
interface MonthlyData {
|
||||
month: string
|
||||
income: number
|
||||
expenses: number
|
||||
}
|
||||
|
||||
interface IncomeChartProps {
|
||||
monthlyData: MonthlyData[]
|
||||
}
|
||||
|
||||
export function IncomeChart({ monthlyData }: IncomeChartProps) {
|
||||
const { trendPercent, trendDirection } = useMemo(() => {
|
||||
if (monthlyData.length < 2) return { trendPercent: 0, trendDirection: 'flat' as const }
|
||||
const current = monthlyData[monthlyData.length - 1]
|
||||
const previous = monthlyData[monthlyData.length - 2]
|
||||
if (previous.income === 0) return { trendPercent: 0, trendDirection: 'flat' as const }
|
||||
const percent = Math.round(((current.income - previous.income) / previous.income) * 100)
|
||||
return {
|
||||
trendPercent: Math.abs(percent),
|
||||
trendDirection: percent > 0 ? 'up' as const : percent < 0 ? 'down' as const : 'flat' as const,
|
||||
}
|
||||
}, [monthlyData])
|
||||
|
||||
if (monthlyData.length === 0) return null
|
||||
|
||||
const formatAmount = (value: number) => {
|
||||
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}k`
|
||||
return String(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-muted-foreground">Intäkter vs kostnader</span>
|
||||
{trendDirection !== 'flat' && (
|
||||
<div className={`flex items-center gap-1 text-xs font-medium ${trendDirection === 'up' ? 'text-success' : 'text-destructive'}`}>
|
||||
{trendDirection === 'up' ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{trendPercent}% vs förra månaden
|
||||
</div>
|
||||
)}
|
||||
{trendDirection === 'flat' && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Minus className="h-3 w-3" />
|
||||
Oförändrat
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-[180px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={monthlyData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="incomeGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0.2} />
|
||||
<stop offset="95%" stopColor="hsl(145, 20%, 36%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="expenseGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0.1} />
|
||||
<stop offset="95%" stopColor="hsl(0, 45%, 55%)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11, fill: 'hsl(30, 8%, 45%)' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={formatAmount}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(40, 40%, 99%)',
|
||||
border: '1px solid hsl(35, 20%, 88%)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => {
|
||||
if (value === undefined) return ['-', name || '']
|
||||
return [
|
||||
new Intl.NumberFormat('sv-SE', { style: 'currency', currency: 'SEK', maximumFractionDigits: 0 }).format(value),
|
||||
name === 'income' ? 'Intäkter' : 'Kostnader',
|
||||
]
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="income"
|
||||
stroke="hsl(145, 20%, 36%)"
|
||||
strokeWidth={2}
|
||||
fill="url(#incomeGradient)"
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="expenses"
|
||||
stroke="hsl(0, 45%, 55%)"
|
||||
strokeWidth={1.5}
|
||||
fill="url(#expenseGradient)"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-6 mt-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-primary rounded" />
|
||||
Intäkter
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-0.5 bg-destructive rounded border-dashed" style={{ borderTop: '2px dashed hsl(0, 45%, 55%)' }} />
|
||||
Kostnader
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
X,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Sparkles,
|
||||
PiggyBank,
|
||||
AlertTriangle,
|
||||
ListTodo,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TourStep {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
highlight?: string // CSS selector or element ID to highlight
|
||||
position?: 'top' | 'bottom' | 'center'
|
||||
}
|
||||
|
||||
const TOUR_COMPLETED_KEY = 'erp_dashboard_tour_completed'
|
||||
|
||||
const tourSteps: TourStep[] = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Välkommen till din översikt!',
|
||||
description:
|
||||
'Här ser du hela din ekonomiska situation på ett ställe. Låt oss gå igenom de viktigaste delarna.',
|
||||
icon: Sparkles,
|
||||
position: 'center',
|
||||
},
|
||||
{
|
||||
id: 'disponibelt',
|
||||
title: 'Ditt att spendera',
|
||||
description:
|
||||
'Detta är vad du faktiskt kan använda efter att vi räknat bort uppskattad skatt och moms. Tänk på det som "säkra pengar".',
|
||||
icon: PiggyBank,
|
||||
highlight: 'disponibelt-section',
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
id: 'fskatt',
|
||||
title: 'F-skatt varning',
|
||||
description:
|
||||
'Vi jämför din faktiska skatt med vad du betalat in via F-skatt. Om det skiljer sig mycket får du en varning så du kan justera.',
|
||||
icon: AlertTriangle,
|
||||
highlight: 'fskatt-section',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
id: 'att-hantera',
|
||||
title: 'Att hantera',
|
||||
description:
|
||||
'Här samlas saker som behöver din uppmärksamhet: okategoriserade transaktioner, obetalda fakturor, och liknande.',
|
||||
icon: ListTodo,
|
||||
highlight: 'alerts-section',
|
||||
position: 'top',
|
||||
},
|
||||
{
|
||||
id: 'snabbatgarder',
|
||||
title: 'Snabbåtgärder',
|
||||
description:
|
||||
'Skapa fakturor, logga körningar eller skanna kvitton med ett klick. De vanligaste uppgifterna finns alltid nära till hands.',
|
||||
icon: Zap,
|
||||
highlight: 'quick-actions',
|
||||
position: 'top',
|
||||
},
|
||||
]
|
||||
|
||||
interface DashboardTourProps {
|
||||
onComplete?: () => void
|
||||
forceShow?: boolean
|
||||
}
|
||||
|
||||
export default function DashboardTour({ onComplete, forceShow = false }: DashboardTourProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isAnimating, setIsAnimating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if tour has been completed
|
||||
const completed = localStorage.getItem(TOUR_COMPLETED_KEY)
|
||||
if (forceShow || completed !== 'true') {
|
||||
// Small delay to let the page render first
|
||||
const timer = setTimeout(() => setIsVisible(true), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [forceShow])
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsAnimating(true)
|
||||
setTimeout(() => {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, 'true')
|
||||
setIsVisible(false)
|
||||
onComplete?.()
|
||||
}, 300)
|
||||
}, [onComplete])
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < tourSteps.length - 1) {
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
} else {
|
||||
completeTour()
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const skipTour = () => {
|
||||
completeTour()
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
const step = tourSteps[currentStep]
|
||||
const Icon = step.icon
|
||||
const isLastStep = currentStep === tourSteps.length - 1
|
||||
const isFirstStep = currentStep === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-background/80 backdrop-blur-sm z-50 transition-opacity duration-300',
|
||||
isAnimating ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
onClick={skipTour}
|
||||
/>
|
||||
|
||||
{/* Tour card */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed z-50 transition-all duration-300',
|
||||
step.position === 'center' && 'inset-0 flex items-center justify-center p-4',
|
||||
step.position === 'top' && 'top-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
|
||||
step.position === 'bottom' && 'bottom-20 md:bottom-4 left-1/2 -translate-x-1/2 w-full max-w-md px-4',
|
||||
isAnimating && 'opacity-0 scale-95'
|
||||
)}
|
||||
>
|
||||
<Card className="shadow-2xl border-primary/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header with icon and close */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">{step.title}</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Steg {currentStep + 1} av {tourSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -mr-2 -mt-2"
|
||||
onClick={skipTour}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Stäng guide</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-center gap-1.5 py-2">
|
||||
{tourSteps.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentStep(idx)}
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-all',
|
||||
idx === currentStep
|
||||
? 'bg-primary w-4'
|
||||
: idx < currentStep
|
||||
? 'bg-primary/40'
|
||||
: 'bg-muted-foreground/20'
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={prevStep}
|
||||
disabled={isFirstStep}
|
||||
className={cn(isFirstStep && 'invisible')}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={skipTour}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Hoppa över
|
||||
</Button>
|
||||
|
||||
<Button size="sm" onClick={nextStep}>
|
||||
{isLastStep ? (
|
||||
'Kom igång'
|
||||
) : (
|
||||
<>
|
||||
Nästa
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook för att starta om touren
|
||||
*/
|
||||
export function useDashboardTour() {
|
||||
const resetTour = () => {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const isTourCompleted = () => {
|
||||
return localStorage.getItem(TOUR_COMPLETED_KEY) === 'true'
|
||||
}
|
||||
|
||||
return { resetTour, isTourCompleted }
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { InfoTooltip } from '@/components/ui/info-tooltip'
|
||||
import { Loader2, ArrowRight, ArrowLeft, Landmark } from 'lucide-react'
|
||||
|
||||
const schema = z.object({
|
||||
bank_name: z.string().optional(),
|
||||
clearing_number: z.string().optional(),
|
||||
account_number: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
interface Step5Props {
|
||||
initialData: Partial<FormData>
|
||||
onNext: (data: FormData) => void
|
||||
onBack: () => void
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export default function Step5BankDetails({
|
||||
initialData,
|
||||
onNext,
|
||||
onBack,
|
||||
isSaving,
|
||||
}: Step5Props) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
bank_name: initialData.bank_name || '',
|
||||
clearing_number: initialData.clearing_number || '',
|
||||
account_number: initialData.account_number || '',
|
||||
iban: initialData.iban || '',
|
||||
bic: initialData.bic || '',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Bankuppgifter för fakturor</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Dessa uppgifter visas på dina fakturor så att kunder kan betala dig.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Landmark className="h-5 w-5" />
|
||||
Bankkonto
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ange kontot som kunder ska betala till.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onNext)} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bank_name">Bank</Label>
|
||||
<Input
|
||||
id="bank_name"
|
||||
placeholder="t.ex. Nordea, SEB, Swedbank"
|
||||
{...register('bank_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är clearingnummer?</p>
|
||||
<p>De första 4-5 siffrorna i ditt kontonummer som identifierar din bank.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: 3300</li>
|
||||
<li>SEB: 5000</li>
|
||||
<li>Swedbank: 8XXX</li>
|
||||
<li>Handelsbanken: 6XXX</li>
|
||||
<li>Avanza: 9550/9551</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Label htmlFor="clearing_number">Clearingnummer</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="clearing_number"
|
||||
placeholder="XXXX"
|
||||
{...register('clearing_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="account_number">Kontonummer</Label>
|
||||
<Input
|
||||
id="account_number"
|
||||
placeholder="XXX XXX XXX"
|
||||
{...register('account_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<h4 className="font-medium mb-4">Internationella betalningar (valfritt)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
För kunder utanför Sverige som betalar via banköverföring.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är IBAN?</p>
|
||||
<p>Internationellt bankkontonummer. Svenska IBAN börjar med SE och har 24 tecken totalt.</p>
|
||||
<p className="text-xs text-muted-foreground">Din bank kan ge dig ditt IBAN - kolla internetbanken eller kontakta dem.</p>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="iban"
|
||||
placeholder="SE00 0000 0000 0000 0000 0000"
|
||||
{...register('iban')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<InfoTooltip
|
||||
content={
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">Vad är BIC/SWIFT?</p>
|
||||
<p>Bankens internationella id-kod. Används tillsammans med IBAN för utlandsbetalningar.</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1">
|
||||
<li>Nordea: NDEASESS</li>
|
||||
<li>SEB: ESSESESS</li>
|
||||
<li>Swedbank: SWEDSESS</li>
|
||||
<li>Handelsbanken: HANDSESS</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
side="right"
|
||||
>
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
</InfoTooltip>
|
||||
<Input
|
||||
id="bic"
|
||||
placeholder="XXXXSESS"
|
||||
{...register('bic')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onBack}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Tillbaka
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Fortsätt
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { CreateSupplierInput, SupplierType } from '@/types'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Namn krävs'),
|
||||
supplier_type: z.enum(['swedish_business', 'eu_business', 'non_eu_business']),
|
||||
email: z.string().email('Ogiltig e-postadress').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
address_line1: z.string().optional(),
|
||||
address_line2: z.string().optional(),
|
||||
postal_code: z.string().optional(),
|
||||
city: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
org_number: z.string().optional(),
|
||||
vat_number: z.string().optional(),
|
||||
bankgiro: z.string().optional(),
|
||||
plusgiro: z.string().optional(),
|
||||
iban: z.string().optional(),
|
||||
bic: z.string().optional(),
|
||||
default_expense_account: z.string().optional(),
|
||||
default_payment_terms: z.number().min(1).optional(),
|
||||
default_currency: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
interface SupplierFormProps {
|
||||
onSubmit: (data: CreateSupplierInput) => Promise<void>
|
||||
isLoading: boolean
|
||||
initialData?: Partial<FormData>
|
||||
}
|
||||
|
||||
export default function SupplierForm({
|
||||
onSubmit,
|
||||
isLoading,
|
||||
initialData,
|
||||
}: SupplierFormProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: initialData?.name || '',
|
||||
supplier_type: initialData?.supplier_type || 'swedish_business',
|
||||
email: initialData?.email || '',
|
||||
phone: initialData?.phone || '',
|
||||
address_line1: initialData?.address_line1 || '',
|
||||
postal_code: initialData?.postal_code || '',
|
||||
city: initialData?.city || '',
|
||||
country: initialData?.country || 'SE',
|
||||
org_number: initialData?.org_number || '',
|
||||
vat_number: initialData?.vat_number || '',
|
||||
bankgiro: initialData?.bankgiro || '',
|
||||
plusgiro: initialData?.plusgiro || '',
|
||||
iban: initialData?.iban || '',
|
||||
bic: initialData?.bic || '',
|
||||
default_expense_account: initialData?.default_expense_account || '',
|
||||
default_payment_terms: initialData?.default_payment_terms || 30,
|
||||
default_currency: initialData?.default_currency || 'SEK',
|
||||
notes: initialData?.notes || '',
|
||||
},
|
||||
})
|
||||
|
||||
const onFormSubmit = (data: FormData) => {
|
||||
onSubmit({
|
||||
...data,
|
||||
email: data.email || undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6">
|
||||
{/* Supplier Type */}
|
||||
<div className="space-y-2">
|
||||
<Label>Leverantörstyp *</Label>
|
||||
<Controller
|
||||
name="supplier_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Välj typ" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
|
||||
<SelectItem value="eu_business">EU-företag</SelectItem>
|
||||
<SelectItem value="non_eu_business">Företag utanför EU</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Namn *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Leverantörens namn"
|
||||
{...register('name')}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-post</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="fakturor@foretag.se"
|
||||
{...register('email')}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-destructive">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Telefon</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
placeholder="+46 8 123 45 67"
|
||||
{...register('phone')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business info */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Företagsuppgifter</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="org_number">Organisationsnummer</Label>
|
||||
<Input
|
||||
id="org_number"
|
||||
placeholder="XXXXXX-XXXX"
|
||||
{...register('org_number')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vat_number">VAT-nummer</Label>
|
||||
<Input
|
||||
id="vat_number"
|
||||
placeholder="SE123456789001"
|
||||
{...register('vat_number')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Adress</h3>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address_line1">Gatuadress</Label>
|
||||
<Input
|
||||
id="address_line1"
|
||||
placeholder="Storgatan 1"
|
||||
{...register('address_line1')}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="postal_code">Postnummer</Label>
|
||||
<Input id="postal_code" placeholder="123 45" {...register('postal_code')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="city">Ort</Label>
|
||||
<Input id="city" placeholder="Stockholm" {...register('city')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Land</Label>
|
||||
<Input id="country" placeholder="SE" {...register('country')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment details */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Betalningsuppgifter</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bankgiro">Bankgiro</Label>
|
||||
<Input id="bankgiro" placeholder="XXX-XXXX" {...register('bankgiro')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plusgiro">Plusgiro</Label>
|
||||
<Input id="plusgiro" placeholder="XXXXXXX-X" {...register('plusgiro')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="iban">IBAN</Label>
|
||||
<Input id="iban" placeholder="SE00 0000 0000 0000 0000 0000" {...register('iban')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bic">BIC/SWIFT</Label>
|
||||
<Input id="bic" placeholder="SWEDSESS" {...register('bic')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defaults */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="font-medium">Standardvärden</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_expense_account">Kostnadskonto</Label>
|
||||
<Input
|
||||
id="default_expense_account"
|
||||
placeholder="5010"
|
||||
{...register('default_expense_account')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Standardkonto för denna leverantör</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_terms">Betalningsvillkor (dagar)</Label>
|
||||
<Input
|
||||
id="payment_terms"
|
||||
type="number"
|
||||
{...register('default_payment_terms', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_currency">Valuta</Label>
|
||||
<Controller
|
||||
name="default_currency"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SEK">SEK</SelectItem>
|
||||
<SelectItem value="EUR">EUR</SelectItem>
|
||||
<SelectItem value="USD">USD</SelectItem>
|
||||
<SelectItem value="GBP">GBP</SelectItem>
|
||||
<SelectItem value="NOK">NOK</SelectItem>
|
||||
<SelectItem value="DKK">DKK</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes">Anteckningar</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Interna anteckningar..."
|
||||
{...register('notes')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Sparar...
|
||||
</>
|
||||
) : (
|
||||
'Spara leverantör'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -100,14 +100,14 @@ export function EmptyInvoices() {
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyCustomers() {
|
||||
export function EmptyCustomers({ onAction }: { onAction?: () => void } = {}) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Inga kunder ännu"
|
||||
description="Lägg till dina kunder för att enkelt skapa fakturor och hålla koll på betalningar."
|
||||
actionLabel="Lägg till kund"
|
||||
actionHref="/customers/new"
|
||||
onAction={onAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ProgressSegment {
|
||||
value: number
|
||||
color: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface SegmentedProgressProps {
|
||||
segments: ProgressSegment[]
|
||||
total: number
|
||||
height?: string
|
||||
className?: string
|
||||
showLabels?: boolean
|
||||
}
|
||||
|
||||
export function SegmentedProgress({
|
||||
segments,
|
||||
total,
|
||||
height = 'h-2',
|
||||
className,
|
||||
showLabels = false,
|
||||
}: SegmentedProgressProps) {
|
||||
const percentages = segments.map((segment) => ({
|
||||
...segment,
|
||||
percentage: total > 0 ? (segment.value / total) * 100 : 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative w-full overflow-hidden rounded-full bg-secondary',
|
||||
height
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full w-full">
|
||||
{percentages.map((segment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-full transition-all duration-700 ease-out',
|
||||
index === 0 && 'rounded-l-full',
|
||||
index === percentages.length - 1 && 'rounded-r-full'
|
||||
)}
|
||||
style={{
|
||||
width: `${segment.percentage}%`,
|
||||
backgroundColor: segment.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showLabels && (
|
||||
<div className="flex justify-between mt-2 text-xs">
|
||||
{percentages.map((segment, index) => (
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: segment.color }}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{segment.label || `${segment.percentage.toFixed(0)}%`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Nordic color scheme for progress bars
|
||||
export const PROGRESS_COLORS = {
|
||||
success: 'hsl(150 30% 40%)', // Forest green
|
||||
warning: 'hsl(38 70% 55%)', // Warm honey
|
||||
destructive: 'hsl(0 45% 55%)', // Muted rose
|
||||
primary: 'hsl(145 20% 36%)', // Sage
|
||||
muted: 'hsl(30 8% 45%)', // Stone
|
||||
// Tax display - Nordic palette
|
||||
disponibelt: '#4d7c65', // Sage green
|
||||
skatt: '#c17f59', // Terracotta
|
||||
moms: '#b8956c', // Warm honey
|
||||
}
|
||||
|
||||
interface TaxProgressProps {
|
||||
disponibelt: number
|
||||
skattReservation: number
|
||||
momsReservation: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TaxProgress({
|
||||
disponibelt,
|
||||
skattReservation,
|
||||
momsReservation,
|
||||
className,
|
||||
}: TaxProgressProps) {
|
||||
const total = disponibelt + skattReservation + momsReservation
|
||||
|
||||
const segments: ProgressSegment[] = [
|
||||
{
|
||||
value: disponibelt,
|
||||
color: PROGRESS_COLORS.disponibelt,
|
||||
label: 'Disponibelt',
|
||||
},
|
||||
{
|
||||
value: skattReservation,
|
||||
color: PROGRESS_COLORS.skatt,
|
||||
label: 'Skatt',
|
||||
},
|
||||
]
|
||||
|
||||
if (momsReservation > 0) {
|
||||
segments.push({
|
||||
value: momsReservation,
|
||||
color: PROGRESS_COLORS.moms,
|
||||
label: 'Moms',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<SegmentedProgress
|
||||
segments={segments}
|
||||
total={total}
|
||||
height="h-3"
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
# Product Requirements Document: Influencer Business-in-a-Box MVP
|
||||
|
||||
## Executive Summary
|
||||
|
||||
A financial management system for Swedish influencers and content creators operating as sole proprietors (enskild firma). The core value proposition: eliminate tax anxiety and administrative burden through automated compliance.
|
||||
|
||||
## Target User
|
||||
|
||||
- Swedish content creators operating as:
|
||||
- Enskild näringsidkare (sole proprietor)
|
||||
- Aktiebolag (limited company)
|
||||
- Income from multiple sources: brand deals, affiliate
|
||||
- Revenue range: 50k-2M SEK/year
|
||||
- Pain: Fear of Skatteverket, hatred of admin, confusion about VAT on international payments
|
||||
|
||||
## Entity Type Differences
|
||||
|
||||
| Aspect | Enskild Firma | Aktiebolag |
|
||||
|--------|---------------|------------|
|
||||
| Tax on profit | Egenavgifter (28.97%) + kommunalskatt (~32%) | Bolagsskatt (20.6%) |
|
||||
| Owner payment | Eget uttag (withdrawal) | Lön + utdelning |
|
||||
| Annual filing | NE-bilaga | Årsredovisning + INK2 |
|
||||
| VAT | Same rules | Same rules |
|
||||
| Personal liability | Yes | No (limited) |
|
||||
|
||||
MVP focuses on shared features (invoicing, VAT, transactions). Entity-specific tax calculations adapt based on company_settings.entity_type.
|
||||
|
||||
## MVP Scope
|
||||
|
||||
### In Scope (Phase 1)
|
||||
|
||||
1. **Real-time Tax Dashboard** ("Disponibelt Saldo")
|
||||
- Show available balance after estimated tax, employer contributions (egenavgifter), and VAT
|
||||
- Visual "locked" vs "spendable" money display
|
||||
- Adapt calculations based on entity type (enskild firma vs AB)
|
||||
- Compare against user's debiterad preliminärskatt (F-skatt level) to warn about under/overpayment
|
||||
|
||||
2. **Bank Integration via Enable Banking (PSD2)**
|
||||
- Connect multiple bank accounts
|
||||
- Real-time transaction sync
|
||||
- Consent renewal handling (90-180 days)
|
||||
|
||||
3. **Transaction Categorization**
|
||||
- Swipe interface: Business / Private / Unsure
|
||||
- Expense categories with schablonavdrag suggestions
|
||||
- Smart defaults: hemmakontor 2000 kr/year, bil 18.50 kr/mil
|
||||
|
||||
4. **Invoice Generation**
|
||||
- Create professional PDF invoices
|
||||
- Multi-currency support (SEK, EUR, USD)
|
||||
- Auto-convert foreign currency to SEK using Riksbanken daily rates
|
||||
- Automatic VAT logic:
|
||||
- Swedish customer: 25% VAT
|
||||
- EU business (with VAT number): Reverse charge
|
||||
- Non-EU: No VAT
|
||||
- Customizable starting invoice number
|
||||
- Credit notes (kreditfaktura) for corrections
|
||||
|
||||
5. **Income Overview**
|
||||
- Aggregate invoiced revenue + platform payouts
|
||||
- Track against VAT registration threshold (80k SEK)
|
||||
- Selectable momsperiod (monthly/quarterly)
|
||||
|
||||
### Out of Scope (Future Phases)
|
||||
|
||||
- Receipt photo capture and matching
|
||||
- Benefits/gifts tracking (förmånshantering)
|
||||
- NE-bilaga / Årsredovisning auto-generation
|
||||
- Automated payment reminders
|
||||
- Quotes/proforma invoices
|
||||
- English UI
|
||||
|
||||
## User Stories
|
||||
|
||||
### Onboarding (Required before feature access)
|
||||
- As a new user, I complete a guided setup wizard before accessing the app
|
||||
- As a user, I select my entity type (enskild firma or aktiebolag)
|
||||
- As a user, I enter my company details (name, org.nr, address)
|
||||
- As a user, I confirm my F-skatt registration status
|
||||
- As a user, I set my VAT registration status and momsperiod
|
||||
- As a user, I enter my preliminary tax (debiterad F-skatt) amount
|
||||
- As a user, I add my bank details for invoice payments
|
||||
- As a user, I connect at least one bank account via Enable Banking
|
||||
- As a user, I cannot skip required fields - all must be completed
|
||||
|
||||
### Dashboard
|
||||
- As a user, I see my total balance, locked tax amount, and spendable amount immediately on login
|
||||
- As a user, I see a warning when approaching VAT registration threshold
|
||||
|
||||
### Transactions
|
||||
- As a user, I connect my bank account securely
|
||||
- As a user, I categorize transactions by swiping
|
||||
- As a user, I can edit categorization later
|
||||
- As a user, I see uncategorized transaction count as a badge
|
||||
|
||||
### Invoicing
|
||||
- As a user, I create an invoice in under 60 seconds
|
||||
- As a user, the system auto-detects if reverse charge applies
|
||||
- As a user, I download PDF or copy link to hosted invoice
|
||||
- As a user, I see invoice status: draft, sent, paid, overdue
|
||||
|
||||
### Settings
|
||||
- As a user, I enter my company details once (org.nr, F-skatt, address)
|
||||
- As a user, I save customer profiles for repeat invoicing
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Time to first invoice: <5 minutes from signup
|
||||
- Transaction categorization rate: >80% within 7 days
|
||||
- User retention at 30 days: >40%
|
||||
|
||||
## Constraints
|
||||
|
||||
- Must comply with Swedish Bokföringslagen
|
||||
- Bank integration must use licensed PSD2 provider (TSP model via Tink/Nordigen)
|
||||
- Invoice PDF must contain all legally required fields
|
||||
- All financial calculations are estimates, not tax advice (disclaimer required)
|
||||
- 7-year retention of all räkenskapsinformation per Bokföringslagen
|
||||
- Non-custodial design: app never holds user funds
|
||||
- GDPR compliant with EU/EES data residency
|
||||
|
||||
## Regulatory Notes for MVP
|
||||
|
||||
### PSD2 Strategy
|
||||
Use Technical Service Provider (TSP) model. Nordigen offers free AISP tier. Avoids 125k EUR capital requirement and FI licensing process.
|
||||
|
||||
### Non-Custodial Principle
|
||||
Critical: System must never hold client funds. For tax savings feature, use PISP to move funds to user's own separate bank account, not an app-controlled wallet. This avoids Lagen om redovisningsmedel requirements.
|
||||
|
||||
### Data Retention
|
||||
- Bokföringslagen requires 7-year retention of verifikationer
|
||||
- GDPR "right to be forgotten" does not override this legal obligation
|
||||
- Technical lock must prevent deletion of accounting records even on account closure
|
||||
- Non-accounting data (support chats, preferences) can be deleted on request
|
||||
|
||||
### Digital Archiving (July 2024 Law)
|
||||
Receipt photos must be stored in immutable format (PDF/A or locked JPEG). Paper originals no longer required if digital capture is tamper-proof.
|
||||
@@ -1,343 +0,0 @@
|
||||
# Technical Architecture Document
|
||||
|
||||
## Stack Overview
|
||||
|
||||
```
|
||||
Frontend: Next.js (App Router) + React + TypeScript
|
||||
Styling: Tailwind CSS + shadcn/ui
|
||||
Backend: Next.js API Routes + Supabase Edge Functions
|
||||
Database: Supabase (PostgreSQL)
|
||||
Auth: Supabase Auth (magic link + Google OAuth)
|
||||
Storage: Supabase Storage (invoice PDFs)
|
||||
Banking: Enable Banking (PSD2 AISP)
|
||||
PDF Gen: @react-pdf/renderer or Puppeteer
|
||||
Hosting: Vercel
|
||||
Currency: Riksbanken API for exchange rates
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/
|
||||
│ ├── (auth)/
|
||||
│ │ ├── login/page.tsx
|
||||
│ │ └── callback/page.tsx
|
||||
│ ├── (onboarding)/
|
||||
│ │ └── onboarding/
|
||||
│ │ ├── page.tsx # Wizard container
|
||||
│ │ ├── step-1/page.tsx # Entity type
|
||||
│ │ ├── step-2/page.tsx # Company details
|
||||
│ │ ├── step-3/page.tsx # Tax registration
|
||||
│ │ ├── step-4/page.tsx # Preliminary tax
|
||||
│ │ ├── step-5/page.tsx # Bank details for invoices
|
||||
│ │ └── step-6/page.tsx # Connect bank (Enable Banking)
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── layout.tsx
|
||||
│ │ ├── page.tsx # Main dashboard
|
||||
│ │ ├── transactions/
|
||||
│ │ │ └── page.tsx # Swipe categorization
|
||||
│ │ ├── invoices/
|
||||
│ │ │ ├── page.tsx # Invoice list
|
||||
│ │ │ ├── new/page.tsx # Create invoice
|
||||
│ │ │ ├── [id]/page.tsx # Invoice detail
|
||||
│ │ │ └── [id]/credit/page.tsx # Create credit note
|
||||
│ │ ├── customers/
|
||||
│ │ │ └── page.tsx # Customer management
|
||||
│ │ └── settings/
|
||||
│ │ └── page.tsx # Company profile
|
||||
│ └── api/
|
||||
│ ├── banking/
|
||||
│ │ ├── connect/route.ts # Initiate Enable Banking connection
|
||||
│ │ ├── callback/route.ts # PSD2 callback
|
||||
│ │ └── sync/route.ts # Fetch transactions
|
||||
│ ├── invoices/
|
||||
│ │ ├── route.ts # CRUD
|
||||
│ │ ├── [id]/pdf/route.ts # Generate PDF
|
||||
│ │ └── [id]/credit/route.ts # Create credit note
|
||||
│ ├── currency/
|
||||
│ │ └── rates/route.ts # Riksbanken exchange rates
|
||||
│ └── webhooks/
|
||||
│ └── banking/route.ts # Transaction webhooks
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn components
|
||||
│ ├── onboarding/
|
||||
│ │ ├── WizardProgress.tsx
|
||||
│ │ ├── EntityTypeSelector.tsx
|
||||
│ │ └── BankConnectButton.tsx
|
||||
│ ├── dashboard/
|
||||
│ │ ├── BalanceCard.tsx
|
||||
│ │ ├── TaxBreakdown.tsx
|
||||
│ │ ├── FskattWarning.tsx
|
||||
│ │ └── RevenueChart.tsx
|
||||
│ ├── transactions/
|
||||
│ │ ├── SwipeCard.tsx
|
||||
│ │ └── TransactionList.tsx
|
||||
│ └── invoices/
|
||||
│ ├── InvoiceForm.tsx
|
||||
│ ├── InvoicePreview.tsx
|
||||
│ ├── CurrencySelector.tsx
|
||||
│ └── CustomerSelect.tsx
|
||||
├── lib/
|
||||
│ ├── supabase/
|
||||
│ │ ├── client.ts # Browser client
|
||||
│ │ ├── server.ts # Server client
|
||||
│ │ └── admin.ts # Service role client
|
||||
│ ├── banking/
|
||||
│ │ └── enable-banking.ts # Enable Banking API wrapper
|
||||
│ ├── currency/
|
||||
│ │ └── riksbanken.ts # Exchange rate fetching
|
||||
│ ├── tax/
|
||||
│ │ ├── calculator.ts # Swedish tax calculations
|
||||
│ │ ├── expense-warnings.ts # Non-deductible expense alerts
|
||||
│ │ ├── schablonavdrag.ts # Standard deductions
|
||||
│ │ └── fskatt-warning.ts # Preliminary tax comparison
|
||||
│ ├── invoice/
|
||||
│ │ ├── vat-rules.ts # VAT logic
|
||||
│ │ └── pdf-generator.ts
|
||||
│ └── utils/
|
||||
│ └── currency.ts
|
||||
├── types/
|
||||
│ └── index.ts # TypeScript interfaces
|
||||
└── supabase/
|
||||
├── migrations/ # Database migrations
|
||||
└── seed.sql # Test data
|
||||
```
|
||||
|
||||
## Authentication Flow
|
||||
|
||||
1. User lands on `/login`
|
||||
2. Enters email → Supabase sends magic link
|
||||
3. User clicks link → redirected to `/callback`
|
||||
4. Callback exchanges code for session
|
||||
5. **Onboarding check:** Query `company_settings` for user
|
||||
- If no settings exist → redirect to `/onboarding`
|
||||
- If settings exist but `onboarding_complete = false` → redirect to `/onboarding`
|
||||
- If settings complete → redirect to `/` (dashboard)
|
||||
|
||||
Alternative: Google OAuth for faster onboarding (still requires completing wizard).
|
||||
|
||||
## Onboarding Flow
|
||||
|
||||
```
|
||||
1. User completes auth
|
||||
2. Middleware checks company_settings.onboarding_complete
|
||||
3. If incomplete → force redirect to /onboarding
|
||||
4. Wizard steps (cannot skip):
|
||||
a. Entity type selection
|
||||
b. Company details (name, org.nr, address)
|
||||
c. Tax registration (F-skatt, VAT, momsperiod)
|
||||
d. Preliminary tax amount (optional but encouraged)
|
||||
e. Bank details for invoices
|
||||
f. Connect bank via Enable Banking (required)
|
||||
5. On completion → set onboarding_complete = true
|
||||
6. Redirect to dashboard
|
||||
```
|
||||
|
||||
**Middleware Logic:**
|
||||
```typescript
|
||||
// middleware.ts - simplified
|
||||
const publicRoutes = ['/login', '/auth/callback']
|
||||
const onboardingRoutes = ['/onboarding']
|
||||
|
||||
if (!user) {
|
||||
// Not logged in → login page
|
||||
return redirect('/login')
|
||||
}
|
||||
|
||||
const settings = await getCompanySettings(user.id)
|
||||
|
||||
if (!settings?.onboarding_complete) {
|
||||
// Onboarding incomplete → force wizard
|
||||
if (!onboardingRoutes.some(r => path.startsWith(r))) {
|
||||
return redirect('/onboarding')
|
||||
}
|
||||
}
|
||||
|
||||
// Onboarding complete → allow access
|
||||
```
|
||||
|
||||
## Bank Connection Flow
|
||||
|
||||
### PSD2 Provider: Enable Banking
|
||||
|
||||
Using Enable Banking as the AISP provider.
|
||||
|
||||
**Why Enable Banking:**
|
||||
- Good coverage of Swedish banks (Nordea, SEB, Handelsbanken, Swedbank, etc.)
|
||||
- Reasonable pricing for startups
|
||||
- TSP model: they hold the license, you process data
|
||||
- REST API with good documentation
|
||||
|
||||
**Consent Lifecycle:**
|
||||
- PSD2 RTS allows 90-180 day consent periods (bank dependent)
|
||||
- System must track consent expiry per bank connection
|
||||
- Proactive notification: "Din bankkoppling till Nordea löper ut om 7 dagar"
|
||||
|
||||
**Multiple Accounts:**
|
||||
- Users can connect multiple bank accounts (business + savings)
|
||||
- Each connection tracked separately with own consent expiry
|
||||
- Dashboard aggregates across all connected accounts
|
||||
|
||||
```
|
||||
1. User clicks "Koppla bank"
|
||||
2. Frontend calls POST /api/banking/connect
|
||||
3. Backend creates Enable Banking session, returns redirect URL
|
||||
4. User selects bank, authenticates with BankID (SCA requirement)
|
||||
5. Enable Banking redirects to /api/banking/callback
|
||||
6. Backend stores connection metadata (consent expiry, account info)
|
||||
7. Initial transaction fetch (90 days history typical)
|
||||
8. User redirected to /transactions
|
||||
9. Webhook receives new transactions in real-time
|
||||
10. At consent expiry - 7 days: prompt re-consent flow
|
||||
```
|
||||
|
||||
### Non-Custodial Design (Critical)
|
||||
|
||||
The app must NEVER hold user funds. This avoids:
|
||||
- Lagen om redovisningsmedel (1944:181)
|
||||
- Klientmedelskonto requirements
|
||||
- Full betalningsinstitut licensing
|
||||
|
||||
For future "tax savings" feature:
|
||||
- Use PISP to instruct user's bank to transfer to user's OWN savings account
|
||||
- App orchestrates, never touches money
|
||||
- User maintains full control and ownership
|
||||
|
||||
## Currency Conversion
|
||||
|
||||
### Riksbanken Integration
|
||||
|
||||
For multi-currency invoicing, convert foreign amounts to SEK for bokföring.
|
||||
|
||||
```typescript
|
||||
// lib/currency/riksbanken.ts
|
||||
|
||||
interface ExchangeRate {
|
||||
currency: string
|
||||
rate: number
|
||||
date: string
|
||||
}
|
||||
|
||||
const RIKSBANKEN_API = 'https://api.riksbank.se/swea/v1/CrossRates'
|
||||
|
||||
export async function getExchangeRate(
|
||||
currency: 'EUR' | 'USD' | 'GBP',
|
||||
date: string // YYYY-MM-DD
|
||||
): Promise<number> {
|
||||
// Riksbanken publishes daily rates
|
||||
// Use booking date for invoice conversion
|
||||
const response = await fetch(
|
||||
`${RIKSBANKEN_API}/${currency}/SEK/${date}`
|
||||
)
|
||||
const data = await response.json()
|
||||
return data.value
|
||||
}
|
||||
|
||||
// Invoice creation: store both original and SEK amounts
|
||||
interface InvoiceAmounts {
|
||||
originalCurrency: string
|
||||
originalTotal: number
|
||||
exchangeRate: number
|
||||
exchangeRateDate: string
|
||||
sekTotal: number // This is what goes in bokföring
|
||||
}
|
||||
```
|
||||
|
||||
**Bokföringslagen requirement:** Foreign currency transactions must be converted to SEK. The exchange rate on the invoice date (fakturadatum) is typically used.
|
||||
|
||||
## Tax Calculation Logic
|
||||
|
||||
Swedish sole proprietor (enskild näringsidkare) tax components:
|
||||
|
||||
```typescript
|
||||
interface TaxBreakdown {
|
||||
grossIncome: number; // Total invoiced + received
|
||||
deductibleExpenses: number; // Categorized business expenses
|
||||
netIncome: number; // grossIncome - deductibleExpenses
|
||||
|
||||
// Egenavgifter (self-employment contributions) ~28.97%
|
||||
egenavgifter: number;
|
||||
|
||||
// Preliminary income tax (kommunalskatt ~32% avg)
|
||||
incomeTax: number;
|
||||
|
||||
// VAT collected (if registered)
|
||||
vatCollected: number;
|
||||
vatDeductible: number;
|
||||
vatPayable: number;
|
||||
|
||||
// Total locked
|
||||
totalLocked: number;
|
||||
|
||||
// Available to spend
|
||||
disponibelt: number;
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: These are estimates. Display clear disclaimer.
|
||||
|
||||
## VAT Rules Engine
|
||||
|
||||
```typescript
|
||||
type VatScenario =
|
||||
| 'swedish_customer' // 25% VAT
|
||||
| 'eu_business_valid_vat' // 0% + reverse charge text
|
||||
| 'eu_consumer' // 25% VAT (or destination country rate for digital services)
|
||||
| 'non_eu' // 0% VAT, export
|
||||
|
||||
interface VatDecision {
|
||||
rate: number;
|
||||
reverseCharge: boolean;
|
||||
invoiceText: string | null; // Legal text to include
|
||||
}
|
||||
```
|
||||
|
||||
Validation: EU VAT numbers validated via VIES API.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Row Level Security (RLS) on all tables
|
||||
- User can only access own data
|
||||
- Banking tokens encrypted at rest (Supabase Vault or env vars)
|
||||
- Invoice PDFs in private bucket, signed URLs for access
|
||||
- No PII in logs
|
||||
- Rate limiting on API routes
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
# Supabase
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Banking (Tink example)
|
||||
TINK_CLIENT_ID=
|
||||
TINK_CLIENT_SECRET=
|
||||
TINK_REDIRECT_URI=
|
||||
|
||||
# App
|
||||
NEXT_PUBLIC_APP_URL=https://app.yourdomain.se
|
||||
|
||||
# PDF
|
||||
PDF_STORAGE_BUCKET=invoices
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Vercel configuration:
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/banking/sync",
|
||||
"schedule": "0 6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Database migrations run via Supabase CLI or dashboard.
|
||||
@@ -1,688 +0,0 @@
|
||||
# Database Schema
|
||||
|
||||
## Entity Relationship Diagram
|
||||
|
||||
```
|
||||
users (Supabase Auth)
|
||||
│
|
||||
├── profiles (1:1)
|
||||
│ └── company_settings (1:1)
|
||||
│
|
||||
├── bank_connections (1:N)
|
||||
│ └── transactions (1:N)
|
||||
│
|
||||
├── customers (1:N)
|
||||
│
|
||||
└── invoices (1:N)
|
||||
└── invoice_items (1:N)
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
### profiles
|
||||
|
||||
Extends Supabase auth.users.
|
||||
|
||||
```sql
|
||||
CREATE TABLE profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
full_name TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can view own profile"
|
||||
ON profiles FOR SELECT
|
||||
USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can update own profile"
|
||||
ON profiles FOR UPDATE
|
||||
USING (auth.uid() = id);
|
||||
```
|
||||
|
||||
### company_settings
|
||||
|
||||
Business/tax information for the company (enskild firma or AB).
|
||||
|
||||
```sql
|
||||
CREATE TYPE entity_type AS ENUM (
|
||||
'enskild_firma',
|
||||
'aktiebolag'
|
||||
);
|
||||
|
||||
CREATE TYPE moms_period AS ENUM (
|
||||
'monthly',
|
||||
'quarterly'
|
||||
);
|
||||
|
||||
CREATE TABLE company_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
|
||||
-- Onboarding status
|
||||
onboarding_complete BOOLEAN DEFAULT FALSE,
|
||||
onboarding_step INTEGER DEFAULT 1, -- Track progress (1-6)
|
||||
|
||||
-- Entity type
|
||||
entity_type entity_type NOT NULL DEFAULT 'enskild_firma',
|
||||
|
||||
-- Company info
|
||||
company_name TEXT NOT NULL,
|
||||
org_number TEXT, -- Organisationsnummer (required for AB)
|
||||
vat_number TEXT, -- SE + org_number + 01 (if VAT registered)
|
||||
f_skatt_registered BOOLEAN DEFAULT TRUE,
|
||||
|
||||
-- Address
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
postal_code TEXT,
|
||||
city TEXT,
|
||||
country TEXT DEFAULT 'SE',
|
||||
|
||||
-- Banking
|
||||
bank_name TEXT,
|
||||
clearing_number TEXT,
|
||||
account_number TEXT,
|
||||
iban TEXT,
|
||||
bic TEXT,
|
||||
|
||||
-- Contact
|
||||
phone TEXT,
|
||||
website TEXT,
|
||||
|
||||
-- Tax settings
|
||||
municipal_tax_rate DECIMAL(5,2) DEFAULT 32.00, -- User's kommun (enskild firma)
|
||||
preliminary_tax_monthly DECIMAL(12,2), -- Debiterad F-skatt per month
|
||||
vat_registered BOOLEAN DEFAULT FALSE,
|
||||
vat_registration_date DATE,
|
||||
moms_period moms_period DEFAULT 'quarterly',
|
||||
|
||||
-- Fiscal year (räkenskapsår)
|
||||
fiscal_year_start_month INTEGER DEFAULT 1, -- 1 = calendar year, other = brutet räkenskapsår
|
||||
-- Example: 7 = July start (July 1 - June 30)
|
||||
|
||||
-- Invoice settings
|
||||
invoice_prefix TEXT DEFAULT 'INV',
|
||||
next_invoice_number INTEGER DEFAULT 1001, -- Allow unusual starting numbers
|
||||
payment_terms_days INTEGER DEFAULT 30,
|
||||
default_currency TEXT DEFAULT 'SEK',
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE company_settings ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own company settings"
|
||||
ON company_settings FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### bank_connections
|
||||
|
||||
PSD2 bank connection records.
|
||||
|
||||
```sql
|
||||
CREATE TABLE bank_connections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
|
||||
provider TEXT NOT NULL, -- 'tink', 'enablebanking'
|
||||
provider_connection_id TEXT, -- External ID
|
||||
bank_name TEXT,
|
||||
account_name TEXT,
|
||||
account_number_masked TEXT, -- Last 4 digits
|
||||
|
||||
status TEXT DEFAULT 'pending', -- pending, active, expired, error
|
||||
consent_expires_at TIMESTAMPTZ,
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
|
||||
-- Encrypted tokens stored in Supabase Vault or as encrypted column
|
||||
access_token_encrypted TEXT,
|
||||
refresh_token_encrypted TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE bank_connections ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own bank connections"
|
||||
ON bank_connections FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### transactions
|
||||
|
||||
Bank transactions imported via PSD2.
|
||||
|
||||
```sql
|
||||
CREATE TYPE transaction_category AS ENUM (
|
||||
'uncategorized',
|
||||
'business_income',
|
||||
'business_expense',
|
||||
'private',
|
||||
'tax_payment',
|
||||
'vat_payment',
|
||||
'transfer'
|
||||
);
|
||||
|
||||
CREATE TYPE expense_type AS ENUM (
|
||||
'equipment',
|
||||
'software',
|
||||
'travel',
|
||||
'meals',
|
||||
'office',
|
||||
'marketing',
|
||||
'professional_services',
|
||||
'other'
|
||||
);
|
||||
|
||||
CREATE TABLE transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
bank_connection_id UUID REFERENCES bank_connections(id) ON DELETE SET NULL,
|
||||
|
||||
-- Transaction data from bank
|
||||
external_id TEXT, -- Bank's transaction ID
|
||||
booking_date DATE NOT NULL,
|
||||
value_date DATE,
|
||||
amount DECIMAL(12,2) NOT NULL, -- Negative = expense, Positive = income
|
||||
currency TEXT DEFAULT 'SEK',
|
||||
description TEXT,
|
||||
counterparty_name TEXT,
|
||||
counterparty_account TEXT,
|
||||
mcc_code TEXT, -- Merchant Category Code from bank
|
||||
|
||||
-- Categorization
|
||||
category transaction_category DEFAULT 'uncategorized',
|
||||
expense_type expense_type,
|
||||
business_percentage INTEGER DEFAULT 100, -- For mixed-use items
|
||||
|
||||
-- BAS Accounting
|
||||
bas_account TEXT, -- e.g., '5410', '2013', '5910'
|
||||
bas_account_auto BOOLEAN DEFAULT FALSE, -- Was this auto-categorized?
|
||||
bas_account_confidence TEXT, -- 'high', 'medium', 'low'
|
||||
reverse_charge_applied BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- VAT tracking
|
||||
vat_amount DECIMAL(12,2), -- Calculated or from receipt
|
||||
vat_rate DECIMAL(5,2), -- 25, 12, 6, or 0
|
||||
|
||||
-- Metadata
|
||||
notes TEXT,
|
||||
receipt_url TEXT, -- Link to uploaded receipt
|
||||
linked_invoice_id UUID, -- If this is payment for an invoice
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, external_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_transactions_user_date ON transactions(user_id, booking_date DESC);
|
||||
CREATE INDEX idx_transactions_category ON transactions(user_id, category);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own transactions"
|
||||
ON transactions FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### customers
|
||||
|
||||
Invoice recipients.
|
||||
|
||||
```sql
|
||||
CREATE TYPE customer_type AS ENUM (
|
||||
'individual',
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business'
|
||||
);
|
||||
|
||||
CREATE TABLE customers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
|
||||
customer_type customer_type NOT NULL,
|
||||
|
||||
-- Basic info
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
|
||||
-- Business info
|
||||
org_number TEXT,
|
||||
vat_number TEXT, -- For EU reverse charge validation
|
||||
vat_number_validated BOOLEAN DEFAULT FALSE,
|
||||
vat_number_validated_at TIMESTAMPTZ,
|
||||
|
||||
-- Address
|
||||
address_line1 TEXT,
|
||||
address_line2 TEXT,
|
||||
postal_code TEXT,
|
||||
city TEXT,
|
||||
country TEXT NOT NULL DEFAULT 'SE',
|
||||
|
||||
-- Defaults for invoicing
|
||||
default_payment_terms_days INTEGER,
|
||||
default_currency TEXT DEFAULT 'SEK',
|
||||
|
||||
notes TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own customers"
|
||||
ON customers FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### invoices
|
||||
|
||||
```sql
|
||||
CREATE TYPE invoice_status AS ENUM (
|
||||
'draft',
|
||||
'sent',
|
||||
'viewed',
|
||||
'paid',
|
||||
'overdue',
|
||||
'cancelled'
|
||||
);
|
||||
|
||||
CREATE TYPE invoice_type AS ENUM (
|
||||
'invoice',
|
||||
'credit_note'
|
||||
);
|
||||
|
||||
CREATE TYPE vat_treatment AS ENUM (
|
||||
'standard', -- Normal VAT rate
|
||||
'reverse_charge', -- EU B2B
|
||||
'export', -- Non-EU
|
||||
'exempt' -- VAT exempt services
|
||||
);
|
||||
|
||||
CREATE TABLE invoices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||
|
||||
-- Invoice identification
|
||||
invoice_number TEXT NOT NULL,
|
||||
invoice_type invoice_type NOT NULL DEFAULT 'invoice',
|
||||
credited_invoice_id UUID REFERENCES invoices(id), -- For credit notes: which invoice is credited
|
||||
reference TEXT, -- Customer's reference/PO number
|
||||
|
||||
-- Dates
|
||||
invoice_date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
due_date DATE NOT NULL,
|
||||
|
||||
-- Original currency (what customer pays)
|
||||
currency TEXT NOT NULL DEFAULT 'SEK',
|
||||
subtotal DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
vat_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
total DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
|
||||
-- SEK conversion (for bokföring)
|
||||
exchange_rate DECIMAL(10,6) DEFAULT 1.0,
|
||||
exchange_rate_date DATE,
|
||||
subtotal_sek DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
vat_amount_sek DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
total_sek DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
|
||||
-- VAT handling
|
||||
vat_treatment vat_treatment NOT NULL DEFAULT 'standard',
|
||||
vat_rate DECIMAL(5,2) DEFAULT 25.00,
|
||||
reverse_charge_text TEXT, -- Legal text for reverse charge
|
||||
moms_ruta INTEGER, -- Which ruta in momsdeklaration (5, 39, 40)
|
||||
|
||||
-- Status
|
||||
status invoice_status DEFAULT 'draft',
|
||||
sent_at TIMESTAMPTZ,
|
||||
viewed_at TIMESTAMPTZ,
|
||||
paid_at TIMESTAMPTZ,
|
||||
paid_amount DECIMAL(12,2),
|
||||
paid_currency TEXT,
|
||||
|
||||
-- PDF
|
||||
pdf_url TEXT,
|
||||
pdf_generated_at TIMESTAMPTZ,
|
||||
|
||||
-- Additional
|
||||
notes TEXT, -- Internal notes
|
||||
customer_notes TEXT, -- Visible on invoice
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
|
||||
UNIQUE(user_id, invoice_number),
|
||||
|
||||
-- Credit notes must reference an original invoice
|
||||
CONSTRAINT credit_note_requires_original CHECK (
|
||||
(invoice_type = 'credit_note' AND credited_invoice_id IS NOT NULL) OR
|
||||
(invoice_type = 'invoice')
|
||||
)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_invoices_user_status ON invoices(user_id, status);
|
||||
CREATE INDEX idx_invoices_due_date ON invoices(user_id, due_date);
|
||||
CREATE INDEX idx_invoices_type ON invoices(user_id, invoice_type);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own invoices"
|
||||
ON invoices FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### invoice_items
|
||||
|
||||
```sql
|
||||
CREATE TABLE invoice_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
|
||||
|
||||
description TEXT NOT NULL,
|
||||
quantity DECIMAL(10,2) NOT NULL DEFAULT 1,
|
||||
unit TEXT DEFAULT 'st', -- st, timmar, etc.
|
||||
unit_price DECIMAL(12,2) NOT NULL,
|
||||
|
||||
-- Calculated
|
||||
line_total DECIMAL(12,2) NOT NULL,
|
||||
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE invoice_items ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own invoice items"
|
||||
ON invoice_items FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM invoices
|
||||
WHERE invoices.id = invoice_items.invoice_id
|
||||
AND invoices.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### salary_payments (Aktiebolag only)
|
||||
|
||||
Track owner salary payments for AB entities.
|
||||
|
||||
```sql
|
||||
CREATE TABLE salary_payments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
|
||||
-- Payment details
|
||||
payment_date DATE NOT NULL,
|
||||
pay_period_start DATE NOT NULL, -- e.g., 2024-01-01
|
||||
pay_period_end DATE NOT NULL, -- e.g., 2024-01-31
|
||||
|
||||
-- Amounts
|
||||
gross_salary DECIMAL(12,2) NOT NULL, -- Bruttolön
|
||||
employer_contributions DECIMAL(12,2) NOT NULL, -- Arbetsgivaravgifter (31.42%)
|
||||
withheld_tax DECIMAL(12,2) NOT NULL, -- Preliminärskatteavdrag
|
||||
net_salary DECIMAL(12,2) NOT NULL, -- Nettolön (what's paid out)
|
||||
|
||||
-- Total cost to company
|
||||
total_cost DECIMAL(12,2) NOT NULL, -- gross + employer_contributions
|
||||
|
||||
-- Reporting status
|
||||
agi_reported BOOLEAN DEFAULT FALSE, -- AGI-deklaration submitted
|
||||
agi_reported_at TIMESTAMPTZ,
|
||||
|
||||
notes TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE salary_payments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "Users can CRUD own salary payments"
|
||||
ON salary_payments FOR ALL
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Index for period queries
|
||||
CREATE INDEX idx_salary_payments_period ON salary_payments(user_id, pay_period_start);
|
||||
```
|
||||
|
||||
### Constants for AB calculations
|
||||
|
||||
```sql
|
||||
-- Current rates (update annually)
|
||||
-- Arbetsgivaravgifter: 31.42% (2024)
|
||||
-- This should be configurable or fetched from a rates table
|
||||
|
||||
CREATE TABLE tax_rates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rate_type TEXT NOT NULL, -- 'arbetsgivaravgifter', 'bolagsskatt', etc.
|
||||
rate DECIMAL(5,4) NOT NULL, -- 0.3142 for 31.42%
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE, -- NULL = currently active
|
||||
|
||||
UNIQUE(rate_type, valid_from)
|
||||
);
|
||||
|
||||
-- Seed current rates
|
||||
INSERT INTO tax_rates (rate_type, rate, valid_from) VALUES
|
||||
('arbetsgivaravgifter', 0.3142, '2024-01-01'),
|
||||
('bolagsskatt', 0.206, '2021-01-01'),
|
||||
('egenavgifter', 0.2897, '2024-01-01');
|
||||
```
|
||||
|
||||
### bas_accounts (Reference Table)
|
||||
|
||||
Available BAS accounts for transaction categorization.
|
||||
|
||||
```sql
|
||||
CREATE TABLE bas_accounts (
|
||||
account_number TEXT PRIMARY KEY, -- e.g., '5410'
|
||||
name_sv TEXT NOT NULL, -- Swedish name
|
||||
name_en TEXT, -- English name
|
||||
account_class INTEGER NOT NULL, -- 1-8
|
||||
account_group INTEGER NOT NULL, -- e.g., 54
|
||||
|
||||
-- Categorization
|
||||
is_expense BOOLEAN DEFAULT FALSE,
|
||||
is_income BOOLEAN DEFAULT FALSE,
|
||||
is_asset BOOLEAN DEFAULT FALSE,
|
||||
is_liability BOOLEAN DEFAULT FALSE,
|
||||
|
||||
-- Influencer-specific
|
||||
common_for_influencers BOOLEAN DEFAULT FALSE,
|
||||
risk_level TEXT, -- 'low', 'medium', 'high', 'very_high'
|
||||
default_vat_rate DECIMAL(5,2),
|
||||
|
||||
-- Usage hints
|
||||
description TEXT,
|
||||
warning_text TEXT, -- Shown when selected
|
||||
requires_documentation BOOLEAN DEFAULT FALSE,
|
||||
|
||||
active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Seed common influencer accounts
|
||||
INSERT INTO bas_accounts (account_number, name_sv, name_en, account_class, account_group, is_expense, common_for_influencers, risk_level, description) VALUES
|
||||
('1210', 'Maskiner och inventarier', 'Equipment', 1, 12, FALSE, TRUE, 'low', 'Kameror, datorer över 29 400 kr'),
|
||||
('2013', 'Eget uttag', 'Owner withdrawals', 2, 20, FALSE, TRUE, 'low', 'Privata utgifter betalda med företagskort'),
|
||||
('3001', 'Försäljning 25%', 'Sales 25% VAT', 3, 30, FALSE, TRUE, 'low', 'Svenska samarbeten'),
|
||||
('3044', 'Försäljning tjänst EU', 'EU service sales', 3, 30, FALSE, TRUE, 'low', 'AdSense, EU-affiliates. Kräver periodisk sammanställning'),
|
||||
('3045', 'Försäljning tjänst export', 'Export service sales', 3, 30, FALSE, TRUE, 'low', 'Twitch, YouTube US. Momsfritt'),
|
||||
('5410', 'Förbrukningsinventarier', 'Consumable equipment', 5, 54, TRUE, TRUE, 'low', 'Kameror, datorer under 29 400 kr'),
|
||||
('5420', 'Programvaror', 'Software', 5, 54, TRUE, TRUE, 'low', 'Adobe CC, Epidemic Sound, SaaS'),
|
||||
('5480', 'Arbetskläder', 'Work clothes', 5, 54, TRUE, TRUE, 'very_high', 'Endast skyddskläder/uniformer. EJ civila kläder!'),
|
||||
('5800', 'Resekostnader', 'Travel expenses', 5, 58, TRUE, TRUE, 'medium', 'Tåg, flyg, hyrbil för tjänsteresor'),
|
||||
('5820', 'Taxikostnader', 'Taxi', 5, 58, TRUE, TRUE, 'medium', 'Taxi/Uber för tjänsteresor'),
|
||||
('5910', 'Annonsering', 'Advertising', 5, 59, TRUE, TRUE, 'low', 'Facebook Ads, Google Ads, TikTok'),
|
||||
('6071', 'Representation, avdragsgill', 'Deductible representation', 6, 60, TRUE, TRUE, 'high', 'Max 60 kr/person. Kräver dokumentation'),
|
||||
('6072', 'Representation, ej avdragsgill', 'Non-deductible representation', 6, 60, TRUE, TRUE, 'high', 'Måltider vid representation'),
|
||||
('6230', 'Datakommunikation', 'Data/web hosting', 6, 62, TRUE, TRUE, 'low', 'Webbhotell, domäner, bredband'),
|
||||
('6500', 'Övriga externa tjänster', 'Other services', 6, 65, TRUE, TRUE, 'low', 'Fotografer, klippare med F-skatt');
|
||||
```
|
||||
|
||||
### mcc_mappings (Reference Table)
|
||||
|
||||
MCC code to BAS account mappings for auto-categorization.
|
||||
|
||||
```sql
|
||||
CREATE TABLE mcc_mappings (
|
||||
mcc_code TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
risk_level TEXT NOT NULL, -- 'low', 'medium', 'high', 'very_high'
|
||||
primary_account TEXT REFERENCES bas_accounts(account_number),
|
||||
secondary_account TEXT REFERENCES bas_accounts(account_number),
|
||||
requires_user_input BOOLEAN DEFAULT FALSE,
|
||||
auto_reject_reason TEXT, -- If very_high risk, why?
|
||||
categorization_logic TEXT,
|
||||
active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
-- Seed MCC mappings (subset - see 08-BAS-ACCOUNTING-GUIDE.md for full list)
|
||||
INSERT INTO mcc_mappings (mcc_code, description, risk_level, primary_account, secondary_account, requires_user_input, auto_reject_reason, categorization_logic) VALUES
|
||||
('7311', 'Advertising Services', 'low', '5910', '5930', FALSE, NULL, 'Standard for Facebook/Google Ads. Triggers EU VAT check.'),
|
||||
('5732', 'Electronics Stores', 'medium', '5410', '1210', FALSE, NULL, 'Amount determines routing. >29,400 kr → 1210.'),
|
||||
('5946', 'Camera & Photo Supply', 'medium', '5410', '1210', FALSE, NULL, 'Same as electronics.'),
|
||||
('5812', 'Eating Places/Restaurants', 'high', '6072', '2013', TRUE, NULL, 'Requires user input: Representation or private?'),
|
||||
('5611', 'Mens Clothing', 'very_high', '2013', '5480', TRUE, 'Clothes usable privately are not deductible (RÅ81 1:29)', 'Default MUST be 2013.'),
|
||||
('5621', 'Womens Ready-to-Wear', 'very_high', '2013', '5480', TRUE, 'Clothes usable privately are not deductible', 'Default MUST be 2013.'),
|
||||
('5977', 'Cosmetic Stores', 'very_high', '2013', '5490', TRUE, 'Cosmetics are private expenses', 'Default private.'),
|
||||
('4111', 'Commuter Transport', 'low', '5800', NULL, FALSE, NULL, 'Train/bus. 6% VAT in Sweden.'),
|
||||
('4722', 'Travel Agencies', 'medium', '5800', '2013', TRUE, NULL, 'Is the trip business-related?'),
|
||||
('7372', 'Computer Programming', 'low', '6230', '5420', FALSE, NULL, 'Web hosting, SaaS.'),
|
||||
('5921', 'Package Stores (Systembolaget)', 'very_high', '2013', NULL, FALSE, 'Alcohol is not deductible', 'Always 2013.'),
|
||||
('7941', 'Sports Clubs/Gyms', 'very_high', '2013', NULL, FALSE, 'Gym is private expense even for fitness influencers', 'Always 2013.');
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
### Calculate invoice totals
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION calculate_invoice_totals()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE invoices
|
||||
SET
|
||||
subtotal = (
|
||||
SELECT COALESCE(SUM(line_total), 0)
|
||||
FROM invoice_items
|
||||
WHERE invoice_id = NEW.invoice_id
|
||||
),
|
||||
vat_amount = (
|
||||
SELECT COALESCE(SUM(line_total), 0) * (vat_rate / 100)
|
||||
FROM invoice_items
|
||||
WHERE invoice_id = NEW.invoice_id
|
||||
),
|
||||
total = (
|
||||
SELECT COALESCE(SUM(line_total), 0) * (1 + vat_rate / 100)
|
||||
FROM invoice_items
|
||||
WHERE invoice_id = NEW.invoice_id
|
||||
),
|
||||
updated_at = NOW()
|
||||
WHERE id = NEW.invoice_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER trigger_calculate_invoice_totals
|
||||
AFTER INSERT OR UPDATE OR DELETE ON invoice_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION calculate_invoice_totals();
|
||||
```
|
||||
|
||||
### Auto-create profile on signup
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (id, email, full_name)
|
||||
VALUES (
|
||||
NEW.id,
|
||||
NEW.email,
|
||||
NEW.raw_user_meta_data->>'full_name'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION handle_new_user();
|
||||
```
|
||||
|
||||
## Views
|
||||
|
||||
### Dashboard summary
|
||||
|
||||
```sql
|
||||
CREATE VIEW dashboard_summary AS
|
||||
SELECT
|
||||
p.id AS user_id,
|
||||
|
||||
-- Revenue YTD
|
||||
COALESCE(SUM(
|
||||
CASE WHEN t.amount > 0 AND t.category = 'business_income'
|
||||
AND t.booking_date >= DATE_TRUNC('year', CURRENT_DATE)
|
||||
THEN t.amount ELSE 0 END
|
||||
), 0) AS revenue_ytd,
|
||||
|
||||
-- Expenses YTD
|
||||
COALESCE(SUM(
|
||||
CASE WHEN t.amount < 0 AND t.category = 'business_expense'
|
||||
AND t.booking_date >= DATE_TRUNC('year', CURRENT_DATE)
|
||||
THEN ABS(t.amount) ELSE 0 END
|
||||
), 0) AS expenses_ytd,
|
||||
|
||||
-- Uncategorized count
|
||||
COUNT(CASE WHEN t.category = 'uncategorized' THEN 1 END) AS uncategorized_count,
|
||||
|
||||
-- Unpaid invoices
|
||||
(SELECT COUNT(*) FROM invoices i
|
||||
WHERE i.user_id = p.id
|
||||
AND i.status IN ('sent', 'overdue')) AS unpaid_invoice_count,
|
||||
|
||||
(SELECT COALESCE(SUM(total), 0) FROM invoices i
|
||||
WHERE i.user_id = p.id
|
||||
AND i.status IN ('sent', 'overdue')) AS unpaid_invoice_total
|
||||
|
||||
FROM profiles p
|
||||
LEFT JOIN transactions t ON t.user_id = p.id
|
||||
GROUP BY p.id;
|
||||
```
|
||||
|
||||
## Migration File
|
||||
|
||||
Save as `supabase/migrations/001_initial_schema.sql`:
|
||||
|
||||
```sql
|
||||
-- Copy all CREATE TABLE, CREATE TYPE, CREATE FUNCTION, CREATE VIEW statements above
|
||||
-- Run with: supabase db push
|
||||
```
|
||||
@@ -1,672 +0,0 @@
|
||||
# API Specification
|
||||
|
||||
## Authentication
|
||||
|
||||
All API routes require authentication via Supabase session cookie.
|
||||
|
||||
```typescript
|
||||
// lib/supabase/server.ts
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
get(name: string) {
|
||||
return cookieStore.get(name)?.value
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Usage in route
|
||||
export async function GET() {
|
||||
const supabase = await createClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// ... route logic
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Company Settings
|
||||
|
||||
#### GET /api/settings
|
||||
|
||||
Returns current user's company settings.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"company_name": "Anna Andersson Content AB",
|
||||
"org_number": "123456-7890",
|
||||
"vat_number": "SE1234567890",
|
||||
"vat_registered": true,
|
||||
"address_line1": "Storgatan 1",
|
||||
"city": "Stockholm",
|
||||
"postal_code": "11122",
|
||||
"payment_terms_days": 30,
|
||||
"next_invoice_number": 15
|
||||
}
|
||||
```
|
||||
|
||||
#### PUT /api/settings
|
||||
|
||||
Updates company settings.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"company_name": "Anna Andersson Content AB",
|
||||
"municipal_tax_rate": 31.5,
|
||||
"payment_terms_days": 14
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Transactions
|
||||
|
||||
#### GET /api/transactions
|
||||
|
||||
List transactions with optional filters.
|
||||
|
||||
**Query params:**
|
||||
- `category`: filter by category
|
||||
- `from`: start date (YYYY-MM-DD)
|
||||
- `to`: end date
|
||||
- `uncategorized`: boolean, only uncategorized
|
||||
- `limit`: default 50
|
||||
- `offset`: pagination
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"booking_date": "2024-01-15",
|
||||
"amount": -1500.00,
|
||||
"currency": "SEK",
|
||||
"description": "ADOBE SYSTEMS",
|
||||
"counterparty_name": "Adobe",
|
||||
"category": "uncategorized",
|
||||
"expense_type": null
|
||||
}
|
||||
],
|
||||
"total": 142,
|
||||
"uncategorized_count": 23
|
||||
}
|
||||
```
|
||||
|
||||
#### PATCH /api/transactions/:id
|
||||
|
||||
Categorize a transaction.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"category": "business_expense",
|
||||
"expense_type": "software",
|
||||
"business_percentage": 100,
|
||||
"notes": "Creative Cloud subscription"
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/transactions/bulk-categorize
|
||||
|
||||
Categorize multiple transactions at once.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"transaction_ids": ["uuid1", "uuid2"],
|
||||
"category": "private"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Banking
|
||||
|
||||
#### POST /api/banking/connect
|
||||
|
||||
Initiates PSD2 bank connection flow.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"provider": "tink"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"redirect_url": "https://link.tink.com/..."
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/banking/callback
|
||||
|
||||
OAuth callback from banking provider. Handles token exchange.
|
||||
|
||||
**Query params:** Provider-specific (code, state, etc.)
|
||||
|
||||
**Redirects to:** `/transactions?connected=true`
|
||||
|
||||
#### POST /api/banking/sync
|
||||
|
||||
Manually trigger transaction sync. Also called by cron.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"synced": 12,
|
||||
"new_transactions": 3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Customers
|
||||
|
||||
#### GET /api/customers
|
||||
|
||||
List all customers.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Influencer Agency AB",
|
||||
"customer_type": "swedish_business",
|
||||
"org_number": "556677-8899",
|
||||
"country": "SE",
|
||||
"invoice_count": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/customers
|
||||
|
||||
Create new customer.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"name": "Google Ireland Ltd",
|
||||
"customer_type": "eu_business",
|
||||
"vat_number": "IE6388047V",
|
||||
"address_line1": "Gordon House",
|
||||
"city": "Dublin",
|
||||
"country": "IE",
|
||||
"email": "payments@google.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 201:** Created customer object
|
||||
|
||||
#### GET /api/customers/:id
|
||||
|
||||
Get single customer with invoice history.
|
||||
|
||||
#### PUT /api/customers/:id
|
||||
|
||||
Update customer.
|
||||
|
||||
#### DELETE /api/customers/:id
|
||||
|
||||
Soft delete (only if no invoices linked).
|
||||
|
||||
---
|
||||
|
||||
### VAT Validation
|
||||
|
||||
#### POST /api/vat/validate
|
||||
|
||||
Validate EU VAT number via VIES (VAT Information Exchange System).
|
||||
|
||||
**Critical for Reverse Charge:** Swedish law requires validated VAT number before applying 0% rate on EU B2B sales. Without validation, you must charge 25% Swedish VAT.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"vat_number": "IE6388047V"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"country_code": "IE",
|
||||
"vat_number": "6388047V",
|
||||
"name": "GOOGLE IRELAND LIMITED",
|
||||
"address": "GORDON HOUSE, BARROW STREET, DUBLIN 4",
|
||||
"validated_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200 (Invalid):**
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"country_code": "IE",
|
||||
"vat_number": "INVALID123",
|
||||
"error": "VAT number not found in VIES database"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Notes:**
|
||||
- Use EU VIES SOAP service or REST wrapper
|
||||
- Cache validation for 24 hours (VAT numbers rarely change)
|
||||
- Store `validated_at` timestamp on customer record
|
||||
- Re-validate periodically for long-term customers
|
||||
|
||||
---
|
||||
|
||||
### Foreign Purchase Handling (Fiktiv Moms)
|
||||
|
||||
#### POST /api/transactions/:id/foreign-purchase
|
||||
|
||||
Mark a transaction as foreign service purchase requiring fiktiv moms.
|
||||
|
||||
When influencers buy services from abroad (Adobe CC, Facebook Ads, AWS), they must self-report VAT.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"supplier_country": "US",
|
||||
"service_type": "digital_service",
|
||||
"vat_rate": 25
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"transaction_id": "uuid",
|
||||
"fiktiv_moms_amount": 375.00,
|
||||
"bookings": [
|
||||
{ "account": "2645", "debit": 375.00, "description": "Beräknad ingående moms utländskt förvärv" },
|
||||
{ "account": "2614", "credit": 375.00, "description": "Utgående moms utländskt förvärv" }
|
||||
],
|
||||
"moms_ruta_21": 1500.00,
|
||||
"moms_ruta_48": 375.00
|
||||
}
|
||||
```
|
||||
|
||||
**Momsdeklaration Impact:**
|
||||
- Ruta 21: Purchase amount (before VAT)
|
||||
- Ruta 48: Self-reported output VAT
|
||||
- Net effect is zero if input VAT is deductible, but both must be declared
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Invoices
|
||||
|
||||
#### GET /api/invoices
|
||||
|
||||
List invoices with filters.
|
||||
|
||||
**Query params:**
|
||||
- `status`: draft, sent, paid, overdue
|
||||
- `customer_id`: filter by customer
|
||||
- `from`, `to`: date range
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"invoice_number": "INV-2024-015",
|
||||
"customer": {
|
||||
"id": "uuid",
|
||||
"name": "Agency AB"
|
||||
},
|
||||
"invoice_date": "2024-01-15",
|
||||
"due_date": "2024-02-14",
|
||||
"total": 25000.00,
|
||||
"currency": "SEK",
|
||||
"status": "sent"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/invoices
|
||||
|
||||
Create new invoice.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"customer_id": "uuid",
|
||||
"invoice_date": "2024-01-15",
|
||||
"due_date": "2024-02-14",
|
||||
"reference": "PO-12345",
|
||||
"items": [
|
||||
{
|
||||
"description": "Instagram kampanj November",
|
||||
"quantity": 1,
|
||||
"unit": "st",
|
||||
"unit_price": 20000.00
|
||||
}
|
||||
],
|
||||
"customer_notes": "Tack för samarbetet!"
|
||||
}
|
||||
```
|
||||
|
||||
The API automatically determines VAT treatment based on customer type:
|
||||
|
||||
```typescript
|
||||
// lib/invoice/vat-rules.ts
|
||||
|
||||
interface VatDecision {
|
||||
rate: number
|
||||
reverseCharge: boolean
|
||||
invoiceText: string | null
|
||||
momsRuta: number // Which ruta in momsdeklaration
|
||||
}
|
||||
|
||||
function determineVatTreatment(customer: Customer, settings: CompanySettings): VatDecision {
|
||||
// User not VAT registered - no VAT on any invoice
|
||||
if (!settings.vat_registered) {
|
||||
return {
|
||||
rate: 0,
|
||||
reverseCharge: false,
|
||||
invoiceText: 'Säljaren är inte momsregistrerad',
|
||||
momsRuta: 0 // Not applicable
|
||||
}
|
||||
}
|
||||
|
||||
switch (customer.customer_type) {
|
||||
case 'swedish_business':
|
||||
case 'individual':
|
||||
return {
|
||||
rate: 25,
|
||||
reverseCharge: false,
|
||||
invoiceText: null,
|
||||
momsRuta: 5 // Ruta 05: Momspliktig försäljning
|
||||
}
|
||||
|
||||
case 'eu_business':
|
||||
if (customer.vat_number_validated) {
|
||||
return {
|
||||
rate: 0,
|
||||
reverseCharge: true,
|
||||
invoiceText: 'Omvänd skattskyldighet / Reverse charge - Article 196 Council Directive 2006/112/EC',
|
||||
momsRuta: 39 // Ruta 39: Tjänsteförsäljning EU
|
||||
}
|
||||
}
|
||||
// EU business without valid VAT = charge Swedish VAT
|
||||
return {
|
||||
rate: 25,
|
||||
reverseCharge: false,
|
||||
invoiceText: null,
|
||||
momsRuta: 5
|
||||
}
|
||||
|
||||
case 'non_eu_business':
|
||||
return {
|
||||
rate: 0,
|
||||
reverseCharge: false,
|
||||
invoiceText: 'Export av tjänst - moms utgår ej',
|
||||
momsRuta: 40 // Ruta 40: Export
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Momsdeklaration Ruta Reference:**
|
||||
| Ruta | Description | When Used |
|
||||
|------|-------------|-----------|
|
||||
| 05 | Momspliktig försäljning | Swedish domestic sales |
|
||||
| 39 | Tjänsteförsäljning EU | EU B2B with reverse charge |
|
||||
| 40 | Export | Non-EU sales |
|
||||
| 21 | Inköp av tjänster från EU | Foreign service purchases (input) |
|
||||
| 48 | Utgående moms på inköp | Fiktiv moms on foreign purchases |
|
||||
|
||||
**EU Sales Reporting:**
|
||||
Transactions with `momsRuta: 39` must be aggregated into Periodisk Sammanställning (quarterly report to Skatteverket), separate from regular momsdeklaration.
|
||||
|
||||
**Response 201:** Created invoice with calculated totals
|
||||
|
||||
#### GET /api/invoices/:id
|
||||
|
||||
Get full invoice details including items.
|
||||
|
||||
#### PUT /api/invoices/:id
|
||||
|
||||
Update draft invoice. Cannot modify sent/paid invoices.
|
||||
|
||||
#### POST /api/invoices/:id/send
|
||||
|
||||
Mark invoice as sent and optionally email to customer.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"send_email": true,
|
||||
"email_to": "invoice@client.com",
|
||||
"email_message": "Hej! Här kommer fakturan för vårt samarbete."
|
||||
}
|
||||
```
|
||||
|
||||
**Email Implementation:** Uses Supabase built-in email via Edge Functions.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"status": "sent",
|
||||
"sent_at": "2024-01-15T10:30:00Z",
|
||||
"email_sent": true
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Invoice PDF attached to email. Payment method shown: bank transfer only (Bankgiro/IBAN). Future: third-party payment links (Stripe, Klarna).
|
||||
|
||||
#### POST /api/invoices/:id/mark-paid
|
||||
|
||||
Mark invoice as paid.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"paid_at": "2024-02-10",
|
||||
"paid_amount": 25000.00,
|
||||
"linked_transaction_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/invoices/:id/pdf
|
||||
|
||||
Generate and return invoice PDF.
|
||||
|
||||
**Query params:**
|
||||
- `regenerate`: force regenerate cached PDF
|
||||
|
||||
**Response:** PDF file or redirect to signed storage URL
|
||||
|
||||
---
|
||||
|
||||
### Salary Payments (Aktiebolag only)
|
||||
|
||||
#### GET /api/salary
|
||||
|
||||
List salary payments for current fiscal year.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"payment_date": "2024-01-25",
|
||||
"pay_period_start": "2024-01-01",
|
||||
"pay_period_end": "2024-01-31",
|
||||
"gross_salary": 50000.00,
|
||||
"employer_contributions": 15710.00,
|
||||
"withheld_tax": 15000.00,
|
||||
"net_salary": 35000.00,
|
||||
"total_cost": 65710.00,
|
||||
"agi_reported": false
|
||||
}
|
||||
],
|
||||
"ytd_totals": {
|
||||
"gross_salary": 50000.00,
|
||||
"employer_contributions": 15710.00,
|
||||
"withheld_tax": 15000.00,
|
||||
"total_cost": 65710.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### POST /api/salary
|
||||
|
||||
Create salary payment record.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"pay_period_start": "2024-01-01",
|
||||
"pay_period_end": "2024-01-31",
|
||||
"gross_salary": 50000.00,
|
||||
"tax_table": 33
|
||||
}
|
||||
```
|
||||
|
||||
**Automatic calculations:**
|
||||
- `employer_contributions`: gross × 31.42%
|
||||
- `withheld_tax`: from Swedish tax tables based on gross + table number
|
||||
- `net_salary`: gross - withheld_tax
|
||||
- `total_cost`: gross + employer_contributions
|
||||
|
||||
**Response 201:** Created salary payment with all calculated fields.
|
||||
|
||||
#### POST /api/salary/:id/mark-agi-reported
|
||||
|
||||
Mark salary payment as reported in AGI-deklaration.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"agi_reported": true,
|
||||
"agi_reported_at": "2024-02-12T14:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Dashboard
|
||||
|
||||
#### GET /api/dashboard/summary
|
||||
|
||||
Get aggregated financial overview.
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"period": {
|
||||
"year": 2024,
|
||||
"month": 1
|
||||
},
|
||||
"revenue": {
|
||||
"ytd": 450000.00,
|
||||
"this_month": 75000.00
|
||||
},
|
||||
"expenses": {
|
||||
"ytd": 85000.00,
|
||||
"this_month": 12000.00
|
||||
},
|
||||
"net_income": {
|
||||
"ytd": 365000.00
|
||||
},
|
||||
"tax_estimate": {
|
||||
"egenavgifter": 105745.00,
|
||||
"income_tax": 116800.00,
|
||||
"vat_payable": 91250.00,
|
||||
"total_locked": 313795.00
|
||||
},
|
||||
"disponibelt": 136205.00,
|
||||
"vat_threshold": {
|
||||
"limit": 80000,
|
||||
"current": 450000.00,
|
||||
"registered": true
|
||||
},
|
||||
"pending": {
|
||||
"uncategorized_transactions": 23,
|
||||
"unpaid_invoices": 2,
|
||||
"unpaid_amount": 45000.00
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### GET /api/dashboard/chart
|
||||
|
||||
Monthly revenue/expense data for charts.
|
||||
|
||||
**Query params:**
|
||||
- `months`: number of months (default 12)
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"month": "2024-01",
|
||||
"revenue": 75000,
|
||||
"expenses": 12000,
|
||||
"net": 63000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in consistent format:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid VAT number format",
|
||||
"details": {
|
||||
"field": "vat_number",
|
||||
"value": "invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Common status codes:**
|
||||
- 400: Validation error
|
||||
- 401: Not authenticated
|
||||
- 403: Forbidden (RLS violation)
|
||||
- 404: Resource not found
|
||||
- 409: Conflict (duplicate invoice number)
|
||||
- 500: Internal error
|
||||
@@ -1,728 +0,0 @@
|
||||
# UI/UX Specification
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Calm over busy**: Financial apps induce anxiety. Use whitespace, muted colors, clear hierarchy.
|
||||
2. **One action per screen**: Each view has one primary action.
|
||||
3. **Instant feedback**: Every action shows immediate result.
|
||||
4. **Mobile-first**: Influencers manage finances on phone.
|
||||
|
||||
Color System
|
||||
CSS
|
||||
|
||||
:root {
|
||||
/* Primary - Deep Forest & Sage (Trustworthy but aesthetic) */
|
||||
--primary-50: #f4fbf7;
|
||||
--primary-500: #4d8b73;
|
||||
--primary-600: #3a6b58;
|
||||
--primary-700: #2c4f42;
|
||||
|
||||
/* Success - Muted Mint (Profit/Growth) */
|
||||
--success-500: #10b981;
|
||||
|
||||
/* Warning - Burnt Amber (Pending/Alerts) */
|
||||
--warning-500: #d97706;
|
||||
|
||||
/* Danger - Soft Brick (Expenses/Loss) */
|
||||
--danger-500: #e11d48;
|
||||
|
||||
/* Neutral - "Stone" (Warmer than standard gray, feels like paper) */
|
||||
--gray-50: #fafaf9; /* Background base */
|
||||
--gray-100: #f5f5f4; /* Card background */
|
||||
--gray-200: #e7e5e4; /* Borders */
|
||||
--gray-500: #78716c; /* Secondary text */
|
||||
--gray-900: #1c1917; /* Primary text (Soft Black) */
|
||||
}
|
||||
Typography
|
||||
Headings: Plus Jakarta Sans or Outfit (600/700 weight) — More editorial feel
|
||||
|
||||
Body: Inter (400/500 weight) — Clean readability
|
||||
|
||||
Numbers/Money: JetBrains Mono or Inter (Tabular figures enabled)
|
||||
|
||||
## Page Layouts
|
||||
|
||||
### Onboarding Wizard (/onboarding)
|
||||
|
||||
Full-screen wizard. User cannot access app until complete. Progress saved between sessions.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Logo Steg 1 av 6 │
|
||||
│ │
|
||||
│ ━━━━━━━━━━○○○○○○ │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ VÄLKOMMEN! LÅT OSS KOMMA IGÅNG │
|
||||
│ │
|
||||
│ Vilken bolagsform har du? │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ○ Enskild firma │ │
|
||||
│ │ Enskild näringsidkare │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ○ Aktiebolag (AB) │ │
|
||||
│ │ Eget bolag med org.nummer │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Fortsätt → │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Wizard Steps:**
|
||||
|
||||
1. **Entity Type** - Enskild firma or Aktiebolag
|
||||
2. **Company Details** - Name, org.nr (required for AB), address
|
||||
3. **Tax Registration** - F-skatt status, VAT registered?, momsperiod
|
||||
4. **Preliminary Tax** - Monthly F-skatt amount (for comparison warnings)
|
||||
5. **Bank Details** - Account info for invoice payments (IBAN/BIC or Swedish format)
|
||||
6. **Connect Bank** - Enable Banking integration (at least one account required)
|
||||
|
||||
```
|
||||
STEP 2: FÖRETAGSUPPGIFTER
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Företagsnamn * │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Anna Andersson Content │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Organisationsnummer * (för AB) │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 559123-4567 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Adress * │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Storgatan 1 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Postnummer * Ort * │
|
||||
│ ┌──────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ 111 22 │ │ Stockholm │ │
|
||||
│ └──────────────┘ └────────────────────────────┘ │
|
||||
│ │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌─────────────────────────────────┐ │
|
||||
│ │ ← Tillbaka │ │ Fortsätt → │ │
|
||||
│ └──────────────┘ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```
|
||||
STEP 3: SKATTEREGISTRERING
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ☑ Jag har F-skattsedel │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Räkenskapsår * │
|
||||
│ ○ Kalenderår (jan-dec) │
|
||||
│ ○ Brutet räkenskapsår │
|
||||
│ │
|
||||
│ [Visas om brutet:] │
|
||||
│ Räkenskapsåret börjar * │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Juli (1 jul - 30 jun) ▼ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Är du momsregistrerad? * │
|
||||
│ │
|
||||
│ ○ Ja, jag är momsregistrerad │
|
||||
│ ○ Nej, min omsättning är under 80 000 kr/år │
|
||||
│ │
|
||||
│ [Visas om momsregistrerad:] │
|
||||
│ │
|
||||
│ Momsnummer │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ SE559123456701 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Momsperiod * │
|
||||
│ ○ Kvartalsvis (vanligast) │
|
||||
│ ○ Månadsvis │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```
|
||||
STEP 4: PRELIMINÄRSKATT
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Hur mycket betalar du i F-skatt per månad? │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 15 000 kr │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ⓘ Vi använder detta för att varna dig om din beräknade │
|
||||
│ skatt skiljer sig mycket från det du betalar in. │
|
||||
│ │
|
||||
│ Hittar du det på: │
|
||||
│ • Ditt senaste F-skattbeslut från Skatteverket │
|
||||
│ • Skattekontot på skatteverket.se │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Jag vet inte just nu - hoppa över │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
```
|
||||
STEP 6: KOPPLA BANK
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Koppla ditt företagskonto │
|
||||
│ │
|
||||
│ Vi hämtar dina transaktioner automatiskt så du slipper │
|
||||
│ mata in dem manuellt. │
|
||||
│ │
|
||||
│ 🔒 Säkert via BankID. Vi kan aldrig flytta dina pengar. │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 🏦 Koppla bankkonto │ │
|
||||
│ └─────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Populära banker: │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ Nordea │ │ SEB │ │Swedbank │ │ Handels │ │
|
||||
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
│ │
|
||||
│ ⓘ Du kan koppla fler konton senare i inställningar. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Validation Rules:**
|
||||
- All fields marked * are required
|
||||
- Org.nr validated against format (NNNNNN-NNNN)
|
||||
- At least one bank account must be connected
|
||||
- Cannot proceed without completing current step
|
||||
|
||||
---
|
||||
|
||||
### Dashboard (/)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Logo [Settings] [Avatar]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ DISPONIBELT ATT SPENDERA │ │
|
||||
│ │ │ │
|
||||
│ │ 136 205 kr │ │
|
||||
│ │ ─────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ Total: 450 000 kr │ │
|
||||
│ │ ┌────────────────────────────┬──────────────┐ │ │
|
||||
│ │ │██████████████████████████ │░░░░░░░░░░░░░░│ │ │
|
||||
│ │ │ Disponibelt 30% │ Låst 70% │ │ │
|
||||
│ │ └────────────────────────────┴──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [Visa skatteberäkning ↓] │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ ⚠️ 23 transaktioner │ │ 📄 2 obetalda │ │
|
||||
│ │ att sortera │ │ fakturor │ │
|
||||
│ │ │ │ 45 000 kr │ │
|
||||
│ │ [Sortera nu →] │ │ [Visa →] │ │
|
||||
│ └─────────────────────┘ └─────────────────────┘ │
|
||||
│ │
|
||||
│ INTÄKTER 2024 ┌────────────────────────┐ │
|
||||
│ ────────────── │ ____│ │
|
||||
│ 450 000 kr │ ____/ │ │
|
||||
│ │ ____/ │ │
|
||||
│ │ ____/ │ │
|
||||
│ │____/ │ │
|
||||
│ └────────────────────────┘ │
|
||||
│ Jan Feb Mar Apr May Jun │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ [Dashboard] [Transaktioner] [Fakturor] [+] │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **BalanceCard**: Hero component showing available balance
|
||||
- Large number, animated on load
|
||||
- Expandable tax breakdown panel
|
||||
- Color-coded bar chart (green=available, gray=locked)
|
||||
|
||||
2. **AlertCard**: Action items requiring attention
|
||||
- Badge with count
|
||||
- Clear CTA button
|
||||
- Subtle warning color for urgency
|
||||
|
||||
3. **RevenueChart**: Simple line/area chart
|
||||
- Recharts or Chart.js
|
||||
- Hover shows month details
|
||||
- No unnecessary decoration
|
||||
|
||||
---
|
||||
|
||||
### Tax Breakdown (Expanded)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ SKATTEBERÄKNING 2024 [Så räknar vi] │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Intäkter 450 000 kr │
|
||||
│ - Avdragsgilla kostnader -85 000 kr │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ = Resultat 365 000 kr │
|
||||
│ │
|
||||
│ Egenavgifter (28,97%) -105 745 kr ░░░░ │
|
||||
│ Inkomstskatt (~32%) -116 800 kr ░░░░░ │
|
||||
│ Moms att betala -91 250 kr ░░░░ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ = Låst för skatt 313 795 kr │
|
||||
│ │
|
||||
│ ⓘ Detta är en uppskattning. Faktisk skatt kan variera. │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Transactions - Swipe View (/transactions)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Tillbaka Sortera transaktioner │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 23 kvar att sortera │
|
||||
│ │
|
||||
│ ← PRIVAT FÖRETAG → │
|
||||
│ │
|
||||
│ ┌─────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ADOBE SYSTEMS │ │
|
||||
│ │ -1 500,00 kr │ │
|
||||
│ │ │ │
|
||||
│ │ 15 jan 2024 │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────┐ │ │
|
||||
│ │ │ Företag │ │ │
|
||||
│ │ │ [▼ Välj typ] │ │ │
|
||||
│ │ └─────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────┘ │
|
||||
│ │
|
||||
│ [Osäker?] │
|
||||
│ │
|
||||
│ ──────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Redan sorterade [Visa alla] │
|
||||
│ │
|
||||
│ ● Adobe Systems -1 500 kr Programvara ✓ │
|
||||
│ ● Spotify AB -149 kr Privat ✓ │
|
||||
│ ● Influencer AB +25 000 kr Inkomst ✓ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Interaction:**
|
||||
- Swipe right = Business (show expense type selector)
|
||||
- Swipe left = Private
|
||||
- Tap "Osäker?" = Mark for later / add note
|
||||
- Cards animate smoothly with spring physics
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **SwipeCard**: Draggable card with gesture handling
|
||||
- Use `@use-gesture/react` + `framer-motion`
|
||||
- Threshold: 100px triggers categorization
|
||||
- Background color hints direction
|
||||
|
||||
2. **ExpenseTypeSelector**: Dropdown/bottom sheet
|
||||
- Only shown for business expenses
|
||||
- Quick-select common types
|
||||
|
||||
---
|
||||
|
||||
### Transactions - List View (/transactions?view=list)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Transaktioner [Filter] [+ Manual] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔍 Sök transaktioner... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [Alla] [Osorterade (23)] [Företag] [Privat] │
|
||||
│ │
|
||||
│ JANUARI 2024 │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 15 jan ADOBE SYSTEMS -1 500,00 kr │
|
||||
│ Software subscription [Programvara] → │
|
||||
│ │
|
||||
│ 14 jan Influencer Agency AB +25 000,00 kr │
|
||||
│ Faktura #INV-2024-012 [Inkomst] → │
|
||||
│ │
|
||||
│ 12 jan ICA MAXI -847,00 kr │
|
||||
│ Osorterad [Sortera] → │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Invoice (/invoices/new)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Avbryt Ny faktura [Förhandsv]│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ KUND │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 🔍 Sök eller skapa ny kund... │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Google Ireland │ │ Influencer AB │ │
|
||||
│ │ EU-företag │ │ Svenskt företag │ │
|
||||
│ └──────────────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ FAKTURADETALJER │
|
||||
│ │
|
||||
│ Fakturadatum Förfallodatum │
|
||||
│ ┌────────────┐ ┌────────────┐ │
|
||||
│ │ 2024-01-15 │ │ 2024-02-14 │ (30 dagar) │
|
||||
│ └────────────┘ └────────────┘ │
|
||||
│ │
|
||||
│ Er referens │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ PO-12345 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ RADER │
|
||||
│ │
|
||||
│ Beskrivning Antal À-pris │
|
||||
│ ┌────────────────────────────────┐ ┌────┐ ┌─────────┐ │
|
||||
│ │ Instagram kampanj November │ │ 1 │ │ 20 000 │ │
|
||||
│ └────────────────────────────────┘ └────┘ └─────────┘ │
|
||||
│ = 20 000 kr │
|
||||
│ │
|
||||
│ [+ Lägg till rad] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Summa exkl. moms 20 000,00 kr │
|
||||
│ Moms 25% 5 000,00 kr │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ ATT BETALA 25 000,00 kr │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [Spara utkast] [Skapa & skicka] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**VAT Logic Display:**
|
||||
|
||||
For EU customer with valid VAT:
|
||||
```
|
||||
│ Summa 20 000,00 kr │
|
||||
│ Moms 0,00 kr │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ ATT BETALA 20 000,00 kr │
|
||||
│ │
|
||||
│ ⓘ Omvänd skattskyldighet tillämpas (EU B2B) │
|
||||
```
|
||||
|
||||
**Components:**
|
||||
|
||||
1. **CustomerSelect**: Searchable dropdown with recent customers
|
||||
- Shows customer type badge
|
||||
- "Create new" option at bottom
|
||||
|
||||
2. **InvoiceLineItem**: Repeatable row
|
||||
- Auto-calculate line total
|
||||
- Drag to reorder
|
||||
|
||||
3. **VatSummary**: Auto-updating totals
|
||||
- Shows applicable VAT rule
|
||||
- Explanation tooltip
|
||||
|
||||
---
|
||||
|
||||
### Invoice Preview (Modal/Slide-over)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Förhandsgranskning [✕ Stäng] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ ANNA ANDERSSON CONTENT │ │
|
||||
│ │ Storgatan 1, 111 22 Stockholm │ │
|
||||
│ │ F-skatt: Ja | Moms: SE123456789001 │ │
|
||||
│ │ │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ FAKTURA │ │
|
||||
│ │ Fakturanummer: INV-2024-015 │ │
|
||||
│ │ Fakturadatum: 2024-01-15 │ │
|
||||
│ │ Förfallodatum: 2024-02-14 │ │
|
||||
│ │ │ │
|
||||
│ │ Till: │ │
|
||||
│ │ Google Ireland Limited │ │
|
||||
│ │ Gordon House, Barrow Street │ │
|
||||
│ │ Dublin 4, Ireland │ │
|
||||
│ │ VAT: IE6388047V │ │
|
||||
│ │ │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ │ │
|
||||
│ │ Beskrivning Antal Pris Summa │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ YouTube AdSense Jan 2024 1 st €1,850 €1,850 │ │
|
||||
│ │ │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ Summa exkl. moms €1,850 │ │
|
||||
│ │ Moms (0%) €0 │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ ATT BETALA €1,850 │ │
|
||||
│ │ │ │
|
||||
│ │ Reverse charge - Article 196 Council Directive │ │
|
||||
│ │ 2006/112/EC │ │
|
||||
│ │ │ │
|
||||
│ │ ───────────────────────────────────────────────── │ │
|
||||
│ │ Betalning till: IBAN SE12 3456 7890 1234 5678 90 │ │
|
||||
│ │ BIC: SWEDSESS │ │
|
||||
│ │ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ [Ladda ner PDF] [Skicka via email] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Salary Management (/salary) - Aktiebolag only
|
||||
|
||||
Only visible for users with entity_type = 'aktiebolag'.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Tillbaka Lön & Arbetsgivaravgifter│
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 2024 [+ Registrera lön]│
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ HITTILLS I ÅR │ │
|
||||
│ │ │ │
|
||||
│ │ Bruttolön 150 000 kr │ │
|
||||
│ │ Arbetsgivaravgifter 47 130 kr │ │
|
||||
│ │ ───────────────────────────────── │ │
|
||||
│ │ Total lönekostnad 197 130 kr │ │
|
||||
│ │ │ │
|
||||
│ │ Innehållen skatt 45 000 kr ⚠️ Att betala in │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ LÖNEUTBETALNINGAR │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Mars 2024 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Brutto 50 000 kr Arb.avg 15 710 kr │ │
|
||||
│ │ Skatt -15 000 kr Totalt 65 710 kr │ │
|
||||
│ │ ───────────────── │ │
|
||||
│ │ Netto 35 000 kr AGI: ✓ Rapporterad │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Februari 2024 │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Brutto 50 000 kr Arb.avg 15 710 kr │ │
|
||||
│ │ Skatt -15 000 kr Totalt 65 710 kr │ │
|
||||
│ │ ───────────────── │ │
|
||||
│ │ Netto 35 000 kr AGI: ⚠️ Ej rapporterad │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Register Salary Modal:**
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Registrera löneutbetalning [✕] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Löneperiod * │
|
||||
│ ┌──────────────┐ till ┌──────────────┐ │
|
||||
│ │ 2024-03-01 │ │ 2024-03-31 │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ Bruttolön * │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 50 000 kr │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Skattetabell * │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Tabell 33 - Stockholm ▼ │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ⓘ Hittas på ditt skattsedelsbesked │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ BERÄKNAT │
|
||||
│ Arbetsgivaravgifter (31,42%) 15 710 kr │
|
||||
│ Preliminärskatt (enligt tabell) 15 000 kr │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ Nettolön till dig 35 000 kr │
|
||||
│ Total kostnad för bolaget 65 710 kr │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Registrera löneutbetalning │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Settings (/settings)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ← Tillbaka Inställningar │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ FÖRETAGSINFORMATION │
|
||||
│ │
|
||||
│ Företagsnamn │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Anna Andersson Content │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ☑ Jag har F-skattsedel │
|
||||
│ ☑ Jag är momsregistrerad │
|
||||
│ │
|
||||
│ Momsnummer │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ SE199001011234 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ SKATTEBERÄKNING │
|
||||
│ │
|
||||
│ Kommunalskattesats │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 31.5 % [Hitta min →] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ ⓘ Används för att beräkna ungefärlig inkomstskatt │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ FAKTURAINSTÄLLNINGAR │
|
||||
│ │
|
||||
│ Betalningsvillkor (dagar) │
|
||||
│ ┌────────┐ │
|
||||
│ │ 30 │ │
|
||||
│ └────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ BANKUPPGIFTER (visas på fakturor) │
|
||||
│ │
|
||||
│ Bank Clearingnummer Kontonummer │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ Nordea │ │ 3300 │ │ 123 456 789 │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ KOPPLAD BANK │
|
||||
│ │
|
||||
│ ✓ Nordea ****4567 [Koppla bort] │
|
||||
│ Senast synkad: idag 08:15 │
|
||||
│ │
|
||||
│ [Spara ändringar] │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Library
|
||||
|
||||
Use shadcn/ui as base. Key components needed:
|
||||
|
||||
### Core
|
||||
- Button (primary, secondary, ghost, destructive)
|
||||
- Input (text, number, date)
|
||||
- Select (with search)
|
||||
- Checkbox
|
||||
- Card
|
||||
- Badge
|
||||
- Dialog / Sheet (slide-over)
|
||||
- Toast (notifications)
|
||||
|
||||
### Custom
|
||||
- SwipeCard (gesture-based)
|
||||
- MoneyDisplay (formatted SEK/EUR with color)
|
||||
- StatusBadge (invoice/transaction status)
|
||||
- PercentageBar (disponibelt visualization)
|
||||
- DatePicker (Swedish locale)
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
| Breakpoint | Layout |
|
||||
|------------|--------|
|
||||
| < 640px (mobile) | Single column, bottom nav, swipe UI default |
|
||||
| 640-1024px (tablet) | Two columns where appropriate |
|
||||
| > 1024px (desktop) | Sidebar nav, multi-column dashboard |
|
||||
|
||||
Mobile is primary. All features must work on 375px width.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
- All interactive elements keyboard accessible
|
||||
- ARIA labels for icons
|
||||
- Color contrast AA minimum
|
||||
- Focus indicators visible
|
||||
- Screen reader tested
|
||||
@@ -1,519 +0,0 @@
|
||||
# Future Features & Regulatory Integrations
|
||||
|
||||
## Phase 2: SIE File Import (Migration)
|
||||
|
||||
### Overview
|
||||
|
||||
SIE (Standard Import Export) is the Swedish standard for accounting data exchange. Users migrating from Fortnox, Visma, or other systems can export their data as SIE files.
|
||||
|
||||
### SIE File Types
|
||||
|
||||
| Type | Content | Use Case |
|
||||
|------|---------|----------|
|
||||
| SIE1 | Årssaldon | Year-end balances only |
|
||||
| SIE2 | Periodsaldon | Monthly/periodic balances |
|
||||
| SIE3 | Objektsaldon | Balances with cost centers |
|
||||
| SIE4 | Transaktioner | Full transaction history |
|
||||
|
||||
**Recommended:** Support SIE4 for complete migration, SIE1/2 for simpler imports.
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
```typescript
|
||||
// lib/import/sie-parser.ts
|
||||
|
||||
interface SIETransaction {
|
||||
verifikationsnummer: string
|
||||
datum: Date
|
||||
kontonummer: string
|
||||
belopp: number
|
||||
text: string
|
||||
}
|
||||
|
||||
interface SIEImportResult {
|
||||
company: {
|
||||
name: string
|
||||
orgNumber: string
|
||||
fiscalYearStart: Date
|
||||
}
|
||||
accounts: Array<{ number: string; name: string }>
|
||||
transactions: SIETransaction[]
|
||||
balances: Array<{ account: string; balance: number }>
|
||||
}
|
||||
|
||||
// SIE files use CP437 encoding and specific format
|
||||
// Example line: #VER A 1 20240115 "Faktura 1001"
|
||||
```
|
||||
|
||||
### Mapping Decisions Needed
|
||||
|
||||
Before implementing, determine mapping for:
|
||||
- BAS account numbers → system categories
|
||||
- Opening balances → how to represent
|
||||
- Customer/supplier data (if present in SIE)
|
||||
- Historical invoices → create as "imported" status?
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Benefits & Gifts Module (Förmånshantering)
|
||||
|
||||
### Problem
|
||||
|
||||
Influencers receive products ("PR-utskick", "gifted collabs") that may be taxable income. Skatteverket actively audits this. Getting it wrong = skattetillägg.
|
||||
|
||||
### Decision Tree Logic
|
||||
|
||||
```typescript
|
||||
// lib/benefits/gift-classifier.ts
|
||||
|
||||
interface GiftClassification {
|
||||
taxable: boolean
|
||||
marketValue: number
|
||||
deductibleAsExpense: boolean
|
||||
bookingType: 'income' | 'income_and_expense' | 'tax_free'
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
interface GiftInput {
|
||||
estimatedValue: number
|
||||
hasMotprestation: boolean // Required post/video/mention?
|
||||
usedInBusiness: boolean // Used as props/equipment?
|
||||
usedPrivately: boolean // Personal use?
|
||||
isSimplePromoItem: boolean // Pen, mug, basic merch?
|
||||
}
|
||||
|
||||
const TAX_FREE_PROMO_THRESHOLD = 450 // SEK, Skatteverket guideline
|
||||
|
||||
export function classifyGift(input: GiftInput): GiftClassification {
|
||||
// Rule 1: Simple promotional items under threshold = tax free
|
||||
if (input.isSimplePromoItem && input.estimatedValue <= TAX_FREE_PROMO_THRESHOLD && !input.hasMotprestation) {
|
||||
return {
|
||||
taxable: false,
|
||||
marketValue: input.estimatedValue,
|
||||
deductibleAsExpense: false,
|
||||
bookingType: 'tax_free',
|
||||
reasoning: 'Enklare reklamgåva under 450 kr utan krav på motprestation'
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 2: Motprestation exists = always taxable
|
||||
if (input.hasMotprestation) {
|
||||
if (input.usedInBusiness && !input.usedPrivately) {
|
||||
return {
|
||||
taxable: true,
|
||||
marketValue: input.estimatedValue,
|
||||
deductibleAsExpense: true,
|
||||
bookingType: 'income_and_expense',
|
||||
reasoning: 'Skattepliktig förmån (motprestation krävdes). Avdragsgill som rekvisita då den endast används i verksamheten.'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
taxable: true,
|
||||
marketValue: input.estimatedValue,
|
||||
deductibleAsExpense: false,
|
||||
bookingType: 'income',
|
||||
reasoning: 'Skattepliktig förmån (motprestation krävdes). Ej avdragsgill då produkten används privat.'
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 3: High value without motprestation but used privately
|
||||
if (input.estimatedValue > TAX_FREE_PROMO_THRESHOLD && input.usedPrivately) {
|
||||
return {
|
||||
taxable: true,
|
||||
marketValue: input.estimatedValue,
|
||||
deductibleAsExpense: false,
|
||||
bookingType: 'income',
|
||||
reasoning: 'Värdet överstiger gränsen för skattefria reklamgåvor och produkten används privat.'
|
||||
}
|
||||
}
|
||||
|
||||
// Default: taxable if significant value
|
||||
return {
|
||||
taxable: input.estimatedValue > TAX_FREE_PROMO_THRESHOLD,
|
||||
marketValue: input.estimatedValue,
|
||||
deductibleAsExpense: input.usedInBusiness,
|
||||
bookingType: input.estimatedValue > TAX_FREE_PROMO_THRESHOLD ? 'income' : 'tax_free',
|
||||
reasoning: 'Klassificering baserad på värde och användning'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UI Flow
|
||||
|
||||
1. User taps "Logga gåva/produkt"
|
||||
2. Photo capture or manual entry
|
||||
3. System queries price APIs (Prisjakt, Google Shopping) for value estimate
|
||||
4. Decision tree questions:
|
||||
- "Fanns krav på att du skulle posta om denna produkt?" [Ja/Nej]
|
||||
- "Använder du produkten i din content-produktion?" [Ja/Nej]
|
||||
- "Använder du produkten privat?" [Ja/Nej]
|
||||
5. System shows classification + tax impact
|
||||
6. Auto-booking to correct accounts
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: NE-bilaga Generation
|
||||
|
||||
### SRU File Format
|
||||
|
||||
Skatteverket accepts SRU (Standardiserat RäkenskapsUtdrag) files for tax form import.
|
||||
|
||||
```typescript
|
||||
// lib/tax/sru-generator.ts
|
||||
|
||||
interface NEBilaga {
|
||||
// R1-series: Income
|
||||
R1_nettoomsattning: number // Net revenue
|
||||
R2_ovriga_intakter: number // Other income
|
||||
|
||||
// R3-series: Costs
|
||||
R3_varor: number // Cost of goods
|
||||
R4_ovriga_kostnader: number // Other expenses
|
||||
R5_personal: number // Personnel costs (usually 0 for sole proprietor)
|
||||
R6_avskrivningar: number // Depreciation
|
||||
|
||||
// R7-series: Financial
|
||||
R7_ranteintakter: number
|
||||
R8_rantekostnader: number
|
||||
|
||||
// Result
|
||||
R9_resultat: number // Profit/loss before adjustments
|
||||
|
||||
// Adjustments
|
||||
R10_periodiseringsfond_avsatt: number // Max 30% of profit
|
||||
R11_periodiseringsfond_aterforing: number
|
||||
R12_expansionsfond: number
|
||||
|
||||
// Final
|
||||
R14_overskott_underskott: number
|
||||
}
|
||||
|
||||
function generateSRUFile(data: NEBilaga, personnummer: string, year: number): string {
|
||||
const lines: string[] = []
|
||||
|
||||
// Header
|
||||
lines.push('#DATABESKRIVNING_START')
|
||||
lines.push('#PRODUKT SIE')
|
||||
lines.push('#FORMAT PC8')
|
||||
lines.push('#FILTYP NARINGSBILAGA')
|
||||
lines.push(`#UPPGIFTSLAMNARE ${personnummer}`)
|
||||
lines.push(`#INKOMSTAR ${year}`)
|
||||
lines.push('#DATABESKRIVNING_SLUT')
|
||||
|
||||
// Data rows (simplified)
|
||||
lines.push(`#UPPGIFT 7001 ${data.R1_nettoomsattning}`) // R1
|
||||
lines.push(`#UPPGIFT 7002 ${data.R2_ovriga_intakter}`) // R2
|
||||
// ... continue for all fields
|
||||
|
||||
lines.push('#UPPGIFT_SLUT')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
### BAS Account to NE Mapping
|
||||
|
||||
| BAS Account | Description | NE Ruta |
|
||||
|-------------|-------------|---------|
|
||||
| 3000-3999 | Intäkter | R1 |
|
||||
| 3900-3999 | Övriga rörelseintäkter | R2 |
|
||||
| 4000-4999 | Varuinköp | R3 |
|
||||
| 5000-6999 | Övriga externa kostnader | R4 |
|
||||
| 7000-7699 | Personalkostnader | R5 |
|
||||
| 7800-7899 | Avskrivningar | R6 |
|
||||
| 8300-8399 | Ränteintäkter | R7 |
|
||||
| 8400-8499 | Räntekostnader | R8 |
|
||||
|
||||
### Tax Optimization Suggestions
|
||||
|
||||
Before generating NE-bilaga, system should suggest:
|
||||
|
||||
1. **Periodiseringsfond** (max 30% of profit)
|
||||
- Defer tax to future years
|
||||
- Must be reversed within 6 years
|
||||
- Good for income smoothing
|
||||
|
||||
2. **Expansionsfond**
|
||||
- Tax profit at 22% corporate rate instead of marginal personal rate
|
||||
- Good if marginal rate > 22%
|
||||
- More complex rules
|
||||
|
||||
```typescript
|
||||
function suggestTaxOptimization(profit: number, marginalTaxRate: number) {
|
||||
const suggestions = []
|
||||
|
||||
if (profit > 0) {
|
||||
const maxPeriodisering = profit * 0.30
|
||||
suggestions.push({
|
||||
type: 'periodiseringsfond',
|
||||
amount: maxPeriodisering,
|
||||
taxSaved: maxPeriodisering * marginalTaxRate,
|
||||
description: `Sätt av ${formatSEK(maxPeriodisering)} till periodiseringsfond för att skjuta upp ${formatSEK(maxPeriodisering * marginalTaxRate)} i skatt.`
|
||||
})
|
||||
}
|
||||
|
||||
if (profit > 100000 && marginalTaxRate > 0.30) {
|
||||
suggestions.push({
|
||||
type: 'expansionsfond',
|
||||
description: 'Din marginalskatt är hög. Överväg expansionsfond för att beskattas med 22% istället.'
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b: AB Salary Optimization (3:12-reglerna)
|
||||
|
||||
### Overview
|
||||
|
||||
For aktiebolag owners, the split between lön (salary) and utdelning (dividend) significantly impacts total tax. The 3:12 rules (Inkomstskattelagen kapitel 57) determine how much dividend can be taxed at the favorable 20% rate vs marginal income tax rates.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
| Term | Description |
|
||||
|------|-------------|
|
||||
| Gränsbelopp | Annual limit for dividend taxed at 20% |
|
||||
| Löneunderlag | Salary base that increases gränsbelopp |
|
||||
| Kvalificerade andelar | Shares where owner is "active" in company |
|
||||
| Sparat utdelningsutrymme | Unused gränsbelopp carried forward |
|
||||
|
||||
### Gränsbelopp Calculation (Simplified)
|
||||
|
||||
```typescript
|
||||
// lib/tax/ab-optimization.ts
|
||||
|
||||
interface GransbeloppCalculation {
|
||||
schablonbelopp: number // 2.75 × inkomstbasbelopp
|
||||
lonebaserat: number // 50% of löneunderlag (if meets salary requirement)
|
||||
sparat: number // Carried forward from previous years
|
||||
totalGransbelopp: number
|
||||
}
|
||||
|
||||
const INKOMSTBASBELOPP_2024 = 74300
|
||||
|
||||
function calculateGransbelopp(
|
||||
ownerSalary: number,
|
||||
totalCompanySalaries: number,
|
||||
sparatUtdelningsutrymme: number
|
||||
): GransbeloppCalculation {
|
||||
// Schablonbelopp: 2.75 × IBB
|
||||
const schablonbelopp = 2.75 * INKOMSTBASBELOPP_2024 // ~204k
|
||||
|
||||
// Lönebaserat: requires owner salary ≥ 6 IBB (or 9.6% of total + 6 IBB)
|
||||
const minOwnerSalary = Math.min(
|
||||
6 * INKOMSTBASBELOPP_2024,
|
||||
0.096 * totalCompanySalaries + 6 * INKOMSTBASBELOPP_2024
|
||||
)
|
||||
|
||||
const lonebaserat = ownerSalary >= minOwnerSalary
|
||||
? totalCompanySalaries * 0.50
|
||||
: 0
|
||||
|
||||
return {
|
||||
schablonbelopp,
|
||||
lonebaserat,
|
||||
sparat: sparatUtdelningsutrymme,
|
||||
totalGransbelopp: schablonbelopp + lonebaserat + sparatUtdelningsutrymme
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Optimization Engine
|
||||
|
||||
```typescript
|
||||
interface OptimizationRecommendation {
|
||||
recommendedSalary: number
|
||||
recommendedDividend: number
|
||||
taxOnSalary: number
|
||||
taxOnDividend: number
|
||||
totalTax: number
|
||||
savingsVsAllSalary: number
|
||||
explanation: string
|
||||
}
|
||||
|
||||
function optimizeSalaryDividendSplit(
|
||||
availableProfit: number,
|
||||
ownerMarginalTaxRate: number,
|
||||
sparatUtdelningsutrymme: number
|
||||
): OptimizationRecommendation {
|
||||
// Consider:
|
||||
// - Minimum salary for lönebaserat gränsbelopp (6 IBB = ~446k)
|
||||
// - Arbetsgivaravgifter (31.42%) on salary
|
||||
// - 20% tax on dividend within gränsbelopp
|
||||
// - Marginal tax on dividend above gränsbelopp
|
||||
// - Bolagsskatt (20.6%) already paid on profit
|
||||
|
||||
// Return optimal split with explanation
|
||||
}
|
||||
```
|
||||
|
||||
### UI Concept
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 💡 OPTIMERINGSFÖRSLAG │
|
||||
│ │
|
||||
│ Baserat på ditt resultat och skattesituation: │
|
||||
│ │
|
||||
│ Rekommenderad lön: 445 800 kr/år │
|
||||
│ Rekommenderad utdelning: 204 325 kr │
|
||||
│ │
|
||||
│ Beräknad total skatt: ~142 000 kr │
|
||||
│ vs allt som lön: ~185 000 kr │
|
||||
│ ───────────────────────────────────────────────────────── │
|
||||
│ Potentiell besparing: ~43 000 kr │
|
||||
│ │
|
||||
│ ⚠️ Detta är en uppskattning. Rådgör med revisor. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implementation Requirements
|
||||
|
||||
- Track sparat utdelningsutrymme (historical data)
|
||||
- Input for owner's other income (affects marginal rate)
|
||||
- Annual update of inkomstbasbelopp
|
||||
- Strong disclaimer: Not tax advice
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Skatteverket API Integrations
|
||||
|
||||
### Available APIs
|
||||
|
||||
Skatteverket offers several APIs that can enhance the product:
|
||||
|
||||
#### 1. Skattekonto API
|
||||
|
||||
Fetch user's tax account balance and transactions.
|
||||
|
||||
**Prerequisite:** User must grant "Läsombud" access in Skatteverket's system.
|
||||
|
||||
```typescript
|
||||
// Conceptual - actual implementation requires OAuth2 + organizational agreement
|
||||
interface SkattekontoBala {
|
||||
saldo: number
|
||||
senastInbetalning: Date
|
||||
kommandeForfall: Array<{
|
||||
datum: Date
|
||||
belopp: number
|
||||
typ: string // 'F-skatt', 'Moms', etc.
|
||||
}>
|
||||
}
|
||||
|
||||
// Use case: "Du har 15 000 kr på skattekontot, men beräknad skatt är 20 000 kr. Sätt in 5 000 kr nu."
|
||||
```
|
||||
|
||||
#### 2. Momsdeklaration API
|
||||
|
||||
Submit VAT declaration directly from app.
|
||||
|
||||
**Prerequisites:**
|
||||
- Registered as e-tjänstleverantör with Skatteverket
|
||||
- User authentication via BankID
|
||||
- Signed agreement with Skatteverket
|
||||
|
||||
```typescript
|
||||
interface MomsdeklarationSubmission {
|
||||
period: string // '2024-01' for January
|
||||
ruta05: number // Momspliktig försäljning
|
||||
ruta39: number // Tjänsteförsäljning EU
|
||||
ruta40: number // Export
|
||||
ruta21: number // Inköp tjänster EU
|
||||
ruta48: number // Utgående moms inköp
|
||||
// ... all required rutor
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Arbetsgivardeklaration API
|
||||
|
||||
Not typically needed for sole proprietors without employees, but relevant if they hire.
|
||||
|
||||
### Integration Roadmap
|
||||
|
||||
| Phase | Integration | User Value |
|
||||
|-------|-------------|------------|
|
||||
| MVP | None (manual) | - |
|
||||
| v1.5 | VIES VAT validation | Automated reverse charge |
|
||||
| v2.0 | Skattekonto (read) | Real-time tax position |
|
||||
| v2.5 | Momsdeklaration (submit) | One-click VAT filing |
|
||||
| v3.0 | NE-bilaga (submit) | Full declaration automation |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Reklammärkning Compliance
|
||||
|
||||
### Marknadsföringslagen Requirement
|
||||
|
||||
Swedish law requires clear ad disclosure. The "Kissie" case established that marking must be:
|
||||
- At the very beginning of content
|
||||
- Immediately visible
|
||||
- Unambiguous ("Reklam" or "Annons", not just "Samarbete")
|
||||
|
||||
### Implementation
|
||||
|
||||
In Deal-to-Invoice flow, add mandatory checkbox:
|
||||
|
||||
```typescript
|
||||
interface DealComplianceCheck {
|
||||
reklamMarkerad: boolean
|
||||
markeringTyp: 'inlagg' | 'video' | 'story' | 'podcast'
|
||||
bekraftelse: string // Timestamp of user confirmation
|
||||
}
|
||||
|
||||
// Before invoice can be marked as complete:
|
||||
const complianceText = `Jag intygar att allt publicerat material för detta samarbete har reklammarkerats tydligt i början av varje inlägg/video i enlighet med Marknadsföringslagen (2008:486).`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data Retention & GDPR
|
||||
|
||||
### Conflict Resolution
|
||||
|
||||
**Bokföringslagen**: 7 years mandatory retention for räkenskapsinformation
|
||||
**GDPR**: Right to erasure
|
||||
|
||||
Resolution:
|
||||
- Accounting records (verifikationer, fakturor, kvitton): 7-year mandatory retention
|
||||
- Non-accounting data (support chats, preferences, analytics): Can be deleted on request
|
||||
- Technical implementation: Separate data stores with different retention policies
|
||||
|
||||
```typescript
|
||||
// Database design consideration
|
||||
interface UserDataPolicy {
|
||||
accountingData: {
|
||||
retention: '7_years_from_fiscal_year_end',
|
||||
deletable: false,
|
||||
legal_basis: 'Bokföringslagen 7 kap. 2 §'
|
||||
},
|
||||
operationalData: {
|
||||
retention: 'until_deletion_request',
|
||||
deletable: true,
|
||||
legal_basis: 'Consent / Legitimate interest'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Digital Archive Requirements (July 2024 Law)
|
||||
|
||||
Receipt photos must be:
|
||||
- Stored in immutable format (PDF/A-3, locked JPEG)
|
||||
- Backed up regularly
|
||||
- Hosted within EU/EES or adequate country
|
||||
- Tamper-evident (hash verification)
|
||||
|
||||
```typescript
|
||||
interface ReceiptArchive {
|
||||
originalFilename: string
|
||||
storedAs: string // UUID.pdf
|
||||
format: 'PDF/A-3'
|
||||
sha256Hash: string
|
||||
capturedAt: Date
|
||||
linkedTransactionId: string
|
||||
storageLocation: 'eu-north-1' // Must be EU
|
||||
}
|
||||
```
|
||||
@@ -1,903 +0,0 @@
|
||||
# BAS Accounting Guide for Creator Economy
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides the accounting logic layer for automatic transaction categorization using the Swedish BAS-kontoplan. It covers MCC code mapping, expense validation rules, and VAT handling for influencer-specific transactions.
|
||||
|
||||
## BAS Account Structure
|
||||
|
||||
The BAS-kontoplan uses a hierarchical 4-digit system where each position carries semantic meaning:
|
||||
|
||||
| Position | Name | Example | Meaning |
|
||||
|----------|------|---------|---------|
|
||||
| 1 | Kontoklass | **5**410 | Category (5 = External costs) |
|
||||
| 2 | Kontogrupp | 5**4**10 | Type (54 = Consumables) |
|
||||
| 3 | Huvudkonto | 54**1**0 | Main account for reporting |
|
||||
| 4 | Underkonto | 541**0** | Detail level, VAT codes |
|
||||
|
||||
This enables validation logic built directly on account number structure.
|
||||
|
||||
---
|
||||
|
||||
## Account Classes Overview
|
||||
|
||||
| Class | Swedish | English | Influencer Relevance |
|
||||
|-------|---------|---------|---------------------|
|
||||
| 1xxx | Tillgångar | Assets | Equipment, bank accounts |
|
||||
| 2xxx | Eget kapital & Skulder | Equity & Liabilities | VAT, owner withdrawals |
|
||||
| 3xxx | Intäkter | Revenue | Platform income, sponsorships |
|
||||
| 4xxx | Direkta kostnader | Direct costs | Merchandise COGS (rare) |
|
||||
| 5xxx | Övriga externa kostnader | Other external costs | 90% of expenses land here |
|
||||
| 6xxx | Övriga externa kostnader | Other external costs | Services, representation |
|
||||
| 7xxx | Personalkostnader | Personnel costs | AB salary (see salary module) |
|
||||
| 8xxx | Finansiella poster | Financial items | Interest, currency gains/losses |
|
||||
|
||||
---
|
||||
|
||||
## Critical BAS Accounts for Influencers
|
||||
|
||||
### Assets (Class 1)
|
||||
|
||||
| Account | Name | Use Case | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| 1210 | Maskiner och inventarier | Cameras, computers > 29,400 kr | Depreciate over 5 years (20%/year) |
|
||||
| 1930 | Företagskonto | Main bank account | Primary reconciliation account |
|
||||
|
||||
### Equity & Liabilities (Class 2)
|
||||
|
||||
| Account | Name | Use Case | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| 2010 | Eget kapital | Owner's equity | Rolling balance |
|
||||
| 2013 | Eget uttag | Private expenses paid with business card | **Critical for rejected deductions** |
|
||||
| 2018 | Egen insättning | Owner deposits / travel allowance credit | Traktamente booking |
|
||||
| 2440 | Leverantörsskulder | Accounts payable | If not paying immediately |
|
||||
| 2611 | Utgående moms 25% | Output VAT 25% | Swedish domestic sales |
|
||||
| 2614 | Utg. moms utländska förvärv | Output VAT on foreign purchases | Reverse charge (credit side) |
|
||||
| 2641 | Ingående moms | Input VAT | Deductible VAT on purchases |
|
||||
| 2645 | Ing. moms utländska förvärv | Input VAT on foreign purchases | Reverse charge (debit side) |
|
||||
|
||||
### Revenue (Class 3)
|
||||
|
||||
| Account | Name | Use Case | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| 3001 | Försäljning 25% | Swedish sponsorships/collaborations | Standard domestic |
|
||||
| 3044 | Försäljning tjänst EU | AdSense (Ireland), EU affiliates | VAT-free, requires Periodisk sammanställning |
|
||||
| 3045 | Försäljning tjänst Export | Twitch (USA), non-EU platforms | VAT-free (export) |
|
||||
| 3900 | Övriga intäkter | Stipends, royalties, misc | |
|
||||
|
||||
### Costs (Class 5-6)
|
||||
|
||||
| Account | Name | Use Case | Notes |
|
||||
|---------|------|----------|-------|
|
||||
| 5010 | Lokalhyra | Studio rental | Not for home office (strict rules) |
|
||||
| 5410 | Förbrukningsinventarier | Cameras, computers < 29,400 kr | Direct deduction |
|
||||
| 5420 | Programvaror | Adobe CC, Epidemic Sound, SaaS | Licenses/subscriptions |
|
||||
| 5480 | Arbetskläder | Protective gear, uniforms | **NOT regular clothes** |
|
||||
| 5490 | Övriga förbrukningsartiklar | Props, consumable supplies | |
|
||||
| 5800 | Resekostnader | Train, flights, car rental | VAT varies (6% domestic travel) |
|
||||
| 5820 | Taxikostnader | Taxi, Uber | |
|
||||
| 5831 | Kost och logi Sverige | Hotels in Sweden | 12% VAT |
|
||||
| 5832 | Kost och logi utlandet | Hotels abroad | No VAT recovery |
|
||||
| 5841 | Traktamente, avdragsgillt | Travel allowance | Calculated per Skatteverket rates |
|
||||
| 5890 | Övriga resekostnader | Non-deductible travel portion | For mixed-use trips |
|
||||
| 5910 | Annonsering | Facebook Ads, Google Ads | Often triggers reverse charge |
|
||||
| 5930 | Reklamtrycksaker | Promotional materials | |
|
||||
| 6071 | Representation, avdragsgill | Simple refreshments (max 60 kr/person) | Very limited deduction |
|
||||
| 6072 | Representation, ej avdragsgill | Meals for representation | VAT still deductible |
|
||||
| 6230 | Datakommunikation | Web hosting, domains, internet | Infrastructure |
|
||||
| 6500 | Övriga externa tjänster | Photographers, editors (with F-skatt) | Consultant services |
|
||||
| 6991 | Övriga avdragsgilla kostnader | Miscellaneous deductible | Use sparingly |
|
||||
| 6992 | Övriga ej avdragsgilla | Fines, penalties | No tax effect |
|
||||
|
||||
---
|
||||
|
||||
## MCC Code Mapping
|
||||
|
||||
Merchant Category Codes (MCC) are the primary signal for automatic categorization from bank transactions.
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// lib/accounting/mcc-mapping.ts
|
||||
|
||||
interface MCCMapping {
|
||||
mcc: string
|
||||
description: string
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'very_high'
|
||||
primaryAccount: string
|
||||
secondaryAccount?: string
|
||||
requiresUserInput: boolean
|
||||
autoRejectReason?: string
|
||||
logic: string
|
||||
}
|
||||
|
||||
const MCC_MAPPINGS: MCCMapping[] = [
|
||||
// ADVERTISING & MARKETING
|
||||
{
|
||||
mcc: '7311',
|
||||
description: 'Advertising Services',
|
||||
riskLevel: 'low',
|
||||
primaryAccount: '5910',
|
||||
secondaryAccount: '5930',
|
||||
requiresUserInput: false,
|
||||
logic: 'Standard for Facebook/Google Ads. Triggers EU VAT check.'
|
||||
},
|
||||
|
||||
// ELECTRONICS & EQUIPMENT
|
||||
{
|
||||
mcc: '5732',
|
||||
description: 'Electronics Stores',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5410',
|
||||
secondaryAccount: '1210',
|
||||
requiresUserInput: false,
|
||||
logic: 'Amount determines routing. >29,400 kr → 1210 (asset).'
|
||||
},
|
||||
{
|
||||
mcc: '5946',
|
||||
description: 'Camera & Photo Supply',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5410',
|
||||
secondaryAccount: '1210',
|
||||
requiresUserInput: false,
|
||||
logic: 'Same as electronics. Check amount threshold.'
|
||||
},
|
||||
|
||||
// RESTAURANTS & FOOD - HIGH RISK
|
||||
{
|
||||
mcc: '5812',
|
||||
description: 'Eating Places/Restaurants',
|
||||
riskLevel: 'high',
|
||||
primaryAccount: '6072',
|
||||
secondaryAccount: '2013',
|
||||
requiresUserInput: true,
|
||||
logic: 'Requires user input: Representation or private? Default to private (2013).'
|
||||
},
|
||||
{
|
||||
mcc: '5814',
|
||||
description: 'Fast Food',
|
||||
riskLevel: 'high',
|
||||
primaryAccount: '2013',
|
||||
requiresUserInput: false,
|
||||
autoRejectReason: 'Fast food is typically private expense',
|
||||
logic: 'Default to eget uttag. Rarely valid business expense.'
|
||||
},
|
||||
|
||||
// CLOTHING - VERY HIGH RISK (Almost always rejected)
|
||||
{
|
||||
mcc: '5611',
|
||||
description: "Men's Clothing",
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
secondaryAccount: '5480',
|
||||
requiresUserInput: true,
|
||||
autoRejectReason: 'Clothes that can be worn privately are not deductible (RÅ81 1:29)',
|
||||
logic: 'Default MUST be 2013 (private). Only 5480 if protective/uniform.'
|
||||
},
|
||||
{
|
||||
mcc: '5621',
|
||||
description: "Women's Ready-to-Wear",
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
secondaryAccount: '5480',
|
||||
requiresUserInput: true,
|
||||
autoRejectReason: 'Clothes that can be worn privately are not deductible',
|
||||
logic: 'Default MUST be 2013 (private).'
|
||||
},
|
||||
{
|
||||
mcc: '5631',
|
||||
description: "Women's Accessories",
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
requiresUserInput: false,
|
||||
autoRejectReason: 'Accessories are private expenses',
|
||||
logic: 'Always 2013.'
|
||||
},
|
||||
|
||||
// COSMETICS - VERY HIGH RISK
|
||||
{
|
||||
mcc: '5977',
|
||||
description: 'Cosmetic Stores',
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
secondaryAccount: '5490',
|
||||
requiresUserInput: true,
|
||||
autoRejectReason: 'Cosmetics are private expenses unless specific props',
|
||||
logic: 'Default private (2013). Only 5490 if clearly production props.'
|
||||
},
|
||||
|
||||
// TRAVEL - MEDIUM RISK
|
||||
{
|
||||
mcc: '4111',
|
||||
description: 'Commuter Transport',
|
||||
riskLevel: 'low',
|
||||
primaryAccount: '5800',
|
||||
requiresUserInput: false,
|
||||
logic: 'Train/bus. 6% VAT in Sweden.'
|
||||
},
|
||||
{
|
||||
mcc: '4112',
|
||||
description: 'Passenger Railways',
|
||||
riskLevel: 'low',
|
||||
primaryAccount: '5800',
|
||||
requiresUserInput: false,
|
||||
logic: 'Train tickets. 6% VAT.'
|
||||
},
|
||||
{
|
||||
mcc: '4121',
|
||||
description: 'Taxicabs/Limousines',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5820',
|
||||
secondaryAccount: '2013',
|
||||
requiresUserInput: true,
|
||||
logic: 'Business trip or private? Ask user.'
|
||||
},
|
||||
{
|
||||
mcc: '4722',
|
||||
description: 'Travel Agencies',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5800',
|
||||
secondaryAccount: '2013',
|
||||
requiresUserInput: true,
|
||||
logic: 'Is the trip business-related? May need to split.'
|
||||
},
|
||||
{
|
||||
mcc: '7011',
|
||||
description: 'Hotels/Motels',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5831',
|
||||
secondaryAccount: '5832',
|
||||
requiresUserInput: false,
|
||||
logic: '5831 for Sweden (12% VAT), 5832 for abroad (no VAT).'
|
||||
},
|
||||
|
||||
// SOFTWARE & DIGITAL SERVICES - LOW RISK
|
||||
{
|
||||
mcc: '7372',
|
||||
description: 'Computer Programming',
|
||||
riskLevel: 'low',
|
||||
primaryAccount: '6230',
|
||||
secondaryAccount: '5420',
|
||||
requiresUserInput: false,
|
||||
logic: 'Web hosting, domains, SaaS subscriptions.'
|
||||
},
|
||||
{
|
||||
mcc: '5734',
|
||||
description: 'Computer Software Stores',
|
||||
riskLevel: 'low',
|
||||
primaryAccount: '5420',
|
||||
requiresUserInput: false,
|
||||
logic: 'Software licenses.'
|
||||
},
|
||||
|
||||
// ALCOHOL - ALWAYS REJECT
|
||||
{
|
||||
mcc: '5921',
|
||||
description: 'Package Stores (Systembolaget)',
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
requiresUserInput: false,
|
||||
autoRejectReason: 'Alcohol is not deductible',
|
||||
logic: 'Always 2013. No exceptions.'
|
||||
},
|
||||
|
||||
// GROCERIES - HIGH RISK
|
||||
{
|
||||
mcc: '5411',
|
||||
description: 'Grocery Stores',
|
||||
riskLevel: 'high',
|
||||
primaryAccount: '2013',
|
||||
secondaryAccount: '6072',
|
||||
requiresUserInput: true,
|
||||
autoRejectReason: 'Food is typically private expense',
|
||||
logic: 'Default 2013. Only 6072 if documented representation.'
|
||||
},
|
||||
|
||||
// STREAMING SERVICES
|
||||
{
|
||||
mcc: '4899',
|
||||
description: 'Cable/Streaming Services',
|
||||
riskLevel: 'medium',
|
||||
primaryAccount: '5420',
|
||||
secondaryAccount: '2013',
|
||||
requiresUserInput: true,
|
||||
logic: 'Netflix for research? Usually private. Ask user.'
|
||||
},
|
||||
|
||||
// GYM & FITNESS - ALWAYS REJECT
|
||||
{
|
||||
mcc: '7941',
|
||||
description: 'Sports Clubs/Gyms',
|
||||
riskLevel: 'very_high',
|
||||
primaryAccount: '2013',
|
||||
requiresUserInput: false,
|
||||
autoRejectReason: 'Gym memberships are private expenses, even for fitness influencers',
|
||||
logic: 'Always 2013. IL 9 kap. 2 §'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Keyword-Based Categorization
|
||||
|
||||
For generic MCCs (Amazon, PayPal), analyze transaction description text.
|
||||
|
||||
```typescript
|
||||
// lib/accounting/keyword-rules.ts
|
||||
|
||||
interface KeywordRule {
|
||||
keywords: string[]
|
||||
account: string
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
vatTreatment?: 'standard' | 'reverse_charge' | 'none'
|
||||
}
|
||||
|
||||
const KEYWORD_RULES: KeywordRule[] = [
|
||||
// Software & Subscriptions
|
||||
{
|
||||
keywords: ['adobe', 'creative cloud'],
|
||||
account: '5420',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge' // Adobe Ireland
|
||||
},
|
||||
{
|
||||
keywords: ['epidemic sound'],
|
||||
account: '5420',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'standard' // Swedish company
|
||||
},
|
||||
{
|
||||
keywords: ['canva'],
|
||||
account: '5420',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge'
|
||||
},
|
||||
{
|
||||
keywords: ['spotify', 'spotify ab'],
|
||||
account: '2013', // Usually private
|
||||
confidence: 'medium'
|
||||
},
|
||||
{
|
||||
keywords: ['spotify for business', 'soundtrack'],
|
||||
account: '5420',
|
||||
confidence: 'high'
|
||||
},
|
||||
|
||||
// Web Infrastructure
|
||||
{
|
||||
keywords: ['one.com', 'loopia', 'godaddy', 'namecheap', 'cloudflare'],
|
||||
account: '6230',
|
||||
confidence: 'high'
|
||||
},
|
||||
{
|
||||
keywords: ['vercel', 'netlify', 'heroku', 'aws', 'digitalocean'],
|
||||
account: '6230',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge'
|
||||
},
|
||||
|
||||
// Advertising Platforms
|
||||
{
|
||||
keywords: ['facebook', 'meta', 'instagram ads', 'fb ads'],
|
||||
account: '5910',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge' // Meta Ireland
|
||||
},
|
||||
{
|
||||
keywords: ['google ads', 'adwords'],
|
||||
account: '5910',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge' // Google Ireland
|
||||
},
|
||||
{
|
||||
keywords: ['tiktok ads', 'tiktok for business'],
|
||||
account: '5910',
|
||||
confidence: 'high',
|
||||
vatTreatment: 'reverse_charge'
|
||||
},
|
||||
|
||||
// Transport
|
||||
{
|
||||
keywords: ['uber', 'bolt', 'taxi'],
|
||||
account: '5820',
|
||||
confidence: 'medium' // Could be private
|
||||
},
|
||||
{
|
||||
keywords: ['sj', 'mtrx', 'sj biljett'],
|
||||
account: '5800',
|
||||
confidence: 'high'
|
||||
},
|
||||
{
|
||||
keywords: ['sas', 'norwegian', 'ryanair', 'flygbiljett'],
|
||||
account: '5800',
|
||||
confidence: 'medium' // Could be private trip
|
||||
},
|
||||
|
||||
// Always Private
|
||||
{
|
||||
keywords: ['systembolaget'],
|
||||
account: '2013',
|
||||
confidence: 'high'
|
||||
},
|
||||
{
|
||||
keywords: ['apotek', 'apoteket', 'apotea'],
|
||||
account: '2013',
|
||||
confidence: 'high'
|
||||
},
|
||||
{
|
||||
keywords: ['ica', 'coop', 'willys', 'hemköp', 'lidl'],
|
||||
account: '2013',
|
||||
confidence: 'medium' // Usually private groceries
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Amount-Based Routing (Asset vs Expense)
|
||||
|
||||
```typescript
|
||||
// lib/accounting/asset-routing.ts
|
||||
|
||||
const PRISBASBELOPP_2025 = 58800
|
||||
const HALF_PBB = PRISBASBELOPP_2025 / 2 // 29,400 kr
|
||||
|
||||
interface AssetDecision {
|
||||
shouldCapitalize: boolean
|
||||
account: string
|
||||
depreciationYears?: number
|
||||
depreciationRate?: number
|
||||
}
|
||||
|
||||
function routeEquipmentPurchase(
|
||||
amountExVat: number,
|
||||
mccCode: string
|
||||
): AssetDecision {
|
||||
const isEquipmentMCC = ['5732', '5946', '5045'].includes(mccCode)
|
||||
|
||||
if (!isEquipmentMCC) {
|
||||
return { shouldCapitalize: false, account: '5410' }
|
||||
}
|
||||
|
||||
if (amountExVat > HALF_PBB) {
|
||||
return {
|
||||
shouldCapitalize: true,
|
||||
account: '1210',
|
||||
depreciationYears: 5,
|
||||
depreciationRate: 0.20
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shouldCapitalize: false,
|
||||
account: '5410'
|
||||
}
|
||||
}
|
||||
|
||||
// Update HALF_PBB annually - Skatteverket publishes in November
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reverse Charge VAT Handling
|
||||
|
||||
When purchasing services from foreign (usually EU) companies, Swedish businesses must self-report VAT.
|
||||
|
||||
### Detection Logic
|
||||
|
||||
```typescript
|
||||
// lib/accounting/reverse-charge.ts
|
||||
|
||||
interface ReverseChargeResult {
|
||||
applies: boolean
|
||||
reason: string
|
||||
bookings: Array<{
|
||||
account: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
description: string
|
||||
}>
|
||||
}
|
||||
|
||||
const EU_DIGITAL_SERVICES_VENDORS = [
|
||||
{ name: 'meta', country: 'IE', keywords: ['facebook', 'meta', 'instagram'] },
|
||||
{ name: 'google', country: 'IE', keywords: ['google', 'youtube', 'adwords'] },
|
||||
{ name: 'adobe', country: 'IE', keywords: ['adobe'] },
|
||||
{ name: 'microsoft', country: 'IE', keywords: ['microsoft', 'office 365', 'azure'] },
|
||||
{ name: 'amazon', country: 'LU', keywords: ['aws', 'amazon web services'] },
|
||||
{ name: 'canva', country: 'AU', keywords: ['canva'] }, // Non-EU, different treatment
|
||||
]
|
||||
|
||||
function handleForeignPurchase(
|
||||
amount: number,
|
||||
vendorName: string,
|
||||
vendorCountry: string,
|
||||
expenseAccount: string
|
||||
): ReverseChargeResult {
|
||||
const isEU = isEUCountry(vendorCountry)
|
||||
const vatRate = 0.25
|
||||
const vatAmount = amount * vatRate
|
||||
|
||||
if (isEU && vendorCountry !== 'SE') {
|
||||
// EU purchase - reverse charge applies
|
||||
return {
|
||||
applies: true,
|
||||
reason: `Omvänd skattskyldighet - inköp från ${vendorCountry}`,
|
||||
bookings: [
|
||||
{ account: expenseAccount, debit: amount, description: 'Kostnad' },
|
||||
{ account: '2645', debit: vatAmount, description: 'Beräknad ing. moms utl. förvärv' },
|
||||
{ account: '2614', credit: vatAmount, description: 'Utg. moms utl. förvärv' },
|
||||
{ account: '1930', credit: amount, description: 'Utbetalning' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEU) {
|
||||
// Non-EU purchase - no reverse charge, just expense
|
||||
return {
|
||||
applies: false,
|
||||
reason: 'Import från land utanför EU',
|
||||
bookings: [
|
||||
{ account: expenseAccount, debit: amount, description: 'Kostnad' },
|
||||
{ account: '1930', credit: amount, description: 'Utbetalning' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// Swedish domestic
|
||||
return {
|
||||
applies: false,
|
||||
reason: 'Inrikes inköp',
|
||||
bookings: [] // Normal VAT handling
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Momsdeklaration Mapping
|
||||
|
||||
| Scenario | Ruta | Description |
|
||||
|----------|------|-------------|
|
||||
| Domestic purchase 25% VAT | 48 (utgående), deduct in 48 | Normal |
|
||||
| EU service purchase (reverse charge) | 21 (purchase amount), 48 (calculated VAT) | Self-report |
|
||||
| Non-EU service purchase | No VAT reporting | Just expense |
|
||||
|
||||
---
|
||||
|
||||
## Revenue Account Selection
|
||||
|
||||
```typescript
|
||||
// lib/accounting/revenue-routing.ts
|
||||
|
||||
interface RevenueRouting {
|
||||
account: string
|
||||
vatRate: number
|
||||
momsRuta: number
|
||||
requiresPeriodiskSammanstallning: boolean
|
||||
}
|
||||
|
||||
function routeRevenue(
|
||||
customerCountry: string,
|
||||
customerType: 'business' | 'consumer',
|
||||
hasValidVatNumber: boolean
|
||||
): RevenueRouting {
|
||||
// Swedish customer
|
||||
if (customerCountry === 'SE') {
|
||||
return {
|
||||
account: '3001', // Försäljning 25%
|
||||
vatRate: 25,
|
||||
momsRuta: 5,
|
||||
requiresPeriodiskSammanstallning: false
|
||||
}
|
||||
}
|
||||
|
||||
// EU business with VAT number
|
||||
if (isEUCountry(customerCountry) && customerType === 'business' && hasValidVatNumber) {
|
||||
return {
|
||||
account: '3044', // Försäljning tjänst EU
|
||||
vatRate: 0,
|
||||
momsRuta: 39,
|
||||
requiresPeriodiskSammanstallning: true // Quarterly report required!
|
||||
}
|
||||
}
|
||||
|
||||
// EU consumer (OSS rules may apply if >10k EUR/year)
|
||||
if (isEUCountry(customerCountry) && customerType === 'consumer') {
|
||||
return {
|
||||
account: '3001', // Or OSS-specific account
|
||||
vatRate: 25, // Swedish VAT until OSS threshold
|
||||
momsRuta: 5,
|
||||
requiresPeriodiskSammanstallning: false
|
||||
}
|
||||
}
|
||||
|
||||
// Non-EU (USA, etc.) - Export
|
||||
return {
|
||||
account: '3045', // Försäljning tjänst Export
|
||||
vatRate: 0,
|
||||
momsRuta: 40,
|
||||
requiresPeriodiskSammanstallning: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conservative Bias Principle
|
||||
|
||||
The system must be designed with a **conservative bias** - prefer rejecting deductions over accepting questionable ones.
|
||||
|
||||
### Rationale
|
||||
- Burden of proof lies with taxpayer (IL 9 kap. 2 §)
|
||||
- Skattetillägg (tax surcharge) is 40% of avoided tax
|
||||
- Better to warn user than cause audit problems
|
||||
|
||||
### Implementation
|
||||
|
||||
```typescript
|
||||
// lib/accounting/risk-assessment.ts
|
||||
|
||||
interface RiskAssessment {
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'very_high'
|
||||
suggestedAccount: string
|
||||
requiresConfirmation: boolean
|
||||
warningMessage?: string
|
||||
legalBasis?: string
|
||||
}
|
||||
|
||||
function assessTransactionRisk(
|
||||
mcc: string,
|
||||
amount: number,
|
||||
description: string
|
||||
): RiskAssessment {
|
||||
const mapping = getMCCMapping(mcc)
|
||||
|
||||
if (mapping.riskLevel === 'very_high') {
|
||||
return {
|
||||
riskLevel: 'very_high',
|
||||
suggestedAccount: '2013', // Default to private (eget uttag)
|
||||
requiresConfirmation: true,
|
||||
warningMessage: mapping.autoRejectReason,
|
||||
legalBasis: 'IL 9 kap. 2 § - Privata levnadskostnader'
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping.riskLevel === 'high') {
|
||||
return {
|
||||
riskLevel: 'high',
|
||||
suggestedAccount: mapping.primaryAccount,
|
||||
requiresConfirmation: true,
|
||||
warningMessage: `Denna typ av utgift kräver dokumentation. ${mapping.logic}`
|
||||
}
|
||||
}
|
||||
|
||||
// Low/medium risk - auto-categorize but allow override
|
||||
return {
|
||||
riskLevel: mapping.riskLevel,
|
||||
suggestedAccount: mapping.primaryAccount,
|
||||
requiresConfirmation: false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Private Use Split
|
||||
|
||||
For items with mixed business/private use:
|
||||
|
||||
```typescript
|
||||
// lib/accounting/split-usage.ts
|
||||
|
||||
interface SplitTransaction {
|
||||
businessPortion: {
|
||||
account: string
|
||||
amount: number
|
||||
}
|
||||
privatePortion: {
|
||||
account: string // Always 2013
|
||||
amount: number
|
||||
}
|
||||
}
|
||||
|
||||
function splitMixedUse(
|
||||
totalAmount: number,
|
||||
businessPercentage: number,
|
||||
expenseAccount: string
|
||||
): SplitTransaction {
|
||||
const businessAmount = totalAmount * (businessPercentage / 100)
|
||||
const privateAmount = totalAmount - businessAmount
|
||||
|
||||
return {
|
||||
businessPortion: {
|
||||
account: expenseAccount,
|
||||
amount: Math.round(businessAmount * 100) / 100
|
||||
},
|
||||
privatePortion: {
|
||||
account: '2013',
|
||||
amount: Math.round(privateAmount * 100) / 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UI should ask: "Används denna uteslutande i företaget?"
|
||||
// If no → prompt for percentage split
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Travel Allowance (Traktamente)
|
||||
|
||||
For business trips, the owner can claim tax-free travel allowance at Skatteverket's rates.
|
||||
|
||||
```typescript
|
||||
// lib/accounting/traktamente.ts
|
||||
|
||||
// 2024 rates - update annually
|
||||
const TRAKTAMENTE_RATES = {
|
||||
sweden: {
|
||||
fullDay: 260, // Hel dag
|
||||
halfDay: 130, // Halv dag
|
||||
night: 130 // Nattraktamente
|
||||
},
|
||||
// Foreign rates vary by country - see Skatteverket's list
|
||||
foreign: {
|
||||
USA: { fullDay: 620, reduced: 434 },
|
||||
UK: { fullDay: 550, reduced: 385 },
|
||||
Germany: { fullDay: 420, reduced: 294 },
|
||||
// ... etc
|
||||
}
|
||||
}
|
||||
|
||||
interface TraktamenteCalculation {
|
||||
amount: number
|
||||
bookings: Array<{
|
||||
account: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
}>
|
||||
}
|
||||
|
||||
function calculateTraktamente(
|
||||
destination: 'sweden' | string,
|
||||
fullDays: number,
|
||||
halfDays: number,
|
||||
nights: number
|
||||
): TraktamenteCalculation {
|
||||
const rates = destination === 'sweden'
|
||||
? TRAKTAMENTE_RATES.sweden
|
||||
: TRAKTAMENTE_RATES.foreign[destination] || TRAKTAMENTE_RATES.sweden
|
||||
|
||||
const amount =
|
||||
(fullDays * rates.fullDay) +
|
||||
(halfDays * (rates.halfDay || rates.fullDay / 2)) +
|
||||
(nights * (rates.night || 0))
|
||||
|
||||
return {
|
||||
amount,
|
||||
bookings: [
|
||||
{ account: '5841', debit: amount }, // Traktamente, avdragsgillt
|
||||
{ account: '2018', credit: amount } // Egen insättning (skuld till ägare)
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Non-Cash Transactions (Barter/Gifts)
|
||||
|
||||
Influencers receive products that must be accounted for even without bank transaction.
|
||||
|
||||
```typescript
|
||||
// lib/accounting/non-cash.ts
|
||||
|
||||
interface BarterTransaction {
|
||||
productValue: number
|
||||
hasMotprestation: boolean // Required post/exposure?
|
||||
usedInBusiness: boolean
|
||||
bookings: Array<{
|
||||
account: string
|
||||
debit?: number
|
||||
credit?: number
|
||||
description: string
|
||||
}>
|
||||
}
|
||||
|
||||
function bookBarterTransaction(
|
||||
productValue: number,
|
||||
hasMotprestation: boolean,
|
||||
usedInBusiness: boolean
|
||||
): BarterTransaction['bookings'] {
|
||||
if (!hasMotprestation && productValue < 450) {
|
||||
// Tax-free promotional gift
|
||||
return [] // No booking required
|
||||
}
|
||||
|
||||
if (hasMotprestation) {
|
||||
// Taxable barter - must recognize as income
|
||||
const bookings = [
|
||||
{ account: '3001', credit: productValue, description: 'Förmån/Byteshandel' },
|
||||
{ account: '1510', debit: productValue, description: 'Fordran (kvittad mot produkt)' }
|
||||
]
|
||||
|
||||
if (usedInBusiness) {
|
||||
// Can also expense it
|
||||
bookings.push(
|
||||
{ account: '5490', debit: productValue, description: 'Rekvisita' },
|
||||
{ account: '1510', credit: productValue, description: 'Kvittning fordran' }
|
||||
)
|
||||
} else {
|
||||
// Private use - eget uttag
|
||||
bookings.push(
|
||||
{ account: '2013', debit: productValue, description: 'Eget uttag (förmån)' },
|
||||
{ account: '1510', credit: productValue, description: 'Kvittning fordran' }
|
||||
)
|
||||
}
|
||||
|
||||
return bookings
|
||||
}
|
||||
|
||||
// Gift without motprestation but valuable - still taxable
|
||||
return [
|
||||
{ account: '3001', credit: productValue, description: 'Skattepliktig gåva' },
|
||||
{ account: '2013', debit: productValue, description: 'Eget uttag (gåva)' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Configuration
|
||||
|
||||
```typescript
|
||||
// lib/accounting/config.ts
|
||||
|
||||
export const ACCOUNTING_CONFIG = {
|
||||
// Update annually
|
||||
prisbasbelopp: 58800, // 2025
|
||||
halfPrisbasbelopp: 29400,
|
||||
|
||||
// VAT rates
|
||||
vatRates: {
|
||||
standard: 25,
|
||||
reduced: 12, // Food, hotels
|
||||
low: 6, // Transport, books
|
||||
},
|
||||
|
||||
// Depreciation defaults
|
||||
depreciation: {
|
||||
equipment: { years: 5, rate: 0.20 },
|
||||
computers: { years: 3, rate: 0.33 },
|
||||
vehicles: { years: 5, rate: 0.20 },
|
||||
},
|
||||
|
||||
// Risk thresholds
|
||||
riskSettings: {
|
||||
autoRejectVeryHigh: true,
|
||||
requireConfirmationHigh: true,
|
||||
allowOverrideMedium: true,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
||||
This guide integrates with:
|
||||
|
||||
1. **Transaction categorization UI** (05-UI-SPECIFICATION.md) - Swipe interface uses MCC mapping
|
||||
2. **Tax calculator** (06-IMPLEMENTATION-GUIDE.md) - Uses account classifications for tax estimation
|
||||
3. **Invoice VAT rules** (04-API-SPECIFICATION.md) - Revenue account selection aligns with momsdeklaration
|
||||
4. **Expense warnings** (06-IMPLEMENTATION-GUIDE.md) - Risk levels drive warning display
|
||||
|
||||
---
|
||||
|
||||
## Annual Maintenance
|
||||
|
||||
Update each January:
|
||||
1. Prisbasbelopp (from Skatteverket, published November)
|
||||
2. Traktamente rates (Skatteverket)
|
||||
3. Any BAS account changes (BAS-kontogruppen)
|
||||
4. Tax rates if changed by Riksdag
|
||||
@@ -1,406 +0,0 @@
|
||||
Base ERP Architecture: Core + Add-on System (v2)
|
||||
The Core
|
||||
Every item here is non-negotiable for legal compliance or market viability. Nothing below can be an add-on.
|
||||
|
||||
Authentication, Tenancy & Access Control
|
||||
Magic link auth via Supabase
|
||||
Onboarding wizard (entity type EF/AB, company details, tax registration, fiscal year, bank connection)
|
||||
RLS on every table, user_id scoping
|
||||
Multi-company support per user
|
||||
Delegated access roles: owner, accountant (full read/write), audit:read (read-only, scoped to fiscal year). The audit role is the foundation for Digital Audit 2026 compliance where Skatteverket gets API access to a specific fiscal year.
|
||||
|
||||
Document Archive (Compliance Layer)
|
||||
The July 2024 Bokföringslagen amendment makes the system the legal archive. This is not a feature, it is a legal obligation.
|
||||
Hash-on-upload. Every uploaded file (receipt image, e-invoice XML, PDF) gets a SHA-256 hash computed and stored alongside the blob. The hash is the proof of integrity.
|
||||
WORM storage. Uploaded documents are write-once. Any modification (crop, contrast, re-scan) creates a new version. The original remains accessible and linked in the version chain.
|
||||
Deletion blocking. The system hard-rejects any attempt to delete a document linked to a committed voucher or a locked period. No admin override.
|
||||
Digitization metadata. Every upload logs: user who uploaded, timestamp, source (camera, file upload, e-invoice), and a digitization date field. This justifies destruction of the paper original.
|
||||
Linkage integrity. Strict foreign key from journal_entry_lines to document_attachments. No orphaned documents, no undocumented entries.
|
||||
|
||||
Seven-Year Retention & Purge Prevention
|
||||
System calculates retention expiry: fiscal year end + 7 calendar years.
|
||||
All delete operations (company, fiscal year, journal entries, documents) are blocked within the retention window.
|
||||
"Delete Company" requires a verified full SIE4 export + linked document archive before proceeding, and only after retention expires.
|
||||
GDPR conflict resolution: pseudonymize CRM master data on request, but never touch the ledger. Invoice snapshots with names remain intact as part of the fiscal record.
|
||||
|
||||
Chart of Accounts (BAS Kontoplan)
|
||||
BAS seeding per entity type (EF/AB), K1 vs full plan
|
||||
Account CRUD: add, deactivate, rename. Deactivated accounts preserve historical data but block new postings.
|
||||
Account metadata: type (tillgång/skuld/intäkt/kostnad), default tax code, SRU code mapping
|
||||
SRU mapping table. Every BAS account maps to an SRU code. This is what makes tax filing work. Without it the system cannot generate Inkomstdeklaration 2 data.
|
||||
Annual BAS updates. Migration mechanism for BAS Group changes. Deprecated accounts get frozen (no new postings), not deleted.
|
||||
Dimensions. Minimum two dimension types: Kostnadsställe (cost center) and Projekt. Stored on journal entry lines. Required for SIE4 dimension export (#OBJEKT) and expected by any consultancy or construction firm.
|
||||
|
||||
Double-Entry Bookkeeping (Immutable Ledger)
|
||||
Draft/Commit lifecycle:
|
||||
Journal entries start as drafts with temporary IDs (TMP-xxxx). Drafts are freely editable.
|
||||
On commit ("Bokför"), the system assigns the next permanent voucher number from the series. At this moment the row becomes immutable.
|
||||
DB-level enforcement: committed rows have UPDATE and DELETE restrictions. Application code cannot bypass this.
|
||||
Voucher series management:
|
||||
Sequential numbering per series per fiscal year. Gaps are impermissible under Bokföringslagen.
|
||||
Gap detection: background check that flags any missing numbers in a committed series.
|
||||
Concurrent write safety: SELECT ... FOR UPDATE or advisory locks to prevent duplicate number assignment.
|
||||
Storno correction logic:
|
||||
Posted vouchers are never edited. Corrections follow the three-step flow:
|
||||
Step 1: System generates a reversal voucher (storno) that nullifies the original.
|
||||
Step 2: System generates the corrected entry with the right data.
|
||||
Step 3: All three vouchers (original, reversal, correction) are linked in the behandlingshistorik.
|
||||
UI presents a single "Correct" button. The user sees a "Corrected" status tag. The triple-entry logic runs in the background.
|
||||
Debit == Credit validation: enforced at DB level via check constraint or trigger. No exceptions.
|
||||
Guaranteed delivery: transactional outbox pattern for journal creation from upstream events (invoice created, payment received, etc.). Failed entries go to a dead letter queue with alerting. Silent failure is not acceptable.
|
||||
|
||||
Audit Trail (Behandlingshistorik)
|
||||
Every mutation to journal entries, accounts, documents, settings, user roles logged with: actor, timestamp, action type, before-state, after-state.
|
||||
Committed vouchers log all correction chains (original -> storno -> corrected).
|
||||
Attempted deletions of protected data logged as security events.
|
||||
The audit log itself is append-only. No updates, no deletes.
|
||||
|
||||
Period Management
|
||||
Fiscal year definition with support for broken fiscal years (brutet räkenskapsår).
|
||||
Multi-fiscal-year support with clean year boundaries.
|
||||
Period locking (låsning av period). Locked periods reject all writes to journal entries and documents within that period. Locking is one-way without admin unlock + audit log entry.
|
||||
Year-end closing (årsbokslut):
|
||||
Zero out result accounts (class 3-8).
|
||||
Transfer net result to equity (account 2099).
|
||||
Generate closing entries as committed vouchers.
|
||||
Calculate and verify that UB of year N == IB of year N+1.
|
||||
Block manual editing of IB to prevent breaking continuity.
|
||||
Opening balances workflow for new companies or mid-year migrations.
|
||||
|
||||
Tax Code Engine
|
||||
Decoupled from the chart of accounts. Tax codes tag transaction lines independently.
|
||||
Code
|
||||
Rate
|
||||
Description
|
||||
Momsdeklaration Boxes
|
||||
MP1
|
||||
25%
|
||||
Standard output VAT
|
||||
05 (basis) + 10 (tax)
|
||||
MP2
|
||||
12%
|
||||
Food/hotel
|
||||
06 (basis) + 11 (tax)
|
||||
MP3
|
||||
6%
|
||||
Books/transport/culture
|
||||
07 (basis) + 12 (tax)
|
||||
MPI
|
||||
25/12/6%
|
||||
Standard input VAT
|
||||
48
|
||||
IV
|
||||
0%
|
||||
Intra-community acquisition
|
||||
20 (basis) + 30 (input) + 30 (output)
|
||||
EUS
|
||||
0%
|
||||
EU sale of goods/services
|
||||
35/36 + Periodisk sammanställning
|
||||
IP
|
||||
0%
|
||||
Import of goods
|
||||
50 (basis) + 60 (output) + 48 (input)
|
||||
EXP
|
||||
0%
|
||||
Export outside EU
|
||||
08 (basis)
|
||||
OSS
|
||||
varies
|
||||
One Stop Shop (e-commerce)
|
||||
Excluded from boxes 05-49, routed to OSS report
|
||||
|
||||
Momsdeklaration generated by summing per tax code, not per account. This survives any account plan customization.
|
||||
Validation: calculated tax (basis * rate) must match reported tax within tolerance. Deviations trigger warnings.
|
||||
Periodisk sammanställning (EC Sales List) auto-populated from EUS-tagged lines.
|
||||
|
||||
Financial Reports
|
||||
Resultaträkning (income statement) by BAS class
|
||||
Balansräkning (balance sheet) with assets == equity + liabilities validation
|
||||
Råbalans (trial balance) with zero-sum verification
|
||||
Momsdeklaration (all rutor 05-49) generated from tax code engine
|
||||
SRU-based tax data for Inkomstdeklaration 2 (sums per SRU code)
|
||||
All reports respect period locks, fiscal year boundaries, and dimension filters (kostnadsställe, projekt)
|
||||
|
||||
SIE4
|
||||
Export: spec-validated output including #IB, #UB, #RES, #VER, #OBJEKT (dimensions), #KONTO with all used accounts. Explicit character encoding handling (CP437/Latin-1) with Swedish character validation. #ORGNR validated against Luhn algorithm.
|
||||
Import: 4-step wizard (upload, parse, map accounts, review & execute). Creates journal entries from imported data. Validates that imported IB matches existing UB if prior year exists.
|
||||
Round-trip integrity: export from system, re-import, verify all balances match with zero difference.
|
||||
Cross-system validation: export must parse without errors in Visma and Fortnox.
|
||||
|
||||
Invoicing
|
||||
Create, edit, send, track invoices
|
||||
Credit notes with automatic storno reversal entries
|
||||
VAT via tax code engine (not hardcoded per account)
|
||||
Multi-currency with Riksbanken exchange rates
|
||||
Currency gain/loss (kursdifferens). When payment arrives at a different rate than invoiced, system auto-books the difference to 3960/7960.
|
||||
PDF generation
|
||||
Peppol BIS Billing 3.0. Generate and send e-invoices via Peppol network. This is the mandated B2G standard and increasingly B2B. Validate output against Peppol Schematron. This replaces email delivery for Peppol-capable recipients.
|
||||
Public payment/dispute page (token-based, no auth)
|
||||
Configurable reminder system (intervals, templates, enable/disable)
|
||||
|
||||
Banking
|
||||
PSD2 connection via Enable Banking for transaction sync
|
||||
OAuth consent flow with 90-day renewal handling
|
||||
Transaction sync with deduplication (unique(user_id, external_id))
|
||||
Invoice-to-payment matching (amount + date + OCR reference)
|
||||
ISO 20022 file handling:
|
||||
PAIN.001 generation for outgoing supplier payments. Batch multiple payments per PaymentInformation block. Validate against bank-specific XSD before download.
|
||||
CAMT.053 parsing for end-of-day bank statements. Feed into reconciliation engine matching to general ledger.
|
||||
CAMT.054 parsing for incoming payment notifications with OCR references. Auto-mark invoices as paid.
|
||||
|
||||
Transaction Management
|
||||
Transaction list with categorization
|
||||
Manual categorization creates journal entries (via draft/commit flow)
|
||||
Mapping rules engine: MCC code, merchant name, description pattern, amount threshold
|
||||
Extensible rule types (hook for add-ons to register custom rules)
|
||||
|
||||
Customers
|
||||
Name, org number, VAT number (validated format), address, payment terms, international flag
|
||||
Peppol participant ID (for e-invoice routing)
|
||||
Linked to invoices
|
||||
Subject to GDPR pseudonymization (but not deletion if linked to fiscal records)
|
||||
|
||||
Tax Calendar
|
||||
Auto-generated Swedish tax deadlines: F-skatt, arbetsgivardeklaration, momsdeklaration (monthly/quarterly), inkomstdeklaration, årsredovisning, bokslut
|
||||
Calendar views (month/week/day) + ICS export
|
||||
Deadline status tracking (upcoming, due, overdue, filed)
|
||||
|
||||
The Extension Architecture
|
||||
1. Event Bus
|
||||
The core emits events. Extensions subscribe. One-way dependency.
|
||||
typescript
|
||||
// lib/events/types.ts
|
||||
export type CoreEvent =
|
||||
// Bookkeeping
|
||||
| { type: 'journal_entry.drafted'; payload: DraftJournalEntry }
|
||||
| { type: 'journal_entry.committed'; payload: JournalEntry }
|
||||
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry } }
|
||||
// Documents
|
||||
| { type: 'document.uploaded'; payload: Document & { hash: string } }
|
||||
// Invoicing
|
||||
| { type: 'invoice.created'; payload: Invoice }
|
||||
| { type: 'invoice.sent'; payload: Invoice }
|
||||
| { type: 'invoice.paid'; payload: Invoice & { transaction: Transaction; kursdifferens?: number } }
|
||||
| { type: 'invoice.overdue'; payload: Invoice & { days: number } }
|
||||
| { type: 'credit_note.created'; payload: CreditNote }
|
||||
// Banking
|
||||
| { type: 'transaction.synced'; payload: Transaction[] }
|
||||
| { type: 'transaction.categorized'; payload: Transaction & { account: string; taxCode: string } }
|
||||
| { type: 'bank.statement_received'; payload: CAMT053Statement }
|
||||
| { type: 'bank.payment_notification'; payload: CAMT054Notification }
|
||||
// Periods
|
||||
| { type: 'period.locked'; payload: { fiscalYear: number; period: number } }
|
||||
| { type: 'period.year_closed'; payload: { fiscalYear: number } }
|
||||
// Customers
|
||||
| { type: 'customer.created'; payload: Customer }
|
||||
| { type: 'customer.pseudonymized'; payload: { customerId: string } }
|
||||
// Audit
|
||||
| { type: 'audit.security_event'; payload: AuditSecurityEvent }
|
||||
Implementation: in-process handlers initially. Add webhook dispatch (POST to registered URLs) when external plugin consumers exist.
|
||||
2. Extension Registry
|
||||
typescript
|
||||
// lib/extensions/types.ts
|
||||
export interface Extension {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
|
||||
// Surfaces
|
||||
routes?: RouteDefinition[]
|
||||
apiRoutes?: ApiRouteDefinition[]
|
||||
sidebarItems?: SidebarItem[]
|
||||
eventHandlers?: EventSubscription[]
|
||||
mappingRuleTypes?: MappingRuleType[]
|
||||
reportTypes?: ReportDefinition[]
|
||||
settingsPanel?: SettingsPanelDef
|
||||
taxCodes?: TaxCodeDefinition[] // for add-ons introducing new tax scenarios
|
||||
dimensionTypes?: DimensionDefinition[] // for add-ons adding custom dimensions beyond the base two
|
||||
|
||||
onInstall?(ctx: ExtensionContext): Promise<void>
|
||||
onUninstall?(ctx: ExtensionContext): Promise<void>
|
||||
}
|
||||
3. Database Extension Pattern
|
||||
First-party add-ons: own migration folder, own tables with user_id + RLS.
|
||||
Third-party add-ons: use API routes + webhook events + generic extension_data table:
|
||||
sql
|
||||
create table extension_data (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid references auth.users not null,
|
||||
extension_id text not null,
|
||||
key text not null,
|
||||
value jsonb not null,
|
||||
created_at timestamptz default now(),
|
||||
unique(user_id, extension_id, key)
|
||||
);
|
||||
```
|
||||
|
||||
### Core constraint
|
||||
|
||||
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
|
||||
|
||||
Each add-on is self-contained. Listed by priority tier.
|
||||
|
||||
### Tier 1: High value, build soon after core
|
||||
|
||||
**`receipt-ocr`**
|
||||
- Subscribes to: `document.uploaded`
|
||||
- Does: Claude Vision OCR, extracts merchant/date/line items/totals, fuzzy-matches to bank transactions (±3 days, amount similarity), suggests BAS account + tax code
|
||||
- Special rules: Systembolaget -> non-deductible, restaurant -> representation (90 kr/person limit)
|
||||
- Registers: custom mapping rule types for OCR-based categorization
|
||||
|
||||
**`ai-categorization`**
|
||||
- Subscribes to: `transaction.synced`
|
||||
- Does: suggests BAS account + tax code for uncategorized transactions
|
||||
- No hard dependency on any specific AI provider. Interface-based so the model is swappable.
|
||||
|
||||
**`ne-bilaga`**
|
||||
- Registers as: `reportType` via extension registry
|
||||
- Does: generates NE-bilaga (income tax appendix for enskild firma, fields R1-R11) from journal entries
|
||||
- Only relevant for EF entity type. Hidden for AB.
|
||||
|
||||
**`sru-export`**
|
||||
- Registers as: `reportType`
|
||||
- Does: generates SRU files for Skatteverket electronic filing. Reads SRU mappings from core account metadata.
|
||||
|
||||
**`push-notifications`**
|
||||
- Subscribes to: `invoice.overdue`, `period.locked`, deadline events from tax calendar
|
||||
- Does: Web Push via VAPID. Per-user preferences with quiet hours.
|
||||
|
||||
**`owner-payroll`**
|
||||
- Registers: routes, sidebar item, settings panel
|
||||
- Does: single-employee salary for AB owner. Gross salary, tax deduction, employer contributions (arbetsgivaravgifter). Monthly AGI XML generation for Skatteverket. Box 821 absence reporting (VAB, parental leave) with date tracking. Bilförmån and traktamente input fields with Skatteverket standard rates.
|
||||
- Subscribes to: tax calendar deadline events for arbetsgivardeklaration due dates
|
||||
|
||||
### Tier 2: Market differentiation
|
||||
|
||||
**`annual-report`**
|
||||
- Registers as: `reportType` + routes
|
||||
- Does: K2 taxonomy mapping from BAS accounts. Generates iXBRL for Bolagsverket digital filing. API integration: validate, upload, redirect to BankID signing. Board member signature flow.
|
||||
- K3 support as a sub-toggle within this add-on (component depreciation, fair value).
|
||||
|
||||
**`ai-chat`**
|
||||
- Registers: floating widget component, routes for session management
|
||||
- Does: RAG-powered Swedish tax/accounting assistant using LangChain + embeddings. Session history. Rate limited.
|
||||
- No event subscriptions. Read-only access to user's bookkeeping data for context.
|
||||
|
||||
**`bankid`**
|
||||
- Registers: auth provider, signing flow component
|
||||
- Does: BankID integration for login and document signing. Secure Start (animated QR code, mandatory since May 2024). Certificate management for merchant certificates.
|
||||
- Used by: `annual-report` (Bolagsverket signing), `owner-payroll` (AGI signing), future audit access.
|
||||
|
||||
**`deductions`**
|
||||
- Registers: routes, sidebar item, report types
|
||||
- Does: Schablonavdrag for mileage (korjournal, 25 kr/mil) and home office (2,000-4,000 kr/year). Generates journal entries.
|
||||
|
||||
### Tier 3: Vertical enablers
|
||||
|
||||
**`inventory-value`**
|
||||
- Registers: routes, report type
|
||||
- Does: tracks financial value of stock on account 1400. Accepts journal entries from vertical inventory modules (retail, construction, food). Does not do logistics, variants, batches, or expiry tracking. That is the vertical's job.
|
||||
|
||||
**`multi-currency-advanced`**
|
||||
- Registers: additional tax codes, report types
|
||||
- Does: automated unrealized gain/loss calculations at period end. Currency revaluation entries. Beyond the base kursdifferens on invoice payment.
|
||||
|
||||
**`oss-reporting`**
|
||||
- Registers: report type, tax codes
|
||||
- Does: OSS (One Stop Shop) VAT return for e-commerce sellers. Transactions tagged with OSS tax codes excluded from standard momsdeklaration and routed here.
|
||||
|
||||
**`saf-t-export`**
|
||||
- Registers: report type
|
||||
- Does: SAF-T XML generation. Forward-looking compliance for potential 2026 EU mandate. Maps from the core's granular data model (header -> line -> tax detail).
|
||||
|
||||
---
|
||||
|
||||
## Repo Structure
|
||||
```
|
||||
app/
|
||||
(auth)/
|
||||
(onboarding)/
|
||||
(dashboard)/
|
||||
bookkeeping/
|
||||
invoices/
|
||||
transactions/
|
||||
banking/
|
||||
customers/
|
||||
reports/
|
||||
calendar/
|
||||
settings/
|
||||
extensions/ → marketplace / management
|
||||
(public)/
|
||||
api/
|
||||
journal-entries/
|
||||
invoices/
|
||||
transactions/
|
||||
banking/
|
||||
reports/
|
||||
customers/
|
||||
deadlines/
|
||||
documents/ → upload, hash verification, version history
|
||||
audit/ → audit log queries, security events
|
||||
extensions/ → register, list, config
|
||||
webhooks/ → outbound event delivery
|
||||
|
||||
lib/
|
||||
core/
|
||||
bookkeeping/ → draft/commit, storno, voucher series, period locking
|
||||
accounts/ → BAS kontoplan, SRU mapping, dimensions
|
||||
reports/ → resultaträkning, balansräkning, råbalans, moms
|
||||
invoicing/ → create, send, credit, VAT, Peppol, reminders
|
||||
banking/ → PSD2, sync, matching, ISO 20022 (PAIN/CAMT)
|
||||
transactions/ → categorization, mapping rules
|
||||
tax/ → tax code engine, deadlines, fiscal year, year-end closing
|
||||
sie/ → import + export with dimension support
|
||||
documents/ → hash-on-upload, WORM storage, deletion blocking, versioning
|
||||
audit/ → append-only audit log, behandlingshistorik
|
||||
retention/ → purge prevention, retention expiry calculation
|
||||
events/ → event bus, types, webhook dispatch
|
||||
extensions/ → registry, types, loader
|
||||
|
||||
extensions/ → first-party add-ons
|
||||
receipt-ocr/
|
||||
ai-categorization/
|
||||
ai-chat/
|
||||
ne-bilaga/
|
||||
sru-export/
|
||||
push-notifications/
|
||||
owner-payroll/
|
||||
annual-report/
|
||||
bankid/
|
||||
deductions/
|
||||
inventory-value/
|
||||
multi-currency-advanced/
|
||||
oss-reporting/
|
||||
saf-t-export/
|
||||
|
||||
components/
|
||||
ui/ → Radix primitives, design system
|
||||
core/ → base feature components
|
||||
extensions/ → shared extension UI patterns
|
||||
|
||||
supabase/
|
||||
migrations/ → base schema only
|
||||
|
||||
types/
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
# Förtydligad feedback med tolkningar
|
||||
|
||||
## 1. Onboarding-flöde
|
||||
|
||||
**Influencer Business → Influencer Assistant**
|
||||
- "Influencer Business" står troligen som appnamn/rubrik i onboarding-flödet
|
||||
- Ska ersättas med "Influencer Assistant" överallt där det förekommer
|
||||
- Förmodligen i header, sidtitel och eventuellt metadata
|
||||
|
||||
**Localhost redirect problem vid auth genom mail**
|
||||
- Magic link-autentisering redirectar till `localhost:3000` istället för produktions-URL
|
||||
- Environment variable för NEXT_PUBLIC_SITE_URL är förmodligen inte satt korrekt i Supabase
|
||||
|
||||
**Ta bort alla detaljer kring EF/AB**
|
||||
- Nuvarande onboarding har förklarande text typ "Enklare bokföring, lägre egenavgifter" vid bolagsformsval
|
||||
- Ska bli en ren dropdown med endast "Enskild firma", "Aktiebolag", "Inget bolag ännu (frilans)"
|
||||
- Ingen pedagogisk text, bara valet
|
||||
|
||||
**Lägg till "Inget bolag ännu (frilans)"**
|
||||
- Tredje alternativ för creators som tar sina första samarbeten men inte formaliserat verksamheten än
|
||||
- Låter dem använda appen för att spåra inkomster innan de registrerar bolag
|
||||
- Förmodligen med begränsad funktionalitet (ingen bokföring, bara spårning)
|
||||
|
||||
**Verksamhetsnamn lägg till (Eller ditt namn vid EF)**
|
||||
- Labeln "Verksamhetsnamn" ska ha suffix-text "(Eller ditt namn vid EF)"
|
||||
- Placeholder bör vara "Alices Influencer-verksamhet" eller "Alice Andersson"
|
||||
- Gör det tydligt att EF-användare kan använda personnamn
|
||||
|
||||
**Org nummer förklara att det är ens personnummer vid EF**
|
||||
- Placeholder-text: "ÅÅMMDD-XXXX (ditt personnummer vid EF)"
|
||||
- Eller hjälptext under fältet: "Vid enskild firma är orgnummer samma som ditt personnummer"
|
||||
- Görs obligatoriskt för EF/AB (ta bort "frivilligt")
|
||||
|
||||
**Ta bort frivilligt för EF**
|
||||
- Orgnummer är obligatoriskt för både EF och AB
|
||||
- Required-validering ska gälla
|
||||
|
||||
**Ändra till brun färg där det är grönt**
|
||||
- Primärfärg i appen är troligen grön idag
|
||||
- Ska bytas till brun enligt brand guidelines
|
||||
- Gäller knappar, accenter, aktiva tillstånd
|
||||
|
||||
**Bankkonto ta bort? Eller direkt till Enable Banking?**
|
||||
- Nuvarande flöde: manuellt IBAN-fält i onboarding
|
||||
- Ska ersättas med Enable Banking-integration direkt
|
||||
- Dvs. istället för textfält → knapp "Koppla bankkonto" som öppnar Enable Banking
|
||||
- Hoppar över manuell input helt? Eller?
|
||||
|
||||
**Fixa knapp så att den går direkt vid klick på bank och inte två steg**
|
||||
- Nuvarande: Klicka på en bank , sedan ändå behöva scrolla ner och klicka "autentisera till X" knapp.
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard (första sidan)
|
||||
|
||||
**"Godmorgon Alice, idag ska du göra X" eller "Allt är som det ska"**
|
||||
- Personaliserad hero-sektion högst upp på dashboarden
|
||||
- "Godmorgon Alice, idag ska du: Skicka faktura till H&M (förfaller om 3 dagar), Betala moms (deadline 12 feb)"
|
||||
- Om inga pending items: "Godmorgon Alice, allt är som det ska"
|
||||
- Hämtar från kommande kalenderhändelser + fakturor nära förfall + obetalda fakturor
|
||||
|
||||
**Pengarna under**
|
||||
- "Pengarna under" = summan pengar som finns under/kvar efter att preliminärskatt dragits av
|
||||
- Visar nettoresultat efter beräknad skatt
|
||||
- Dashboard ska visa: Bruttoinkomst, Beräknad skatt, Kvar att ta ut
|
||||
- "Kvar att ta ut: 45 000 kr" typ
|
||||
|
||||
**Undersök skatteberäkning?**
|
||||
|
||||
**Intäkter / Kostnader också 0, undersök**
|
||||
- Dashboard visar 0 kr för både intäkter och kostnader, undersök detta om det stämmer
|
||||
|
||||
**Ny kampanj → Nytt samarbete**
|
||||
- CTA-knapp eller sektionshuvud säger "Ny kampanj"
|
||||
- Ska vara "Nytt samarbete" eller "+ Skapa samarbete"
|
||||
- Matchar creators mentala modell bättre
|
||||
|
||||
**Spåra leverabler svårt ord**
|
||||
- "Leverabler" är projektledarjargong, inte creator-språk
|
||||
- Ska vara "Spåra innehåll" eller "Håll koll på publiceringar"
|
||||
- Mindre formellt, mer intuitivt
|
||||
|
||||
**Ta bort logga körning från första sidan**
|
||||
- Det finns en sektion/knapp för mileage tracking på dashboarden
|
||||
- Ska tas bort helt (inte relevant för målgruppen)
|
||||
- Creators kör sällan tjänsteresor, prioritera inte detta
|
||||
|
||||
**Skita i personlig kortkoppling, arbeta endast med företagskort**
|
||||
- Endast företagskort via Enable Banking
|
||||
- Kategorisera alla transaktioner från företagskonto
|
||||
- Enklare modell, färre edge cases
|
||||
|
||||
**Ta bort sammanfattning månadsvis kalendervy**
|
||||
- Kalendervyn har förmodligen en rad per månad som aggregerar events
|
||||
- Tar onödigt utrymme och är inte så användbar
|
||||
- Visa bara enskilda events i kronologisk ordning
|
||||
|
||||
**Kalender större och mer tilltalande**
|
||||
- Nuvarande kalender är förmodligen en liten widget eller sidomodul
|
||||
- Ska ta mer plats (förmodligen halva skärmbredden eller större)
|
||||
- Kanske full-width på mobil
|
||||
- Mer visuellt prominent, då det är core feature
|
||||
|
||||
**Ta bort "Håll koll på betalningar och deadlines"**
|
||||
- Beskrivande text under kalendersektion
|
||||
- Är överflödig/upprepande
|
||||
- Ta bort, låt kalendern tala för sig själv
|
||||
|
||||
---
|
||||
|
||||
## 3. Samarbeten (f.d. kampanjer)
|
||||
|
||||
**Kampanjer → Samarbete**
|
||||
- Överallt där "Kampanj" används som term
|
||||
|
||||
**Flytta upp så att det är "+ Skapa samarbete" och "Importera avtal" där uppe istället**
|
||||
- Nuvarande: CTA-knappar längre ner på sidan eller i subnavigation
|
||||
- Ska vara: Primary actions i toppen av sidan (header-nivå)
|
||||
|
||||
**Start och slutdatum för samarbete ändras när man ändrar publiceringsdatum**
|
||||
- Bugg: När användaren ändrar publiceringsdatum, ändras även start/slutdatum automatiskt
|
||||
- Ska inte hända - olika fält, oberoende av varandra
|
||||
- Eller: Ta bort start/slutdatum helt (se nästa punkt)
|
||||
|
||||
**Start och slutdatum irrelevant**
|
||||
- Start/slutdatum är inte viktiga för creators
|
||||
- Relevant är: Publiceringsdatum (när contentet går live) och Utkastdeadline (när drafts ska skickas)
|
||||
- Ta bort Start/Slutdatum-fält helt, ersätt med:
|
||||
- "Publiceringsdatum" (datum)
|
||||
- "Utkastdeadline" (datum, optional)
|
||||
|
||||
**Att det ska stå varumärke / direktkund men inte lägga in som kund**
|
||||
- Fält för "Varumärke" (t.ex. "H&M") - fritextfält
|
||||
- Fält för "Byrå" (t.ex. "Swim Communication") - dropdown/autocomplete som skapar Customer-post
|
||||
- Endast byrå ska finnas i Customers-tabellen
|
||||
- Varumärke är metadata på samarbetet, inte en relation
|
||||
|
||||
**Endast byrå ska läggas in som kund**
|
||||
- Customer-tabellen innehåller bara byråer (de som faktiskt betalar)
|
||||
- Varumärken är bara en textsträng på samarbetet
|
||||
- Fakturan går till byrån, inte varumärket
|
||||
|
||||
**Slutkund är ibland varumärke, ibland byrå**
|
||||
- "Slutkund" = den entitet som ska synas på fakturan och i kommunikation
|
||||
- Om direktkund (inget mellanled): slutkund = varumärke
|
||||
- Om via byrå: slutkund = byrå
|
||||
- Automatisk logik: Om byrå finns → byrå är slutkund. Annars → varumärke är slutkund
|
||||
|
||||
**"Skapa ny kund" knapp inte helt intuitiv i ladda upp avtal flödet**
|
||||
|
||||
**Byt ut leverabler (svårt ord), extraheras kan också beskrivas lättare**
|
||||
- "Leverabler" → "Innehåll" eller "Publiceringar"
|
||||
- "Extraheras" → "Hittades i avtalet" eller "AI fyllde i automatiskt"
|
||||
- Enklare språk, mindre jargong
|
||||
|
||||
**Exklusivitet inte extraherat korrekt (competing brands i x veckor)**
|
||||
- AI-extrahering hittar inte exklusivitetsklausuler korrekt
|
||||
- Exempel: "No competing brands for 4 weeks" ska extraheras till "4 veckor"
|
||||
- Förbättra prompt eller lägg till specifik parsing för exklusivitet
|
||||
|
||||
**Lägg till funktion att man kan lägga in exklusivitet själv**
|
||||
- Om AI missar exklusivitet, ska användaren kunna lägga till manuellt (även där det står "ingen exklusivitet hittades")
|
||||
- Fält: "Exklusivitet (valfritt)" med dropdown för antal veckor och fritextfält för competing brands
|
||||
- Eller: Edit-knapp vid extraherad exklusivitet
|
||||
|
||||
**Textinnehåll sammanfatta på briefing med AI**
|
||||
-"Klistra in mailetråd"
|
||||
- Användaren klistrar in hela mejlkonversationen eller annan text.
|
||||
- AI extraherar och sammanfattar till strukturerad briefing
|
||||
|
||||
**Från kampanj till faktura: populera automatiskt**
|
||||
- När användaren skapar faktura från ett samarbete
|
||||
- Auto-populera: Kund (byrå), Belopp (från samarbetsavtalet), Beskrivning (samarbetsnamn), Förfallodatum (publiceringsdatum + 30 dagar)
|
||||
- Knapp i samarbetsdetaljer: "Skapa faktura" som öppnar fakturaformulär med förifyllda fält
|
||||
|
||||
**Fixa UI kundsida**
|
||||
- Kundsidan (kund-detaljer) har dålig layout/design
|
||||
- Behöver omarbetas: bättre typografi, spacing, färgschema enligt brand
|
||||
- Förmodligen legacy-design som inte uppdaterats efter rebrand
|
||||
|
||||
---
|
||||
|
||||
## 4. Transaktioner & Kategorisering
|
||||
|
||||
**Automatiskt fetcha banktransaktioner**
|
||||
- Enable Banking är redan integrerat men transaktioner hämtas inte automatiskt
|
||||
- Implementera: Cron job eller webhook som hämtar transaktioner dagligen
|
||||
- Supabase Edge Function + scheduled trigger för att synka transactions
|
||||
|
||||
**Kategorisera logiken lägger av efter 3/6 transaktioner**
|
||||
- AI-kategorisering fungerar för de första transaktionerna
|
||||
- Efter 3-6 transaktioner slutar den kategorisera (returnerar null eller default-kategori)
|
||||
- Förmodligen: Rate limit, token limit, eller bugg i loop-logiken
|
||||
- Fix: Batch-processa i chunks, kolla error handling
|
||||
|
||||
**Undersök balans/resultat??**
|
||||
- Rapporterad balans/resultat matchar inte faktiskt banksaldo eller förväntad vinst (kanske)
|
||||
- Förmodligen: Kategoriseringsbuggen ovan gör att många transaktioner blir okategoriserade
|
||||
- Eller: Dubbelbokföring, felaktig summering
|
||||
- Fix: Debugga query som beräknar balans, verifiera mot banksaldo
|
||||
|
||||
**Gör om swipe funktionen till att man swipear till rätt kostnadskonto istället**
|
||||
- De mest troliga kostnadskonton visas på skärmen, alternativ för fler om det inte stämmer.
|
||||
|
||||
**Lägga till fler alternativ för varje kostnad (ex bankkostnad är inte med)**
|
||||
- Nuvarande kategorilista är ofullständig
|
||||
- "Bankkostnad" saknas (avgifter från banken)
|
||||
- Lägg till: Bankkostnad, Kortavgifter, Valutaväxling, eventuellt fler
|
||||
- Förmodligen hårdkodad enum, utöka listan
|
||||
|
||||
**Faktura ska mappas till transaktioner**
|
||||
- När en faktura betalas, ska den kopplas till motsvarande banktransaktion
|
||||
- Auto-matching baserat på: Belopp (±1%), Datum (±7 dagar), Kund-referens
|
||||
- UI: Ikon som visar "Kopplad till faktura #123" på transaktionen
|
||||
- Underlättar reconciliation
|
||||
- Detta kan vara en del av kategoriserings swipe funktionen.
|
||||
|
||||
---
|
||||
|
||||
## 5. Kalender & Deadlines
|
||||
|
||||
**Lägg in så att prel skatte inbetalningsdatum ligger i kalendern**
|
||||
- Preliminärskatt ska visas som återkommande events i kalendern
|
||||
- Månatlig (om stor omsättning), kvartalsvis (standard), eller årlig (låg omsättning)
|
||||
- Automatisk beräkning baserat på ackumulerad vinst
|
||||
- Event: "Preliminärskatt 12 500 kr" med datum och länk till betalning
|
||||
|
||||
**Månadsvis/kvartal/år**
|
||||
- Intervall för prelskatt beror på förväntad omsättning:
|
||||
- Månad: >1M kr/år
|
||||
- Kvartal: 100k-1M kr/år (default)
|
||||
- År: <100k kr/år
|
||||
- Automatisk detektering baserat på faktiska intäkter
|
||||
- Eller: Användarinställning om de vet sin skatteklass
|
||||
|
||||
---
|
||||
|
||||
## 6. Intäktstyper
|
||||
|
||||
**Lägg till "Podcast" som sätt att man får betalt**
|
||||
- Nuvarande intäktstyper: Instagram, TikTok, YouTube, Sponsrad post, etc.
|
||||
- Lägg till: "Podcast" (brandintegration i podcast-episoder)
|
||||
- Ska vara eget val i dropdown "Typ av samarbete" eller "Plattform"
|
||||
- Samma hantering som andra samarbetstyper: fakturering, kategorisering, momsklassificering
|
||||
|
||||
ISSUES:
|
||||
## Error Type
|
||||
Runtime SyntaxError
|
||||
|
||||
## Error Message
|
||||
Unexpected token '<'
|
||||
|
||||
Next.js version: 16.1.5 (Turbopack)
|
||||
|
||||
|
||||
Fixa så att det är tre cards istället: EF, AB, Frilans (coming soon)
|
||||
|
||||
Blir inte automatiskt redirectad till nästa steg efter bank connect:
|
||||
|
||||
POST /api/banking/connect 200 in 988ms (compile: 37ms, render: 951ms)
|
||||
GET /api/banking/callback?state=acdf00c6-82a1-4068-ac07-7510ee41667d&code=061d67b7-81d7-4996-8bb5-9cd731651108 307 in 2.0s (compile: 35ms, render: 2.0s)
|
||||
GET /onboarding 200 in 167ms (compile: 5ms, proxy.ts: 127ms, render: 35ms)
|
||||
GET /onboarding 200 in 315ms (compile: 5ms, proxy.ts: 276ms, render: 33ms)
|
||||
|
||||
Dessa är på fel ställe och är inkorrekta:
|
||||
|
||||
Välkommen till din översikt!
|
||||
Steg 1 av 5
|
||||
|
||||
Stäng guide
|
||||
Här ser du hela din ekonomiska situation på ett ställe. Låt oss gå igenom de viktigaste delarna.
|
||||
|
||||
Fixa namn / bolagsnamn efter God kväll på main page.
|
||||
|
||||
Trippla lägg till samarbetsknappar på https://marguerite-nonhieratical-becki.ngrok-free.dev/campaigns
|
||||
|
||||
Får ## Error Type
|
||||
Runtime SyntaxError
|
||||
|
||||
## Error Message
|
||||
Unexpected token '<'
|
||||
|
||||
Next.js version: 16.1.5 (Turbopack)
|
||||
när jag försöker ta bort exklusivitetsmärke från AI extraheringen.
|
||||
|
||||
|
||||
Och detta när jag förösker lägga till exklusivitet manuellt:
|
||||
## Error Type
|
||||
Runtime TypeError
|
||||
|
||||
## Error Message
|
||||
Cannot read properties of undefined (reading 'length')
|
||||
|
||||
|
||||
at Step5Exclusivity (components/contracts/ContractImportWizard.tsx:1169:50)
|
||||
at renderStepContent (components/contracts/ContractImportWizard.tsx:324:16)
|
||||
at ContractImportWizard (components/contracts/ContractImportWizard.tsx:403:10)
|
||||
at CampaignImportPage (app/(dashboard)/campaigns/import/page.tsx:37:7)
|
||||
|
||||
## Code Frame
|
||||
1167 | </div>
|
||||
1168 |
|
||||
> 1169 | {extraction.exclusivity.excludedBrands.length > 0 && (
|
||||
| ^
|
||||
1170 | <div>
|
||||
1171 | <Label className="mb-2 block">Uteslutna varumärken</Label>
|
||||
1172 | <div className="flex flex-wrap gap-2">
|
||||
|
||||
Next.js version: 16.1.5 (Turbopack)
|
||||
|
||||
"Kunde inte kategorisera. Tryck "Hoppa över" för att gå vidare." när jag förösker välja bankkostnader / kortkostnader
|
||||
|
||||
POST /api/transactions/d100f6a5-5512-464f-a500-1f63f561e72b/categorize 200 in 1440ms (compile: 474ms, render: 966ms)
|
||||
Failed to update transaction: {
|
||||
code: '23514',
|
||||
details: null,
|
||||
hint: null,
|
||||
message: 'new row for relation "transactions" violates check constraint "transactions_category_check"'
|
||||
}
|
||||
POST /api/transactions/5d926784-7acb-483c-8859-e34ff29bdd49/categorize 500 in 1045ms (compile: 10ms, render: 1035ms)
|
||||
Failed to update transaction: {
|
||||
code: '23514',
|
||||
details: null,
|
||||
hint: null,
|
||||
message: 'new row for relation "transactions" violates check constraint "transactions_category_check"'
|
||||
}
|
||||
POST /api/transactions/5d926784-7acb-483c-8859-e34ff29bdd49/categorize 500 in 799ms (compile: 6ms, render: 793ms)
|
||||
POST /api/transactions/c81e8863-0085-465a-a96b-f3647f28551f/categorize 200 in 1055ms (compile: 6ms, render: 1049ms)
|
||||
POST /api/transactions/7001bd21-c4e0-46a2-b069-fbb6dc16cd43/categorize 200 in 848ms (compile: 7ms, render: 842ms)
|
||||
POST /api/transactions/f7dc202a-0938-4614-99cc-254c43e63144/categorize 200 in 902ms (compile: 6ms, render: 896ms)
|
||||
POST /api/transactions/batch-match-invoices 200 in 425ms (compile: 3ms, render: 422ms)
|
||||
POST /api/transactions/suggest-categories 200 in 291ms (compile: 1823µs, render: 289ms)
|
||||
POST /api/transactions/2ebaeaa7-b452-437c-94d3-3716d45085d4/categorize 200 in 836ms (compile: 3ms, render: 833ms)
|
||||
POST /api/transactions/9bc5e6bb-0dec-4ce0-8732-e6540f44e229/categorize 200 in 860ms (compile: 6ms, render: 853ms)
|
||||
POST /api/transactions/batch-match-invoices 200 in 124ms (compile: 5ms, render: 119ms)
|
||||
POST /api/transactions/suggest-categories 200 in 235ms (compile: 2ms, render: 233ms)
|
||||
Failed to update transaction: {
|
||||
code: '23514',
|
||||
details: null,
|
||||
hint: null,
|
||||
message: 'new row for relation "transactions" violates check constraint "transactions_category_check"'
|
||||
}
|
||||
POST /api/transactions/5d926784-7acb-483c-8859-e34ff29bdd49/categorize 500 in 920ms (compile: 6ms, render: 914ms)
|
||||
|
||||
Fixa transaktionslogiken allmänt. Ta bort privatvalet helt.
|
||||
|
||||
Undersök om alla intäkter ska vara 3900 övriga rörelseintäkter
|
||||
|
||||
Gör en TODO (tex "två passerade deadlines" är direkt under God kväll)
|
||||
|
||||
Fixa så titel på samarbete syns.
|
||||
|
||||
Värde -> Arvode
|
||||
|
||||
Se över bankuppgifter i faktura. Och fakturanr, IBAN, EU momsnmr.
|
||||
|
||||
Ta bort skatte "ditt att spendera"
|
||||
@@ -1,469 +0,0 @@
|
||||
1/9
|
||||
BAS Förenklat årsbokslut (K1) – Kontoplan 2018
|
||||
Inga ändring eller tillägg har gjorts
|
||||
jämfört med 2017.
|
||||
1 Tillgångar
|
||||
10 Immateriella anläggningstillgångar
|
||||
BAS-konton Rad Underkonton Rad
|
||||
1000 Immateriella
|
||||
anläggningstillgångar
|
||||
B1 1009 Årets avskrivningar på
|
||||
immateriella
|
||||
B1
|
||||
anläggningstillgångar
|
||||
11 Byggnader och mark
|
||||
BAS-konton Rad Underkonton Rad
|
||||
1110 Byggnader B2 1119 Ackumulerade avskrivningar på
|
||||
byggnader
|
||||
B2
|
||||
1130 Mark B3
|
||||
1150 Markanläggningar B2 1159 Ackumulerade avskrivningar på
|
||||
markanläggningar
|
||||
B2
|
||||
1180 Pågående nyanläggningar och
|
||||
förskott för byggnader och
|
||||
B3
|
||||
mark
|
||||
12 Maskiner och inventarier
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
1220 Maskiner och inventarier B4 1221 Årets nyanskaffning av
|
||||
maskiner och inventarier
|
||||
B4
|
||||
1222 Årets ersättning för maskiner
|
||||
och inventarier
|
||||
B4
|
||||
1229 Årets avskrivningar på maskiner
|
||||
och inventarier
|
||||
B4
|
||||
1230 Byggnads- och markinventarier B4 1231 Årets nyanskaffning av
|
||||
byggnads- och markinventarier
|
||||
B4
|
||||
1232 Årets ersättning för byggnads-
|
||||
och markinventarier
|
||||
B4
|
||||
1239 Årets avskrivningar på
|
||||
byggnads- och markinventarier
|
||||
B4
|
||||
1240 Bilar och andra transportmedel B4 1241 Årets nyanskaffning av bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
1242 Årets ersättning för bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
1249 Årets avskrivningar på bilar och
|
||||
andra transportmedel
|
||||
B4
|
||||
13 Övriga anläggningstillgångar
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
1300 Andelar B5
|
||||
14 Lager
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
Kontoplan_K1_2018_ver1
|
||||
2/9
|
||||
1400 Lager B6
|
||||
15 Kundfordringar
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
1500 Kundfordringar B7
|
||||
16 Övriga fordringar
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
1600 Övriga fordringar B8
|
||||
1650 Momsfordran B8
|
||||
17 Förskott till leverantörer
|
||||
BAS-konton Underkonton
|
||||
Rad
|
||||
1700 Förskott till leverantörer B8
|
||||
19 Kassa och bank
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad
|
||||
1910 Kassa B9
|
||||
1920 PlusGiro B9
|
||||
1930 Företagskonto/checkkonto/affär
|
||||
skonto
|
||||
B9
|
||||
1940 Övriga bankkonton B9
|
||||
1970 Särskilda bankkonton B9
|
||||
2 EGET KAPITAL OCH SKULDER
|
||||
20 Eget kapital
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
2010 Eget kapital, delägare 1 B10 2011 Egna varuuttag B10
|
||||
2012 Avräkning för skatter och
|
||||
avgifter (skattekonto)
|
||||
B10
|
||||
2013 Övriga egna uttag B10
|
||||
2014 Uttag förmåner B10
|
||||
2017 Egna insättningar B10
|
||||
2019 Årets resultat, delägare 1 B10
|
||||
2020 Eget kapital, delägare 2 B10 Se delägre 1
|
||||
2030 Eget kapital, delägare 3 B10 Se delägre 1
|
||||
2040 Eget kapital, delägare 4 B10 Se delägre 1
|
||||
2050 Avsättning till expansionsfond U2
|
||||
2060 Ersättningsfond U3
|
||||
2070 Insatsemissioner,
|
||||
avbetalningsplan på skog,
|
||||
skogskonto,
|
||||
U4
|
||||
2080 Periodiseringsfonder upphovsmannakonto
|
||||
U1 2083 Periodiseringsfond vid 2012 års
|
||||
taxering
|
||||
U1
|
||||
Kontoplan_K1_2018_ver1
|
||||
3/9
|
||||
2084 Periodiseringsfond vid 2013 års
|
||||
taxering
|
||||
U1
|
||||
2085 Periodiseringsfond 2013 U1
|
||||
2086 Periodiseringsfond 2014 U1
|
||||
2087 Periodiseringsfond 2015 U1
|
||||
2088 Periodiseringsfond 2016 U1
|
||||
2089 Periodiseringsfond 2017 U1
|
||||
2090 Utjämningskonto upplysningar
|
||||
1-4
|
||||
23 Låneskulder
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
2330 Checkräkningskredit B13
|
||||
2350 Skulder till kreditinstitut B13
|
||||
2390 Övriga låneskulder B13
|
||||
24 Skulder till leverantörer
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
2440 Leverantörsskulder B15
|
||||
26 Moms och särskilda punktskatter
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
2610 Utgående moms, 25 % B14 2611 Utgående moms på försäljning
|
||||
inom Sverige, 25 %
|
||||
B14
|
||||
2612 Utgående moms på egna uttag,
|
||||
25 %
|
||||
B14
|
||||
2613 Utgående moms för uthyrning,
|
||||
25 %
|
||||
B14
|
||||
2614 Utgående moms omvänd
|
||||
skattskyldighet, 25 %
|
||||
B14
|
||||
2615 Utgående moms import av
|
||||
varor, 25 %
|
||||
B14
|
||||
2618 Vilande utgående moms, 25 % B14
|
||||
2620 Utgående moms, 12 % B14 2621 Utgående moms på försäljning
|
||||
inom Sverige, 12 %
|
||||
B14
|
||||
2622 Utgående moms på egna uttag,
|
||||
12 %
|
||||
B14
|
||||
2623 Utgående moms för uthyrning,
|
||||
12 %
|
||||
B14
|
||||
2624 Utgående moms omvänd
|
||||
skattskyldighet, 12 %
|
||||
B14
|
||||
2625 Utgående moms import av
|
||||
varor, 12 %
|
||||
B14
|
||||
2628 Vilande utgående moms, 12 % B14
|
||||
2630 Utgående moms, 6 % B14 2631 Utgående moms på försäljning
|
||||
inom Sverige, 6 %
|
||||
B14
|
||||
2632 Utgående moms på egna uttag,
|
||||
6 %
|
||||
B14
|
||||
Kontoplan_K1_2018_ver1
|
||||
4/9
|
||||
2633 Utgående moms för uthyrning,
|
||||
6 %
|
||||
B14
|
||||
2634 Utgående moms omvänd
|
||||
skattskyldighet, 6 %
|
||||
B14
|
||||
2635 Utgående moms import av
|
||||
varor, 6 %
|
||||
B14
|
||||
2638 Vilande utgående moms, 6 % B14
|
||||
2640 Ingående moms B14 2641 Debiterad ingående moms B14
|
||||
2642 Debiterad ingående moms i
|
||||
anslutning till frivillig
|
||||
B14
|
||||
2645 Beräknad ingående moms på
|
||||
skattskyldighet
|
||||
förvärv från utlandet
|
||||
B14
|
||||
2646 Ingående moms på uthyrning B14
|
||||
2648 Vilande ingående moms B14
|
||||
2649 Ingående moms, blandad
|
||||
verksamhet
|
||||
B14
|
||||
2650 Redovisningskonto för moms B14 B14
|
||||
2660 Särskilda punktskatter B14 B14
|
||||
27 Personalens skatter, avgifter och löneavdrag
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
2710 Personalskatt B14
|
||||
2730 Lagstadgade/avtalade sociala
|
||||
avgifter och särskild löneskatt
|
||||
B14
|
||||
29 Övriga skulder
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
2900 Övriga skulder B16
|
||||
3 RÖRELSENS INKOMSTER/INTÄKTER
|
||||
30-34 Huvudintäkter
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
3000 Försäljning och utfört arbete
|
||||
samt övriga momspliktiga
|
||||
R1
|
||||
3100 intäkter
|
||||
Momsfria intäkter R2
|
||||
3200 Bil- och bostadsförmån m.m. R3
|
||||
35 Fakturerade kostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
3500 Fakturerade kostnader R1
|
||||
37 Intäktskorrigeringar
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
3700 Lämnade rabatter, bonus etc. R1/R2
|
||||
Kontoplan_K1_2018_ver1
|
||||
5/9
|
||||
39 Övriga rörelseintäkter
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
3900 Övriga rörelseintäkter R1/R2
|
||||
3970 Vinst vid avyttring av
|
||||
immateriella och materiella
|
||||
R2
|
||||
3980 anläggningstillgångar
|
||||
Erhållna bidrag R2
|
||||
4 UTGIFTER/KOSTNADER FÖR VAROR, MATERIAL OCH VISSA
|
||||
KÖPTA TJÄNSTER
|
||||
40-45 Inköp av varor och material
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
4000 Varor R5
|
||||
46 Legoarbeten, underentreprenader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
4600 Legoarbeten och
|
||||
underentreprenader
|
||||
R5
|
||||
47 Reduktion av inköpspriser
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
4700 Erhållna rabatter, bonus etc. R6
|
||||
49 Förändring av lager
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
4900 Förändring av lager R5
|
||||
5-6 ÖVRIGA EXTERNA RÖRELSEUTGIFTER/KOSTNADER
|
||||
50 Lokalkostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5000 Lokalkostnader R6
|
||||
51 Fastighetskostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5100 Fastighetskostnader R6
|
||||
52 Hyra av anläggningstillgångar
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
5200 Hyra av anläggningstillgångar R6
|
||||
54 Förbrukningsinventarier och förbrukningsmaterial
|
||||
Kontoplan_K1_2018_ver1
|
||||
6/9
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
5400 Förbrukningsinventarier och
|
||||
förbrukningsmaterial
|
||||
R6
|
||||
55 Reparation och underhåll
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5500 Reparation och underhåll R6
|
||||
56 Kostnader för transportmedel
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5600 Kostnader för transportmedel R6
|
||||
5610 Personbilskostnader R6 5611 Drivmedel för personbilar R6
|
||||
5612 Försäkring och skatt för
|
||||
R6
|
||||
5613 Reparation och underhåll av
|
||||
personbilar
|
||||
personbilar
|
||||
R6
|
||||
5615 Leasing av personbilar R6
|
||||
5618 Schablonmässig milkostnad
|
||||
privat personbil
|
||||
R6
|
||||
5619 Övriga personbilskostnader R6
|
||||
5620 Lastbilskostnader R6
|
||||
57 Frakter och transporter
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5700 Frakter och transporter R6
|
||||
58 Resekostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5800 Resekostnader R6
|
||||
59 Reklam och PR
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
5900 Reklam och PR R6
|
||||
60 Övriga försäljningskostnader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6000 Övriga försäljningskostnader
|
||||
R6
|
||||
R6 R6
|
||||
6070 Representation R6 R6 6071 Representation, avdragsgill
|
||||
6072 Representation, ej avdragsgill R6
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
Kontoplan_K1_2018_ver1
|
||||
7/9
|
||||
61 Kontorsmateriel och trycksaker
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6100 Kontorsmateriel och trycksaker R6
|
||||
62 Tele och post
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6200 Tele och post R6
|
||||
63 Företagsförsäkringar och övriga riskkostnader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6300 Företagsförsäkringar och
|
||||
övriga riskkostnader
|
||||
R6
|
||||
6310 Företagsförsäkringar R6
|
||||
65 Övriga externa tjänster
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6500 Övriga externa tjänster R6
|
||||
68 Inhyrd personal
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
6800 Inhyrd personal R6
|
||||
69 Övriga kostnader
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
6900 Övriga kostnader R6
|
||||
6980 Föreningsavgifter R6
|
||||
7 UTGIFTER/KOSTNADER FÖR PERSONAL, AVSKRIVNINGAR
|
||||
70 Löner till anställda
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7000 Löner till anställda R7
|
||||
73 Kostnadsersättningar och förmåner
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7300 Kostnadsersättningar och
|
||||
förmåner
|
||||
R7
|
||||
74 Pensionskostnader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7400 Pensionskostnader R7
|
||||
Kontoplan_K1_2018_ver1
|
||||
8/9
|
||||
75 Sociala och andra avgifter enligt lag och avtal
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7500 Sociala och andra avgifter
|
||||
enligt lag och avtal
|
||||
R7
|
||||
76 Övriga personalkostnader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7600 Övriga personalkostnader R7 7631 Personalrepresentation,
|
||||
avdragsgill
|
||||
R7
|
||||
7632 Personalrepresentation, ej
|
||||
avdragsgill
|
||||
R7
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
77 Nedskrivningar och återföring av nedskrivningar
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7700 Nedskrivningar R9
|
||||
alt.
|
||||
R10
|
||||
78 Avskrivningar
|
||||
BAS-konton
|
||||
Underkonton
|
||||
Rad Rad
|
||||
7810 Avskrivningar på immateriella
|
||||
anläggningstillgångar
|
||||
R10
|
||||
7820 Avskrivningar på byggnader
|
||||
och markanläggningar
|
||||
R9
|
||||
7830 Avskrivningar på maskiner och
|
||||
inventarier
|
||||
R10
|
||||
79 Övriga rörelsekostnader
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
7970 Förlust vid avyttring av
|
||||
immateriella och materiella
|
||||
anläggningstillgångar
|
||||
R6
|
||||
7980 Ersättningsfonder R9/
|
||||
10
|
||||
8 FINANSIELLA OCH ANDRA INKOMSTER/INTÄKTER OCH
|
||||
UTGIFTER/ KOSTNADER
|
||||
83 Övriga ränteintäkter och liknande resultatposter
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
Kontoplan_K1_2018_ver1
|
||||
9/9
|
||||
8310 Ränteintäkter och utdelningar R4 8314 Skattefria ränteintäkter R4
|
||||
+
|
||||
NE
|
||||
sid.
|
||||
2
|
||||
8330 Valutakursdifferenser på
|
||||
fordringar och placeringar
|
||||
R4
|
||||
84 Räntekostnader och liknande
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
8410 Räntekostnader för skulder R8
|
||||
8430 Valutakursdifferenser på
|
||||
skulder R8
|
||||
88 Bokslutsdispositioner
|
||||
89 Årets resultat
|
||||
BAS-konton Underkonton
|
||||
Rad Rad
|
||||
8990 Resultat R11 R11 8999 Årets resultat R11
|
||||
Kontoplan_K1_2018_ver1
|
||||
@@ -1,41 +0,0 @@
|
||||
. Media Kit-generator
|
||||
Standardverktyg i creator-ekonomin 2025, men inget svenskt verktyg erbjuder det. Kreatörer bygger idag media kits manuellt i Canva.
|
||||
Implementation: Dra in analytics från Instagram, TikTok, YouTube via API. Generera professionell PDF med demografi, engagemangsgrad, tidigare samarbeten. Inkludera prisförslag baserat på följarantal och engagemang. Automatisk uppdatering kvartalsvis.
|
||||
Svårighetsgrad: Medel. API-integrationer finns redan delvis i din app.
|
||||
3. Avtalsmallar och e-signering
|
||||
Creator-specifika avtal saknas helt på den svenska marknaden. Verktyg som CreatorIQ, RosterGrid och HoneyBook erbjuder detta internationellt. Svenska kreatörer använder inga avtal alls eller kopierar generiska mallar.
|
||||
Implementation: Mallbibliotek anpassat för svenska influencersamarbeten: leveranser, betalningsvillkor, contenträttigheter, exklusivitet, bytesaffärer. Integrera e-signering (BankID om möjligt). Lagra i kampanjhanteringssystemet.
|
||||
Svårighetsgrad: Medel. Juridisk granskning krävs men templaten är straightforward.
|
||||
4. Automatiserade betalningspåminnelser och inkasso-workflow
|
||||
Ingen kreatör vill jaga betalningar. Det är det näst vanligaste klagomålet efter sena betalningar överhuvudtaget.
|
||||
Implementation: Automatiska påminnelser vid 15, 30 och 45 dagar. Eskaleringsflöde. Professionell inkassokommunikation. Eventuellt kopplad till fakturafinaliseringen ovan.
|
||||
Svårighetsgrad: Låg. Primärt e-post/SMS-automation.
|
||||
5. Multi-currency rate cards och prisriktlinjer
|
||||
Kreatörer, särskilt nya, underprissätter sig systematiskt. Internationella plattformar erbjuder pricing benchmarks.
|
||||
Implementation: Föreslå priser baserat på följarantal, engagemang, plattform, nisch. Svenska branschriktlinjer. Visa vad liknande kreatörer tar betalt. Integrera med faktureringsflödet.
|
||||
Svårighetsgrad: Låg-medel. Datainsamling är huvudutmaningen.
|
||||
|
||||
Features att FÖRBÄTTRA
|
||||
1. AI-assistent: Scenariospecifik, inte generisk
|
||||
Din kunskapsbas med 30+ scenarion är en stark grund. Men assistenten behöver bli proaktiv, inte reaktiv.
|
||||
Förbättring: "Du har 3 obetalda fakturor äldre än 30 dagar." "Din barter-deal med X saknar värdering, jag behöver marknadsvärdet." "Du har inte deklarerat periodisk sammanställning för Q3." Assistenten ska agera som en digital revisor som håller koll, inte som en FAQ.
|
||||
2. Bokföring: "Creator Mode" som default
|
||||
BAS-kontoplanen är korrekt men onödig för 90% av målgruppen. De behöver inte se konto 2641.
|
||||
Förbättring: Förenklad vy med creator-kategorier: "Plattformsintäkter", "Samarbeten", "Barter", "Resor", "Utrustning". Full BAS i bakgrunden för SIE-export. Automatisk kategorisering via AI. En-klicks-avstämning för vanliga transaktioner.
|
||||
3. Bankintegration: Automatisk matchning
|
||||
PSD2-synk och swipe-kategorisering finns. Saknas: automatisk matchning av banktransaktioner mot fakturor. Flagga transaktioner som kan vara oredovisade intäkter. Kassaflödesprognos baserad på betalningshistorik.
|
||||
4. Gåvo-/produktspårning: Fullständig skattehantering
|
||||
Nuvarande feature finns men behöver utökas: automatisk beräkning av marknadsvärde för skatt, spåra om produkt behålls/returneras/ges bort/används som arbetsredskap, generera dokumentation mot Skatteverket, varning när en produkt blir en skattepliktig händelse. Skatteverket granskar influencersektorn aktivt. Det här är compliance-kritiskt.
|
||||
5. Kampanjhantering: Content-workflow
|
||||
Lägg till content approval-flöden, varumärkesportal där kunder kan granska leveranser, automatisk spårning av publiceringsschema, prestandamätning per kampanj (räckvidd, engagemang, konverteringar).
|
||||
|
||||
Features att ÖVERVÄGA (lägre prioritet)
|
||||
Affiliate-integration: Många kreatörer tjänar via Adtraction, Awin, Amazon Associates. Auto-import av provisionsdata och skatterapportering. Relevant men inte brådskande.
|
||||
Kvittomatchning: OCR finns. Lägg till automatisk matchning av kvitto mot banktransaktion. Minskar dubbelinmatning.
|
||||
Team-access: Kreatörer som skalar anställer assistenter. Begränsade roller (bokförare, assistent, revisor) med auditlogg.
|
||||
|
||||
Features att IFRÅGASÄTTA
|
||||
Fullständigt bokföringsmodulen
|
||||
De flesta kreatörer behöver inte fullständig BAS-bokföring och kommer ändå outsourca till revisor. Överväg att göra "Exportera till revisor"-paket till huvudflödet och behålla förenklat läge som default. Den avancerade vyn blir opt-in, inte standard.
|
||||
Kalender som fristående feature
|
||||
Deadlines är viktiga men kan duplicera verktyg kreatören redan använder. Om användningen är låg, integrera med Google Calendar istället och ta bort egen kalendervy. Deadlines och påminnelser kan leva i appen utan att vara en separat sektion.
|
||||
@@ -1,186 +0,0 @@
|
||||
# Influencer Business-in-a-Box MVP
|
||||
|
||||
## Quick Context for AI Builders
|
||||
|
||||
Financial management SaaS for Swedish influencers/content creators. Supports both enskild firma (sole proprietor) and aktiebolag (limited company). Core problem: tax anxiety and admin overload. Core solution: automated compliance and simplified invoicing.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 (App Router) + TypeScript
|
||||
- **UI**: Tailwind CSS + shadcn/ui + Framer Motion
|
||||
- **Database**: Supabase (PostgreSQL with RLS)
|
||||
- **Auth**: Supabase Auth (magic link)
|
||||
- **Hosting**: Vercel
|
||||
- **PDF**: @react-pdf/renderer
|
||||
- **Banking**: Enable Banking (PSD2 AISP)
|
||||
- **Currency**: Riksbanken API for exchange rates
|
||||
|
||||
## Documentation Index
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [01-PRD.md](./docs/01-PRD.md) | Product requirements, user stories, success metrics |
|
||||
| [02-ARCHITECTURE.md](./docs/02-ARCHITECTURE.md) | Technical architecture, PSD2 strategy, security |
|
||||
| [03-DATABASE-SCHEMA.md](./docs/03-DATABASE-SCHEMA.md) | Full Supabase schema with RLS policies |
|
||||
| [04-API-SPECIFICATION.md](./docs/04-API-SPECIFICATION.md) | All API endpoints, VAT rules, momsdeklaration rutor |
|
||||
| [05-UI-SPECIFICATION.md](./docs/05-UI-SPECIFICATION.md) | Wireframes, component specs, design system |
|
||||
| [06-IMPLEMENTATION-GUIDE.md](./docs/06-IMPLEMENTATION-GUIDE.md) | Step-by-step build guide with code examples |
|
||||
| [07-FUTURE-FEATURES.md](./docs/07-FUTURE-FEATURES.md) | Phase 2+ features: benefits tracking, NE-bilaga, Skatteverket APIs |
|
||||
| [08-BAS-ACCOUNTING-GUIDE.md](./docs/08-BAS-ACCOUNTING-GUIDE.md) | BAS account mapping, MCC codes, auto-categorization rules |
|
||||
|
||||
## MVP Feature Scope
|
||||
|
||||
### Included in MVP
|
||||
|
||||
1. **Entity Support**: Both enskild firma and aktiebolag with appropriate tax logic
|
||||
2. **Bank Integration**: Enable Banking (PSD2) with multiple account support
|
||||
3. **Tax Dashboard**: "Disponibelt saldo" with F-skatt comparison warning
|
||||
4. **Transaction Categorization**: Swipe UI with schablonavdrag suggestions
|
||||
5. **Multi-currency Invoicing**: SEK, EUR, USD with Riksbanken auto-conversion
|
||||
6. **VAT Automation**: Swedish (25%), EU reverse charge (0%), Export (0%)
|
||||
7. **Credit Notes**: Kreditfaktura support for invoice corrections
|
||||
8. **Expense Warnings**: Alert on non-deductible lifestyle costs
|
||||
9. **Configurable Settings**: Momsperiod (monthly/quarterly), custom invoice numbering
|
||||
|
||||
### Deferred to Later
|
||||
|
||||
- Receipt photo capture and matching
|
||||
- Benefits/gifts tracking (förmånshantering)
|
||||
- NE-bilaga / Årsredovisning auto-generation
|
||||
- Automated payment reminders
|
||||
- Quotes/proforma invoices
|
||||
- English UI
|
||||
|
||||
## Entity Type Tax Differences
|
||||
|
||||
| Aspect | Enskild Firma | Aktiebolag |
|
||||
|--------|---------------|------------|
|
||||
| Profit tax | Egenavgifter (28.97%) + kommunalskatt (~32%) | Bolagsskatt (20.6%) |
|
||||
| Owner payment | Eget uttag | Lön + utdelning |
|
||||
| Annual filing | NE-bilaga | Årsredovisning + INK2 |
|
||||
|
||||
## Key Swedish Tax & Legal Concepts
|
||||
|
||||
| Term | English | Rate/Rule |
|
||||
|------|---------|-----------|
|
||||
| Enskild firma | Sole proprietorship | Personal liability, NE-bilaga |
|
||||
| Aktiebolag (AB) | Limited company | 20.6% bolagsskatt, årsredovisning |
|
||||
| Egenavgifter | Self-employment contributions | 28.97% (enskild firma only) |
|
||||
| Arbetsgivaravgifter | Employer contributions | 31.42% (AB paying salary) |
|
||||
| Bolagsskatt | Corporate tax | 20.6% on AB profit |
|
||||
| Kommunalskatt | Municipal income tax | ~30-35% varies |
|
||||
| F-skatt | Business tax registration | Required for invoicing |
|
||||
| Debiterad preliminärskatt | Preliminary tax set | Monthly F-skatt payment amount |
|
||||
| Moms | VAT | 25% standard, threshold 80k SEK |
|
||||
| Momsperiod | VAT reporting period | Monthly or quarterly |
|
||||
| Räkenskapsår | Fiscal year | Calendar or brutet (broken) |
|
||||
| Brutet räkenskapsår | Non-calendar fiscal year | e.g., July 1 - June 30 |
|
||||
| Omvänd skattskyldighet | Reverse charge | EU B2B: buyer reports VAT |
|
||||
| Kreditfaktura | Credit note | Corrects/cancels issued invoice |
|
||||
| Schablonavdrag | Standard deduction | Fixed amounts (hemmakontor 2000 kr/år) |
|
||||
| AGI-deklaration | Employer declaration | Monthly report for AB salaries |
|
||||
| SIE-fil | Standard Import Export | Accounting data exchange format |
|
||||
|
||||
## Entity Type Tax Differences
|
||||
|
||||
| Aspect | Enskild Firma | Aktiebolag |
|
||||
|--------|---------------|------------|
|
||||
| Profit tax | Egenavgifter (28.97%) + kommunalskatt (~32%) | Bolagsskatt (20.6%) |
|
||||
| Owner payment | Eget uttag | Lön + utdelning |
|
||||
| Salary costs | N/A | Arbetsgivaravgifter (31.42%) |
|
||||
| Annual filing | NE-bilaga | Årsredovisning + INK2 |
|
||||
| Periodiseringsfond | Max 30% | Max 25% |
|
||||
|
||||
**MVP Note:** For AB, system tracks salary payments and calculates arbetsgivaravgifter. Lön/utdelning optimization (3:12-reglerna) deferred to future phase.
|
||||
|
||||
## MVP Technical Notes
|
||||
|
||||
| Aspect | Implementation |
|
||||
|--------|----------------|
|
||||
| Banking | Enable Banking (PSD2 AISP) |
|
||||
| Email | Supabase built-in |
|
||||
| Payment on invoices | Bank transfer only (future: third-party) |
|
||||
| Partial payments | Not supported (paid/unpaid binary) |
|
||||
| Fiscal year | Calendar + brutet räkenskapsår |
|
||||
| Multi-user | Single user per account |
|
||||
| UI Language | Swedish only |
|
||||
| Data import | SIE file (future feature) |
|
||||
|
||||
## Momsdeklaration Ruta Quick Reference
|
||||
|
||||
| Ruta | When Used |
|
||||
|------|-----------|
|
||||
| 05 | Swedish domestic sales with 25% VAT |
|
||||
| 39 | EU B2B services (reverse charge) |
|
||||
| 40 | Export to non-EU |
|
||||
| 21 | Service purchases from EU (input) |
|
||||
| 48 | Self-assessed output VAT on foreign purchases |
|
||||
|
||||
## Critical Implementation Notes
|
||||
|
||||
1. **All tax figures are estimates** - display disclaimer prominently
|
||||
2. **VIES validation required** for EU reverse charge - without it, charge 25% VAT
|
||||
3. **Non-custodial design** - app NEVER holds user funds
|
||||
4. **Multi-currency**: Store original + SEK converted amounts, use Riksbanken rate on invoice date
|
||||
5. **Invoice numbers**: Allow custom starting number (user may have history elsewhere)
|
||||
6. **Credit notes**: Must reference original invoice, use negative amounts
|
||||
7. **Expense warnings** - clothing, cosmetics, gym are NOT deductible per Kammarrätten rulings
|
||||
8. **7-year retention** - Bokföringslagen requires, GDPR does not override for accounting records
|
||||
9. **RLS on all tables** - users must only access own data
|
||||
10. **Swedish UI only** for MVP
|
||||
|
||||
## Schablonavdrag Defaults
|
||||
|
||||
| Type | Amount | Notes |
|
||||
|------|--------|-------|
|
||||
| Hemmakontor | 2 000 kr/år | If dedicated room |
|
||||
| Bilkostnader | 18.50 kr/mil | Business travel |
|
||||
| Telefon | Actual or % split | Based on business use |
|
||||
|
||||
## Quick Start for Builder AI
|
||||
|
||||
1. Read docs in order: PRD → Architecture → Schema → API → UI → Implementation → Future
|
||||
2. Start with auth + middleware (including onboarding redirect logic)
|
||||
3. Build onboarding wizard (6 steps, all required except preliminary tax)
|
||||
4. Implement Enable Banking integration in onboarding step 6
|
||||
5. Build dashboard with tax calculations (adapt for entity type)
|
||||
6. Add invoice creation with multi-currency + VIES validation
|
||||
7. Implement credit note flow
|
||||
8. Polish UI (Swedish language)
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── app/
|
||||
│ ├── (auth)/login/page.tsx
|
||||
│ ├── (onboarding)/onboarding/ # Wizard steps 1-6
|
||||
│ ├── (dashboard)/
|
||||
│ │ ├── page.tsx # Dashboard
|
||||
│ │ ├── transactions/page.tsx
|
||||
│ │ ├── invoices/
|
||||
│ │ │ ├── page.tsx
|
||||
│ │ │ ├── new/page.tsx
|
||||
│ │ │ └── [id]/credit/page.tsx # Credit notes
|
||||
│ │ ├── customers/page.tsx
|
||||
│ │ └── settings/page.tsx
|
||||
│ └── api/...
|
||||
├── components/
|
||||
│ ├── ui/ # shadcn components
|
||||
│ ├── onboarding/ # Wizard components
|
||||
│ ├── dashboard/
|
||||
│ ├── transactions/
|
||||
│ └── invoices/
|
||||
├── lib/
|
||||
│ ├── supabase/
|
||||
│ ├── banking/enable-banking.ts
|
||||
│ ├── currency/riksbanken.ts
|
||||
│ ├── tax/
|
||||
│ │ ├── calculator.ts
|
||||
│ │ ├── expense-warnings.ts
|
||||
│ │ ├── schablonavdrag.ts
|
||||
│ │ └── fskatt-warning.ts
|
||||
│ └── invoice/vat-rules.ts
|
||||
├── types/index.ts
|
||||
└── supabase/migrations/
|
||||
```
|
||||
@@ -1,381 +0,0 @@
|
||||
# AI Categorization Extension — Implementation Summary
|
||||
|
||||
This document describes the ai-categorization extension: the second first-party extension built on the Part 3 event bus and extension registry. It uses Claude Haiku to suggest BAS account categorizations for bank transactions, following the same canonical pattern established by receipt-ocr.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Transaction categorization is the most frequent daily task. Every downstream report (momsdeklaration, income statement, balance sheet, NE-bilaga, SRU export) depends on transactions being mapped to the correct BAS accounts.
|
||||
|
||||
Before this extension, suggestions came only from:
|
||||
|
||||
- **Mapping rules** — user-defined merchant/description patterns (confidence 0.8)
|
||||
- **Pattern matching** — built-in regex heuristics from `expense-warnings.ts` (confidence 0.6)
|
||||
- **User history** — most frequently used categories (confidence 0.1–0.5)
|
||||
|
||||
These sources cover common recurring transactions but fail on novel descriptions, edge cases, and new users with no history.
|
||||
|
||||
## Solution
|
||||
|
||||
An `ai-categorization` extension that:
|
||||
|
||||
1. Listens to `transaction.synced` events and auto-generates AI-powered category suggestions for uncategorized transactions
|
||||
2. Stores suggestions in `extension_data` (pre-computed, ready when the user opens the transaction list)
|
||||
3. Exposes an on-demand API for manual "AI suggest" triggers
|
||||
4. Merges AI suggestions into the existing suggestion pipeline alongside rule/pattern/history sources
|
||||
|
||||
**Key constraint:** Suggestions only, never auto-commit. The extension stores suggestions in `extension_data` but never creates journal entries. The user confirms via the existing categorization UI, preserving audit trail integrity.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `extensions/ai-categorization/categorizer.ts` | AI provider interface + Anthropic implementation |
|
||||
| `extensions/ai-categorization/index.ts` | Extension: settings, event handler, public API, extension object |
|
||||
| `app/api/extensions/ai-categorization/settings/route.ts` | GET/PATCH API for per-user extension settings |
|
||||
| `app/api/extensions/ai-categorization/suggestions/route.ts` | GET (pre-computed) / POST (on-demand) suggestions API |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/extensions/loader.ts` | Imported and registered `aiCategorizationExtension` |
|
||||
| `lib/transactions/category-suggestions.ts` | Added `'ai'` to `SuggestedCategory.source` union; added `mergeAiSuggestions()` |
|
||||
| `app/api/transactions/suggest-categories/route.ts` | Reads pre-computed AI suggestions from `extension_data` and merges into results |
|
||||
|
||||
---
|
||||
|
||||
## Provider Abstraction
|
||||
|
||||
The architecture doc requires "no hard dependency on any specific AI provider". The categorizer implements this via a `CategorizationProvider` interface.
|
||||
|
||||
### `CategorizationProvider` interface
|
||||
|
||||
```typescript
|
||||
interface CategorizationProvider {
|
||||
categorize(
|
||||
transactions: TransactionForCategorization[],
|
||||
context: CategorizationContext
|
||||
): Promise<CategorizationSuggestion[]>
|
||||
}
|
||||
```
|
||||
|
||||
### `TransactionForCategorization`
|
||||
|
||||
Minimal transaction data sent to the AI:
|
||||
|
||||
```typescript
|
||||
interface TransactionForCategorization {
|
||||
id: string
|
||||
description: string
|
||||
amount: number // negative = expense, positive = income
|
||||
date: string
|
||||
merchant_name: string | null
|
||||
mcc_code: number | null
|
||||
currency: string
|
||||
}
|
||||
```
|
||||
|
||||
### `CategorizationContext`
|
||||
|
||||
Contextual data that improves accuracy:
|
||||
|
||||
```typescript
|
||||
interface CategorizationContext {
|
||||
entityType: EntityType // 'enskild_firma' | 'aktiebolag'
|
||||
recentHistory: { description: string; category: string }[] // last 50 categorized
|
||||
}
|
||||
```
|
||||
|
||||
### `CategorizationSuggestion`
|
||||
|
||||
The result per transaction:
|
||||
|
||||
```typescript
|
||||
interface CategorizationSuggestion {
|
||||
transactionId: string
|
||||
category: TransactionCategory
|
||||
basAccount: string // BAS account number (e.g. '5420')
|
||||
taxCode: string | null // 'MPI', 'MP1', or null
|
||||
confidence: number // 0.0–1.0
|
||||
reasoning: string // Swedish-language explanation
|
||||
isPrivate: boolean // true = likely private expense
|
||||
}
|
||||
```
|
||||
|
||||
### `AnthropicCategorizationProvider`
|
||||
|
||||
The default implementation using `@anthropic-ai/sdk` (already a project dependency):
|
||||
|
||||
- Model: `claude-haiku-4-5-20251001` (same as receipt-analyzer, chosen for cost efficiency)
|
||||
- Batch size: max 20 transactions per API call (cross-transaction pattern recognition)
|
||||
- Retry logic: 3 attempts with exponential backoff, no retry on JSON parse errors
|
||||
- Response validation: filters to valid transaction IDs and valid `TransactionCategory` values
|
||||
|
||||
The system prompt includes:
|
||||
|
||||
1. Full `TransactionCategory` → BAS account mapping table
|
||||
2. Entity type (EF uses 2013 for private, AB uses 2893)
|
||||
3. Swedish non-deductible expense rules (kläder, gym, kosmetika, etc. with legal references)
|
||||
4. VAT treatment rules (bank fees exempt, standard 25% otherwise)
|
||||
5. User's recent categorization history (up to 30 entries) for learning patterns
|
||||
|
||||
---
|
||||
|
||||
## Extension: `extensions/ai-categorization/index.ts`
|
||||
|
||||
### Settings
|
||||
|
||||
```typescript
|
||||
interface AiCategorizationSettings {
|
||||
autoSuggestEnabled: boolean // default: true
|
||||
confidenceThreshold: number // default: 0.7
|
||||
providerModel: string // default: 'claude-haiku-4-5-20251001'
|
||||
}
|
||||
```
|
||||
|
||||
Stored as an `extension_data` row with `extension_id='ai-categorization'`, `key='settings'`, `value=<jsonb>`.
|
||||
|
||||
- `getSettings(userId)` reads from DB and merges with defaults (forward-compatible)
|
||||
- `saveSettings(userId, partial)` merges partial update with current, upserts on `(user_id, extension_id, key)`
|
||||
|
||||
### Event Handler: `transaction.synced`
|
||||
|
||||
When new transactions arrive from banking sync:
|
||||
|
||||
1. **Gate:** Is `autoSuggestEnabled` in user's settings? — if not, return
|
||||
2. **Gate:** Filter to uncategorized transactions only (`is_business === null`) — if none, return
|
||||
3. Fetch entity type from `company_settings`
|
||||
4. Fetch user's last 50 categorized transactions (for learning patterns)
|
||||
5. Call `provider.categorize(batch, context)`
|
||||
6. Filter suggestions to those above `confidenceThreshold`
|
||||
7. Store each qualified suggestion to `extension_data` as `key: "suggestion:{transactionId}"`
|
||||
8. Log summary with `[ai-categorization]` prefix
|
||||
|
||||
### Public API: `categorizeTransactions(userId, transactionIds)`
|
||||
|
||||
Exported function for on-demand categorization (used by the suggestions POST endpoint):
|
||||
|
||||
1. Fetch transactions by IDs
|
||||
2. Build context (entity type + history)
|
||||
3. Call provider
|
||||
4. Store all suggestions (no threshold filtering — user explicitly requested)
|
||||
5. Return suggestions
|
||||
|
||||
### Extension Object
|
||||
|
||||
```typescript
|
||||
export const aiCategorizationExtension: Extension = {
|
||||
id: 'ai-categorization',
|
||||
name: 'AI Kategorisering',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'AI Kategorisering',
|
||||
path: '/settings/extensions/ai-categorization',
|
||||
},
|
||||
async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Suggestion Storage
|
||||
|
||||
Suggestions are stored as individual rows in `extension_data`:
|
||||
|
||||
| Column | Value |
|
||||
|--------|-------|
|
||||
| `user_id` | The user who owns the transaction |
|
||||
| `extension_id` | `'ai-categorization'` |
|
||||
| `key` | `'suggestion:{transactionId}'` |
|
||||
| `value` | The full `CategorizationSuggestion` object as JSONB |
|
||||
|
||||
This per-transaction key scheme allows:
|
||||
|
||||
- Fast lookup by transaction ID (used by the suggest-categories route)
|
||||
- Batch lookup via `IN` clause on keys
|
||||
- Natural overwrite on re-categorization (upsert on unique constraint)
|
||||
|
||||
---
|
||||
|
||||
## Suggestions API: `app/api/extensions/ai-categorization/suggestions/route.ts`
|
||||
|
||||
### `GET ?transaction_ids=id1,id2,...`
|
||||
|
||||
Reads pre-computed suggestions from `extension_data`. Returns only what's already stored — no AI call.
|
||||
|
||||
Response: `{ suggestions: { [txId]: CategorizationSuggestion } }`
|
||||
|
||||
### `POST { transaction_ids: [...] }`
|
||||
|
||||
Triggers on-demand AI categorization via `categorizeTransactions()`. Stores results and returns them.
|
||||
|
||||
Response: `{ suggestions: { [txId]: CategorizationSuggestion } }`
|
||||
|
||||
Both endpoints limit to 50 transaction IDs per request.
|
||||
|
||||
---
|
||||
|
||||
## Settings API: `app/api/extensions/ai-categorization/settings/route.ts`
|
||||
|
||||
Mirrors the receipt-ocr settings route exactly:
|
||||
|
||||
- **GET** — Returns the current user's merged settings (DB value + defaults)
|
||||
- **PATCH** — Accepts a partial settings object, validates keys against allowlist (`autoSuggestEnabled`, `confidenceThreshold`, `providerModel`), saves via `saveSettings()`
|
||||
|
||||
---
|
||||
|
||||
## Integration with Existing Suggestion Pipeline
|
||||
|
||||
### `lib/transactions/category-suggestions.ts`
|
||||
|
||||
Two changes:
|
||||
|
||||
1. **Source type extended:** `SuggestedCategory.source` union widened from `'mapping_rule' | 'pattern' | 'history'` to `'mapping_rule' | 'pattern' | 'history' | 'ai'`
|
||||
|
||||
2. **New merge function:**
|
||||
|
||||
```typescript
|
||||
function mergeAiSuggestions(
|
||||
existing: SuggestedCategory[],
|
||||
aiSuggestions: { category: string; basAccount: string; confidence: number; reasoning: string }[]
|
||||
): SuggestedCategory[]
|
||||
```
|
||||
|
||||
Inserts AI suggestions into the list, deduplicating by category (skips categories already present from higher-priority sources). Returns top 5 sorted by confidence.
|
||||
|
||||
### `app/api/transactions/suggest-categories/route.ts`
|
||||
|
||||
After computing rule/pattern/history suggestions for each transaction, the route now:
|
||||
|
||||
1. Fetches pre-computed AI suggestions from `extension_data` for all requested transaction IDs (single batch query)
|
||||
2. For each transaction with an AI suggestion, calls `mergeAiSuggestions()` to blend it in
|
||||
3. Returns the merged result
|
||||
|
||||
This means AI suggestions appear alongside existing sources with no latency — they were pre-computed during bank sync.
|
||||
|
||||
---
|
||||
|
||||
## Suggestion Priority
|
||||
|
||||
The existing pipeline already sorts by confidence. With AI added, the effective priority becomes:
|
||||
|
||||
| Source | Typical Confidence | When |
|
||||
|--------|--------------------|------|
|
||||
| Mapping rules | 0.8 | User-defined patterns match |
|
||||
| AI | 0.7–0.95 | Pre-computed from sync |
|
||||
| Pattern matching | 0.6 | Built-in regex matches |
|
||||
| User history | 0.1–0.5 | Most frequently used categories |
|
||||
|
||||
AI suggestions naturally slot between mapping rules and pattern matching. For novel transactions where no mapping rule or pattern exists, AI becomes the top suggestion.
|
||||
|
||||
---
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Bank Sync
|
||||
|
|
||||
POST /banking/sync
|
||||
|
|
||||
emit transaction.synced
|
||||
|
|
||||
+---> receipt-ocr extension (auto-match receipts)
|
||||
|
|
||||
+---> ai-categorization extension
|
||||
|
|
||||
Gate: autoSuggestEnabled?
|
||||
Gate: has uncategorized transactions?
|
||||
|
|
||||
Fetch entity type + history
|
||||
Call AnthropicCategorizationProvider.categorize()
|
||||
Filter by confidenceThreshold
|
||||
Store to extension_data (suggestion:{txId})
|
||||
|
|
||||
[suggestions pre-computed and waiting]
|
||||
|
||||
|
||||
User opens transaction list
|
||||
|
|
||||
POST /api/transactions/suggest-categories
|
||||
|
|
||||
+---> getSuggestedCategories() [mapping rules + patterns + history]
|
||||
+---> Read extension_data [pre-computed AI suggestions]
|
||||
+---> mergeAiSuggestions()
|
||||
|
|
||||
v
|
||||
Response: merged suggestions with source labels
|
||||
|
|
||||
User sees: "AI: Programvara (5420) — confidence 0.9"
|
||||
|
||||
|
||||
User clicks "AI suggest" button (on-demand)
|
||||
|
|
||||
POST /api/extensions/ai-categorization/suggestions
|
||||
|
|
||||
+---> categorizeTransactions()
|
||||
| Call AI provider
|
||||
| Store results
|
||||
|
|
||||
v
|
||||
Response: fresh AI suggestions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Existing Code Reused
|
||||
|
||||
| Import | From | Used in |
|
||||
|--------|------|---------|
|
||||
| `Anthropic` | `@anthropic-ai/sdk` | `AnthropicCategorizationProvider` |
|
||||
| `getSettings()`/`saveSettings()` pattern | `extensions/receipt-ocr/index.ts` | Settings management (same pattern) |
|
||||
| `getSuggestedCategories()` | `lib/transactions/category-suggestions.ts` | Existing pipeline (unchanged) |
|
||||
| `createClient()` | `lib/supabase/server.ts` | DB access throughout |
|
||||
|
||||
No existing service logic was duplicated. The extension adds a new AI-powered source to the existing suggestion pipeline.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Patterns Followed
|
||||
|
||||
1. **Suggestions only, never auto-commit.** AI writes to `extension_data`, never to `journal_entries`. The user confirms via existing categorization UI.
|
||||
2. **Provider abstraction from day one.** `CategorizationProvider` interface means the AI model is swappable without changing extension logic.
|
||||
3. **Cost-efficient model.** Claude Haiku (same as receipt-analyzer) keeps per-sync costs low.
|
||||
4. **Batch processing.** One AI call per sync handles up to 20 transactions. Cross-transaction context (e.g., "all ICA transactions = groceries") improves accuracy.
|
||||
5. **Pre-computed suggestions.** AI runs on sync, results are stored. No user-facing latency when opening the transaction list.
|
||||
6. **Graceful degradation.** If the AI call fails, the handler catches and logs. Existing rule/pattern/history suggestions still work. No user-facing error.
|
||||
7. **Gate-guarded.** Every handler checks user settings before doing work.
|
||||
8. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports the extension object.
|
||||
9. **`[ai-categorization]` prefix.** Console logging convention for grep-ability.
|
||||
|
||||
---
|
||||
|
||||
## No New Migrations
|
||||
|
||||
No database schema changes were needed. The existing `extension_data` table (created in Part 3, migration `20240101000020_extension_data.sql`) handles all storage:
|
||||
|
||||
- Settings: `key='settings'`
|
||||
- Per-transaction suggestions: `key='suggestion:{transactionId}'`
|
||||
|
||||
The unique constraint `(user_id, extension_id, key)` ensures upsert semantics.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit` — zero TypeScript errors
|
||||
- `npx vitest run` — all 78 existing tests pass (11 test files)
|
||||
- Manual: trigger bank sync → check console for `[ai-categorization]` logs
|
||||
- Manual: open transactions page → uncategorized transactions show AI suggestions (source: `'ai'`) alongside existing pattern/history suggestions
|
||||
- Manual: disable `autoSuggestEnabled` in settings → sync does not trigger AI
|
||||
- Manual: `POST /api/extensions/ai-categorization/suggestions` with transaction IDs → returns on-demand suggestions
|
||||
- Manual: `GET /api/extensions/ai-categorization/settings` → returns default settings
|
||||
- Manual: `PATCH /api/extensions/ai-categorization/settings` → updates settings
|
||||
@@ -1,260 +0,0 @@
|
||||
# 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
|
||||
@@ -1,169 +0,0 @@
|
||||
# Part 1: Database Foundation & Compliance Core — Implementation Record
|
||||
|
||||
## What was implemented
|
||||
|
||||
8 Supabase migrations, TypeScript type updates, 4 new service files, and modifications to 3 existing files. No UI changes.
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
### Migration 11: ALTER Existing Tables
|
||||
`supabase/migrations/20240101000011_alter_existing_tables.sql`
|
||||
|
||||
- `chart_of_accounts` — added `sru_code text` for Skatteverket SRU mapping
|
||||
- `journal_entries` — added `committed_at timestamptz`, `reversed_by_id uuid FK→self`, `reverses_id uuid FK→self`, `correction_of_id uuid FK→self`
|
||||
- `journal_entries` — expanded `source_type` CHECK to include `storno`, `correction`, `import`, `system`
|
||||
- `journal_entry_lines` — added `tax_code text`, `cost_center text`, `project text`
|
||||
- `fiscal_periods` — added `locked_at timestamptz`, `retention_expires_at date`
|
||||
|
||||
### Migration 12: Tax Code Engine
|
||||
`supabase/migrations/20240101000012_tax_codes.sql`
|
||||
|
||||
New table `tax_codes` with columns: `id`, `user_id`, `code`, `description`, `rate`, `moms_basis_boxes text[]`, `moms_tax_boxes text[]`, `moms_input_boxes text[]`, flags (`is_output_vat`, `is_reverse_charge`, `is_eu`, `is_export`, `is_oss`, `is_system`).
|
||||
|
||||
RLS: select own + system (user_id IS NULL), insert/update/delete own only.
|
||||
|
||||
Seeded 12 system tax codes: MP1 (25%), MP2 (12%), MP3 (6%), MPI, MPI12, MPI6, IV (intra-EU), EUS (EU sale), IP (import), EXP (export), OSS, NONE.
|
||||
|
||||
New function `seed_tax_codes_for_user(p_user_id)` copies system codes to user scope.
|
||||
|
||||
### Migration 13: Document Archive
|
||||
`supabase/migrations/20240101000013_document_archive.sql`
|
||||
|
||||
New table `document_attachments` with: storage fields (`storage_path`, `file_name`, `file_size_bytes`, `mime_type`), integrity (`sha256_hash NOT NULL`), version chain (`version`, `original_id FK→self`, `superseded_by_id FK→self`, `is_current_version`), digitization metadata (`uploaded_by`, `upload_source`, `digitization_date`), linkage (`journal_entry_id FK ON DELETE RESTRICT`, `journal_entry_line_id FK ON DELETE RESTRICT`).
|
||||
|
||||
No DELETE RLS policy — deletion handled by trigger in migration 17.
|
||||
|
||||
### Migration 14: Audit Log
|
||||
`supabase/migrations/20240101000014_audit_log.sql`
|
||||
|
||||
New table `audit_log`: `user_id uuid NOT NULL` (no FK cascade — survives user deletion), `action text` with CHECK constraint, `table_name`, `record_id`, `actor_id`, `old_state jsonb`, `new_state jsonb`, `description`. No `updated_at` — append-only.
|
||||
|
||||
BEFORE UPDATE and BEFORE DELETE triggers raise exception to enforce immutability.
|
||||
|
||||
### Migration 15: Dimensions
|
||||
`supabase/migrations/20240101000015_dimensions.sql`
|
||||
|
||||
Two new tables:
|
||||
- `cost_centers` (`user_id`, `code`, `name`, `is_active`) with UNIQUE(user_id, code)
|
||||
- `projects` (`user_id`, `code`, `name`, `is_active`, `start_date`, `end_date`) with UNIQUE(user_id, code)
|
||||
|
||||
Both with standard RLS and updated_at triggers.
|
||||
|
||||
### Migration 16: Voucher Sequence Hardening
|
||||
`supabase/migrations/20240101000016_voucher_sequences.sql`
|
||||
|
||||
New table `voucher_sequences` (`user_id`, `fiscal_period_id`, `voucher_series`, `last_number`) for tracking sequence state.
|
||||
|
||||
Replaced `next_voucher_number()` with concurrent-safe version using `INSERT ON CONFLICT DO UPDATE RETURNING` (row-level lock instead of MAX+1).
|
||||
|
||||
New DEFERRABLE constraint trigger `check_balance_on_post` validates debit==credit when an entry transitions from draft to posted.
|
||||
|
||||
New function `detect_voucher_gaps(p_user_id, p_fiscal_period_id, p_series)` returns gap ranges for compliance reporting.
|
||||
|
||||
### Migration 17: Enforcement Triggers
|
||||
`supabase/migrations/20240101000017_enforcement_triggers.sql`
|
||||
|
||||
8 trigger functions:
|
||||
|
||||
1. **`enforce_journal_entry_immutability()`** — allows draft→draft, draft→posted, posted→reversed. Blocks all other updates/deletes on committed entries.
|
||||
2. **`enforce_journal_entry_line_immutability()`** — blocks modifications to lines of posted/reversed entries.
|
||||
3. **`enforce_period_lock()`** — rejects journal_entries writes when `is_closed=true` OR `locked_at IS NOT NULL`.
|
||||
4. **`enforce_period_lock_documents()`** — blocks document attachment to entries in locked periods.
|
||||
5. **`block_document_deletion()`** — blocks deletion if linked to committed entry or within retention window. Logs blocked attempts to audit_log.
|
||||
6. **`enforce_retention_journal_entries()`** — blocks journal entry deletion within 7-year retention window.
|
||||
7. **`set_committed_at()`** — auto-sets `committed_at = now()` on draft→posted transition.
|
||||
8. **`calculate_retention_expiry()`** — auto-sets `retention_expires_at = period_end + 7 years`. Backfills existing rows.
|
||||
|
||||
### Migration 18: Audit Logging Triggers
|
||||
`supabase/migrations/20240101000018_audit_triggers.sql`
|
||||
|
||||
SECURITY DEFINER function `write_audit_log()` that detects action type from TG_OP and state transitions (draft→posted = COMMIT, posted→reversed = REVERSE, locked_at set = LOCK_PERIOD, is_closed set = CLOSE_PERIOD). Captures old_state/new_state as JSONB.
|
||||
|
||||
AFTER triggers on: `journal_entries`, `journal_entry_lines`, `chart_of_accounts`, `document_attachments`, `fiscal_periods`, `company_settings`, `tax_codes`.
|
||||
|
||||
---
|
||||
|
||||
## TypeScript Changes
|
||||
|
||||
### Modified types in `types/index.ts`
|
||||
|
||||
| Type | Change |
|
||||
|------|--------|
|
||||
| `JournalEntrySourceType` | Added `'storno' \| 'correction' \| 'import' \| 'system'` |
|
||||
| `JournalEntry` | Added `committed_at`, `reversed_by_id`, `reverses_id`, `correction_of_id` |
|
||||
| `JournalEntryLine` | Added `tax_code`, `cost_center`, `project` |
|
||||
| `CreateJournalEntryLineInput` | Added optional `tax_code`, `cost_center`, `project` |
|
||||
| `FiscalPeriod` | Added `locked_at`, `retention_expires_at` |
|
||||
| `BASAccount` | Added `sru_code` |
|
||||
|
||||
### New types added to `types/index.ts`
|
||||
|
||||
- `TaxCode` interface, `TaxCodeId` union type
|
||||
- `DocumentAttachment` interface, `DocumentUploadSource` type, `CreateDocumentAttachmentInput`
|
||||
- `AuditLogEntry` interface, `AuditAction` union type
|
||||
- `CostCenter` interface
|
||||
- `Project` interface
|
||||
- `VoucherGap` interface
|
||||
|
||||
---
|
||||
|
||||
## New Service Files
|
||||
|
||||
### `lib/core/audit/audit-service.ts`
|
||||
Read-only service (audit log is written by DB triggers):
|
||||
- `getAuditLog(userId, filters)` — paginated query with action/table/date filters
|
||||
- `getEntityHistory(userId, tableName, recordId)` — full mutation history of one record
|
||||
- `getCorrectionChain(userId, journalEntryId)` — traces original→storno→corrected via linked IDs
|
||||
|
||||
### `lib/core/documents/document-service.ts`
|
||||
- `uploadDocument(userId, file, metadata)` — computes SHA-256 via Web Crypto, uploads to Supabase Storage, creates record
|
||||
- `createNewVersion(userId, originalId, file)` — creates new version, marks old as superseded (WORM)
|
||||
- `linkToJournalEntry(userId, documentId, journalEntryId)` — links document to entry
|
||||
- `verifyIntegrity(userId, documentId)` — re-downloads, re-hashes, compares to stored hash
|
||||
|
||||
### `lib/core/tax/tax-code-service.ts`
|
||||
- `getTaxCodes(userId)` — returns user codes + system codes
|
||||
- `getTaxCodeByCode(userId, code)` — single lookup, user code takes precedence
|
||||
- `calculateMomsFromTaxCodes(userId, periodStart, periodEnd)` — sums journal lines by tax_code, maps to moms boxes via tax_codes table
|
||||
- `seedTaxCodes(userId)` — calls `seed_tax_codes_for_user` RPC
|
||||
|
||||
### `lib/core/bookkeeping/storno-service.ts`
|
||||
- `correctEntry(userId, originalEntryId, correctedLines)` — 3-step correction:
|
||||
1. Creates storno entry with swapped debits/credits, `source_type='storno'`, `reverses_id` set
|
||||
2. Creates corrected entry with new data, `source_type='correction'`, `correction_of_id` set
|
||||
3. Marks original as reversed with `reversed_by_id` set
|
||||
4. Returns `{ reversal, corrected }`
|
||||
|
||||
---
|
||||
|
||||
## Modified Existing Files
|
||||
|
||||
### `lib/bookkeeping/engine.ts`
|
||||
|
||||
- New `buildLineInserts()` helper that includes `tax_code`, `cost_center`, `project` in all line inserts
|
||||
- New `createDraftEntry(userId, input)` — inserts as draft with `voucher_number=0`, no commit
|
||||
- New `commitEntry(userId, entryId)` — assigns voucher number via `next_voucher_number` RPC, transitions to posted (DB triggers handle `committed_at` and balance validation)
|
||||
- Existing `createJournalEntry()` kept as convenience wrapper (create + immediate commit)
|
||||
- `reverseEntry()` rewritten: now sets `reverses_id` on the reversal entry, sets `reversed_by_id` on the original, uses `source_type='storno'`, preserves dimensions on reversed lines
|
||||
|
||||
### `lib/reports/vat-declaration.ts`
|
||||
|
||||
- Added `TaxCode` import
|
||||
- New `calculateVatDeclarationFromTaxCodes(userId, periodType, year, period)` — generates momsdeklaration by querying journal_entry_lines grouped by `tax_code`, then mapping via `tax_codes` table to moms boxes
|
||||
- Legacy `calculateVatDeclaration()` preserved for backward compatibility (invoice/transaction/receipt approach)
|
||||
|
||||
### `lib/reports/sie-export.ts`
|
||||
|
||||
- Now fetches `cost_centers` and `projects` tables
|
||||
- Outputs `#DIM 1 "Kostnadsställe"` and `#DIM 6 "Projekt"` dimension definitions
|
||||
- Outputs `#OBJEKT` records for each cost center and project
|
||||
- Outputs `#SRU` records from `chart_of_accounts.sru_code` after each `#KONTO`
|
||||
- `#TRANS` lines now include dimension object lists: `{1 "CC01" 6 "P01"}` when cost_center/project are set
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit` passes with zero errors
|
||||
@@ -1,198 +0,0 @@
|
||||
# Part 2: Period Management & Year-End Closing
|
||||
|
||||
## Overview
|
||||
|
||||
Part 2 implements **year-end closing (årsbokslut)** — the process that legally closes a fiscal year per Bokföringslagen. This includes period locking, closing entry generation, opening balance propagation, and the API surface to drive the workflow.
|
||||
|
||||
**Depends on Part 1:** immutable ledger, audit trail, tax codes, document archive, period lock enforcement, retention protection.
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Migration 19: Period Closing Metadata
|
||||
|
||||
**File:** `supabase/migrations/20240101000019_period_closing.sql`
|
||||
|
||||
Three new columns on `fiscal_periods`:
|
||||
|
||||
| Column | Type | Purpose |
|
||||
|--------|------|---------|
|
||||
| `closing_entry_id` | `uuid FK → journal_entries` | Links to the year-end closing journal entry |
|
||||
| `opening_balance_entry_id` | `uuid FK → journal_entries` | Links to the opening balance entry in this period |
|
||||
| `previous_period_id` | `uuid FK → fiscal_periods` | Chain link to the prior period for validation |
|
||||
|
||||
One new trigger:
|
||||
|
||||
- **`enforce_opening_balance_immutability`** — Once `opening_balance_entry_id` or `closing_entry_id` are set, they cannot be changed. This prevents tampering with the closing chain after the fact.
|
||||
|
||||
---
|
||||
|
||||
### TypeScript Types
|
||||
|
||||
**File:** `types/index.ts`
|
||||
|
||||
Extended `FiscalPeriod` with the three new nullable fields.
|
||||
|
||||
New interfaces:
|
||||
|
||||
| Interface | Purpose |
|
||||
|-----------|---------|
|
||||
| `YearEndValidation` | Result of readiness check: `ready`, `errors[]`, `warnings[]`, `draftCount`, `voucherGaps[]`, `trialBalanceBalanced` |
|
||||
| `YearEndPreview` | Preview of closing: `netResult`, `closingAccount` (2099/2010), `closingLines[]`, `resultAccountSummary[]` |
|
||||
| `YearEndResult` | Result of execution: `closingEntry`, `nextPeriod`, `openingBalanceEntry` |
|
||||
| `PeriodStatus` | Status summary: lock/close/draft/opening state |
|
||||
|
||||
---
|
||||
|
||||
### Period Service
|
||||
|
||||
**File:** `lib/core/bookkeeping/period-service.ts`
|
||||
|
||||
| Function | What it does |
|
||||
|----------|-------------|
|
||||
| `lockPeriod(userId, fiscalPeriodId)` | Sets `locked_at = now()`. Validates period exists, belongs to user, isn't already locked/closed. After locking, the `enforce_period_lock` trigger (from Part 1) blocks new journal entries. |
|
||||
| `closePeriod(userId, fiscalPeriodId)` | Sets `is_closed = true, closed_at = now()`. Requires: already locked AND `closing_entry_id` is set. This is the final, permanent state. |
|
||||
| `createNextPeriod(userId, currentPeriodId)` | Creates the next fiscal year. Computes dates from the current period's length to handle **brutet räkenskapsår** (broken fiscal years, e.g. Jul–Jun). Sets `previous_period_id` for chain validation. Auto-generates name like "FY 2025" or "FY 2025/2026". |
|
||||
| `getPeriodStatus(userId, fiscalPeriodId)` | Returns a summary: `is_locked`, `is_closed`, `has_closing_entry`, `has_opening_balances`, `draft_count`, `next_period_exists`. |
|
||||
|
||||
---
|
||||
|
||||
### Year-End Service
|
||||
|
||||
**File:** `lib/core/bookkeeping/year-end-service.ts`
|
||||
|
||||
This is the core new logic.
|
||||
|
||||
#### `validateYearEndReadiness(userId, fiscalPeriodId)` → `YearEndValidation`
|
||||
|
||||
Checks preconditions before allowing year-end closing:
|
||||
|
||||
- **Blocking errors** (prevent closing):
|
||||
- Period already closed
|
||||
- Closing entry already exists
|
||||
- Draft journal entries exist (must be posted or deleted)
|
||||
- Trial balance is not balanced
|
||||
- **Warnings** (informational):
|
||||
- Voucher number gaps detected (via `detect_voucher_gaps()` SQL function)
|
||||
- No posted entries in the period
|
||||
|
||||
#### `previewYearEndClosing(userId, fiscalPeriodId)` → `YearEndPreview`
|
||||
|
||||
Generates a preview without persisting anything:
|
||||
|
||||
1. Looks up `entity_type` from `company_settings` → determines closing account:
|
||||
- **Aktiebolag (AB):** account `2099` (Årets resultat)
|
||||
- **Enskild firma (EF):** account `2010` (Eget kapital)
|
||||
2. Runs income statement to get `net_result`
|
||||
3. Gets trial balance, filters to class 3–8 accounts
|
||||
4. For each account with a non-zero balance: creates a line that zeros it
|
||||
5. Adds a final balancing line to the closing account (2099/2010)
|
||||
6. Returns the preview with all lines and a summary of result accounts
|
||||
|
||||
#### `executeYearEndClosing(userId, fiscalPeriodId)` → `YearEndResult`
|
||||
|
||||
Full orchestration (the main entry point):
|
||||
|
||||
```
|
||||
1. validateYearEndReadiness() → abort if errors
|
||||
2. previewYearEndClosing() → get closing lines
|
||||
3. createJournalEntry() → create closing entry (source_type: 'year_end')
|
||||
4. UPDATE fiscal_periods → set closing_entry_id
|
||||
5. lockPeriod() → lock the period
|
||||
6. closePeriod() → permanently close
|
||||
7. createNextPeriod() → create next fiscal year
|
||||
8. generateOpeningBalances() → carry forward class 1-2 balances
|
||||
9. Return { closingEntry, nextPeriod, openingBalanceEntry }
|
||||
```
|
||||
|
||||
#### `generateOpeningBalances(userId, closedPeriodId, nextPeriodId)` → `JournalEntry`
|
||||
|
||||
Creates opening balance entries in the new period:
|
||||
|
||||
1. Gets trial balance of the closed period (after closing entry)
|
||||
2. Filters to balance sheet accounts (class 1–2) with non-zero closing balance
|
||||
3. Creates a journal entry with `source_type: 'opening_balance'`:
|
||||
- Debit accounts get debit opening, credit accounts get credit opening
|
||||
4. Verifies the entry is balanced (total debit = total credit)
|
||||
5. Sets `opening_balance_entry_id` and `opening_balances_set = true` on the next period
|
||||
|
||||
**Key invariant:** UB (utgående balans) of year N == IB (ingående balans) of year N+1.
|
||||
|
||||
---
|
||||
|
||||
### API Routes
|
||||
|
||||
All follow the existing pattern: authenticate via Supabase, delegate to service, return JSON.
|
||||
|
||||
| Method | Path | Handler |
|
||||
|--------|------|---------|
|
||||
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/lock` | `lockPeriod()` |
|
||||
| `GET` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `validateYearEndReadiness()` + `previewYearEndClosing()` |
|
||||
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `executeYearEndClosing()` |
|
||||
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/close` | `closePeriod()` |
|
||||
|
||||
---
|
||||
|
||||
## Reused Components
|
||||
|
||||
| Component | From | Used by |
|
||||
|-----------|------|---------|
|
||||
| `generateTrialBalance()` | `lib/reports/trial-balance.ts` | Balance aggregation for closing + opening entries |
|
||||
| `generateIncomeStatement()` | `lib/reports/income-statement.ts` | Net result calculation |
|
||||
| `createJournalEntry()` | `lib/bookkeeping/engine.ts` | Creating closing + opening entries (auto-posts) |
|
||||
| `validateBalance()` | `lib/bookkeeping/engine.ts` | Pre-flight balance check |
|
||||
| `detect_voucher_gaps()` | Migration 16 SQL function | Gap validation during readiness check |
|
||||
| `enforce_period_lock` trigger | Migration 17 | Blocks writes after locking |
|
||||
| `enforce_journal_entry_immutability` trigger | Migration 17 | Protects closing/opening entries after posting |
|
||||
|
||||
---
|
||||
|
||||
## Period Lifecycle Diagram
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
│ OPEN │ ← Journal entries can be posted
|
||||
└────┬────┘
|
||||
│ lockPeriod()
|
||||
▼
|
||||
┌─────────┐
|
||||
│ LOCKED │ ← No new entries (enforce_period_lock trigger)
|
||||
└────┬────┘
|
||||
│ closePeriod() (requires closing_entry_id)
|
||||
▼
|
||||
┌─────────┐
|
||||
│ CLOSED │ ← Permanent, immutable
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
The `executeYearEndClosing()` function drives the full flow from OPEN → CLOSED in one call, including creating the closing entry, locking, closing, creating the next period, and generating opening balances.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] `npx tsc --noEmit` — zero TypeScript errors
|
||||
- [ ] Migration 19 applies cleanly (`\d fiscal_periods` shows new columns)
|
||||
- [ ] `GET /api/bookkeeping/fiscal-periods/[id]/year-end` returns preview with net result
|
||||
- [ ] `POST /api/bookkeeping/fiscal-periods/[id]/year-end` creates closing entry, locks, closes, creates next period, generates opening balances
|
||||
- [ ] Closing entry zeros all class 3–8 accounts
|
||||
- [ ] Opening balance entry in next period matches UB of closed period (class 1–2 only)
|
||||
- [ ] Closed period rejects new journal entries (period lock trigger)
|
||||
- [ ] Period with draft entries → validation fails with blocking error
|
||||
- [ ] Period already closed → validation fails
|
||||
- [ ] EF entity type → closing goes to 2010 (not 2099)
|
||||
|
||||
---
|
||||
|
||||
## Files Changed/Created
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `supabase/migrations/20240101000019_period_closing.sql` | **Created** — 3 ALTER columns + 1 trigger |
|
||||
| `types/index.ts` | **Modified** — extended FiscalPeriod, added 4 new interfaces |
|
||||
| `lib/core/bookkeeping/period-service.ts` | **Created** — lockPeriod, closePeriod, createNextPeriod, getPeriodStatus |
|
||||
| `lib/core/bookkeeping/year-end-service.ts` | **Created** — validateYearEndReadiness, previewYearEndClosing, executeYearEndClosing, generateOpeningBalances |
|
||||
| `app/api/bookkeeping/fiscal-periods/[id]/lock/route.ts` | **Created** — POST lock endpoint |
|
||||
| `app/api/bookkeeping/fiscal-periods/[id]/year-end/route.ts` | **Created** — GET preview + POST execute |
|
||||
| `app/api/bookkeeping/fiscal-periods/[id]/close/route.ts` | **Created** — POST close endpoint |
|
||||
@@ -1,235 +0,0 @@
|
||||
Part 3: Event Bus & Extension Registry — Implementation
|
||||
|
||||
## What Was Built
|
||||
|
||||
An in-process event bus, extension registry with static discovery, database tables for extension data and event observability, and event emission retrofitted into all existing service and API route code paths. Backend only, no UI.
|
||||
|
||||
This is the foundation for all Tier 1 add-ons (receipt-ocr, ai-categorization, push-notifications, ne-bilaga, etc.). Without it, every add-on would need to be hardwired into core services.
|
||||
|
||||
---
|
||||
|
||||
## New Files
|
||||
|
||||
### Event Bus — `lib/events/`
|
||||
|
||||
**`lib/events/types.ts`**
|
||||
|
||||
Defines the `CoreEvent` discriminated union covering 18 event types across six domains:
|
||||
|
||||
| Domain | Events |
|
||||
|--------|--------|
|
||||
| Bookkeeping | `journal_entry.drafted`, `journal_entry.committed`, `journal_entry.corrected` |
|
||||
| Documents | `document.uploaded` |
|
||||
| Invoicing | `invoice.created`, `invoice.sent`, `invoice.paid`, `invoice.overdue`, `credit_note.created` |
|
||||
| Banking | `transaction.synced`, `transaction.categorized`, `bank.statement_received`, `bank.payment_notification` |
|
||||
| Periods | `period.locked`, `period.year_closed` |
|
||||
| Customers | `customer.created`, `customer.pseudonymized` |
|
||||
| Audit | `audit.security_event` |
|
||||
|
||||
Helper types for consuming events:
|
||||
|
||||
- `CoreEventType` — string literal union of all event type names
|
||||
- `EventPayload<T>` — extracts the payload type for a given event type
|
||||
- `EventHandler<T>` — handler function signature for a specific event type
|
||||
- `EventSubscription<T>` — event type + handler pair
|
||||
|
||||
**`lib/events/bus.ts`**
|
||||
|
||||
The event bus singleton. Key design:
|
||||
|
||||
- `eventBus.on(eventType, handler)` — subscribe, returns unsubscribe function
|
||||
- `eventBus.emit(event)` — runs all handlers via `Promise.allSettled` (a failing handler never crashes the emitter)
|
||||
- `eventBus.clear()` — remove all handlers (for testing)
|
||||
- Handlers run concurrently, errors logged to console
|
||||
- Module-level singleton (persists across requests in same Node.js process)
|
||||
|
||||
**`lib/events/index.ts`** — Barrel export.
|
||||
|
||||
### Extension Registry — `lib/extensions/`
|
||||
|
||||
**`lib/extensions/types.ts`**
|
||||
|
||||
The `Extension` interface — the contract for all add-ons:
|
||||
|
||||
```typescript
|
||||
interface Extension {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
|
||||
// Surfaces
|
||||
routes?: RouteDefinition[]
|
||||
apiRoutes?: ApiRouteDefinition[]
|
||||
sidebarItems?: SidebarItem[]
|
||||
eventHandlers?: ExtensionEventHandler[]
|
||||
mappingRuleTypes?: MappingRuleTypeDefinition[]
|
||||
reportTypes?: ReportDefinition[]
|
||||
settingsPanel?: SettingsPanelDefinition
|
||||
taxCodes?: TaxCodeDefinition[]
|
||||
dimensionTypes?: DimensionDefinition[]
|
||||
|
||||
// Lifecycle
|
||||
onInstall?(ctx: ExtensionContext): Promise<void>
|
||||
onUninstall?(ctx: ExtensionContext): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
Supporting types: `RouteDefinition`, `ApiRouteDefinition`, `SidebarItem`, `ReportDefinition`, `SettingsPanelDefinition`, `TaxCodeDefinition`, `DimensionDefinition`, `MappingRuleTypeDefinition`, `ExtensionEventHandler`, `ExtensionContext`.
|
||||
|
||||
**`lib/extensions/registry.ts`**
|
||||
|
||||
The `extensionRegistry` singleton:
|
||||
|
||||
- `register(extension)` — stores extension, wires event handlers to the bus
|
||||
- `unregister(extensionId)` — unhooks handlers, removes extension
|
||||
- `getAll()` — all registered extensions
|
||||
- `get(id)` — specific extension by ID
|
||||
- `getByCapability(key)` — extensions that have a specific surface (e.g. all extensions with `reportTypes`)
|
||||
- `clear()` — remove all (for testing)
|
||||
|
||||
**`lib/extensions/loader.ts`**
|
||||
|
||||
Static extension discovery. Next.js bundling requires explicit imports, not dynamic filesystem scanning. Contains an empty `FIRST_PARTY_EXTENSIONS` array — extensions are added here as they are built. `loadExtensions()` has an idempotency guard.
|
||||
|
||||
**`lib/extensions/index.ts`** — Barrel export.
|
||||
|
||||
### Initialization — `lib/init.ts`
|
||||
|
||||
`ensureInitialized()` — calls `loadExtensions()` once. Called from API routes that emit events (at module scope, not per-request).
|
||||
|
||||
### Example Extension — `extensions/example-logger/index.ts`
|
||||
|
||||
Minimal reference implementation that logs `journal_entry.committed` and `document.uploaded` events to console. Not wired into the loader by default — exists as a template for building real extensions.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
**`supabase/migrations/20240101000020_extension_data.sql`**
|
||||
|
||||
Two tables:
|
||||
|
||||
**`extension_data`** — generic key-value store for extensions:
|
||||
- Columns: `id`, `user_id`, `extension_id`, `key`, `value` (jsonb), `created_at`, `updated_at`
|
||||
- `UNIQUE(user_id, extension_id, key)`
|
||||
- RLS: select, insert, update, delete own rows
|
||||
- Auto-update `updated_at` trigger
|
||||
|
||||
**`event_log`** — append-only event observability:
|
||||
- Columns: `id`, `user_id`, `event_type`, `payload` (jsonb), `created_at`
|
||||
- RLS: select + insert only (no update, no delete — append-only)
|
||||
- Indexes on `(user_id, event_type)` and `created_at`
|
||||
|
||||
---
|
||||
|
||||
## Type Additions — `types/index.ts`
|
||||
|
||||
Placeholder types for event payloads not yet fully built:
|
||||
|
||||
| Type | Purpose |
|
||||
|------|---------|
|
||||
| `CreditNote` | Extends `Invoice` with required `credited_invoice_id` |
|
||||
| `CAMT053Statement` | Bank statement (CAMT parsing not yet implemented) |
|
||||
| `CAMT054Notification` | Payment notification (CAMT parsing not yet implemented) |
|
||||
| `AuditSecurityEvent` | Security event payload for audit events |
|
||||
| `ExtensionDataRecord` | Row type for the `extension_data` table |
|
||||
|
||||
---
|
||||
|
||||
## Retrofitted Event Emissions
|
||||
|
||||
The pattern is identical everywhere: import `eventBus`, call `await eventBus.emit(...)` after the successful operation. No control flow changes, no return type changes. All events include a `userId` field for RLS-scoped observability.
|
||||
|
||||
### Phase A — Service Layer
|
||||
|
||||
| File | Function | Event |
|
||||
|------|----------|-------|
|
||||
| `lib/bookkeeping/engine.ts` | `createDraftEntry()` | `journal_entry.drafted` |
|
||||
| `lib/bookkeeping/engine.ts` | `commitEntry()` | `journal_entry.committed` |
|
||||
| `lib/bookkeeping/engine.ts` | `createJournalEntry()` | `journal_entry.committed` |
|
||||
| `lib/bookkeeping/engine.ts` | `reverseEntry()` | `journal_entry.committed` |
|
||||
| `lib/core/bookkeeping/storno-service.ts` | `correctEntry()` | `journal_entry.corrected` |
|
||||
| `lib/core/documents/document-service.ts` | `uploadDocument()` | `document.uploaded` |
|
||||
| `lib/core/bookkeeping/period-service.ts` | `lockPeriod()` | `period.locked` |
|
||||
| `lib/core/bookkeeping/year-end-service.ts` | `executeYearEndClosing()` | `period.year_closed` |
|
||||
|
||||
### Phase B — API Routes
|
||||
|
||||
| File | Event |
|
||||
|------|-------|
|
||||
| `app/api/invoices/route.ts` (POST) | `invoice.created` |
|
||||
| `app/api/invoices/route.ts` (createCreditNote) | `credit_note.created` |
|
||||
| `app/api/invoices/[id]/send/route.ts` | `invoice.sent` |
|
||||
| `app/api/customers/route.ts` (POST) | `customer.created` |
|
||||
| `app/api/transactions/[id]/categorize/route.ts` | `transaction.categorized` |
|
||||
| `app/api/banking/sync/route.ts` | `transaction.synced` |
|
||||
|
||||
API routes also call `ensureInitialized()` at module scope to ensure extensions are loaded before events are emitted.
|
||||
|
||||
### Deferred (Phase C)
|
||||
|
||||
These events are defined in the type system but not yet emitted because the underlying infrastructure doesn't exist:
|
||||
|
||||
| Event | Reason |
|
||||
|-------|--------|
|
||||
| `invoice.paid` | Payment matching with kursdifferens not fully wired |
|
||||
| `invoice.overdue` | Needs cron-based detection |
|
||||
| `bank.statement_received` | CAMT053 parsing not implemented |
|
||||
| `bank.payment_notification` | CAMT054 parsing not implemented |
|
||||
| `customer.pseudonymized` | GDPR flow not implemented |
|
||||
| `audit.security_event` | Already logged at DB level; app-level TBD |
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **In-process bus** — architecture specifies "in-process handlers initially, add webhook dispatch when external plugin consumers exist." No message queue, no outbox pattern at the event bus level.
|
||||
|
||||
2. **`Promise.allSettled`** — a failing handler never crashes the emitting service. Errors are logged to console. The emitter's control flow is never affected.
|
||||
|
||||
3. **Module-level singletons** — `eventBus` and `extensionRegistry` persist across requests in the same Node.js process. They are not per-request or per-user.
|
||||
|
||||
4. **Static extension imports** — Next.js bundling requires explicit imports in `loader.ts`, not dynamic `fs.readdirSync`. Extensions are added to the `FIRST_PARTY_EXTENSIONS` array as they are built.
|
||||
|
||||
5. **One-way dependency** — `lib/events/` depends on nothing except `types/`. Core services import from `lib/events/`. Extensions import from `lib/core/`, `lib/events/`, and `lib/extensions/`. The base never imports from `extensions/`.
|
||||
|
||||
6. **`ensureInitialized()` at module scope** — API routes call this at the top of the file (not inside request handlers). This means extensions are loaded once when the module is first imported by Next.js, not on every request.
|
||||
|
||||
7. **Every event payload includes `userId`** — enables RLS-scoped event logging and per-user extension behavior without needing to pass auth context through the bus.
|
||||
|
||||
---
|
||||
|
||||
## How to Build an Extension
|
||||
|
||||
1. Create a directory under `extensions/your-extension/`
|
||||
2. Export an object satisfying the `Extension` interface
|
||||
3. Import it in `lib/extensions/loader.ts` and add to `FIRST_PARTY_EXTENSIONS`
|
||||
|
||||
Example (see `extensions/example-logger/index.ts`):
|
||||
|
||||
```typescript
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
|
||||
export const myExtension: Extension = {
|
||||
id: 'my-extension',
|
||||
name: 'My Extension',
|
||||
version: '0.1.0',
|
||||
eventHandlers: [
|
||||
{
|
||||
eventType: 'journal_entry.committed',
|
||||
handler: async (payload: EventPayload<'journal_entry.committed'>) => {
|
||||
// Your logic here
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit` — zero errors
|
||||
- All existing API routes unchanged in behavior — event emission is additive, never blocking
|
||||
- Extension system is fully wired but dormant (empty extension list) until extensions are added to the loader
|
||||
@@ -1,251 +0,0 @@
|
||||
# Part 4: Year-End Closing UI (Årsbokslut)
|
||||
|
||||
## Overview
|
||||
|
||||
Part 4 implements the **user interface for year-end closing** — a 4-step wizard that guides the user through validating, previewing, and executing the annual closing per Bokföringslagen. This is a pure frontend implementation; all backend services, API routes, and database migrations were completed in Part 2.
|
||||
|
||||
**Depends on Part 2:** period-service, year-end-service, fiscal period API routes (`GET`/`POST` at `/api/bookkeeping/fiscal-periods/[id]/year-end`).
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Year-End Wizard Page
|
||||
|
||||
**File:** `app/(dashboard)/bookkeeping/year-end/page.tsx`
|
||||
|
||||
A single `'use client'` page containing a 4-step wizard at route `/bookkeeping/year-end`. The wizard maps directly to the existing API surface:
|
||||
|
||||
| Step | Label | API Call | Purpose |
|
||||
|------|-------|----------|---------|
|
||||
| 0 | Välj period | `GET /api/bookkeeping/fiscal-periods` | Select which fiscal period to close |
|
||||
| 1 | Validering | `GET /api/bookkeeping/fiscal-periods/[id]/year-end` | Check readiness (errors + warnings) |
|
||||
| 2 | Förhandsgranskning | *(uses data from step 1)* | Review closing entry before committing |
|
||||
| 3 | Genomför | `POST /api/bookkeeping/fiscal-periods/[id]/year-end` | Execute with confirmation dialog |
|
||||
|
||||
#### Step 0: Period Selection
|
||||
|
||||
- Fetches all fiscal periods on mount
|
||||
- Pre-selects the first open (non-closed) period
|
||||
- Displays each period as a selectable card with:
|
||||
- Period name and date range (`period_start – period_end`)
|
||||
- Status badge: **Öppen** (default), **Låst** (outline), **Stängd** (secondary, disabled)
|
||||
- Closed periods are visually dimmed and not selectable
|
||||
|
||||
#### Step 1: Validation
|
||||
|
||||
- Calls `GET /api/bookkeeping/fiscal-periods/[id]/year-end` which returns `{ validation, preview }` from parallel `validateYearEndReadiness()` + `previewYearEndClosing()`
|
||||
- Displays a ready/not-ready banner:
|
||||
- Green `CheckCircle2` + "Perioden är redo för årsbokslut" when `validation.ready === true`
|
||||
- Red `AlertCircle` + "Perioden kan inte stängas ännu" when `validation.ready === false`
|
||||
- **Blocking errors** (red): draft entries, unbalanced trial balance, already closed, closing entry exists
|
||||
- **Warnings** (amber): voucher number gaps, no posted entries
|
||||
- Detail cards showing draft count and trial balance status
|
||||
- Voucher gap badges when gaps exist
|
||||
- "Validera igen" button to re-run checks after fixing issues
|
||||
- "Nästa" button gated on `validation.ready === true`
|
||||
|
||||
#### Step 2: Preview
|
||||
|
||||
Three cards displaying the preview data:
|
||||
|
||||
1. **Net result highlight** — Large centered number with color coding (green for profit, red for loss), closing account label (e.g. "2099 — Årets resultat")
|
||||
2. **Result account summary** — Table of class 3–8 accounts being zeroed (account number, name, amount)
|
||||
3. **Closing journal lines** — Expandable/collapsible table showing the full closing entry (account, description, debit, credit) with a totals row
|
||||
|
||||
#### Step 3: Execute
|
||||
|
||||
Pre-execution state:
|
||||
- Summary of actions: closing entry creation, period lock + close, next period + opening balances
|
||||
- Irreversibility warning banner (amber) referencing Bokföringslagen
|
||||
- "Genomför årsbokslut" button (destructive variant) opens a confirmation dialog
|
||||
|
||||
Confirmation dialog (`Dialog` component):
|
||||
- Repeats period name and net result
|
||||
- "Avbryt" and "Stäng perioden" (destructive) buttons
|
||||
|
||||
Post-execution success state:
|
||||
- `SuccessAnimation` overlay with celebration variant
|
||||
- Summary card showing: closing entry link, closed period badge, new period name, opening balances status
|
||||
- "Tillbaka till bokföring" navigation
|
||||
|
||||
---
|
||||
|
||||
### Bookkeeping Page Link
|
||||
|
||||
**File:** `app/(dashboard)/bookkeeping/page.tsx`
|
||||
|
||||
Added a header action button linking to the year-end wizard:
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/bookkeeping/year-end">
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
Årsbokslut
|
||||
</Link>
|
||||
</Button>
|
||||
```
|
||||
|
||||
The header was restructured from a plain `<div>` to a `flex items-center justify-between` layout to accommodate the button alongside the existing title and description.
|
||||
|
||||
---
|
||||
|
||||
## User Flow Diagram
|
||||
|
||||
```
|
||||
/bookkeeping
|
||||
│
|
||||
│ Click "Årsbokslut" button
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Step 0: Välj period │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ FY 2024 (2024-01-01 – 2024-12-31) │ [Öppen] │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ ┌───────────────────────────────────┐ │
|
||||
│ │ FY 2023 (2023-01-01 – 2023-12-31) │ [Stängd] │
|
||||
│ └───────────────────────────────────┘ │
|
||||
│ [Nästa →] │
|
||||
└────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
GET /api/bookkeeping/fiscal-periods/[id]/year-end
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Step 1: Validering │
|
||||
│ ✅ Perioden är redo för årsbokslut │
|
||||
│ ─ or ─ │
|
||||
│ ❌ 3 draft entries must be posted │
|
||||
│ ⚠️ Voucher gaps: 5–7 │
|
||||
│ │
|
||||
│ [← Tillbaka] [Validera igen] [Nästa →] │
|
||||
└────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Step 2: Förhandsgranskning │
|
||||
│ │
|
||||
│ Årets resultat: 150 000,00 kr │
|
||||
│ Bokförs på 2099 — Årets resultat │
|
||||
│ │
|
||||
│ ┌─ Resultatkonton som nollställs ────────────────┐ │
|
||||
│ │ 3001 Tjänsteintäkter -500 000,00 │ │
|
||||
│ │ 5010 Lokalhyra 200 000,00 │ │
|
||||
│ │ 6570 Bankavgifter 150 000,00 │ │
|
||||
│ └────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [← Tillbaka] [Nästa →] │
|
||||
└────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Step 3: Genomför │
|
||||
│ │
|
||||
│ ⚠️ Denna åtgärd kan inte ångras │
|
||||
│ │
|
||||
│ [← Tillbaka] [Genomför årsbokslut] │
|
||||
│ │ │
|
||||
│ ┌─────────▼──────────┐ │
|
||||
│ │ Bekräfta årsbokslut │ │
|
||||
│ │ Stäng FY 2024? │ │
|
||||
│ │ │ │
|
||||
│ │ [Avbryt] [Stäng] │ │
|
||||
│ └─────────┬──────────┘ │
|
||||
│ │ │
|
||||
│ POST /api/.../year-end │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ✅ Årsbokslutet är genomfört │
|
||||
│ • Bokslutsverifikation [Visa] │
|
||||
│ • Period stängd [Stängd] │
|
||||
│ • Nytt räkenskapsår FY 2025 │
|
||||
│ • Ingående balanser [Skapade] │
|
||||
│ │
|
||||
│ [← Tillbaka till bokföring] │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Management
|
||||
|
||||
All state is local to the page component via `useState`:
|
||||
|
||||
| State | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
| `step` | `number` (0–3) | Current wizard step |
|
||||
| `periods` | `FiscalPeriod[]` | All fiscal periods from API |
|
||||
| `selectedPeriodId` | `string` | Currently selected period |
|
||||
| `validation` | `YearEndValidation \| null` | Validation result from API |
|
||||
| `preview` | `YearEndPreview \| null` | Preview result from API |
|
||||
| `result` | `YearEndResult \| null` | Execution result from API |
|
||||
| `loading` | `boolean` | Loading state for validation fetch |
|
||||
| `loadingPeriods` | `boolean` | Loading state for periods fetch |
|
||||
| `executing` | `boolean` | Loading state for POST execution |
|
||||
| `error` | `string \| null` | Error message banner |
|
||||
| `showConfirmDialog` | `boolean` | Confirmation dialog visibility |
|
||||
| `showLinesDetail` | `boolean` | Expandable closing lines table |
|
||||
| `showSuccess` | `boolean` | Success animation overlay |
|
||||
|
||||
---
|
||||
|
||||
## Reused Components
|
||||
|
||||
| Component | From | Used for |
|
||||
|-----------|------|----------|
|
||||
| `Card`, `CardContent`, `CardHeader`, `CardTitle` | `components/ui/card.tsx` | All step containers |
|
||||
| `Button` | `components/ui/button.tsx` | Navigation, actions, links |
|
||||
| `Badge` | `components/ui/badge.tsx` | Period status, voucher gaps, success indicators |
|
||||
| `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow` | `components/ui/table.tsx` | Result accounts + closing lines |
|
||||
| `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter` | `components/ui/dialog.tsx` | Execution confirmation |
|
||||
| `Skeleton` | `components/ui/skeleton.tsx` | Loading states |
|
||||
| `SuccessAnimation` | `components/ui/success-animation.tsx` | Post-execution celebration overlay |
|
||||
| `useToast` | `components/ui/use-toast.tsx` | Error notifications |
|
||||
| `formatAmount()` | Inline helper (same pattern as `reports/page.tsx`) | Swedish locale number formatting |
|
||||
|
||||
Lucide icons used: `CheckCircle2`, `AlertCircle`, `AlertTriangle`, `ArrowLeft`, `ArrowRight`, `Loader2`, `Lock`, `BookOpen`, `ChevronDown`, `ChevronUp`.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
No new API routes were created. The wizard consumes existing endpoints from Part 2:
|
||||
|
||||
| Method | Path | Response | Used in step |
|
||||
|--------|------|----------|--------------|
|
||||
| `GET` | `/api/bookkeeping/fiscal-periods` | `{ data: FiscalPeriod[] }` | 0 (period list) |
|
||||
| `GET` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `{ data: { validation: YearEndValidation, preview: YearEndPreview } }` | 1 + 2 (validation + preview) |
|
||||
| `POST` | `/api/bookkeeping/fiscal-periods/[id]/year-end` | `{ data: YearEndResult }` | 3 (execution) |
|
||||
|
||||
---
|
||||
|
||||
## Files Changed/Created
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `app/(dashboard)/bookkeeping/year-end/page.tsx` | **Created** — 4-step year-end closing wizard (700 lines) |
|
||||
| `app/(dashboard)/bookkeeping/page.tsx` | **Modified** — Added "Årsbokslut" link button in header, restructured header layout |
|
||||
|
||||
**No new API routes.** No backend changes. No new dependencies. No database migrations.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] `npx tsc --noEmit` — zero TypeScript errors
|
||||
- [x] `npm run build` — builds clean, `/bookkeeping/year-end` route registered
|
||||
- [x] `npx vitest run` — all 78 existing tests pass
|
||||
- [ ] Navigate to `/bookkeeping` → "Årsbokslut" button visible in header
|
||||
- [ ] Click "Årsbokslut" → wizard loads at step 0 with period selector
|
||||
- [ ] Closed periods appear dimmed and cannot be selected
|
||||
- [ ] Select open period → "Nästa" → validation step loads with skeleton, then shows results
|
||||
- [ ] Period with draft entries → red error "X draft journal entries must be posted or deleted"
|
||||
- [ ] Period with unbalanced trial balance → red error "Trial balance is not balanced"
|
||||
- [ ] Period with voucher gaps → amber warning with gap badges
|
||||
- [ ] Fully valid period → green "Perioden är redo för årsbokslut", "Nästa" enabled
|
||||
- [ ] Preview step → net result displayed with correct color, result accounts table, expandable closing lines
|
||||
- [ ] EF entity type → closing account shows "2010 — Eget kapital"
|
||||
- [ ] AB entity type → closing account shows "2099 — Årets resultat"
|
||||
- [ ] Execute step → irreversibility warning shown, "Genomför årsbokslut" opens confirmation dialog
|
||||
- [ ] Confirmation dialog → "Stäng perioden" triggers POST, success animation + summary displayed
|
||||
- [ ] Success state → shows new period name, closing entry link, opening balances badge
|
||||
@@ -1,312 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,260 +0,0 @@
|
||||
# Receipt-OCR Extension — Implementation Summary
|
||||
|
||||
This document describes the receipt-ocr extension: the first real extension built on the Part 3 event bus and extension registry infrastructure. It bridges the document archive to the receipt pipeline via events and establishes the canonical pattern for all future extensions.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Receipt-OCR functionality existed as built-in code (`lib/receipts/`, `app/api/receipts/`), but was disconnected from the event system:
|
||||
|
||||
- Uploading a document via the archive did not trigger OCR
|
||||
- New bank transactions did not auto-match to receipts
|
||||
- No domain events were emitted when receipts were extracted, matched, or confirmed
|
||||
|
||||
The event bus and extension registry (Part 3) were built but had zero real extensions using them.
|
||||
|
||||
## Solution
|
||||
|
||||
A `receipt-ocr` extension that:
|
||||
|
||||
1. Listens to `document.uploaded` events and auto-triggers OCR on images
|
||||
2. Listens to `transaction.synced` events and auto-matches receipts to new transactions
|
||||
3. Emits its own domain events (`receipt.extracted`, `receipt.matched`, `receipt.confirmed`) so downstream extensions can react
|
||||
|
||||
**Principle followed:** Services = reusable logic in `lib/`. Extensions = event-driven glue. API routes = HTTP interface.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `extensions/receipt-ocr/index.ts` | The extension: settings, event handlers, extension object |
|
||||
| `app/api/extensions/receipt-ocr/settings/route.ts` | GET/PATCH API for per-user extension settings |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `lib/events/types.ts` | Added `Receipt` import and 3 new events to `CoreEvent` union |
|
||||
| `lib/extensions/loader.ts` | Imported and registered `receiptOcrExtension` |
|
||||
| `app/api/receipts/upload/route.ts` | Emits `receipt.extracted` after successful OCR |
|
||||
| `app/api/receipts/[id]/match/route.ts` | Emits `receipt.matched` after manual match; upgraded selects to fetch full objects |
|
||||
| `app/api/receipts/[id]/confirm/route.ts` | Emits `receipt.confirmed` with computed business/private totals |
|
||||
|
||||
---
|
||||
|
||||
## New Events
|
||||
|
||||
Three events were added to `lib/events/types.ts`:
|
||||
|
||||
### `receipt.extracted`
|
||||
|
||||
Fires when OCR extraction completes on a receipt image, whether via the direct upload path or the document archive path.
|
||||
|
||||
```typescript
|
||||
{ type: 'receipt.extracted'; payload: {
|
||||
receipt: Receipt;
|
||||
documentId: string | null; // null when from direct upload path
|
||||
confidence: number;
|
||||
userId: string;
|
||||
}}
|
||||
```
|
||||
|
||||
### `receipt.matched`
|
||||
|
||||
Fires when a receipt is linked to a bank transaction, whether by user manual action or extension auto-match.
|
||||
|
||||
```typescript
|
||||
{ type: 'receipt.matched'; payload: {
|
||||
receipt: Receipt;
|
||||
transaction: Transaction;
|
||||
confidence: number;
|
||||
autoMatched: boolean; // true = extension, false = user manual
|
||||
userId: string;
|
||||
}}
|
||||
```
|
||||
|
||||
### `receipt.confirmed`
|
||||
|
||||
Fires when a user confirms line item classifications (business vs private).
|
||||
|
||||
```typescript
|
||||
{ type: 'receipt.confirmed'; payload: {
|
||||
receipt: Receipt;
|
||||
businessTotal: number;
|
||||
privateTotal: number;
|
||||
userId: string;
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Event Retrofitting
|
||||
|
||||
Existing API routes were retrofitted to emit events after their success paths. Each route received:
|
||||
|
||||
- `import { eventBus } from '@/lib/events/bus'`
|
||||
- `import { ensureInitialized } from '@/lib/init'`
|
||||
- `ensureInitialized()` at module scope
|
||||
- `await eventBus.emit(...)` after the successful operation, before the response
|
||||
|
||||
### `app/api/receipts/upload/route.ts`
|
||||
|
||||
Emits `receipt.extracted` after the complete receipt (with line items) is fetched, with `documentId: null` since this is the direct upload path.
|
||||
|
||||
### `app/api/receipts/[id]/match/route.ts`
|
||||
|
||||
The PATCH handler's ownership verification queries were upgraded from `select('id')` to `select('*, line_items:receipt_line_items(*)')` and `select('*')` respectively, so the full receipt and transaction objects are available for the event payload. Emits `receipt.matched` with `autoMatched: false`.
|
||||
|
||||
### `app/api/receipts/[id]/confirm/route.ts`
|
||||
|
||||
Computes `businessTotal` and `privateTotal` by iterating over the updated receipt's line items. Emits `receipt.confirmed` with these totals.
|
||||
|
||||
---
|
||||
|
||||
## Extension: `extensions/receipt-ocr/index.ts`
|
||||
|
||||
### Settings
|
||||
|
||||
```typescript
|
||||
interface ReceiptOcrSettings {
|
||||
autoOcrEnabled: boolean // default: true
|
||||
autoMatchEnabled: boolean // default: true
|
||||
autoMatchThreshold: number // default: 0.8
|
||||
ocrConfidenceThreshold: number // default: 0.6
|
||||
}
|
||||
```
|
||||
|
||||
Stored as an `extension_data` row with `extension_id='receipt-ocr'`, `key='settings'`, `value=<jsonb>`.
|
||||
|
||||
- `getSettings(userId)` reads from DB and merges with defaults (forward-compatible when new settings are added)
|
||||
- `saveSettings(userId, partial)` merges partial update with current settings, then upserts on the unique constraint `(user_id, extension_id, key)`
|
||||
|
||||
### Event Handler: `document.uploaded`
|
||||
|
||||
When an image is uploaded via the document archive:
|
||||
|
||||
1. **Gate:** Is `document.mime_type` an image? (`image/jpeg|png|webp|gif`) — if not, return
|
||||
2. **Gate:** Is `autoOcrEnabled` in user's settings? — if not, return
|
||||
3. Downloads image from `documents` storage bucket
|
||||
4. Converts to base64, calls `analyzeReceipt()` from `lib/receipts/receipt-analyzer.ts`
|
||||
5. **Gate:** Is `extraction.confidence >= ocrConfidenceThreshold`? — if not, return
|
||||
6. Calls `processLineItems()` from `lib/receipts/receipt-categorizer.ts`
|
||||
7. Creates receipt record (status: `extracted`) + line items in DB
|
||||
8. Emits `receipt.extracted` with `documentId: document.id`
|
||||
|
||||
### Event Handler: `transaction.synced`
|
||||
|
||||
When new transactions arrive from banking sync:
|
||||
|
||||
1. **Gate:** Is `autoMatchEnabled`? — if not, return
|
||||
2. Filters to expense transactions only (amount < 0)
|
||||
3. Fetches unmatched receipts (`status IN ('extracted','confirmed')`, `matched_transaction_id IS NULL`)
|
||||
4. Calls `autoMatchReceipts()` from `lib/receipts/receipt-matcher.ts` with `settings.autoMatchThreshold`
|
||||
5. For each match: updates receipt + transaction bidirectional link, emits `receipt.matched` with `autoMatched: true`
|
||||
|
||||
### Extension Object
|
||||
|
||||
```typescript
|
||||
export const receiptOcrExtension: Extension = {
|
||||
id: 'receipt-ocr',
|
||||
name: 'Receipt OCR',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'document.uploaded', handler: handleDocumentUploaded },
|
||||
{ eventType: 'transaction.synced', handler: handleTransactionSynced },
|
||||
],
|
||||
mappingRuleTypes: [
|
||||
{ id: 'receipt-ocr-merchant', name: 'OCR Merchant Match', ... },
|
||||
{ id: 'receipt-ocr-category', name: 'OCR Category Suggestion', ... },
|
||||
],
|
||||
settingsPanel: { label: 'Receipt OCR', path: '/settings/extensions/receipt-ocr' },
|
||||
async onInstall(ctx) { await saveSettings(ctx.userId, DEFAULT_SETTINGS) },
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings API: `app/api/extensions/receipt-ocr/settings/route.ts`
|
||||
|
||||
Establishes the convention `app/api/extensions/{id}/settings/route.ts` for all extensions.
|
||||
|
||||
- **GET** — Returns the current user's merged settings (DB value + defaults)
|
||||
- **PATCH** — Accepts a partial settings object, validates keys against an allowlist, saves via `saveSettings()`
|
||||
|
||||
---
|
||||
|
||||
## Existing Code Reused
|
||||
|
||||
| Import | From | Used in |
|
||||
|--------|------|---------|
|
||||
| `analyzeReceipt()` | `lib/receipts/receipt-analyzer.ts` | `handleDocumentUploaded` |
|
||||
| `processLineItems()` | `lib/receipts/receipt-categorizer.ts` | `handleDocumentUploaded` |
|
||||
| `autoMatchReceipts()` | `lib/receipts/receipt-matcher.ts` | `handleTransactionSynced` |
|
||||
| `eventBus` | `lib/events/bus.ts` | Both handlers + retrofit |
|
||||
| `createClient()` | `lib/supabase/server.ts` | Settings + DB ops |
|
||||
|
||||
No service logic was duplicated. The extension only acts as event-driven glue between existing services.
|
||||
|
||||
---
|
||||
|
||||
## Event Flow
|
||||
|
||||
```
|
||||
Document Archive Upload Direct Receipt Upload Bank Sync
|
||||
| | |
|
||||
uploadDocument() POST /receipts/upload POST /banking/sync
|
||||
| | |
|
||||
emit document.uploaded analyzeReceipt() inline emit transaction.synced
|
||||
| | |
|
||||
v v v
|
||||
+-----------------+ emit receipt.extracted +---------------------+
|
||||
| receipt-ocr | | receipt-ocr |
|
||||
| extension | | extension |
|
||||
| | | |
|
||||
| Gate: image? | | Gate: enabled? |
|
||||
| Gate: enabled? | | Fetch unmatched |
|
||||
| Download image | | autoMatchReceipts() |
|
||||
| analyzeReceipt()| | Link matches |
|
||||
| Create receipt | | emit receipt.matched|
|
||||
| emit receipt. | +---------------------+
|
||||
| extracted |
|
||||
+-----------------+
|
||||
|
||||
User confirms receipt --> POST /receipts/[id]/confirm
|
||||
|
|
||||
emit receipt.confirmed
|
||||
|
|
||||
v
|
||||
[Future extensions]
|
||||
push-notifications
|
||||
ne-bilaga, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architectural Patterns Established
|
||||
|
||||
1. **Extensions never duplicate service logic.** They call existing functions from `lib/`.
|
||||
2. **Extensions are gate-guarded.** Every handler checks user settings before doing work.
|
||||
3. **Extensions emit domain events.** Downstream extensions react without coupling.
|
||||
4. **Events fire from both paths.** Whether a receipt enters via archive (event-driven) or direct upload (API), the same `receipt.extracted` event fires.
|
||||
5. **Settings use `extension_data` with `key='settings'`.** Helpers merge with defaults for forward-compatible schema evolution.
|
||||
6. **`onInstall` seeds defaults.** Idempotent via upsert.
|
||||
7. **Handlers never crash the emitter.** `Promise.allSettled` in the bus handles this.
|
||||
8. **Console logging with `[extension-id]` prefix.** Convention for grep-ability.
|
||||
9. **One-way dependency.** Base never imports from `extensions/`. Only `loader.ts` imports extension objects.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx tsc --noEmit` passes with zero errors
|
||||
- Manual: upload image via document archive -> receipt auto-created with OCR extraction
|
||||
- Manual: sync bank transactions -> unmatched receipts auto-matched
|
||||
- Manual: direct receipt upload still works unchanged, now also emits `receipt.extracted`
|
||||
- Manual: confirm receipt -> emits `receipt.confirmed`
|
||||
@@ -1,257 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,234 +0,0 @@
|
||||
# AI-Agent Snabbreferens
|
||||
|
||||
## Dokumentöversikt
|
||||
|
||||
| Fil | Innehåll | Användning |
|
||||
|-----|----------|------------|
|
||||
| 01-karnregler.md | Grundläggande regler, tröskelvärden, bolagsformer | Faktafrågor om regler |
|
||||
| 02-scenariobibliotek.md | 30 vanliga situationer med lösningar | "Vad gör jag om..." frågor |
|
||||
| 03-plattformskatalog.md | Plattformsspecifik info (VAT, moms, utbetalningar) | Plattformsfrågor |
|
||||
| 04-berakningsexempel.md | Konkreta sifferexempel | "Hur beräknar jag..." frågor |
|
||||
| 05-beslutstrаd.md | Visuella beslutsflöden | Hjälp med val |
|
||||
| 06-felsokningsguide.md | Problemlösning | "Det stämmer inte..." frågor |
|
||||
| 07-kalender-deadlines.md | Alla deadlines och påminnelser | Tidsfrågor |
|
||||
| 08-varningsflaggor.md | Automatiska kontroller och varningar | Proaktiva varningar |
|
||||
|
||||
---
|
||||
|
||||
## Trigger-ord → Dokument
|
||||
|
||||
### Produkter och barter
|
||||
- "fick produkt", "pressutskick", "gratis", "barter" → Scenario 001-004
|
||||
- "marknadsvärde", "värdering" → Beräkningsexempel 1.2
|
||||
|
||||
### Plattformar
|
||||
- "YouTube", "AdSense", "Google" → Plattformskatalog (YouTube)
|
||||
- "Twitch", "subs", "bits" → Plattformskatalog (Twitch)
|
||||
- "Instagram", "Meta", "Reels bonus" → Plattformskatalog (Instagram)
|
||||
- "TikTok", "Creator Fund" → Plattformskatalog (TikTok)
|
||||
- "Patreon" → Plattformskatalog (Patreon)
|
||||
- "affiliate", "Adtraction" → Plattformskatalog (Affiliate)
|
||||
|
||||
### Moms
|
||||
- "moms", "VAT", "faktura" → Kärnregler sektion 3, Beslutsträd 2.1
|
||||
- "periodisk sammanställning" → Kärnregler 3.3, Kalender
|
||||
- "Reverse Charge", "EU" → Scenario 007-010, Beslutsträd 2.1
|
||||
- "OSS", "privatperson EU" → Kärnregler 3.4, Scenario 018-019
|
||||
|
||||
### Bolagsform
|
||||
- "EF", "enskild firma" → Kärnregler 4.1, Beslutsträd 3.1
|
||||
- "AB", "aktiebolag" → Kärnregler 4.2, Beslutsträd 3.1
|
||||
- "byta bolagsform", "starta AB" → Scenario 028, Beslutsträd 3.2
|
||||
- "utdelning", "3:12" → Scenario 027, Beräkningsexempel 3.1
|
||||
|
||||
### Avdrag
|
||||
- "avdrag", "dra av" → Kärnregler 5, Beslutsträd 4.1
|
||||
- "kläder", "smink" → Kärnregler 5.2, Scenario 016-017
|
||||
- "resa", "arbetsresa" → Scenario 005-006, Beslutsträd 1.2
|
||||
- "hemmakontor", "arbetsrum" → Kärnregler 5.3, Beräkningsexempel 5.2
|
||||
|
||||
### Social trygghet
|
||||
- "SGI", "sjukpenning" → Kärnregler 7, Scenario 023
|
||||
- "föräldraledighet", "VAB", "barn" → Beräkningsexempel 3.3, Varningsflaggor 3.3
|
||||
- "pension" → Kärnregler 7.2
|
||||
|
||||
### Juridik
|
||||
- "reklam", "markering", "MFL" → Kärnregler 6, Scenario 024
|
||||
- "upphovsrätt", "musik", "licens" → Scenario 025, Varningsflaggor 5.2
|
||||
|
||||
### Dubbel struktur
|
||||
- "EF och AB", "båda bolag" → Kärnregler 8, Scenario 029-030
|
||||
- "internfaktura" → Scenario 030, Varningsflaggor 4.1-4.2
|
||||
|
||||
### Deadlines
|
||||
- "deadline", "när ska", "förfaller" → Kalender
|
||||
- "momsdeklaration" → Kalender (kvartalsvis/månadsvis)
|
||||
- "inkomstdeklaration" → Kalender (maj/juli)
|
||||
|
||||
### Problem
|
||||
- "stämmer inte", "fel", "problem" → Felsökningsguide
|
||||
- "Skatteverket", "granskning" → Felsökningsguide 8
|
||||
|
||||
---
|
||||
|
||||
## Kritiska tröskelvärden (snabbåtkomst)
|
||||
|
||||
| Belopp | Betydelse |
|
||||
|--------|-----------|
|
||||
| 1 800 kr | Skattefri tävlingsvinst |
|
||||
| 2 000 kr | Schablonavdrag arbetsrum EF |
|
||||
| 5 000 kr | Skattefritt friskvårdsbidrag AB |
|
||||
| 25 000 kr | Aktiekapital AB |
|
||||
| 80 000 kr | Momsbefrielse omsättning |
|
||||
| 99 680 kr | OSS-tröskel EU-privatpersoner |
|
||||
| 204 325 kr | Schablonbelopp 3:12 (2024) |
|
||||
| 615 300 kr | Brytpunkt statlig skatt |
|
||||
| 681 600 kr | Lönekrav för lönebaserat 3:12-utrymme |
|
||||
|
||||
---
|
||||
|
||||
## Vanliga plattformars momshantering (snabbåtkomst)
|
||||
|
||||
| Plattform | Moms | Ruta | Period. samnst. |
|
||||
|-----------|------|------|-----------------|
|
||||
| YouTube | Reverse Charge | 39 | Ja |
|
||||
| Twitch | Export | 40 | Nej |
|
||||
| Instagram | Reverse Charge | 39 | Ja |
|
||||
| TikTok | Reverse Charge | 39 | Ja |
|
||||
| Patreon | Export | 40 | Nej |
|
||||
| Spotify | Svensk 25% | 05-08 | Nej |
|
||||
| Adtraction | Svensk 25% | 05-08 | Nej |
|
||||
|
||||
---
|
||||
|
||||
## Standardsvar-mallar
|
||||
|
||||
### Vid fråga om skatteplikt
|
||||
|
||||
```
|
||||
[Inkomsttyp] är [skattepliktig/ej skattepliktig].
|
||||
|
||||
Värdering: [Marknadsvärde inkl. moms / Nominellt belopp]
|
||||
|
||||
Bokföring:
|
||||
- Debet [konto]: [belopp]
|
||||
- Kredit [konto]: [belopp]
|
||||
|
||||
[Eventuella villkor eller undantag]
|
||||
```
|
||||
|
||||
### Vid fråga om momshantering
|
||||
|
||||
```
|
||||
Kund: [Kundtyp och land]
|
||||
Momsregel: [Svensk 25% / Reverse Charge / Export]
|
||||
|
||||
Faktura:
|
||||
- Belopp exkl. moms: [X] kr
|
||||
- Moms: [Y] kr / 0 kr
|
||||
- Text på faktura: [Om tillämpligt]
|
||||
|
||||
Redovisning:
|
||||
- Momsdeklaration ruta: [X]
|
||||
- Periodisk sammanställning: [Ja/Nej]
|
||||
```
|
||||
|
||||
### Vid fråga om avdragsrätt
|
||||
|
||||
```
|
||||
[Kostnadstyp] är [avdragsgill/ej avdragsgill].
|
||||
|
||||
Villkor för avdrag:
|
||||
- [Villkor 1]
|
||||
- [Villkor 2]
|
||||
|
||||
[Om delvis avdragsgill: Beräkning av andel]
|
||||
|
||||
Dokumentationskrav:
|
||||
- [Krav]
|
||||
```
|
||||
|
||||
### Vid varning
|
||||
|
||||
```
|
||||
VARNING: [Typ av varning]
|
||||
|
||||
Problem: [Beskrivning]
|
||||
|
||||
Konsekvens om ej åtgärdat:
|
||||
- [Konsekvens 1]
|
||||
- [Konsekvens 2]
|
||||
|
||||
Åtgärd:
|
||||
1. [Steg 1]
|
||||
2. [Steg 2]
|
||||
|
||||
Deadline: [Om tillämpligt]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konteringsguide (vanliga poster)
|
||||
|
||||
### Intäkter
|
||||
|
||||
| Typ | Debet | Kredit |
|
||||
|-----|-------|--------|
|
||||
| Svensk försäljning | 1510 Kundfordringar | 3010 Intäkter + 2610 Utg. moms |
|
||||
| EU-försäljning (B2B) | 1510 Kundfordringar | 3011 Försäljning EU |
|
||||
| Export (utanför EU) | 1510 Kundfordringar | 3012 Försäljning export |
|
||||
| Kontant plattformsintäkt | 1940 Bank | 3011/3012 beroende på plattform |
|
||||
| Barter-produkt | 4010 Inköp + 2640 Ing. moms | 3010 Intäkter |
|
||||
|
||||
### Kostnader
|
||||
|
||||
| Typ | Debet | Kredit |
|
||||
|-----|-------|--------|
|
||||
| Inköp med moms | [Kostnadskonto] + 2640 Ing. moms | 1940 Bank / 2440 Lev.skuld |
|
||||
| Inventarier | 1220 Inventarier + 2640 Ing. moms | 1940 Bank |
|
||||
| Prenumerationer | 6540 IT-tjänster + 2640 Ing. moms | 1940 Bank |
|
||||
|
||||
### Eget uttag (EF)
|
||||
|
||||
| Typ | Debet | Kredit |
|
||||
|-----|-------|--------|
|
||||
| Privat uttag | 2013 Eget uttag | 1940 Bank |
|
||||
| Barter till privat bruk | 2013 Eget uttag | 4010 Inköp + 2640 Ing. moms |
|
||||
|
||||
### Valutakursdifferenser
|
||||
|
||||
| Typ | Debet | Kredit |
|
||||
|-----|-------|--------|
|
||||
| Kursvinst | 1940 Bank | 1510 Kundfordringar + 3960 Kursvinst |
|
||||
| Kursförlust | 1940 Bank + 7960 Kursförlust | 1510 Kundfordringar |
|
||||
|
||||
---
|
||||
|
||||
## Eskaleringsmatris
|
||||
|
||||
| Situation | Åtgärd |
|
||||
|-----------|--------|
|
||||
| Enkel faktafråga | Svara direkt från dokumentation |
|
||||
| Komplex beräkning | Använd beräkningsexempel som mall |
|
||||
| Osäkerhet om regler | Hänvisa till Skatteverket.se |
|
||||
| Juridisk tvist | Rekommendera jurist |
|
||||
| Skatteverket-kontakt | Rekommendera redovisningskonsult |
|
||||
| Allvarlig varning | Flagga tydligt, föreslå åtgärd |
|
||||
| Utanför scope | Tydliggör begränsning, hänvisa vidare |
|
||||
|
||||
---
|
||||
|
||||
## Begränsningar att kommunicera
|
||||
|
||||
AI-agenten kan INTE:
|
||||
- Ge juridisk rådgivning (endast information)
|
||||
- Garantera skattekonsekvenser
|
||||
- Ersätta revisor/redovisningskonsult
|
||||
- Lämna in deklarationer
|
||||
- Kontakta myndigheter
|
||||
|
||||
AI-agenten SKA:
|
||||
- Ge saklig information baserad på dokumentation
|
||||
- Flagga risker och varningar
|
||||
- Rekommendera professionell hjälp vid behov
|
||||
- Hänvisa till officiella källor
|
||||
@@ -1,219 +0,0 @@
|
||||
# Kärnregler: Influencer Ekonomi
|
||||
|
||||
## 1. Verksamhetsklassificering
|
||||
|
||||
### Tre kumulativa krav för näringsverksamhet
|
||||
|
||||
| Krav | Definition | Influencer-exempel |
|
||||
|------|------------|-------------------|
|
||||
| Vinstsyfte | Objektiv förväntan på överskott över tid | Affärsplan visar skalbar intäktsmodell |
|
||||
| Varaktighet | Regelbunden aktivitet, längre period | Daglig/veckovis publicering, löpande samarbeten |
|
||||
| Självständighet | Egen utrustning, flera kunder, eget risktagande | Egen kamera, 5+ varumärkessamarbeten/år |
|
||||
|
||||
### Klassificeringslogik
|
||||
|
||||
```
|
||||
ALLA tre krav uppfyllda → Näringsverksamhet (F-skatt, NE/AB)
|
||||
EJ alla krav men inkomst finns → Hobbyverksamhet (T2-blankett)
|
||||
Tillfälliga uppdrag utan F-skatt → Tjänsteinkomst (A-skatt dras av uppdragsgivare)
|
||||
```
|
||||
|
||||
### Hobbyverksamhet
|
||||
|
||||
- Beskattas som inkomst av tjänst
|
||||
- Egenavgifter: 25-29%
|
||||
- Underskott: Sparas max 5 år, kvittas endast mot framtida hobbyöverskott
|
||||
- Moms: Registreringsplikt om omsättning > 80 000 kr
|
||||
|
||||
---
|
||||
|
||||
## 2. Skattepliktiga inkomster
|
||||
|
||||
**Grundprincip:** Allt som erhålls på grund av verksamheten är skattepliktigt oavsett form.
|
||||
|
||||
### Inkomsttabell
|
||||
|
||||
| Typ | Skattepliktigt | Värdering | Undantag |
|
||||
|-----|----------------|-----------|----------|
|
||||
| Kontant ersättning | Ja | Nominellt belopp | - |
|
||||
| Produkter (avtalat samarbete) | Ja | Marknadsvärde inkl. moms | - |
|
||||
| Pressutskick (oombett) | Villkorat | Marknadsvärde inkl. moms | Returnerad/kasserad utan användning |
|
||||
| Sponsrad resa | Delvis/helt | Resans faktiska värde | Arbete >30h/vecka, försumbart nöjesinslag |
|
||||
| Medföljande på resa | Ja | Medföljarens resekostnad | - |
|
||||
| Donations/Bits/Stars/Subs | Ja | Mottaget belopp | - |
|
||||
| Affiliate-provision | Ja | Utbetalt belopp | - |
|
||||
| Rabattkod eget bruk | Ja | Marknadspris minus betalt pris | - |
|
||||
| Tävlingsvinster | Villkorat | Vinstens värde | ≤1 800 kr eller minnesföremål |
|
||||
| Virtuella items (skins etc.) | Ja vid försäljning | Försäljningspris | - |
|
||||
|
||||
---
|
||||
|
||||
## 3. Momsregler
|
||||
|
||||
### Tröskelvärden
|
||||
|
||||
| Gräns | Belopp | Konsekvens |
|
||||
|-------|--------|------------|
|
||||
| Momsbefrielse | 80 000 kr omsättning | Under: Valfri registrering. Över: Obligatorisk. |
|
||||
| OSS-tröskel | 99 680 kr (10 000 EUR) | Försäljning till EU-privatpersoner |
|
||||
|
||||
### Momshantering per kundtyp
|
||||
|
||||
| Kund | Plats | Moms på faktura | Redovisning |
|
||||
|------|-------|-----------------|-------------|
|
||||
| Svenskt företag | Sverige | 25% | Ruta 05-08 |
|
||||
| EU-företag | Annat EU-land | 0% (Reverse Charge) | Ruta 39 + Periodisk sammanställning |
|
||||
| Företag utanför EU | Tredjeland | 0% (Export) | Ruta 40 |
|
||||
| Privatperson EU | Annat EU-land | Mottagarlandets moms* | OSS-deklaration |
|
||||
| Privatperson utanför EU | Tredjeland | 0% | Ruta 40 |
|
||||
|
||||
*Under 99 680 kr/år: Valfritt svensk moms 25%
|
||||
|
||||
### Periodisk sammanställning
|
||||
|
||||
- **När:** Kvartalsvis vid försäljning till EU-företag
|
||||
- **Innehåll:** Köparens VAT-nummer, totalvärde per kund
|
||||
- **Deadline:** 20:e i månaden efter kvartalets slut
|
||||
- **Förseningsavgift:** 1 250 kr
|
||||
- **Kritiskt:** Belopp måste matcha Ruta 39 i momsdeklaration
|
||||
|
||||
---
|
||||
|
||||
## 4. Bolagsformer
|
||||
|
||||
### Jämförelse
|
||||
|
||||
| Aspekt | Enskild Firma | Aktiebolag |
|
||||
|--------|---------------|------------|
|
||||
| Kapitalinsats | 0 kr | 25 000 kr |
|
||||
| Ansvar | Personligt, obegränsat | Begränsat till aktiekapital |
|
||||
| Beskattning | Inkomstskatt + egenavgifter på överskott | Bolagsskatt 20,6% + skatt på lön/utdelning |
|
||||
| Utdelning 20% skatt | Ej tillgängligt | Ja, upp till gränsbelopp (3:12) |
|
||||
| Friskvårdsbidrag | Ej avdragsgillt | Skattefritt ≤5 000 kr |
|
||||
| SGI-grund | Nettoöverskott | Uttagen lön |
|
||||
| Administration | NE-bilaga | Årsredovisning |
|
||||
| Rekommenderad vinst | <500 000 kr | >500 000 kr |
|
||||
|
||||
### Brytpunkter 2024/2025
|
||||
|
||||
| Gräns | Belopp |
|
||||
|-------|--------|
|
||||
| Statlig inkomstskatt | 615 300 kr |
|
||||
| Aktiekapital AB | 25 000 kr |
|
||||
|
||||
---
|
||||
|
||||
## 5. Avdragsregler
|
||||
|
||||
### Avdragsgilla kostnader
|
||||
|
||||
| Kategori | Exempel | Villkor |
|
||||
|----------|---------|---------|
|
||||
| Teknik | Kamera, dator, mikrofon, ljus, drönare | Yrkesmässig användning |
|
||||
| Mjukvara | Redigeringsprogram, molntjänster | Verksamhetsrelaterad |
|
||||
| Agentarvode | Provision till manager/agentur | Direkt kopplad till intäkt |
|
||||
| Extern lokal | Studiohyra | Exklusiv verksamhetsanvändning |
|
||||
| Resor | Arbetsresor med dokumenterat syfte | Ej hem-arbete |
|
||||
|
||||
### Ej avdragsgilla kostnader
|
||||
|
||||
| Kategori | Motivering |
|
||||
|----------|------------|
|
||||
| Kläder (normala) | Användbart privat |
|
||||
| Smink, hudvård | Privat levnadskostnad |
|
||||
| Skönhetsingrepp | Privat levnadskostnad |
|
||||
| Accessoarer (väskor, smycken) | Användbart privat |
|
||||
| Träning, gym | Privat (undantag: friskvård i AB) |
|
||||
|
||||
### Arbetsrum i bostad
|
||||
|
||||
| Typ | Krav | Avdrag |
|
||||
|-----|------|--------|
|
||||
| Faktiskt avdrag | Separat rum, ej bostad, exklusiv användning | Faktisk kostnad |
|
||||
| Schablon (EF) | >800 h arbete i bostad/år | 2 000 kr/år |
|
||||
|
||||
---
|
||||
|
||||
## 6. Marknadsföringslagen
|
||||
|
||||
### Krav på reklammarkering
|
||||
|
||||
| Element | Krav |
|
||||
|---------|------|
|
||||
| Placering | Början av inlägg/video |
|
||||
| Text | "REKLAM" eller "ANNONS" |
|
||||
| Synlighet | Framträdande färg, storlek |
|
||||
| Video | Muntligt och/eller textgrafik |
|
||||
| Sändarangivelse | Vem som betalar ska framgå |
|
||||
|
||||
### Överträdelser
|
||||
|
||||
- Influencern har eget medverkansansvar
|
||||
- Förbud vid vite
|
||||
- Marknadsstörningsavgift
|
||||
- Skadestånd
|
||||
|
||||
### Upphovsrätt
|
||||
|
||||
- Licens krävs för musik/material i kommersiella sammanhang
|
||||
- Referensfall: ABBA-fallet, 200 000 kr ersättning
|
||||
|
||||
---
|
||||
|
||||
## 7. Social trygghet
|
||||
|
||||
### SGI (Sjukpenninggrundande inkomst)
|
||||
|
||||
| Bolagsform | SGI baseras på |
|
||||
|------------|----------------|
|
||||
| Enskild Firma | Skattemässigt överskott |
|
||||
| Aktiebolag | Uttagen lön (ej utdelning) |
|
||||
| Dubbel struktur | Summan av båda |
|
||||
|
||||
**Uppbyggnadsskede EF:** Första 36 månaderna kan jämförelseinkomst användas.
|
||||
|
||||
### Pension
|
||||
|
||||
- Egenföretagare saknar automatisk tjänstepension
|
||||
- Avdragsgillt sparande: Max 35% av inkomst, max ~573 000 kr/år
|
||||
- Rekommendation: Spara 4,5-6% av inkomst
|
||||
|
||||
---
|
||||
|
||||
## 8. Dubbel struktur (EF + AB)
|
||||
|
||||
### Användningsområden
|
||||
|
||||
| EF används för | AB används för |
|
||||
|----------------|----------------|
|
||||
| Småuppdrag <50 000 kr | Större kampanjer >100 000 kr |
|
||||
| Lågriskuppdrag | Högriskkampanjer |
|
||||
| Konsultation | Huvudsaklig verksamhet |
|
||||
| Avvecklingsperiod | Ny huvudverksamhet |
|
||||
|
||||
### Interntransaktioner
|
||||
|
||||
- Armlängdsprissättning obligatorisk
|
||||
- Skriftliga avtal krävs
|
||||
- Moms på internfakturor
|
||||
- Separata bankkonton och bokföring
|
||||
|
||||
### SGI vid dubbel struktur
|
||||
|
||||
```
|
||||
Total SGI = Överskott EF + Lön från AB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Kritiska tröskelvärden
|
||||
|
||||
| Belopp | Betydelse |
|
||||
|--------|-----------|
|
||||
| 1 800 kr | Skattefri tävlingsvinst |
|
||||
| 2 000 kr | Schablonavdrag arbetsrum EF |
|
||||
| 5 000 kr | Skattefritt friskvårdsbidrag AB |
|
||||
| 25 000 kr | Aktiekapital AB |
|
||||
| 80 000 kr | Momsbefrielse omsättning |
|
||||
| 99 680 kr | OSS-tröskel EU-privatpersoner |
|
||||
| 615 300 kr | Brytpunkt statlig skatt |
|
||||
@@ -1,678 +0,0 @@
|
||||
# Scenariobibliotek: Vanliga situationer
|
||||
|
||||
## Produkter och barter
|
||||
|
||||
### SCENARIO 001: Mottagen produkt med samarbetsavtal
|
||||
|
||||
**Triggers:** "fick produkt", "samarbete", "ska posta om", "i utbyte mot"
|
||||
|
||||
**Situation:** Influencer får produkt värd X kr mot att göra inlägg.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Finns skriftligt/muntligt avtal om motprestation?
|
||||
→ Ja → Hela marknadsvärdet inkl. moms är skattepliktig intäkt
|
||||
```
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Intäkt: Produktens marknadsvärde inkl. moms
|
||||
- Om EF: Bokför som intäkt + eget uttag om privat bruk
|
||||
- Om AB: Bokför som intäkt + förmån om privat bruk
|
||||
|
||||
**Bokföringsexempel (EF):**
|
||||
```
|
||||
Produkt: Väska värd 8 000 kr inkl. moms
|
||||
|
||||
Debet 4010 Inköp varor: 6 400 kr
|
||||
Debet 2640 Ingående moms: 1 600 kr
|
||||
Kredit 3010 Intäkter: 8 000 kr
|
||||
|
||||
Om privat bruk:
|
||||
Debet 2013 Eget uttag: 8 000 kr
|
||||
Kredit 4010 Inköp varor: 6 400 kr
|
||||
Kredit 2640 Ingående moms: 1 600 kr
|
||||
```
|
||||
|
||||
**Checklista:**
|
||||
- [ ] Dokumentera marknadsvärde (spara prislapp/screenshot)
|
||||
- [ ] Spara avtal/mailkonversation
|
||||
- [ ] Bokför som intäkt
|
||||
- [ ] Märk inlägg med REKLAM
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 002: Oombett pressutskick (behålls)
|
||||
|
||||
**Triggers:** "fick skickat", "pressutskick", "utan att fråga", "PR-paket"
|
||||
|
||||
**Situation:** Influencer får produkt utan förfrågan, behåller och använder privat.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Fick du produkten oombedd?
|
||||
→ Ja
|
||||
Behåller du den / använder privat?
|
||||
→ Ja → Skattepliktig intäkt (marknadsvärde)
|
||||
→ Nej (returnerar/slänger utan användning) → Ej skattepliktig
|
||||
```
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Skattepliktig även utan motprestation om produkten behålls
|
||||
- Värderas till marknadsvärde inkl. moms
|
||||
|
||||
**Dokumentationskrav:**
|
||||
- Fotografera produkten vid mottagande
|
||||
- Notera avsändare och datum
|
||||
- Om returnerad: Spara kvitto på returfrakten
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 003: Oombett pressutskick (returneras/kastas)
|
||||
|
||||
**Triggers:** "skickade tillbaka", "returnerade", "slängde", "gav bort"
|
||||
|
||||
**Situation:** Influencer får produkt utan förfrågan, returnerar eller kastar.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Ej skattepliktigt om ingen privat användning skett
|
||||
- Bevisbördan ligger på influencern
|
||||
|
||||
**Dokumentationskrav:**
|
||||
- Kvitto på returfrakten
|
||||
- Foto av kasserad produkt (om relevant)
|
||||
- Notering om när och hur produkten avyttrades
|
||||
|
||||
**Varning:** Om produkten säljs på "bloppis" blir försäljningsintäkten skattepliktig.
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 004: Rabattkod för eget bruk
|
||||
|
||||
**Triggers:** "rabattkod", "köpte med rabatt", "fick X% rabatt"
|
||||
|
||||
**Situation:** Influencer får personlig rabattkod som del av ersättning.
|
||||
|
||||
**Beräkning:**
|
||||
```
|
||||
Skattepliktig inkomst = Marknadspris - Faktiskt betalt pris
|
||||
|
||||
Exempel:
|
||||
Marknadspris: 5 000 kr
|
||||
Betalt: 0 kr
|
||||
Skattepliktig inkomst: 5 000 kr
|
||||
```
|
||||
|
||||
**Bokföring:** Som intäkt i verksamheten.
|
||||
|
||||
---
|
||||
|
||||
## Resor och evenemang
|
||||
|
||||
### SCENARIO 005: Sponsrad resa (arbetsresa)
|
||||
|
||||
**Triggers:** "pressresa", "influencer trip", "betald resa", "bjuden resa"
|
||||
|
||||
**Situation:** Influencer bjuds på resa av varumärke.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Är resan nödvändig för verksamheten?
|
||||
→ Nej → Helt skattepliktig
|
||||
→ Ja
|
||||
Upptar arbete huvudsaklig tid (≥30h/vecka eller ≥6h/dag)?
|
||||
→ Nej → Delvis/helt skattepliktig (nöjesandel)
|
||||
→ Ja
|
||||
Är nöjesinslagen försumbara?
|
||||
→ Nej → Nöjesandelen skattepliktig
|
||||
→ Ja → Skattefri
|
||||
```
|
||||
|
||||
**Skattefri resa kräver:**
|
||||
1. Nödvändig för verksamheten
|
||||
2. Arbete ≥30 timmar/vecka ELLER ≥6 timmar/dag
|
||||
3. Nöjesinslag försumbara
|
||||
|
||||
**Dokumentationskrav:**
|
||||
- Detaljerat schema över arbetade timmar
|
||||
- Leverabler (antal inlägg, stories, videos)
|
||||
- Avtal som specificerar arbetsuppgifter
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 006: Medföljande på resa
|
||||
|
||||
**Triggers:** "tog med partner", "min sambo följde med", "vän fick följa med"
|
||||
|
||||
**Situation:** Influencerns partner/vän följer med på sponsrad resa kostnadsfritt.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Medföljarens resekostnad är skattepliktig förmån för influencern
|
||||
- Gäller oavsett om influencerns egen resa är skattefri
|
||||
|
||||
**Beräkning:**
|
||||
```
|
||||
Förmånsvärde = Flygbiljett + Hotell + Eventuella aktiviteter för medföljaren
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plattformsintäkter
|
||||
|
||||
### SCENARIO 007: YouTube AdSense-utbetalning
|
||||
|
||||
**Triggers:** "YouTube betalade", "AdSense", "annonsintäkter YouTube"
|
||||
|
||||
**Situation:** Månatlig utbetalning från Google/YouTube.
|
||||
|
||||
**Fakta:**
|
||||
- Utbetalare: Google Ireland Ltd
|
||||
- VAT-nummer: IE6388047V
|
||||
- Valuta: USD (normalt)
|
||||
- Momsregel: Reverse Charge (EU B2B)
|
||||
|
||||
**Bokföringsexempel:**
|
||||
```
|
||||
Mottaget: $3,200 USD
|
||||
Valutakurs vid inbetalning: 10.45 SEK/USD
|
||||
Belopp i SEK: 33,440 kr
|
||||
|
||||
Debet 1940 Bank: 33,440 kr
|
||||
Kredit 3011 Försäljning tjänster EU: 33,440 kr
|
||||
```
|
||||
|
||||
**Momsredovisning:**
|
||||
- Ruta 39: 33,440 kr
|
||||
- Periodisk sammanställning: Google Ireland Ltd, IE6388047V, 33,440 kr
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 008: Twitch-utbetalning
|
||||
|
||||
**Triggers:** "Twitch betalade", "subs", "bits", "Twitch revenue"
|
||||
|
||||
**Situation:** Utbetalning från Twitch (prenumerationer, bits, annonser).
|
||||
|
||||
**Fakta:**
|
||||
- Utbetalare: Twitch Interactive Inc
|
||||
- Säte: USA (San Francisco)
|
||||
- Momsregel: Export (utanför EU)
|
||||
|
||||
**Bokföringsexempel:**
|
||||
```
|
||||
Mottaget: $1,500 USD
|
||||
Valutakurs vid inbetalning: 10.52 SEK/USD
|
||||
Belopp i SEK: 15,780 kr
|
||||
|
||||
Debet 1940 Bank: 15,780 kr
|
||||
Kredit 3012 Försäljning tjänster utanför EU: 15,780 kr
|
||||
```
|
||||
|
||||
**Momsredovisning:**
|
||||
- Ruta 40: 15,780 kr
|
||||
- Ingen periodisk sammanställning (ej EU)
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 009: Instagram/Meta bonusar
|
||||
|
||||
**Triggers:** "Instagram bonus", "Reels bonus", "Meta betalade"
|
||||
|
||||
**Situation:** Bonusutbetalning från Meta för Reels-visningar etc.
|
||||
|
||||
**Fakta:**
|
||||
- Utbetalare: Meta Platforms Ireland Ltd
|
||||
- VAT-nummer: IE9692928F
|
||||
- Momsregel: Reverse Charge (EU B2B)
|
||||
|
||||
**Hantering:** Samma som YouTube (Scenario 007).
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 010: TikTok Creator Fund
|
||||
|
||||
**Triggers:** "TikTok betalade", "Creator Fund", "TikTok pengar"
|
||||
|
||||
**Situation:** Utbetalning från TikTok Creator Fund.
|
||||
|
||||
**Fakta:**
|
||||
- Utbetalare: TikTok Technology Ltd (ofta)
|
||||
- VAT-nummer: IE3434547SH
|
||||
- Säte: Irland
|
||||
- Momsregel: Reverse Charge (EU B2B)
|
||||
|
||||
**OBS:** Kontrollera alltid aktuellt avtal då TikToks struktur varierar.
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 011: Donations/Tips (Twitch, YouTube, etc.)
|
||||
|
||||
**Triggers:** "donation", "tips", "någon donerade", "fick pengar av tittare"
|
||||
|
||||
**Situation:** Tittare skickar pengar direkt via plattform.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- INTE gåva i skatterättslig mening
|
||||
- Ersättning för prestation (underhållning)
|
||||
- Fullt skattepliktig inkomst i näringsverksamheten
|
||||
|
||||
**Bokföring:**
|
||||
```
|
||||
Debet 1940 Bank: [belopp]
|
||||
Kredit 3010 Intäkter: [belopp]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Affiliate och samarbeten
|
||||
|
||||
### SCENARIO 012: Affiliate-provision
|
||||
|
||||
**Triggers:** "affiliate", "provision", "trackad länk", "kommission"
|
||||
|
||||
**Situation:** Influencer får provision på försäljning via spårningslänk.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Fullt skattepliktig intäkt
|
||||
- Momshantering beror på var affiliate-nätverket är baserat
|
||||
|
||||
**Vanliga nätverk och momshantering:**
|
||||
|
||||
| Nätverk | Säte | Momsregel |
|
||||
|---------|------|-----------|
|
||||
| Amazon Associates | Luxemburg (EU) | Reverse Charge |
|
||||
| Adtraction | Sverige | 25% moms |
|
||||
| Tradedoubler | Sverige | 25% moms |
|
||||
| AWIN | Varierar | Kontrollera avtal |
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 013: Ambassadörsavtal med fast månadsersättning
|
||||
|
||||
**Triggers:** "ambassadör", "fast ersättning", "månadsarvode", "retainer"
|
||||
|
||||
**Situation:** Löpande avtal med fast månadsersättning.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Intäktsför månadsvis
|
||||
- Moms beroende på kundtyp (se momsregler)
|
||||
- Om svensk kund: Fakturera med 25% moms
|
||||
|
||||
**Bokföring:**
|
||||
```
|
||||
Månadsersättning: 25,000 kr + moms
|
||||
|
||||
Debet 1510 Kundfordringar: 31,250 kr
|
||||
Kredit 3010 Intäkter: 25,000 kr
|
||||
Kredit 2610 Utgående moms: 6,250 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Teknik och utrustning
|
||||
|
||||
### SCENARIO 014: Köp av kamera/utrustning
|
||||
|
||||
**Triggers:** "köpte kamera", "ny utrustning", "investerade i teknik"
|
||||
|
||||
**Situation:** Inköp av professionell utrustning.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Används utrustningen uteslutande i verksamheten?
|
||||
→ Ja → Fullt avdrag + momsavdrag
|
||||
→ Nej (även privat bruk)
|
||||
→ Proportionera avdrag efter verksamhetsandel
|
||||
→ Professionell utrustning: Ofta godtas helavdrag ändå
|
||||
```
|
||||
|
||||
**Bokföringsexempel (kamera 45,000 kr inkl. moms):**
|
||||
```
|
||||
Debet 1220 Inventarier: 36,000 kr
|
||||
Debet 2640 Ingående moms: 9,000 kr
|
||||
Kredit 1940 Bank: 45,000 kr
|
||||
```
|
||||
|
||||
**Avskrivning:**
|
||||
- Datorer, kameror: Normalt 3-5 år
|
||||
- Möjlighet till direktavdrag om <25,900 kr (halvt prisbasbelopp)
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 015: Prenumerationer och mjukvara
|
||||
|
||||
**Triggers:** "Adobe", "prenumeration", "programvara", "app-kostnad"
|
||||
|
||||
**Situation:** Löpande kostnader för mjukvara.
|
||||
|
||||
**Vanliga avdragsgilla prenumerationer:**
|
||||
- Adobe Creative Cloud
|
||||
- Final Cut Pro
|
||||
- Canva Pro
|
||||
- Notion, Airtable
|
||||
- Molnlagring (Google Drive, Dropbox)
|
||||
- Schemaläggningsverktyg (Later, Buffer)
|
||||
|
||||
**Bokföring:** Kostnadsförs löpande som förbrukningsmaterial eller extern tjänst.
|
||||
|
||||
---
|
||||
|
||||
## Kläder och utseende
|
||||
|
||||
### SCENARIO 016: Köp av kläder för content
|
||||
|
||||
**Triggers:** "köpte kläder", "outfit för video", "kläder till inlägg"
|
||||
|
||||
**Situation:** Influencer köper kläder att visa i content.
|
||||
|
||||
**Huvudregel:** Ej avdragsgillt om kläderna kan användas privat.
|
||||
|
||||
**Undantag (avdragsgilla):**
|
||||
- Uniformer
|
||||
- Skyddskläder
|
||||
- Extrema scenkläder (ej användbara privat)
|
||||
- Specifik rekvisita (t.ex. historiska kostymer)
|
||||
|
||||
**Dokumentationskrav för undantag:**
|
||||
- Fotografera plagget
|
||||
- Motivera varför det ej är användbart privat
|
||||
- Bevisbördan ligger på influencern
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 017: Skönhetsbehandlingar
|
||||
|
||||
**Triggers:** "filler", "botox", "behandling", "skönhetsingrepp"
|
||||
|
||||
**Situation:** Influencer gör estetisk behandling.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Aldrig avdragsgillt
|
||||
- Klassas som privat levnadskostnad
|
||||
- Gäller även om utseendet är "del av varumärket"
|
||||
|
||||
---
|
||||
|
||||
## Försäljning och merchandise
|
||||
|
||||
### SCENARIO 018: Försäljning av egen merch till svenska kunder
|
||||
|
||||
**Triggers:** "säljer merch", "egen kollektion", "t-shirts"
|
||||
|
||||
**Situation:** Försäljning av varor till svenska privatpersoner.
|
||||
|
||||
**Skattemässig hantering:**
|
||||
- Intäkt i verksamheten
|
||||
- 25% moms på försäljningen
|
||||
- Inköpskostnad för varorna avdragsgill
|
||||
|
||||
**Bokföringsexempel:**
|
||||
```
|
||||
Såld t-shirt: 299 kr inkl. moms
|
||||
|
||||
Debet 1940 Bank: 299 kr
|
||||
Kredit 3010 Försäljning varor: 239,20 kr
|
||||
Kredit 2610 Utgående moms: 59,80 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 019: Försäljning av merch till EU-kunder
|
||||
|
||||
**Triggers:** "kund i Tyskland", "säljer till EU", "europeisk kund"
|
||||
|
||||
**Situation:** Försäljning av varor till privatpersoner i annat EU-land.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Total EU-privatpersonförsäljning under året:
|
||||
≤99,680 kr → Valfritt: Svensk moms 25% ELLER mottagarlandets moms
|
||||
>99,680 kr → Obligatoriskt: Mottagarlandets moms via OSS
|
||||
```
|
||||
|
||||
**OSS (One Stop Shop):**
|
||||
- Registrering via Skatteverket
|
||||
- Kvartalsvis deklaration
|
||||
- Samlar all utländsk EU-moms på ett ställe
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 020: Försäljning av virtuella items (gaming)
|
||||
|
||||
**Triggers:** "sålde skins", "CS:GO", "in-game items"
|
||||
|
||||
**Situation:** Försäljning av virtuella föremål för riktiga pengar.
|
||||
|
||||
**Beslutsträd:**
|
||||
```
|
||||
Sker försäljning yrkesmässigt och regelbundet?
|
||||
→ Ja → Näringsverksamhet (inkomst av näring)
|
||||
→ Nej (enstaka försäljningar) → Inkomst av kapital (30% på vinsten)
|
||||
```
|
||||
|
||||
**OBS:** Gränsdragningen är svår. Vid regelbunden handel, behandla som näringsverksamhet.
|
||||
|
||||
---
|
||||
|
||||
## Valuta och internationellt
|
||||
|
||||
### SCENARIO 021: Valutakursdifferens
|
||||
|
||||
**Triggers:** "kurs ändrades", "växlingskurs", "valutaförlust"
|
||||
|
||||
**Situation:** Skillnad mellan fakturakurs och betalningskurs.
|
||||
|
||||
**Beräkning:**
|
||||
```
|
||||
Fakturerat: $1,000 @ 10.20 = 10,200 kr
|
||||
Mottaget: $1,000 @ 10.45 = 10,450 kr
|
||||
Valutakursvinst: 250 kr
|
||||
```
|
||||
|
||||
**Bokföring:**
|
||||
```
|
||||
Valutakursvinst:
|
||||
Debet 1940 Bank: 10,450 kr
|
||||
Kredit 1510 Kundfordringar: 10,200 kr
|
||||
Kredit 3960 Valutakursvinster: 250 kr
|
||||
|
||||
Valutakursförlust:
|
||||
Debet 1940 Bank: 9,950 kr
|
||||
Debet 7960 Valutakursförluster: 250 kr
|
||||
Kredit 1510 Kundfordringar: 10,200 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sociala avgifter och trygghet
|
||||
|
||||
### SCENARIO 022: Uppdrag utan F-skatt
|
||||
|
||||
**Triggers:** "har inte F-skatt", "de drog skatt", "fick lön istället"
|
||||
|
||||
**Situation:** Influencer utför uppdrag men saknar F-skatt.
|
||||
|
||||
**Konsekvenser:**
|
||||
- Uppdragsgivaren ska dra A-skatt (~30%)
|
||||
- Uppdragsgivaren betalar arbetsgivaravgifter (31,42%)
|
||||
- Ersättningen behandlas som lön
|
||||
|
||||
**Checklista:**
|
||||
- [ ] Begär kontrolluppgift från uppdragsgivaren
|
||||
- [ ] Kontrollera att skatt faktiskt dragits
|
||||
- [ ] Deklarera som inkomst av tjänst
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 023: Beräkna SGI-konsekvens av utdelning vs lön
|
||||
|
||||
**Triggers:** "sjukpenning", "VAB", "föräldraledighet", "SGI"
|
||||
|
||||
**Situation:** Val mellan lön och utdelning påverkar socialförsäkring.
|
||||
|
||||
**Beräkning:**
|
||||
```
|
||||
Alt 1: Lön 400,000 kr
|
||||
SGI: 400,000 kr
|
||||
Sjukpenning (80%): 320,000 kr/år
|
||||
|
||||
Alt 2: Lön 100,000 kr + Utdelning 300,000 kr
|
||||
SGI: 100,000 kr
|
||||
Sjukpenning (80%): 80,000 kr/år
|
||||
|
||||
Skillnad vid 6 månaders sjukskrivning: 120,000 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Juridik och avtal
|
||||
|
||||
### SCENARIO 024: Glömt reklammarkering
|
||||
|
||||
**Triggers:** "glömde skriva reklam", "markerade inte", "fick klagomål"
|
||||
|
||||
**Situation:** Inlägg publicerades utan korrekt reklammarkering.
|
||||
|
||||
**Åtgärder:**
|
||||
1. Redigera inlägget omedelbart - lägg till "REKLAM" överst
|
||||
2. Dokumentera när rättelsen gjordes
|
||||
3. Informera uppdragsgivaren
|
||||
|
||||
**Risker:**
|
||||
- Varning från Konsumentverket
|
||||
- Förbud vid vite
|
||||
- Marknadsstörningsavgift
|
||||
- Reputationsskada
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 025: Använt musik utan licens
|
||||
|
||||
**Triggers:** "använde låt", "bakgrundsmusik", "copyright claim"
|
||||
|
||||
**Situation:** Musik användes i kommersiellt content utan tillstånd.
|
||||
|
||||
**Konsekvenser:**
|
||||
- Upphovsrättsintrång
|
||||
- Skadeståndsskyldighet
|
||||
- Referens: ABBA-fallet (200,000 kr)
|
||||
|
||||
**Förebyggande:**
|
||||
- Använd royalty-free musik
|
||||
- Köp licens via Epidemic Sound, Artlist etc.
|
||||
- Kontrollera rättigheter innan publicering
|
||||
|
||||
---
|
||||
|
||||
## Bokslut och deklaration
|
||||
|
||||
### SCENARIO 026: Periodiseringsfond (EF)
|
||||
|
||||
**Triggers:** "skjuta upp skatt", "periodiseringsfond", "jämna ut resultat"
|
||||
|
||||
**Situation:** Influencer vill jämna ut beskattning över år.
|
||||
|
||||
**Regler:**
|
||||
- Max 30% av överskottet kan avsättas
|
||||
- Återförs senast år 6
|
||||
- Vid avveckling av EF: Omedelbar återföring
|
||||
|
||||
**Exempel:**
|
||||
```
|
||||
År 1: Överskott 500,000 kr
|
||||
Avsättning (30%): 150,000 kr
|
||||
Beskattas: 350,000 kr
|
||||
|
||||
År 3: Överskott 100,000 kr
|
||||
Återföring: 150,000 kr
|
||||
Beskattas: 250,000 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 027: 3:12-utdelning (AB)
|
||||
|
||||
**Triggers:** "utdelning", "gränsbelopp", "20% skatt"
|
||||
|
||||
**Situation:** Ägare vill ta ut lågbeskattad utdelning från AB.
|
||||
|
||||
**Gränsbelopp 2024:**
|
||||
- Schablonbelopp: 204,325 kr (2,75 x IBB)
|
||||
- ELLER lönebaserat utrymme (kräver minst 681,600 kr i egen lön)
|
||||
|
||||
**Strategi:**
|
||||
1. Ta ut lön upp till brytpunkt statlig skatt (~615,300 kr)
|
||||
2. Ta ut utdelning inom gränsbelopp (20% skatt)
|
||||
3. Spara överskjutande i bolaget eller ta som lön
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 028: Byta från EF till AB
|
||||
|
||||
**Triggers:** "vill byta", "starta AB", "övergång"
|
||||
|
||||
**Situation:** Influencer vill övergå från EF till AB.
|
||||
|
||||
**Process:**
|
||||
1. Starta AB (25,000 kr aktiekapital)
|
||||
2. Inkråmsöverlåtelse: EF säljer tillgångar till AB
|
||||
3. Nya avtal tecknas med AB som part
|
||||
4. Uppdatera plattformar med nytt VAT-nummer
|
||||
5. Avveckla EF
|
||||
|
||||
**Skattekonsekvenser:**
|
||||
- Inkråmsöverlåtelse till marknadsvärde
|
||||
- Moms på försäljningen
|
||||
- Periodiseringsfonder i EF återförs
|
||||
- Realisationsvinst i EF beskattas
|
||||
|
||||
---
|
||||
|
||||
## Dubbel struktur
|
||||
|
||||
### SCENARIO 029: Fördela uppdrag mellan EF och AB
|
||||
|
||||
**Triggers:** "vilket företag", "ska jag fakturera från", "EF eller AB"
|
||||
|
||||
**Situation:** Influencer har både EF och AB, nytt uppdrag inkommer.
|
||||
|
||||
**Beslutsmatris:**
|
||||
|
||||
| Faktor | → EF | → AB |
|
||||
|--------|------|------|
|
||||
| Ersättning <50,000 kr | ✓ | |
|
||||
| Ersättning >100,000 kr | | ✓ |
|
||||
| Hög juridisk risk | | ✓ |
|
||||
| Engångsuppdrag | ✓ | |
|
||||
| Långt ambassadörskap | | ✓ |
|
||||
| Behöver bygga SGI | ✓ | |
|
||||
|
||||
---
|
||||
|
||||
### SCENARIO 030: Internfaktura mellan EF och AB
|
||||
|
||||
**Triggers:** "fakturera mig själv", "mellan bolagen", "internfaktura"
|
||||
|
||||
**Situation:** EF utför tjänst åt eget AB.
|
||||
|
||||
**Krav:**
|
||||
- Armlängdsprissättning (marknadsmässigt pris)
|
||||
- Skriftligt avtal
|
||||
- Affärsmässig motivering
|
||||
- Moms på fakturan (25%)
|
||||
|
||||
**Bokföringsexempel:**
|
||||
```
|
||||
EF fakturerar AB för videoproduktion: 20,000 kr + moms
|
||||
|
||||
I EF:
|
||||
Debet 1510 Kundfordringar: 25,000 kr
|
||||
Kredit 3010 Intäkter: 20,000 kr
|
||||
Kredit 2610 Utgående moms: 5,000 kr
|
||||
|
||||
I AB:
|
||||
Debet 4010 Köpta tjänster: 20,000 kr
|
||||
Debet 2640 Ingående moms: 5,000 kr
|
||||
Kredit 2440 Leverantörsskulder: 25,000 kr
|
||||
```
|
||||
|
||||
**Varning:** Skatteverket granskar internprissättning. Avvikelse >20% från marknad flaggas.
|
||||
@@ -1,398 +0,0 @@
|
||||
# Plattformskatalog
|
||||
|
||||
## YouTube (Google AdSense)
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Google Ireland Ltd |
|
||||
| VAT-nummer | IE6388047V |
|
||||
| Adress | Gordon House, Barrow Street, Dublin 4, Ireland |
|
||||
| Säte | Irland (EU) |
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Reverse Charge |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 39 |
|
||||
| Periodisk sammanställning | Ja, kvartalsvis |
|
||||
|
||||
### Utbetalningar
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | USD |
|
||||
| Utbetalningströskel | $100 |
|
||||
| Utbetalningsfrekvens | Månadsvis (21:a-26:e) |
|
||||
| Utbetalningsmetod | Banköverföring, check |
|
||||
|
||||
### Var hittar jag underlag?
|
||||
|
||||
1. YouTube Studio → Analytics → Revenue
|
||||
2. AdSense → Payments → Transaction history
|
||||
3. Ladda ner CSV för bokföringsunderlag
|
||||
|
||||
### Bokföringskontering
|
||||
|
||||
```
|
||||
Intäkt från YouTube:
|
||||
Debet 1940 Bank: [SEK-belopp]
|
||||
Kredit 3011 Försäljning tjänster EU: [SEK-belopp]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Twitch
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Twitch Interactive Inc |
|
||||
| VAT-nummer | Inget (USA) |
|
||||
| Adress | 350 Bush Street, 2nd Floor, San Francisco, CA 94104, USA |
|
||||
| Säte | USA (utanför EU) |
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Export |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 40 |
|
||||
| Periodisk sammanställning | Nej |
|
||||
|
||||
### Utbetalningar
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | USD |
|
||||
| Utbetalningströskel | $50 (Affiliate), $100 (Partner) |
|
||||
| Utbetalningsfrekvens | 15:e varje månad (för föregående månad) |
|
||||
| Net terms | 45 dagar |
|
||||
|
||||
### Intäktsströmmar
|
||||
|
||||
| Typ | Beskrivning |
|
||||
|-----|-------------|
|
||||
| Subscriptions | Prenumerationer (50% till streamer normalt) |
|
||||
| Bits | Virtuell valuta från tittare |
|
||||
| Ads | Annonsintäkter |
|
||||
| Bounties | Sponsrade kampanjer via Twitch |
|
||||
|
||||
### Var hittar jag underlag?
|
||||
|
||||
1. Creator Dashboard → Channel Analytics → Revenue
|
||||
2. Payout History för historiska utbetalningar
|
||||
3. Tax Interview måste fyllas i (W-8BEN)
|
||||
|
||||
### Bokföringskontering
|
||||
|
||||
```
|
||||
Intäkt från Twitch:
|
||||
Debet 1940 Bank: [SEK-belopp]
|
||||
Kredit 3012 Försäljning tjänster utanför EU: [SEK-belopp]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instagram / Meta
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Meta Platforms Ireland Ltd |
|
||||
| VAT-nummer | IE9692928F |
|
||||
| Adress | 4 Grand Canal Square, Grand Canal Harbour, Dublin 2, Ireland |
|
||||
| Säte | Irland (EU) |
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Reverse Charge |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 39 |
|
||||
| Periodisk sammanställning | Ja, kvartalsvis |
|
||||
|
||||
### Utbetalningar (Bonusprogram)
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | USD eller EUR |
|
||||
| Utbetalningströskel | $100 |
|
||||
| Utbetalningsfrekvens | Månadsvis |
|
||||
| Utbetalningsmetod | Banköverföring, PayPal |
|
||||
|
||||
### Intäktsströmmar
|
||||
|
||||
| Typ | Beskrivning |
|
||||
|-----|-------------|
|
||||
| Reels Bonus | Prestationsbaserat på Reels-visningar |
|
||||
| Badges | Tittare köper badges under Live |
|
||||
| Subscriptions | Prenumerationer på exklusivt innehåll |
|
||||
| Branded Content | Märkta samarbeten (betalning från varumärke) |
|
||||
|
||||
### Var hittar jag underlag?
|
||||
|
||||
1. Professional Dashboard → Monetization → Payouts
|
||||
2. Meta Business Suite → Insights → Monetization
|
||||
3. Payout Settings för bankuppgifter och skatteinfo
|
||||
|
||||
### Bokföringskontering
|
||||
|
||||
```
|
||||
Intäkt från Instagram/Meta:
|
||||
Debet 1940 Bank: [SEK-belopp]
|
||||
Kredit 3011 Försäljning tjänster EU: [SEK-belopp]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TikTok
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | TikTok Technology Ltd (EU) |
|
||||
| VAT-nummer | IE3434547SH |
|
||||
| Adress | 10 Earlsfort Terrace, Dublin 2, D02 T380, Ireland |
|
||||
| Säte | Irland (EU) |
|
||||
|
||||
**OBS:** TikToks struktur varierar. Kontrollera alltid avtalet för korrekt entitet.
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Reverse Charge |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 39 |
|
||||
| Periodisk sammanställning | Ja, kvartalsvis |
|
||||
|
||||
### Utbetalningar
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | USD |
|
||||
| Utbetalningströskel | $10 (Creator Fund) |
|
||||
| Utbetalningsfrekvens | Månadsvis |
|
||||
|
||||
### Intäktsströmmar
|
||||
|
||||
| Typ | Beskrivning |
|
||||
|-----|-------------|
|
||||
| Creator Fund | Prestationsbaserat på visningar |
|
||||
| Creator Rewards | Förbättrat program ersätter Creator Fund |
|
||||
| LIVE Gifts | Virtuella gåvor under livestream |
|
||||
| Series | Betalinnehåll |
|
||||
|
||||
### Var hittar jag underlag?
|
||||
|
||||
1. TikTok Creator Portal → Balance
|
||||
2. Transaction History för detaljer
|
||||
3. Tax Information måste fyllas i
|
||||
|
||||
---
|
||||
|
||||
## Patreon
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Patreon Inc |
|
||||
| VAT-nummer | Inget (USA) |
|
||||
| Adress | 600 Townsend Street, Suite 500, San Francisco, CA 94103, USA |
|
||||
| Säte | USA (utanför EU) |
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Export |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 40 |
|
||||
| Periodisk sammanställning | Nej |
|
||||
|
||||
### Utbetalningar
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | USD |
|
||||
| Utbetalningströskel | $25 |
|
||||
| Utbetalningsfrekvens | Månadsvis (1:a-5:e) |
|
||||
| Avgifter | 5-12% plattformsavgift + betalningsavgifter |
|
||||
|
||||
### Var hittar jag underlag?
|
||||
|
||||
1. Creator Dashboard → Income
|
||||
2. Payout History med CSV-export
|
||||
3. Tax Settings för W-8BEN
|
||||
|
||||
---
|
||||
|
||||
## Spotify (Podcasters)
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Spotify AB |
|
||||
| VAT-nummer | SE556703748501 |
|
||||
| Adress | Regeringsgatan 19, 111 53 Stockholm, Sweden |
|
||||
| Säte | Sverige |
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Svensk inrikes |
|
||||
| Svensk moms på faktura | Ja (25%) |
|
||||
| Momsdeklaration ruta | 05-08 |
|
||||
| Periodisk sammanställning | Nej |
|
||||
|
||||
### Bokföringskontering
|
||||
|
||||
```
|
||||
Intäkt från Spotify:
|
||||
Debet 1940 Bank: [totalbelopp]
|
||||
Kredit 3010 Försäljning tjänster Sverige: [nettobelopp]
|
||||
Kredit 2610 Utgående moms: [moms 25%]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Amazon Associates (Affiliate)
|
||||
|
||||
### Företagsinformation
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Amazon EU S.à r.l. |
|
||||
| VAT-nummer | LU26375245 |
|
||||
| Adress | 38 avenue John F. Kennedy, L-1855 Luxembourg |
|
||||
| Säte | Luxemburg (EU) |
|
||||
|
||||
**OBS:** Amazon har flera entiteter. Kontrollera vilket program/land som är aktuellt.
|
||||
|
||||
### Momshantering
|
||||
|
||||
| Regel | Värde |
|
||||
|-------|-------|
|
||||
| Momstyp | Reverse Charge |
|
||||
| Svensk moms på faktura | Nej (0%) |
|
||||
| Momsdeklaration ruta | 39 |
|
||||
| Periodisk sammanställning | Ja, kvartalsvis |
|
||||
|
||||
### Utbetalningar
|
||||
|
||||
| Parameter | Värde |
|
||||
|-----------|-------|
|
||||
| Valuta | EUR/USD beroende på program |
|
||||
| Utbetalningströskel | €25/£25/$10 |
|
||||
| Utbetalningsfrekvens | ~60 dagar efter månadslut |
|
||||
|
||||
---
|
||||
|
||||
## Svenska affiliate-nätverk
|
||||
|
||||
### Adtraction
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Adtraction Marketing AB |
|
||||
| Organisationsnummer | 556748-8855 |
|
||||
| VAT-nummer | SE556748885501 |
|
||||
| Säte | Sverige |
|
||||
| Moms | 25% svensk moms |
|
||||
|
||||
### Tradedoubler
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | Tradedoubler AB |
|
||||
| Organisationsnummer | 556575-7423 |
|
||||
| VAT-nummer | SE556575742301 |
|
||||
| Säte | Sverige |
|
||||
| Moms | 25% svensk moms |
|
||||
|
||||
### Awin (Nordics)
|
||||
|
||||
| Fält | Värde |
|
||||
|------|-------|
|
||||
| Juridiskt namn | AWIN AG (filial i UK ofta) |
|
||||
| Säte | Varierar |
|
||||
| Moms | Kontrollera avtal |
|
||||
|
||||
---
|
||||
|
||||
## Sammanfattningstabell
|
||||
|
||||
| Plattform | Säte | VAT-nummer | Momsregel | Ruta | Period. sammanst. |
|
||||
|-----------|------|------------|-----------|------|-------------------|
|
||||
| YouTube | Irland | IE6388047V | Reverse Charge | 39 | Ja |
|
||||
| Twitch | USA | - | Export | 40 | Nej |
|
||||
| Instagram/Meta | Irland | IE9692928F | Reverse Charge | 39 | Ja |
|
||||
| TikTok | Irland | IE3434547SH | Reverse Charge | 39 | Ja |
|
||||
| Patreon | USA | - | Export | 40 | Nej |
|
||||
| Spotify | Sverige | SE556703748501 | Svensk 25% | 05-08 | Nej |
|
||||
| Amazon EU | Luxemburg | LU26375245 | Reverse Charge | 39 | Ja |
|
||||
| Adtraction | Sverige | SE556748885501 | Svensk 25% | 05-08 | Nej |
|
||||
| Tradedoubler | Sverige | SE556575742301 | Svensk 25% | 05-08 | Nej |
|
||||
|
||||
---
|
||||
|
||||
## Valutakurser
|
||||
|
||||
### Huvudregel
|
||||
|
||||
Använd valutakursen den dag inbetalningen sker till bankkontot.
|
||||
|
||||
### Kurskällor
|
||||
|
||||
- Riksbankens dagskurser (officiell)
|
||||
- Bankens faktiska växlingskurs (om tillgänglig)
|
||||
|
||||
### Praktisk hantering
|
||||
|
||||
```
|
||||
1. Notera belopp i originalvaluta
|
||||
2. Notera inbetalningsdatum
|
||||
3. Hämta kurs för det datumet
|
||||
4. Beräkna SEK-belopp
|
||||
5. Bokför i SEK
|
||||
6. Vid fakturadatum ≠ betalningsdatum: Bokför kursdifferens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skatteformulär per plattform
|
||||
|
||||
| Plattform | Formulär | Beskrivning |
|
||||
|-----------|----------|-------------|
|
||||
| YouTube/Google | W-8BEN | US tax withholding |
|
||||
| Twitch | W-8BEN | US tax withholding |
|
||||
| TikTok | W-8BEN | US tax withholding |
|
||||
| Patreon | W-8BEN | US tax withholding |
|
||||
| Meta | Tax Information | I plattformen |
|
||||
|
||||
**W-8BEN:** Formulär som intygar att du inte är amerikansk skattskyldig. Utan detta hålls 30% amerikansk skatt inne.
|
||||
|
||||
---
|
||||
|
||||
## Utbetalningskalender (typisk)
|
||||
|
||||
| Plattform | Intjäningsmånad | Utbetalning |
|
||||
|-----------|-----------------|-------------|
|
||||
| YouTube | Januari | 21-26 februari |
|
||||
| Twitch | Januari | 15 mars |
|
||||
| Instagram | Januari | Februari (varierar) |
|
||||
| TikTok | Januari | Februari (varierar) |
|
||||
| Patreon | Januari | 1-5 februari |
|
||||
| Amazon Associates | Januari | Mars (60 dagar) |
|
||||
@@ -1,601 +0,0 @@
|
||||
# Beräkningsexempel
|
||||
|
||||
## 1. Inkomstberäkningar
|
||||
|
||||
### 1.1 YouTube-utbetalning med valutaomräkning
|
||||
|
||||
**Scenario:** Influencer får månatlig AdSense-utbetalning.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Utbetalt: $2,847.63 USD
|
||||
- Inbetalningsdatum: 2024-02-23
|
||||
- Valutakurs (Riksbanken): 10.3842 SEK/USD
|
||||
|
||||
Beräkning:
|
||||
$2,847.63 × 10.3842 = 29,571.87 SEK
|
||||
|
||||
Bokföring:
|
||||
Debet 1940 Bank: 29,571.87 kr
|
||||
Kredit 3011 Försäljning tjänster EU: 29,571.87 kr
|
||||
|
||||
Momsdeklaration:
|
||||
Ruta 39: 29,571.87 kr
|
||||
|
||||
Periodisk sammanställning:
|
||||
Kund: Google Ireland Ltd
|
||||
VAT: IE6388047V
|
||||
Belopp: 29,571.87 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Barter-produkt med skatteberäkning
|
||||
|
||||
**Scenario:** Influencer får väska värd 12,000 kr mot ett Instagram-inlägg.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Produkt: Designerväska
|
||||
- Marknadsvärde: 12,000 kr inkl. moms
|
||||
- Bolagsform: Enskild firma
|
||||
|
||||
Beräkning skattepliktig inkomst:
|
||||
Intäkt: 12,000 kr
|
||||
|
||||
Om privat bruk (eget uttag):
|
||||
Nettovärde: 12,000 / 1.25 = 9,600 kr
|
||||
Moms: 2,400 kr
|
||||
|
||||
Bokföring (produkt mottagen):
|
||||
Debet 4010 Inköp varor: 9,600 kr
|
||||
Debet 2640 Ingående moms: 2,400 kr
|
||||
Kredit 3010 Intäkter: 12,000 kr
|
||||
|
||||
Bokföring (eget uttag för privat bruk):
|
||||
Debet 2013 Eget uttag: 12,000 kr
|
||||
Kredit 4010 Inköp varor: 9,600 kr
|
||||
Kredit 2640 Ingående moms: 2,400 kr
|
||||
|
||||
Skattekonsekvens (antagen marginalskatt 35%):
|
||||
Skatt på 12,000 kr: ~4,200 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Sponsrad resa med medföljande
|
||||
|
||||
**Scenario:** Influencer bjuds på resa, partner följer med gratis.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Flyg influencer: 8,500 kr
|
||||
- Hotell influencer (5 nätter): 15,000 kr
|
||||
- Flyg medföljande: 8,500 kr
|
||||
- Hotell medföljande: ingår i dubbelrum
|
||||
- Aktiviteter (båda): 4,000 kr
|
||||
- Arbetade timmar: 35 timmar under 5 dagar (7h/dag)
|
||||
- Dokumenterat arbete: 12 Instagram-inlägg, 4 Stories/dag, 2 Reels
|
||||
|
||||
Bedömning influencerns resa:
|
||||
- Arbete: 35 timmar = 7 timmar/dag ✓
|
||||
- Kriteriet "huvudsaklig arbetstid" uppfyllt
|
||||
- Skattefri om nöjesinslag försumbara
|
||||
|
||||
Medföljandes resa:
|
||||
- Alltid skattepliktig förmån
|
||||
|
||||
Beräkning förmånsvärde medföljande:
|
||||
Flyg: 8,500 kr
|
||||
Hotell: 0 kr (ingen merkostnad för dubbelrum i detta fall)
|
||||
Aktiviteter (50%): 2,000 kr
|
||||
Totalt förmånsvärde: 10,500 kr
|
||||
|
||||
Skattekonsekvens (marginalskatt 35%):
|
||||
Skatt på 10,500 kr: ~3,675 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Donations/Tips från Twitch
|
||||
|
||||
**Scenario:** Streamer får donations under månad.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Bits mottagna: 45,000 bits
|
||||
- Bits-värde: $0.01 per bit = $450
|
||||
- Direktdonationer via PayPal: $320
|
||||
- Subscriptions (50% av $4.99 × 85 subs): $212.08
|
||||
- Valutakurs: 10.55 SEK/USD
|
||||
|
||||
Beräkning total intäkt:
|
||||
Bits: $450 × 10.55 = 4,747.50 kr
|
||||
Donations: $320 × 10.55 = 3,376.00 kr
|
||||
Subs: $212.08 × 10.55 = 2,237.44 kr
|
||||
Totalt: 10,360.94 kr
|
||||
|
||||
Bokföring (Twitch-utbetalning från USA):
|
||||
Debet 1940 Bank: 10,360.94 kr
|
||||
Kredit 3012 Försäljning tjänster utanför EU: 10,360.94 kr
|
||||
|
||||
Momsdeklaration:
|
||||
Ruta 40: 10,360.94 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Momsberäkningar
|
||||
|
||||
### 2.1 Faktura till svenskt företag
|
||||
|
||||
**Scenario:** Samarbete med svenskt klädmärke.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Arvode: 35,000 kr exkl. moms
|
||||
- Influencer har F-skatt och är momsregistrerad
|
||||
|
||||
Fakturabelopp:
|
||||
Arvode: 35,000 kr
|
||||
Moms 25%: 8,750 kr
|
||||
Totalt att betala: 43,750 kr
|
||||
|
||||
Bokföring vid fakturering:
|
||||
Debet 1510 Kundfordringar: 43,750 kr
|
||||
Kredit 3010 Intäkter: 35,000 kr
|
||||
Kredit 2610 Utgående moms: 8,750 kr
|
||||
|
||||
Bokföring vid betalning:
|
||||
Debet 1940 Bank: 43,750 kr
|
||||
Kredit 1510 Kundfordringar: 43,750 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Faktura till EU-företag (Reverse Charge)
|
||||
|
||||
**Scenario:** Samarbete med tyskt företag.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Arvode: €3,000 EUR
|
||||
- Valutakurs vid fakturadatum: 11.25 SEK/EUR
|
||||
- Kundens VAT-nummer: DE123456789
|
||||
|
||||
Fakturabelopp:
|
||||
Arvode: €3,000 (33,750 SEK)
|
||||
Moms: €0 (Reverse Charge)
|
||||
Totalt: €3,000
|
||||
|
||||
Obligatorisk text på faktura:
|
||||
"Reverse charge - VAT to be accounted for by the recipient"
|
||||
"Art. 196 Council Directive 2006/112/EC"
|
||||
|
||||
Bokföring vid fakturering:
|
||||
Debet 1510 Kundfordringar: 33,750 kr
|
||||
Kredit 3011 Försäljning tjänster EU: 33,750 kr
|
||||
|
||||
Momsdeklaration:
|
||||
Ruta 39: 33,750 kr
|
||||
|
||||
Periodisk sammanställning:
|
||||
Land: DE
|
||||
VAT-nummer: DE123456789
|
||||
Belopp: 33,750 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Merch-försäljning till EU-privatperson
|
||||
|
||||
**Scenario:** Säljer t-shirt till kund i Finland.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- T-shirt pris: 349 kr inkl. moms
|
||||
- Kund: Privatperson i Finland
|
||||
- Total EU-privatpersonförsäljning hittills i år: 45,000 kr
|
||||
|
||||
Bedömning:
|
||||
Under OSS-tröskel (99,680 kr) → Kan välja svensk moms
|
||||
|
||||
Alternativ 1: Svensk moms
|
||||
Pris: 349 kr inkl. 25% svensk moms
|
||||
Netto: 279.20 kr
|
||||
Moms: 69.80 kr
|
||||
|
||||
Alternativ 2: Finsk moms (24%)
|
||||
Netto: 349 / 1.24 = 281.45 kr
|
||||
Moms: 67.55 kr
|
||||
Redovisas via OSS
|
||||
|
||||
Vid överskridande av tröskel:
|
||||
- Obligatoriskt mottagarlandets moms
|
||||
- Registrera för OSS hos Skatteverket
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Kvartalsvis periodisk sammanställning
|
||||
|
||||
**Scenario:** Sammanställning Q1.
|
||||
|
||||
```
|
||||
EU-försäljning under Q1:
|
||||
- Google Ireland Ltd (IE6388047V): 87,450 kr
|
||||
- Meta Platforms Ireland Ltd (IE9692928F): 12,300 kr
|
||||
- TikTok Technology Ltd (IE3434547SH): 8,200 kr
|
||||
- Tyskt varumärke GmbH (DE987654321): 25,000 kr
|
||||
|
||||
Periodisk sammanställning:
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Period: 2024-01-01 till 2024-03-31 │
|
||||
├──────────┬────────────────┬─────────────────────────┤
|
||||
│ Land │ VAT-nummer │ Belopp (SEK) │
|
||||
├──────────┼────────────────┼─────────────────────────┤
|
||||
│ IE │ IE6388047V │ 87,450 │
|
||||
│ IE │ IE9692928F │ 12,300 │
|
||||
│ IE │ IE3434547SH │ 8,200 │
|
||||
│ DE │ DE987654321 │ 25,000 │
|
||||
├──────────┴────────────────┼─────────────────────────┤
|
||||
│ SUMMA │ 132,950 │
|
||||
└───────────────────────────┴─────────────────────────┘
|
||||
|
||||
Kontrollavstämning:
|
||||
Ruta 39 i momsdeklarationen Q1: 132,950 kr ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Skatteoptimering
|
||||
|
||||
### 3.1 Lön vs utdelning i AB
|
||||
|
||||
**Scenario:** AB med vinst 800,000 kr före lön.
|
||||
|
||||
```
|
||||
Förutsättningar:
|
||||
- Vinst före lön: 800,000 kr
|
||||
- Gränsbelopp för lågbeskattad utdelning: 204,325 kr
|
||||
- Brytpunkt statlig skatt: 615,300 kr
|
||||
|
||||
Alternativ 1: Endast lön 800,000 kr
|
||||
Arbetsgivaravgifter (31.42%): 251,360 kr
|
||||
Maximal lönekostnad: 800,000 kr
|
||||
Bruttolön: 800,000 / 1.3142 = 608,719 kr
|
||||
Inkomstskatt (~32%): 194,790 kr
|
||||
Netto till ägaren: 413,929 kr
|
||||
SGI: 608,719 kr ✓
|
||||
|
||||
Alternativ 2: Lön 500,000 kr + Utdelning
|
||||
Lön brutto: 500,000 kr
|
||||
Arbetsgivaravgifter: 157,100 kr
|
||||
Total lönekostnad: 657,100 kr
|
||||
Kvar i bolaget: 142,900 kr
|
||||
Bolagsskatt (20.6%): 29,438 kr
|
||||
Tillgängligt för utdelning: 113,462 kr
|
||||
Utdelningsskatt (20%): 22,692 kr
|
||||
Netto utdelning: 90,770 kr
|
||||
|
||||
Lön efter skatt (~32%): 340,000 kr
|
||||
Total netto: 430,770 kr
|
||||
SGI: 500,000 kr ✓
|
||||
|
||||
Alternativ 3: Lön till brytpunkt + Max utdelning
|
||||
Lön brutto: 615,300 kr
|
||||
Arbetsgivaravgifter: 193,327 kr
|
||||
Total lönekostnad: 808,627 kr
|
||||
→ Överstiger vinst, ej genomförbart
|
||||
|
||||
Rekommendation:
|
||||
Alternativ 2 ger högst netto OCH god SGI.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 EF: Periodiseringsfond
|
||||
|
||||
**Scenario:** Enskild firma med ojämna resultat.
|
||||
|
||||
```
|
||||
År 1: Överskott 600,000 kr (viralt år)
|
||||
År 2: Överskott 150,000 kr (normalt år)
|
||||
År 3: Överskott 100,000 kr (svagt år)
|
||||
|
||||
Utan periodiseringsfond:
|
||||
År 1 skatt (antagen 45% marginal på del): ~200,000 kr
|
||||
År 2 skatt (~32%): ~48,000 kr
|
||||
År 3 skatt (~32%): ~32,000 kr
|
||||
Total skatt: ~280,000 kr
|
||||
|
||||
Med periodiseringsfond:
|
||||
År 1:
|
||||
Överskott: 600,000 kr
|
||||
Avsättning (30%): 180,000 kr
|
||||
Beskattas: 420,000 kr
|
||||
Skatt (~40% snitt): 168,000 kr
|
||||
|
||||
År 2:
|
||||
Överskott: 150,000 kr
|
||||
Avsättning (30%): 45,000 kr
|
||||
Beskattas: 105,000 kr
|
||||
Skatt (~32%): 33,600 kr
|
||||
|
||||
År 3:
|
||||
Överskott: 100,000 kr
|
||||
Återföring: 225,000 kr (180k + 45k)
|
||||
Beskattas: 325,000 kr
|
||||
Skatt (~38% snitt): 123,500 kr
|
||||
|
||||
Total skatt: ~325,100 kr
|
||||
|
||||
Analys:
|
||||
I detta fall blev det dyrare med periodiseringsfond!
|
||||
Fonden lönar sig när framtida inkomster förväntas vara LÄGRE.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 SGI-optimering
|
||||
|
||||
**Scenario:** Influencer planerar barn, vill maximera föräldrapenning.
|
||||
|
||||
```
|
||||
Nuvarande struktur (AB):
|
||||
- Lön: 25,000 kr/månad = 300,000 kr/år
|
||||
- Utdelning: 400,000 kr/år
|
||||
- SGI: 300,000 kr
|
||||
|
||||
Föräldrapenning vid SGI 300,000 kr:
|
||||
Dagpenning (80%): 657 kr/dag (tak)
|
||||
→ SGI under taket 599,250 kr, får 80% av faktisk SGI
|
||||
Faktisk dagpenning: 300,000 × 0.8 / 365 = 657 kr/dag
|
||||
|
||||
Optimerad struktur (12 månader före barn):
|
||||
- Lön: 50,000 kr/månad = 600,000 kr/år
|
||||
- Utdelning: 100,000 kr/år
|
||||
- SGI: 600,000 kr
|
||||
|
||||
Föräldrapenning vid SGI 600,000 kr:
|
||||
Dagpenning (80%): 1,315 kr/dag
|
||||
Men tak finns: 1,116 kr/dag (2024)
|
||||
|
||||
Beräkning per 390 dagar föräldraledighet:
|
||||
Låg SGI (300k): 657 × 390 = 256,230 kr
|
||||
Hög SGI (600k): 1,116 × 390 = 435,240 kr
|
||||
Skillnad: 179,010 kr mer vid optimerad SGI
|
||||
|
||||
Kostnad för omställning:
|
||||
Ökad lön 300,000 kr/år → ökad skatt ~100,000 kr
|
||||
Nettofördel efter 1 års planering: ~79,000 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Valutaberäkningar
|
||||
|
||||
### 4.1 Kursdifferens vid försenad betalning
|
||||
|
||||
**Scenario:** Faktura i EUR med betalning 45 dagar senare.
|
||||
|
||||
```
|
||||
Fakturadatum: 2024-01-15
|
||||
Fakturabelopp: €5,000
|
||||
Kurs 2024-01-15: 11.35 SEK/EUR
|
||||
Fakturerat i SEK: 56,750 kr
|
||||
|
||||
Betalningsdatum: 2024-03-01
|
||||
Kurs 2024-03-01: 11.52 SEK/EUR
|
||||
Mottaget i SEK: 57,600 kr
|
||||
|
||||
Kursdifferens: 57,600 - 56,750 = 850 kr (vinst)
|
||||
|
||||
Bokföring vid fakturering:
|
||||
Debet 1510 Kundfordringar: 56,750 kr
|
||||
Kredit 3011 Försäljning tjänster EU: 56,750 kr
|
||||
|
||||
Bokföring vid betalning:
|
||||
Debet 1940 Bank: 57,600 kr
|
||||
Kredit 1510 Kundfordringar: 56,750 kr
|
||||
Kredit 3960 Valutakursvinster: 850 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Aggregerad månadsintäkt från flera plattformar
|
||||
|
||||
**Scenario:** Månadssammanställning med blandade valutor.
|
||||
|
||||
```
|
||||
Januari 2024 intäkter:
|
||||
|
||||
YouTube (USD):
|
||||
Belopp: $3,245.00
|
||||
Inbetalning: 2024-02-22
|
||||
Kurs: 10.42
|
||||
SEK: 33,812.90
|
||||
|
||||
Twitch (USD):
|
||||
Belopp: $1,876.50
|
||||
Inbetalning: 2024-02-15
|
||||
Kurs: 10.38
|
||||
SEK: 19,477.67
|
||||
|
||||
TikTok (USD):
|
||||
Belopp: $456.00
|
||||
Inbetalning: 2024-02-28
|
||||
Kurs: 10.45
|
||||
SEK: 4,765.20
|
||||
|
||||
Adtraction (SEK):
|
||||
Belopp: 8,450 kr
|
||||
SEK: 8,450.00
|
||||
|
||||
Total intäkt januari: 66,505.77 kr
|
||||
|
||||
Momsredovisning:
|
||||
Ruta 39 (EU - YouTube, TikTok): 38,578.10 kr
|
||||
Ruta 40 (Export - Twitch): 19,477.67 kr
|
||||
Ruta 05-08 (Sverige - Adtraction): 6,760 kr netto + 1,690 kr moms
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Avdragsberäkningar
|
||||
|
||||
### 5.1 Datorutrustning med blandad användning
|
||||
|
||||
**Scenario:** Köp av MacBook Pro för video och privat.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Inköpspris: 35,000 kr inkl. moms
|
||||
- Bedömd verksamhetsanvändning: 80%
|
||||
- Bolagsform: Enskild firma
|
||||
|
||||
Beräkning avdrag:
|
||||
Nettopris: 35,000 / 1.25 = 28,000 kr
|
||||
Moms: 7,000 kr
|
||||
|
||||
Verksamhetsandel 80%:
|
||||
Avdragsgill kostnad: 28,000 × 0.80 = 22,400 kr
|
||||
Avdragsgill moms: 7,000 × 0.80 = 5,600 kr
|
||||
|
||||
Bokföring:
|
||||
Debet 1220 Inventarier: 22,400 kr
|
||||
Debet 2640 Ingående moms: 5,600 kr
|
||||
Debet 2013 Eget uttag: 7,000 kr
|
||||
Kredit 1940 Bank: 35,000 kr
|
||||
|
||||
Avskrivning (5 år):
|
||||
Årligt avdrag: 22,400 / 5 = 4,480 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Hemmakontor (schablon vs faktiskt)
|
||||
|
||||
**Scenario:** Influencer arbetar i lägenhet.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Total hyra: 12,000 kr/månad
|
||||
- Lägenhetens yta: 75 kvm
|
||||
- Arbetsrummets yta: 12 kvm
|
||||
- Arbetade timmar hemma: 1,200 timmar/år
|
||||
|
||||
Alternativ 1: Schablonavdrag
|
||||
Krav: >800 timmar/år ✓
|
||||
Avdrag: 2,000 kr/år
|
||||
|
||||
Alternativ 2: Faktiskt avdrag (om kraven uppfylls)
|
||||
Krav: Separat rum, exklusiv verksamhetsanvändning
|
||||
Andel: 12/75 = 16%
|
||||
Årshyra: 12,000 × 12 = 144,000 kr
|
||||
Avdrag: 144,000 × 0.16 = 23,040 kr
|
||||
|
||||
Skillnad: 21,040 kr/år
|
||||
|
||||
VARNING: Faktiskt avdrag kräver att rummet INTE används
|
||||
för boende. Kraven är mycket stränga. Vid tveksamhet,
|
||||
använd schablon eller hyr extern lokal.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 Resa med delvis privat syfte
|
||||
|
||||
**Scenario:** Resa till New York för samarbete + privat.
|
||||
|
||||
```
|
||||
Uppgifter:
|
||||
- Totalt 7 dagar
|
||||
- Arbetsdagar: 4 dagar (möten, inspelning)
|
||||
- Privata dagar: 3 dagar (sightseeing)
|
||||
- Flygbiljett: 9,500 kr
|
||||
- Hotell: 3,000 kr/natt × 7 = 21,000 kr
|
||||
- Traktamente utland (USA): 614 kr/dag
|
||||
|
||||
Beräkning avdragsgill del:
|
||||
Flyg: 9,500 × (4/7) = 5,429 kr
|
||||
Hotell arbetsdagar: 3,000 × 4 = 12,000 kr
|
||||
Traktamente arbetsdagar: 614 × 4 = 2,456 kr
|
||||
|
||||
Totalt avdragsgillt: 19,885 kr
|
||||
Ej avdragsgillt (privat): 9,500 - 5,429 + 9,000 = 13,071 kr
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Jämförande beräkningar
|
||||
|
||||
### 6.1 EF vs AB vid olika vinstnivåer
|
||||
|
||||
**Scenario:** Jämförelse vid 400k, 600k och 1M kr vinst.
|
||||
|
||||
```
|
||||
Vinst 400,000 kr:
|
||||
|
||||
EF:
|
||||
Egenavgifter (~28%): 112,000 kr
|
||||
Skattepliktig inkomst: 288,000 kr
|
||||
Inkomstskatt (~32%): 92,160 kr
|
||||
Netto: 195,840 kr
|
||||
|
||||
AB (lön 300k + utdelning):
|
||||
Lön brutto: 300,000 kr
|
||||
Arbetsgivaravgifter: 94,260 kr
|
||||
Kvar: 5,740 kr
|
||||
Bolagsskatt: 1,182 kr
|
||||
Lön efter skatt: 204,000 kr
|
||||
Utdelning: 4,558 kr × 0.8 = 3,646 kr
|
||||
Netto: 207,646 kr
|
||||
|
||||
Fördel AB: 11,806 kr
|
||||
|
||||
---
|
||||
|
||||
Vinst 600,000 kr:
|
||||
|
||||
EF:
|
||||
Egenavgifter: 168,000 kr
|
||||
Skattepliktig: 432,000 kr
|
||||
Inkomstskatt (~35%): 151,200 kr
|
||||
Netto: 280,800 kr
|
||||
|
||||
AB (lön 400k + utdelning):
|
||||
Lön brutto: 400,000 kr
|
||||
Arbetsgivaravgifter: 125,680 kr
|
||||
Kvar: 74,320 kr
|
||||
Bolagsskatt: 15,310 kr
|
||||
Lön efter skatt: 272,000 kr
|
||||
Utdelning: 59,010 kr × 0.8 = 47,208 kr
|
||||
Netto: 319,208 kr
|
||||
|
||||
Fördel AB: 38,408 kr
|
||||
|
||||
---
|
||||
|
||||
Vinst 1,000,000 kr:
|
||||
|
||||
EF:
|
||||
Egenavgifter: 280,000 kr
|
||||
Skattepliktig: 720,000 kr
|
||||
Inkomstskatt (~42%): 302,400 kr
|
||||
Netto: 417,600 kr
|
||||
|
||||
AB (lön 600k + utdelning):
|
||||
Lön brutto: 600,000 kr
|
||||
Arbetsgivaravgifter: 188,520 kr
|
||||
Kvar: 211,480 kr
|
||||
Bolagsskatt: 43,565 kr
|
||||
Lön efter skatt: 390,000 kr
|
||||
Utdelning: 167,915 kr × 0.8 = 134,332 kr
|
||||
Netto: 524,332 kr
|
||||
|
||||
Fördel AB: 106,732 kr
|
||||
```
|
||||
|
||||
**Slutsats:** AB blir mer fördelaktigt ju högre vinsten är.
|
||||
Brytpunkt ligger omkring 300-400k kr vinst.
|
||||
@@ -1,554 +0,0 @@
|
||||
# Beslutsträd
|
||||
|
||||
## 1. Produkter och förmåner
|
||||
|
||||
### 1.1 Mottagen produkt
|
||||
|
||||
```
|
||||
┌─ Fick du en produkt?
|
||||
│
|
||||
├─► Fanns avtal/överenskommelse om motprestation?
|
||||
│ │
|
||||
│ ├─► JA (samarbete)
|
||||
│ │ └─► SKATTEPLIKTIG INTÄKT
|
||||
│ │ Värde: Marknadspris inkl. moms
|
||||
│ │ Bokför som: Intäkt + eget uttag (om privat bruk)
|
||||
│ │
|
||||
│ └─► NEJ (oombett pressutskick)
|
||||
│ │
|
||||
│ ├─► Behåller du produkten?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► SKATTEPLIKTIG INTÄKT
|
||||
│ │ │ Värde: Marknadspris inkl. moms
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ │
|
||||
│ │ ├─► Returnerade
|
||||
│ │ │ └─► EJ SKATTEPLIKTIG
|
||||
│ │ │ Krav: Spara returkvitto
|
||||
│ │ │
|
||||
│ │ ├─► Kastade/gav bort
|
||||
│ │ │ └─► EJ SKATTEPLIKTIG
|
||||
│ │ │ Krav: Ingen privat användning dessförinnan
|
||||
│ │ │
|
||||
│ │ └─► Sålde vidare
|
||||
│ │ └─► FÖRSÄLJNINGSINTÄKT SKATTEPLIKTIG
|
||||
│ │ (Ej ursprungsvärdet, utan säljpriset)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Sponsrad resa
|
||||
|
||||
```
|
||||
┌─ Bjuden på resa av uppdragsgivare?
|
||||
│
|
||||
├─► Är resan nödvändig för verksamheten?
|
||||
│ │
|
||||
│ ├─► NEJ
|
||||
│ │ └─► HELA VÄRDET SKATTEPLIKTIGT
|
||||
│ │
|
||||
│ └─► JA
|
||||
│ │
|
||||
│ ├─► Arbetar du ≥30h/vecka ELLER ≥6h/dag under resan?
|
||||
│ │ │
|
||||
│ │ ├─► NEJ
|
||||
│ │ │ └─► DELVIS SKATTEPLIKTIGT
|
||||
│ │ │ Nöjesandelen beskattas
|
||||
│ │ │
|
||||
│ │ └─► JA
|
||||
│ │ │
|
||||
│ │ ├─► Är nöjesinslagen försumbara?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA
|
||||
│ │ │ │ └─► SKATTEFRI RESA
|
||||
│ │ │ │
|
||||
│ │ │ └─► NEJ
|
||||
│ │ │ └─► DELVIS SKATTEPLIKTIGT
|
||||
│ │ │ Nöjesandelen beskattas
|
||||
│
|
||||
├─► Följer partner/vän med gratis?
|
||||
│ └─► JA
|
||||
│ └─► ALLTID SKATTEPLIKTIG FÖRMÅN
|
||||
│ Värde: Medföljarens faktiska reskostnad
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Rabattkod/personalrabatt
|
||||
|
||||
```
|
||||
┌─ Fick du rabatt som del av ersättning?
|
||||
│
|
||||
├─► Rabatt på produkter du ska marknadsföra?
|
||||
│ └─► SKATTEPLIKTIG
|
||||
│ Värde: Marknadspris - betalt pris
|
||||
│
|
||||
├─► Rabatt för eget bruk (ej kopplat till uppdrag)?
|
||||
│ │
|
||||
│ ├─► Erbjuds samma rabatt till allmänheten?
|
||||
│ │ └─► EJ SKATTEPLIKTIG (öppen kampanj)
|
||||
│ │
|
||||
│ └─► Exklusiv rabatt?
|
||||
│ └─► SKATTEPLIKTIG
|
||||
│ Värde: Normalrabatt till allmänheten jämförs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Momshantering
|
||||
|
||||
### 2.1 Val av momsregel vid fakturering
|
||||
|
||||
```
|
||||
┌─ Vem fakturerar du?
|
||||
│
|
||||
├─► Svenskt företag
|
||||
│ └─► SVENSK MOMS 25%
|
||||
│ Ruta: 05-08
|
||||
│ Periodisk sammanställning: Nej
|
||||
│
|
||||
├─► Företag i annat EU-land
|
||||
│ │
|
||||
│ ├─► Har kunden giltigt VAT-nummer?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► REVERSE CHARGE (0% moms)
|
||||
│ │ │ Ruta: 39
|
||||
│ │ │ Periodisk sammanställning: JA
|
||||
│ │ │ Text på faktura: "Reverse charge"
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ └─► Behandla som privatperson
|
||||
│ │ → Se "Privatperson i EU"
|
||||
│ │
|
||||
├─► Företag utanför EU
|
||||
│ └─► EXPORT (0% moms)
|
||||
│ Ruta: 40
|
||||
│ Periodisk sammanställning: Nej
|
||||
│
|
||||
├─► Privatperson i Sverige
|
||||
│ └─► SVENSK MOMS 25%
|
||||
│ Ruta: 05-08
|
||||
│
|
||||
├─► Privatperson i annat EU-land
|
||||
│ │
|
||||
│ ├─► Total EU-privatförsäljning ≤99,680 kr/år?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► VALFRITT:
|
||||
│ │ │ • Svensk moms 25% (enklast)
|
||||
│ │ │ • ELLER mottagarlandets moms via OSS
|
||||
│ │ │
|
||||
│ │ └─► NEJ (över tröskeln)
|
||||
│ │ └─► OBLIGATORISKT mottagarlandets moms
|
||||
│ │ Redovisa via OSS
|
||||
│ │
|
||||
├─► Privatperson utanför EU
|
||||
│ └─► EXPORT (0% moms)
|
||||
│ Ruta: 40
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Momsregistrering
|
||||
|
||||
```
|
||||
┌─ Behöver jag momsregistrera mig?
|
||||
│
|
||||
├─► Bedriver du momspliktig verksamhet?
|
||||
│ │
|
||||
│ ├─► NEJ (t.ex. sjukvård, utbildning)
|
||||
│ │ └─► INGEN MOMSREGISTRERING
|
||||
│ │
|
||||
│ └─► JA
|
||||
│ │
|
||||
│ ├─► Omsättning >80,000 kr/år?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► OBLIGATORISK registrering
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ │
|
||||
│ │ ├─► Säljer du till EU-företag (Reverse Charge)?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA
|
||||
│ │ │ │ └─► REKOMMENDERAD registrering
|
||||
│ │ │ │ (för periodisk sammanställning)
|
||||
│ │ │ │
|
||||
│ │ │ └─► NEJ
|
||||
│ │ │ └─► FRIVILLIG registrering
|
||||
│ │ │ Fördel: Momsavdrag på inköp
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Bolagsform
|
||||
|
||||
### 3.1 EF eller AB?
|
||||
|
||||
```
|
||||
┌─ Vilken bolagsform passar?
|
||||
│
|
||||
├─► Förväntad årlig vinst?
|
||||
│ │
|
||||
│ ├─► <300,000 kr
|
||||
│ │ └─► ENSKILD FIRMA
|
||||
│ │ • Enklare administration
|
||||
│ │ • Skattemässigt likvärdigt
|
||||
│ │
|
||||
│ ├─► 300,000 - 500,000 kr
|
||||
│ │ │
|
||||
│ │ ├─► Accepterar du personligt ansvar?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA
|
||||
│ │ │ │ └─► EF kan fungera
|
||||
│ │ │ │ Överväg AB om växande
|
||||
│ │ │ │
|
||||
│ │ │ └─► NEJ
|
||||
│ │ │ └─► AKTIEBOLAG
|
||||
│ │ │
|
||||
│ ├─► >500,000 kr
|
||||
│ │ └─► AKTIEBOLAG
|
||||
│ │ • Betydande skattefördel via 3:12
|
||||
│ │ • Ansvarsbegränsning
|
||||
│ │
|
||||
├─► Juridisk risk i verksamheten?
|
||||
│ │
|
||||
│ ├─► HÖG (kontroversiellt innehåll, upphovsrättskänsligt)
|
||||
│ │ └─► AKTIEBOLAG
|
||||
│ │ Personlig ekonomi skyddad
|
||||
│ │
|
||||
│ └─► LÅG
|
||||
│ └─► Båda fungerar
|
||||
│
|
||||
├─► Planerar investerare/partners?
|
||||
│ │
|
||||
│ ├─► JA
|
||||
│ │ └─► AKTIEBOLAG
|
||||
│ │ Ägarandelar möjliga
|
||||
│ │
|
||||
│ └─► NEJ
|
||||
│ └─► Båda fungerar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 När byta från EF till AB?
|
||||
|
||||
```
|
||||
┌─ Ska jag byta till AB?
|
||||
│
|
||||
├─► Vinst >500,000 kr konsekvent (2+ år)?
|
||||
│ └─► JA → Stark indikation för AB
|
||||
│
|
||||
├─► Juridisk tvist eller risk?
|
||||
│ └─► JA → AB skyddar privatekonomin
|
||||
│
|
||||
├─► Vill ha utdelning istället för lön?
|
||||
│ └─► JA → AB möjliggör 20% skatt
|
||||
│
|
||||
├─► Vill anställa personal?
|
||||
│ └─► JA → AB ofta lämpligare
|
||||
│
|
||||
├─► Planerar sälja verksamheten?
|
||||
│ └─► JA → AB enklare att överlåta
|
||||
│
|
||||
Om 2+ JA → Överväg starkt att byta till AB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Avdrag
|
||||
|
||||
### 4.1 Är kostnaden avdragsgill?
|
||||
|
||||
```
|
||||
┌─ Är utgiften avdragsgill?
|
||||
│
|
||||
├─► Teknik (kamera, dator, mikrofon, ljus)
|
||||
│ │
|
||||
│ ├─► Används i verksamheten?
|
||||
│ │ │
|
||||
│ │ ├─► Uteslutande verksamhet
|
||||
│ │ │ └─► FULLT AVDRAG
|
||||
│ │ │
|
||||
│ │ ├─► Delvis privat
|
||||
│ │ │ └─► PROPORTIONELLT AVDRAG
|
||||
│ │ │ (Bedöm verksamhetsandel)
|
||||
│ │ │
|
||||
│ │ └─► Huvudsakligen privat
|
||||
│ │ └─► INGET/MARGINELLT AVDRAG
|
||||
│
|
||||
├─► Kläder
|
||||
│ │
|
||||
│ ├─► Kan användas privat?
|
||||
│ │ │
|
||||
│ │ ├─► JA (normala kläder)
|
||||
│ │ │ └─► EJ AVDRAGSGILL
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ │
|
||||
│ │ ├─► Uniform/skyddskläder
|
||||
│ │ │ └─► AVDRAGSGILL
|
||||
│ │ │
|
||||
│ │ ├─► Extrem scenklädsel
|
||||
│ │ │ └─► AVDRAGSGILL
|
||||
│ │ │ (Dokumentera varför ej privat)
|
||||
│ │ │
|
||||
│ │ └─► Historisk kostym/rekvisita
|
||||
│ │ └─► AVDRAGSGILL
|
||||
│
|
||||
├─► Smink och skönhet
|
||||
│ │
|
||||
│ ├─► Vardagssmink/hudvård
|
||||
│ │ └─► EJ AVDRAGSGILL
|
||||
│ │
|
||||
│ ├─► Teatersmink/sfx
|
||||
│ │ └─► AVDRAGSGILL
|
||||
│ │
|
||||
│ └─► Skönhetsingrepp (filler, botox, kirurgi)
|
||||
│ └─► EJ AVDRAGSGILL
|
||||
│ (Oavsett "varumärkes"-argument)
|
||||
│
|
||||
├─► Resa
|
||||
│ │
|
||||
│ ├─► Arbetsresa med dokumenterat syfte?
|
||||
│ │ │
|
||||
│ │ ├─► JA, endast arbete
|
||||
│ │ │ └─► FULLT AVDRAG
|
||||
│ │ │
|
||||
│ │ ├─► JA, men även privata inslag
|
||||
│ │ │ └─► PROPORTIONELLT AVDRAG
|
||||
│ │ │ (Endast arbetsdagar)
|
||||
│ │ │
|
||||
│ │ └─► Huvudsakligen privat
|
||||
│ │ └─► EJ AVDRAGSGILL
|
||||
│ │
|
||||
│ └─► Pendling hem-arbete
|
||||
│ └─► EJ AVDRAGSGILL
|
||||
│
|
||||
├─► Hemmakontor
|
||||
│ │
|
||||
│ ├─► Separat rum, ej bostad, exklusiv användning?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► FAKTISKT AVDRAG
|
||||
│ │ │ (Andel av hyra)
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ └─► SCHABLONAVDRAG 2,000 kr
|
||||
│ │ (Krav: >800 h arbete i bostad)
|
||||
│
|
||||
├─► Agentarvode/provision
|
||||
│ └─► FULLT AVDRAG
|
||||
│ (Direkt kopplat till intäkt)
|
||||
│
|
||||
├─► Programvara/prenumerationer
|
||||
│ │
|
||||
│ ├─► Verksamhetsrelaterad?
|
||||
│ │ │
|
||||
│ │ ├─► JA (Adobe, redigeringsprogram)
|
||||
│ │ │ └─► FULLT AVDRAG
|
||||
│ │ │
|
||||
│ │ └─► NEJ (Netflix, Spotify privat)
|
||||
│ │ └─► EJ AVDRAGSGILL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Social trygghet
|
||||
|
||||
### 5.1 SGI-optimering
|
||||
|
||||
```
|
||||
┌─ Hur optimerar jag SGI?
|
||||
│
|
||||
├─► Bolagsform?
|
||||
│ │
|
||||
│ ├─► Enskild firma
|
||||
│ │ │
|
||||
│ │ ├─► SGI = skattemässigt överskott
|
||||
│ │ │
|
||||
│ │ ├─► Gör du avdrag som sänker överskottet?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA
|
||||
│ │ │ │ └─► VARNING: Lägre SGI!
|
||||
│ │ │ │ Överväg om avdragen är värda
|
||||
│ │ │ │ den lägre sjuk-/föräldrapenningen
|
||||
│ │ │ │
|
||||
│ │ │ └─► NEJ
|
||||
│ │ │ └─► OK
|
||||
│ │ │
|
||||
│ │ └─► Uppbyggnadsskede (<36 månader)?
|
||||
│ │ └─► JA → Kan få jämförelseinkomst
|
||||
│ │
|
||||
│ └─► Aktiebolag
|
||||
│ │
|
||||
│ ├─► SGI = endast utbetald lön
|
||||
│ │ (Utdelning räknas EJ)
|
||||
│ │
|
||||
│ ├─► Tar du ut tillräcklig lön?
|
||||
│ │ │
|
||||
│ │ ├─► JA (≥400,000 kr/år rekommenderat)
|
||||
│ │ │ └─► OK
|
||||
│ │ │
|
||||
│ │ └─► NEJ (låg lön + hög utdelning)
|
||||
│ │ └─► VARNING: Lågt skydd!
|
||||
│ │ Risk vid sjukdom/barn
|
||||
│
|
||||
├─► Planerar du barn inom 12 månader?
|
||||
│ │
|
||||
│ └─► JA
|
||||
│ └─► MAXIMERA SGI NU
|
||||
│ • EF: Minimera avdrag
|
||||
│ • AB: Höj lönen, sänk utdelning
|
||||
│ • SGI tar ~12 månader att bygga upp
|
||||
│
|
||||
├─► Befintligt SGI-skydd tillräckligt?
|
||||
│ │
|
||||
│ ├─► Test: Vad får du i sjukpenning?
|
||||
│ │ Sjukpenning = SGI × 0.8 / 365 per dag
|
||||
│ │
|
||||
│ └─► Rekommendation: SGI ≥400,000 kr
|
||||
│ → ~876 kr/dag i sjukpenning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Marknadsföringslagen
|
||||
|
||||
### 6.1 Ska inlägget märkas som reklam?
|
||||
|
||||
```
|
||||
┌─ Behöver jag märka med REKLAM?
|
||||
│
|
||||
├─► Fick du ersättning (pengar, produkter, tjänster)?
|
||||
│ │
|
||||
│ ├─► JA
|
||||
│ │ │
|
||||
│ │ ├─► Finns avtal/överenskommelse om publicering?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA
|
||||
│ │ │ │ └─► MÄRKNING KRÄVS
|
||||
│ │ │ │ "REKLAM" eller "ANNONS"
|
||||
│ │ │ │ Placering: Överst i inlägg
|
||||
│ │ │ │
|
||||
│ │ │ └─► NEJ (spontan recension)
|
||||
│ │ │ │
|
||||
│ │ │ ├─► Behöll du produkten?
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├─► JA
|
||||
│ │ │ │ │ └─► REKOMMENDERAD märkning
|
||||
│ │ │ │ │ (Transparens)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ └─► NEJ
|
||||
│ │ │ │ └─► Ingen märkning krävs
|
||||
│ │ │
|
||||
│ └─► NEJ (köpte själv, äkta rekommendation)
|
||||
│ │
|
||||
│ ├─► Affiliate-länk inkluderad?
|
||||
│ │ │
|
||||
│ │ ├─► JA
|
||||
│ │ │ └─► MÄRKNING KRÄVS
|
||||
│ │ │ (Du tjänar på klick/köp)
|
||||
│ │ │
|
||||
│ │ └─► NEJ
|
||||
│ │ └─► Ingen märkning krävs
|
||||
│
|
||||
├─► Är det osäkert?
|
||||
│ └─► JA
|
||||
│ └─► MÄRK ÄNDÅ
|
||||
│ Hellre för tydlig än otydlig
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dubbel struktur (EF + AB)
|
||||
|
||||
### 7.1 Vilket företag för uppdraget?
|
||||
|
||||
```
|
||||
┌─ Ska uppdraget faktureras från EF eller AB?
|
||||
│
|
||||
├─► Ersättningsnivå?
|
||||
│ │
|
||||
│ ├─► <50,000 kr
|
||||
│ │ └─► EF (enkelhet)
|
||||
│ │
|
||||
│ ├─► 50,000 - 100,000 kr
|
||||
│ │ └─► Bedöm övriga faktorer ↓
|
||||
│ │
|
||||
│ └─► >100,000 kr
|
||||
│ └─► AB (skatteoptimering)
|
||||
│
|
||||
├─► Juridisk risk?
|
||||
│ │
|
||||
│ ├─► Hög (kontroversiellt, oklara rättigheter)
|
||||
│ │ └─► AB (ansvarsbegränsning)
|
||||
│ │
|
||||
│ └─► Låg
|
||||
│ └─► Båda fungerar
|
||||
│
|
||||
├─► Avtalstyp?
|
||||
│ │
|
||||
│ ├─► Engångsuppdrag
|
||||
│ │ └─► EF (enkelhet)
|
||||
│ │
|
||||
│ └─► Långt ambassadörskap (6+ månader)
|
||||
│ └─► AB (stabilare struktur)
|
||||
│
|
||||
├─► Internationell klient?
|
||||
│ │
|
||||
│ ├─► JA
|
||||
│ │ └─► AB (tydligare för utländska parter)
|
||||
│ │
|
||||
│ └─► NEJ
|
||||
│ └─► Båda fungerar
|
||||
│
|
||||
├─► Behöver bygga SGI?
|
||||
│ │
|
||||
│ ├─► JA, via EF-överskott
|
||||
│ │ └─► EF
|
||||
│ │
|
||||
│ ├─► JA, via AB-lön
|
||||
│ │ └─► AB
|
||||
│ │
|
||||
│ └─► SGI tillräcklig
|
||||
│ └─► Optimera för skatt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Internfaktura mellan EF och AB
|
||||
|
||||
```
|
||||
┌─ Ska EF fakturera AB (eller vice versa)?
|
||||
│
|
||||
├─► Finns verklig tjänst/vara som levereras?
|
||||
│ │
|
||||
│ ├─► JA
|
||||
│ │ │
|
||||
│ │ ├─► Är prissättningen marknadsmässig?
|
||||
│ │ │ │
|
||||
│ │ │ ├─► JA (inom ±20% av marknad)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ ├─► Finns skriftligt avtal?
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ ├─► JA
|
||||
│ │ │ │ │ │ └─► OK - Internfaktura tillåten
|
||||
│ │ │ │ │ │ Glöm ej moms (25%)
|
||||
│ │ │ │ │ │
|
||||
│ │ │ │ │ └─► NEJ
|
||||
│ │ │ │ │ └─► UPPRÄTTA AVTAL
|
||||
│ │ │ │ │ Innan fakturering
|
||||
│ │ │ │ │
|
||||
│ │ │ └─► NEJ (avviker >20%)
|
||||
│ │ │ └─► JUSTERA PRISET
|
||||
│ │ │ Risk: Omklassificering av Skatteverket
|
||||
│ │ │
|
||||
│ └─► NEJ (ren skatteplanering)
|
||||
│ └─► EJ TILLÅTEN
|
||||
│ Risk: Förtäckt lön/utdelning
|
||||
```
|
||||
@@ -1,500 +0,0 @@
|
||||
# Felsökningsguide
|
||||
|
||||
## 1. Momsproblem
|
||||
|
||||
### 1.1 Momsen stämmer inte
|
||||
|
||||
**Symptom:** Momsdeklarationen balanserar inte eller Skatteverket ifrågasätter.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Har du separerat inhemsk/EU/export-försäljning korrekt?
|
||||
→ Inhemsk (25%): Ruta 05-08
|
||||
→ EU B2B (0%): Ruta 39
|
||||
→ Export (0%): Ruta 40
|
||||
|
||||
□ Matchar periodisk sammanställning Ruta 39?
|
||||
→ Summorna MÅSTE vara identiska
|
||||
→ Kontrollera att alla EU-kunder finns med
|
||||
|
||||
□ Har du bokfört barter-produkter med moms?
|
||||
→ Marknadsvärde INKL moms som intäkt
|
||||
→ Momsandel ska redovisas
|
||||
|
||||
□ Finns valutakursdifferenser som påverkar?
|
||||
→ Kursen vid fakturering vs betalning
|
||||
→ Differenser bokförs separat
|
||||
|
||||
□ Har du blandat B2B och B2C inom EU?
|
||||
→ B2B: Reverse Charge
|
||||
→ B2C: OSS eller svensk moms
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Exportera alla försäljningsfakturor
|
||||
2. Kategorisera per momstyp
|
||||
3. Summera per kategori
|
||||
4. Jämför med momsdeklaration
|
||||
5. Korrigera differenser
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Periodisk sammanställning saknas eller är fel
|
||||
|
||||
**Symptom:** Förseningsavgift eller ifrågasättande från Skatteverket.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Har du sålt tjänster till EU-företag under perioden?
|
||||
→ Om ja, periodisk sammanställning obligatorisk
|
||||
|
||||
□ Har du kundens korrekta VAT-nummer?
|
||||
→ Verifiera på VIES: https://ec.europa.eu/taxation_customs/vies/
|
||||
|
||||
□ Har du lämnat sammanställningen i tid?
|
||||
→ Deadline: 20:e i månaden efter kvartalets slut
|
||||
→ Q1: 20 april
|
||||
→ Q2: 20 juli
|
||||
→ Q3: 20 oktober
|
||||
→ Q4: 20 januari
|
||||
|
||||
□ Stämmer beloppen med Ruta 39?
|
||||
→ Summorna MÅSTE matcha exakt
|
||||
```
|
||||
|
||||
**Åtgärder vid miss:**
|
||||
1. Lämna in försenad sammanställning omedelbart
|
||||
2. Betala förseningsavgift (1,250 kr)
|
||||
3. Korrigera momsdeklaration om nödvändigt
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Osäker på vilken momsregel som gäller
|
||||
|
||||
**Frågor att ställa:**
|
||||
|
||||
```
|
||||
1. Vem är kunden?
|
||||
□ Företag → Var är de registrerade?
|
||||
□ Privatperson → Var bor de?
|
||||
|
||||
2. Är det en vara eller tjänst?
|
||||
□ Vara → Leveransplats avgör
|
||||
□ Tjänst → Kundens säte avgör (B2B)
|
||||
|
||||
3. Har kunden VAT-nummer?
|
||||
□ Ja → Verifiera det
|
||||
□ Nej → Behandla som privatperson
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Bokföringsproblem
|
||||
|
||||
### 2.1 Blandade privata och företagstransaktioner
|
||||
|
||||
**Symptom:** Oklara verifikationer, sammanblandade konton.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Har du separata bankkonton?
|
||||
→ Privat
|
||||
→ EF (om tillämpligt)
|
||||
→ AB (om tillämpligt)
|
||||
|
||||
□ Har privata inköp betalats från företagskontot?
|
||||
→ Bokför som eget uttag (EF) eller lån till ägare (AB)
|
||||
|
||||
□ Har företagsinköp betalats privat?
|
||||
→ Bokför som insättning eget kapital (EF) eller lån från ägare (AB)
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Gå igenom alla kontoutdrag
|
||||
2. Markera alla transaktioner som företag/privat
|
||||
3. Bokför korrigeringar för felaktiga
|
||||
4. Upprätta rutin för framtiden
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Saknar verifikationer
|
||||
|
||||
**Symptom:** Transaktioner utan underlag.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Vilken typ av transaktion?
|
||||
→ Plattformsintäkt: Ladda ner från plattformen
|
||||
→ Inköp: Kontakta leverantör för kopia
|
||||
→ Barter: Upprätta eget underlag
|
||||
|
||||
□ Kan du rekonstruera underlaget?
|
||||
→ Screenshot av beställning
|
||||
→ E-postbekräftelse
|
||||
→ Bankutdrag som stöd
|
||||
```
|
||||
|
||||
**Barter-underlag som du själv upprättar:**
|
||||
```
|
||||
VERIFIKATION - MOTTAGEN PRODUKT
|
||||
|
||||
Datum: [mottagningsdatum]
|
||||
Produkt: [beskrivning]
|
||||
Avsändare: [företag/person]
|
||||
Marknadsvärde inkl. moms: [belopp] kr
|
||||
Källa för värdering: [prislapp/webbshop/etc.]
|
||||
Motprestation: [beskrivning av inlägg/samarbete]
|
||||
|
||||
Undertecknad av: [influencerns namn]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Valutaomräkning stämmer inte
|
||||
|
||||
**Symptom:** Differenser mellan faktura och betalning.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Vilken kurs använde du vid fakturering?
|
||||
→ Ska vara kurs vid fakturadatum
|
||||
|
||||
□ Vilken kurs gällde vid betalning?
|
||||
→ Kontrollera Riksbankens officiella kurs
|
||||
|
||||
□ Har du bokfört kursdifferensen separat?
|
||||
→ Vinst: Konto 3960
|
||||
→ Förlust: Konto 7960
|
||||
```
|
||||
|
||||
**Beräkningsexempel:**
|
||||
```
|
||||
Faktura: €1,000 @ 11.50 = 11,500 kr
|
||||
Betalning: €1,000 @ 11.35 = 11,350 kr
|
||||
Kursförlust: 150 kr
|
||||
|
||||
Bokföring:
|
||||
Debet 1940 Bank: 11,350
|
||||
Debet 7960 Valutakursförluster: 150
|
||||
Kredit 1510 Kundfordringar: 11,500
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Skattedeklarationsproblem
|
||||
|
||||
### 3.1 Saknar F-skatt men har fakturerat
|
||||
|
||||
**Symptom:** Uppdragsgivare har inte dragit skatt, eller faktura utställd utan F-skatt.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Hade du F-skatt när fakturan ställdes ut?
|
||||
→ Om nej: Uppdragsgivaren ska ha dragit A-skatt
|
||||
|
||||
□ Drog uppdragsgivaren skatt?
|
||||
→ Kontrollera lönespecifikation/kontrolluppgift
|
||||
|
||||
□ Betalade uppdragsgivaren arbetsgivaravgifter?
|
||||
→ Ska synas på kontrolluppgift
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Om skatt EJ drogs: Deklarera inkomsten, du ansvarar för skatten
|
||||
2. Kontakta uppdragsgivaren för kontrolluppgift
|
||||
3. Ansök om F-skatt om du planerar fortsätta
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Fått preliminärskattebesked med fel belopp
|
||||
|
||||
**Symptom:** Debiterad F-skatt stämmer inte med förväntad inkomst.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Baseras beskedet på gammal/felaktig uppgift?
|
||||
→ Skatteverket använder senaste deklarationen
|
||||
|
||||
□ Har din verksamhet förändrats väsentligt?
|
||||
→ Ökade/minskade intäkter
|
||||
|
||||
□ Har du redovisat alla intäkter korrekt?
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Logga in på Skatteverket
|
||||
2. Ändra preliminär inkomstdeklaration
|
||||
3. Ny debitering beräknas automatiskt
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Kvarskatt trots F-skatt
|
||||
|
||||
**Symptom:** Slutskattebesked visar kvarskatt.
|
||||
|
||||
**Vanliga orsaker:**
|
||||
|
||||
```
|
||||
□ Debiterad F-skatt var för låg
|
||||
→ Inkomsten blev högre än beräknat
|
||||
|
||||
□ Egenavgifter ej medräknade korrekt
|
||||
→ Särskilt relevant för EF
|
||||
|
||||
□ Intäkter utan avdragen skatt
|
||||
→ Utländska plattformar drar ej svensk skatt
|
||||
→ Barter-produkter har ej skatt avdragen
|
||||
|
||||
□ Kapitalvinster/utdelningar
|
||||
→ Beskattas separat
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Analysera slutskattebeskedet
|
||||
2. Justera kommande års preliminärskatt
|
||||
3. Skapa buffert för kvarskatt
|
||||
|
||||
---
|
||||
|
||||
## 4. SGI-problem
|
||||
|
||||
### 4.1 SGI är 0 eller mycket låg
|
||||
|
||||
**Symptom:** Försäkringskassan meddelar låg SGI.
|
||||
|
||||
**Kontrollpunkter för EF:**
|
||||
|
||||
```
|
||||
□ Vad visar NE-bilagan som överskott?
|
||||
→ SGI = överskott
|
||||
|
||||
□ Har du gjort stora avdrag?
|
||||
→ Sänker överskottet och därmed SGI
|
||||
|
||||
□ Är verksamheten i uppbyggnadsskede (<36 mån)?
|
||||
→ Kan få jämförelseinkomst istället
|
||||
```
|
||||
|
||||
**Kontrollpunkter för AB:**
|
||||
|
||||
```
|
||||
□ Hur mycket lön har du tagit ut?
|
||||
→ SGI = utbetald lön
|
||||
|
||||
□ Tar du mest utdelning?
|
||||
→ Utdelning räknas EJ i SGI
|
||||
|
||||
□ Är lönen "marknadsmässig"?
|
||||
→ För låg lön kan ifrågasättas
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Beräkna vilken SGI du behöver
|
||||
2. Justera lön/överskott för att nå nivån
|
||||
3. Kontakta Försäkringskassan för omprövning
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Väntar barn men har låg SGI
|
||||
|
||||
**Symptom:** Oro för låg föräldrapenning.
|
||||
|
||||
**Tidslinje:**
|
||||
```
|
||||
SGI fastställs vid:
|
||||
- Födsel (för föräldrapenning)
|
||||
- Baseras på inkomst ~12 månader bakåt
|
||||
|
||||
Åtgärd om <12 månader kvar:
|
||||
→ Höj lön/överskott OMEDELBART
|
||||
→ Varje månad med högre inkomst hjälper
|
||||
|
||||
Åtgärd om >12 månader kvar:
|
||||
→ Planera inkomstfördelning noggrant
|
||||
→ Sikta på SGI ≥599,250 kr för maximal föräldrapenning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Plattformsproblem
|
||||
|
||||
### 5.1 Hittar inte fakturaunderlag
|
||||
|
||||
**Per plattform:**
|
||||
|
||||
| Plattform | Var hittar jag underlag? |
|
||||
|-----------|--------------------------|
|
||||
| YouTube | YouTube Studio → Analytics → Revenue → Transaction history i AdSense |
|
||||
| Twitch | Creator Dashboard → Channel Analytics → Revenue |
|
||||
| Instagram | Professional Dashboard → Monetization → Payouts |
|
||||
| TikTok | TikTok Creator Portal → Balance → Transaction History |
|
||||
| Patreon | Creator Dashboard → Income → Payout History |
|
||||
|
||||
**Om underlag saknas:**
|
||||
1. Kontakta plattformens support
|
||||
2. Använd bankutdrag som stöd
|
||||
3. Upprätta egen verifikation med tillgänglig information
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Plattform har hållit inne skatt (W-8BEN)
|
||||
|
||||
**Symptom:** 30% avdrag på USA-baserade plattformar.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Har du fyllt i W-8BEN korrekt?
|
||||
→ Formulär som intygar icke-amerikansk skattestatus
|
||||
|
||||
□ Har formuläret löpt ut?
|
||||
→ Giltigt i 3 år
|
||||
→ Måste förnyas
|
||||
|
||||
□ Har du angett korrekt land?
|
||||
→ Sverige har skatteavtal med USA
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Logga in på plattformen
|
||||
2. Gå till skatteuppgifter/tax information
|
||||
3. Fyll i nytt W-8BEN
|
||||
4. Kontakta support om innehållen skatt kan återbetalas
|
||||
|
||||
---
|
||||
|
||||
## 6. Juridiska problem
|
||||
|
||||
### 6.1 Klagomål om reklammarkering
|
||||
|
||||
**Symptom:** Varning från Konsumentverket eller klagomål.
|
||||
|
||||
**Omedelbara åtgärder:**
|
||||
|
||||
```
|
||||
1. Redigera inlägget
|
||||
→ Lägg till "REKLAM" överst
|
||||
→ Se till att det är tydligt synligt
|
||||
|
||||
2. Dokumentera
|
||||
→ Ta screenshot av korrigerat inlägg
|
||||
→ Notera datum och tid för ändring
|
||||
|
||||
3. Informera uppdragsgivaren
|
||||
→ De kan också bli ansvariga
|
||||
```
|
||||
|
||||
**Förebyggande:**
|
||||
- Märk ALLA betalda samarbeten
|
||||
- Placera märkning ÖVERST
|
||||
- Vid osäkerhet: Märk ändå
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Upphovsrättskrav (musik, bilder)
|
||||
|
||||
**Symptom:** Copyright claim, krav på ersättning, nedtagning.
|
||||
|
||||
**Omedelbara åtgärder:**
|
||||
|
||||
```
|
||||
1. Ta ner innehållet
|
||||
→ Stoppar ytterligare intrång
|
||||
|
||||
2. Svara INTE på krav utan juridisk rådgivning
|
||||
→ Medge ingenting
|
||||
|
||||
3. Dokumentera
|
||||
→ Spara allt material
|
||||
→ Notera publiceringsperiod
|
||||
```
|
||||
|
||||
**Förebyggande:**
|
||||
- Använd endast licensierad musik
|
||||
- Epidemic Sound, Artlist = säkra alternativ
|
||||
- Kontrollera bildlicenser
|
||||
|
||||
---
|
||||
|
||||
## 7. Dubbel struktur-problem
|
||||
|
||||
### 7.1 Blandat EF och AB
|
||||
|
||||
**Symptom:** Oklart vilket företag som fakturerat/betalat vad.
|
||||
|
||||
**Kontrollpunkter:**
|
||||
|
||||
```
|
||||
□ Finns transaktioner bokförda i fel företag?
|
||||
→ Gå igenom alla verifikationer
|
||||
|
||||
□ Har samma kostnad bokförts i båda?
|
||||
→ Dubbelräkning ej tillåten
|
||||
|
||||
□ Har internfakturor hanterats korrekt?
|
||||
→ Moms ska vara med
|
||||
→ Avtal ska finnas
|
||||
```
|
||||
|
||||
**Åtgärder:**
|
||||
1. Gör fullständig genomgång av båda bokföringarna
|
||||
2. Upprätta lista över alla internfakturor
|
||||
3. Korrigera felaktiga bokningar
|
||||
4. Upprätta tydliga rutiner
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Skatteverket ifrågasätter internprissättning
|
||||
|
||||
**Symptom:** Frågor om transaktioner mellan EF och AB.
|
||||
|
||||
**Dokumentation som behövs:**
|
||||
|
||||
```
|
||||
□ Skriftliga avtal för alla transaktioner
|
||||
□ Prissättningsunderlag (marknadsjämförelser)
|
||||
□ Affärsmässig motivering
|
||||
□ Faktiska leveranser/prestationer
|
||||
```
|
||||
|
||||
**Försvar:**
|
||||
1. Visa att priset är marknadsmässigt
|
||||
2. Visa att verklig tjänst/vara levererades
|
||||
3. Visa affärsmässigt syfte (ej endast skatteplanering)
|
||||
|
||||
---
|
||||
|
||||
## 8. Akuta situationer
|
||||
|
||||
### 8.1 Missad deadline
|
||||
|
||||
| Deadline | Konsekvens vid miss | Åtgärd |
|
||||
|----------|---------------------|--------|
|
||||
| Momsdeklaration | Förseningsavgift | Lämna in omedelbart |
|
||||
| Periodisk sammanställning | 1,250 kr avgift | Lämna in omedelbart |
|
||||
| Inkomstdeklaration | Förseningsavgift + skönstaxering | Kontakta Skatteverket |
|
||||
| F-skatt betalning | Ränta | Betala + kontakta Skatteverket |
|
||||
|
||||
### 8.2 Kontrollbesked från Skatteverket
|
||||
|
||||
**Steg:**
|
||||
1. Läs noggrant vad de frågar om
|
||||
2. Samla all relevant dokumentation
|
||||
3. Svara inom angiven tid
|
||||
4. Vid osäkerhet: Anlita rådgivare
|
||||
5. Var sanningsenlig men ange inte mer än vad som frågas
|
||||
|
||||
### 8.3 Revision
|
||||
|
||||
**Förberedelser:**
|
||||
1. Samla ALL bokföring för perioden
|
||||
2. Kontrollera att verifikationer finns
|
||||
3. Förbered förklaringar för ovanliga poster
|
||||
4. Anlita redovisningskonsult om möjligt
|
||||
@@ -1,306 +0,0 @@
|
||||
# Kalender och deadlines
|
||||
|
||||
## Årsöversikt
|
||||
|
||||
### Januari
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) december | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration december | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 20 | Periodisk sammanställning Q4 | EU-försäljning |
|
||||
| 26 | Momsdeklaration Q4 (okt-dec) | Kvartalsredovisare |
|
||||
| 31 | Kontrolluppgifter till Skatteverket | AB (löner) |
|
||||
|
||||
### Februari
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) januari | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration januari | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 28 | Preliminär inkomstdeklaration (ändring) | Om behov finns |
|
||||
|
||||
### Mars
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) februari | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration februari | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 31 | Bokslut räkenskapsår (kalenderår) | AB |
|
||||
|
||||
### April
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) mars | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration mars | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 20 | Periodisk sammanställning Q1 | EU-försäljning |
|
||||
| 26 | Momsdeklaration Q1 (jan-mar) | Kvartalsredovisare |
|
||||
|
||||
### Maj
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 2 | Inkomstdeklaration privatperson + NE | EF |
|
||||
| 12 | Momsdeklaration (månads) april | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration april | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
|
||||
### Juni
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) maj | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration maj | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 30 | Årsredovisning till Bolagsverket | AB (kalenderår) |
|
||||
|
||||
### Juli
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 1 | Inkomstdeklaration AB (kalenderår) | AB |
|
||||
| 12 | Momsdeklaration (månads) juni | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration juni | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 20 | Periodisk sammanställning Q2 | EU-försäljning |
|
||||
| 26 | Momsdeklaration Q2 (apr-jun) | Kvartalsredovisare |
|
||||
|
||||
### Augusti
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) juli | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration juli | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| ~15 | Slutskattebesked | Alla |
|
||||
|
||||
### September
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) augusti | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration augusti | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| ~12 | Kvarskatt inbetalning 1 | Vid kvarskatt |
|
||||
|
||||
### Oktober
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) september | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration september | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 20 | Periodisk sammanställning Q3 | EU-försäljning |
|
||||
| 26 | Momsdeklaration Q3 (jul-sep) | Kvartalsredovisare |
|
||||
|
||||
### November
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) oktober | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration oktober | AB med anställda |
|
||||
| ~12 | Kvarskatt inbetalning 2 | Vid kvarskatt |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
|
||||
### December
|
||||
|
||||
| Dag | Händelse | Gäller |
|
||||
|-----|----------|--------|
|
||||
| 12 | Momsdeklaration (månads) november | Månadsredovisare |
|
||||
| 12 | Arbetsgivardeklaration november | AB med anställda |
|
||||
| 17 | F-skatt inbetalning | EF och AB |
|
||||
| 27 | Sista arbetsdag före jul (myndigheter) | Planering |
|
||||
| 31 | Räkenskapsårets slut | Kalenderår |
|
||||
|
||||
---
|
||||
|
||||
## Kvartalsvisa deadlines
|
||||
|
||||
### Momsdeklaration (kvartalsredovisare)
|
||||
|
||||
| Kvartal | Period | Deadline |
|
||||
|---------|--------|----------|
|
||||
| Q1 | Jan-Mar | 12 maj (papper) / 26 april (e-tjänst) |
|
||||
| Q2 | Apr-Jun | 17 aug (papper) / 26 juli (e-tjänst) |
|
||||
| Q3 | Jul-Sep | 12 nov (papper) / 26 okt (e-tjänst) |
|
||||
| Q4 | Okt-Dec | 12 feb (papper) / 26 jan (e-tjänst) |
|
||||
|
||||
### Periodisk sammanställning (EU-försäljning)
|
||||
|
||||
| Kvartal | Period | Deadline |
|
||||
|---------|--------|----------|
|
||||
| Q1 | Jan-Mar | 20 april |
|
||||
| Q2 | Apr-Jun | 20 juli |
|
||||
| Q3 | Jul-Sep | 20 oktober |
|
||||
| Q4 | Okt-Dec | 20 januari |
|
||||
|
||||
### OSS-deklaration (EU-privatpersonsförsäljning)
|
||||
|
||||
| Kvartal | Period | Deadline |
|
||||
|---------|--------|----------|
|
||||
| Q1 | Jan-Mar | 30 april |
|
||||
| Q2 | Apr-Jun | 31 juli |
|
||||
| Q3 | Jul-Sep | 31 oktober |
|
||||
| Q4 | Okt-Dec | 31 januari |
|
||||
|
||||
---
|
||||
|
||||
## F-skatt inbetalningsdatum
|
||||
|
||||
| Månad | Inbetalningsdatum |
|
||||
|-------|-------------------|
|
||||
| Januari | 17 januari |
|
||||
| Februari | 17 februari |
|
||||
| Mars | 17 mars |
|
||||
| April | 17 april |
|
||||
| Maj | 17 maj |
|
||||
| Juni | 17 juni |
|
||||
| Juli | 17 juli |
|
||||
| Augusti | 17 augusti |
|
||||
| September | 17 september |
|
||||
| Oktober | 17 oktober |
|
||||
| November | 17 november |
|
||||
| December | 17 december |
|
||||
|
||||
**OBS:** Om den 17:e infaller på helg, gäller närmast följande vardag.
|
||||
|
||||
---
|
||||
|
||||
## Löpande uppgifter
|
||||
|
||||
### Dagligen/Veckovis
|
||||
|
||||
- [ ] Spara kvitton och verifikationer
|
||||
- [ ] Notera mottagna produkter (barter)
|
||||
- [ ] Dokumentera arbetsresor
|
||||
|
||||
### Månadsvis
|
||||
|
||||
- [ ] Stäm av bankkonton mot bokföring
|
||||
- [ ] Bokför månadens transaktioner
|
||||
- [ ] Kontrollera plattformsutbetalningar
|
||||
- [ ] Beräkna eventuella valutakursdifferenser
|
||||
- [ ] Momsdeklaration (om månadsredovisare)
|
||||
|
||||
### Kvartalsvis
|
||||
|
||||
- [ ] Momsdeklaration (om kvartalsredovisare)
|
||||
- [ ] Periodisk sammanställning (om EU-försäljning)
|
||||
- [ ] OSS-deklaration (om EU-privatpersonsförsäljning)
|
||||
- [ ] Resultatuppföljning
|
||||
- [ ] SGI-kontroll
|
||||
|
||||
### Årligen
|
||||
|
||||
**Januari-februari:**
|
||||
- [ ] Upprätta årsbokslut för föregående år
|
||||
- [ ] Sammanställ alla intäkter och kostnader
|
||||
- [ ] Beräkna avskrivningar
|
||||
- [ ] Hantera periodiseringsfonder (EF)
|
||||
|
||||
**Mars-april:**
|
||||
- [ ] Förbered inkomstdeklaration
|
||||
- [ ] Kontrollera kontrolluppgifter
|
||||
- [ ] Granska förifylld deklaration
|
||||
|
||||
**Maj:**
|
||||
- [ ] Lämna inkomstdeklaration (2 maj)
|
||||
- [ ] NE-bilaga (EF)
|
||||
|
||||
**Juni-juli:**
|
||||
- [ ] Årsredovisning till Bolagsverket (AB)
|
||||
- [ ] Inkomstdeklaration AB (1 juli)
|
||||
|
||||
**Augusti-september:**
|
||||
- [ ] Analysera slutskattebesked
|
||||
- [ ] Justera preliminärskatt för nästa år
|
||||
- [ ] Planera för eventuell kvarskatt
|
||||
|
||||
**Oktober-december:**
|
||||
- [ ] Skatteplanering inför årsskiftet
|
||||
- [ ] 3:12-beräkning (AB)
|
||||
- [ ] Pensionssparande (avdragsgillt)
|
||||
- [ ] Friskvårdsbidrag (AB)
|
||||
|
||||
---
|
||||
|
||||
## Påminnelser per bolagsform
|
||||
|
||||
### Enskild firma (EF)
|
||||
|
||||
| Tidpunkt | Uppgift |
|
||||
|----------|---------|
|
||||
| Månadsvis | Bokföring, avstämning |
|
||||
| Kvartalsvis | Moms, periodisk sammanställning |
|
||||
| 2 maj | Inkomstdeklaration + NE-bilaga |
|
||||
| Löpande | Dokumentera barter, resor, avdrag |
|
||||
|
||||
### Aktiebolag (AB)
|
||||
|
||||
| Tidpunkt | Uppgift |
|
||||
|----------|---------|
|
||||
| Månadsvis | Bokföring, lönehantering, arbetsgivardeklaration |
|
||||
| Kvartalsvis | Moms, periodisk sammanställning |
|
||||
| 31 mars | Bokslut (kalenderår) |
|
||||
| 30 juni | Årsredovisning till Bolagsverket |
|
||||
| 1 juli | Inkomstdeklaration AB |
|
||||
| December | 3:12-beräkning, utdelningsbeslut |
|
||||
|
||||
### Dubbel struktur (EF + AB)
|
||||
|
||||
| Tidpunkt | Uppgift |
|
||||
|----------|---------|
|
||||
| Månadsvis | Separat bokföring båda, internfakturor |
|
||||
| Kvartalsvis | Separata momsdeklarationer, periodiska sammanställningar |
|
||||
| 2 maj | Inkomstdeklaration privatperson + NE |
|
||||
| 30 juni | Årsredovisning AB |
|
||||
| 1 juli | Inkomstdeklaration AB |
|
||||
| Löpande | Avstämning interntransaktioner |
|
||||
|
||||
---
|
||||
|
||||
## Kritiska datum att INTE missa
|
||||
|
||||
### Förseningsavgifter
|
||||
|
||||
| Uppgift | Avgift vid miss |
|
||||
|---------|-----------------|
|
||||
| Periodisk sammanställning | 1,250 kr |
|
||||
| Momsdeklaration | 625 kr (ökar vid upprepad försening) |
|
||||
| Inkomstdeklaration | 1,250 kr (kan öka) |
|
||||
| Årsredovisning | Förseningsavgift + risk för likvidation |
|
||||
|
||||
### Viktiga gränsdatum
|
||||
|
||||
| Datum | Händelse |
|
||||
|-------|----------|
|
||||
| 2 maj | Sista dag inkomstdeklaration privatperson |
|
||||
| 30 juni | Sista dag årsredovisning AB (6 mån efter bokslut) |
|
||||
| 1 juli | Sista dag inkomstdeklaration AB (kalenderår) |
|
||||
| 31 december | Räkenskapsårets slut, sista dag för avdragsgilla pensionsinbetalningar |
|
||||
|
||||
---
|
||||
|
||||
## Platshållare för användarspecifika påminnelser
|
||||
|
||||
### Min bolagsform: [EF / AB / Båda]
|
||||
|
||||
### Mina deadlines:
|
||||
|
||||
| Uppgift | Deadline | Status |
|
||||
|---------|----------|--------|
|
||||
| Momsdeklaration Q_ | | |
|
||||
| Periodisk sammanställning Q_ | | |
|
||||
| F-skatt månad | | |
|
||||
| Inkomstdeklaration | | |
|
||||
| Årsredovisning | | |
|
||||
|
||||
### Mina återkommande uppgifter:
|
||||
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
@@ -1,542 +0,0 @@
|
||||
# Varningsflaggor
|
||||
|
||||
## 1. Bokföringsvarningar
|
||||
|
||||
### 1.1 Sammanblandning av entiteter
|
||||
|
||||
**Flagga:** Transaktion verkar bokförd i fel företag
|
||||
|
||||
**Triggers:**
|
||||
- Samma fakturanummer i både EF och AB
|
||||
- Kostnad bokförd i AB men betald från EF-konto
|
||||
- Intäkt från plattform bokförd i båda entiteterna
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Denna transaktion kan vara bokförd i fel entitet.
|
||||
Kontrollera:
|
||||
- Vilken entitet har avtalet med kunden?
|
||||
- Från vilket konto gjordes betalningen?
|
||||
- Har samma transaktion bokförts i den andra entiteten?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Privata kostnader i verksamheten
|
||||
|
||||
**Flagga:** Misstänkt privat kostnad bokförd som företagskostnad
|
||||
|
||||
**Triggers:**
|
||||
- Klädinköp (ej uniformer/rekvisita)
|
||||
- Smink och skönhetsprodukter
|
||||
- Gym/träningskort (i EF)
|
||||
- Restaurangbesök utan dokumenterad affärsanledning
|
||||
- Resor utan arbetssyfte
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Denna kostnad kan vara privat och ej avdragsgill.
|
||||
[Typ av kostnad] kräver:
|
||||
- Dokumentation av affärssyfte
|
||||
- Kan endast dras av om [specifika villkor]
|
||||
Vill du fortsätta bokföra detta som företagskostnad?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Saknad verifikation
|
||||
|
||||
**Flagga:** Transaktion utan tillräckligt underlag
|
||||
|
||||
**Triggers:**
|
||||
- Inbetalning utan faktura/avtal
|
||||
- Barter-produkt utan värderingsunderlag
|
||||
- Resa utan syftesdokumentation
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Verifikation saknas eller är ofullständig.
|
||||
För att bokföra behövs:
|
||||
- [Lista på kravd dokumentation]
|
||||
Vill du skapa ett eget underlag?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Orimliga belopp
|
||||
|
||||
**Flagga:** Belopp avviker kraftigt från normalt
|
||||
|
||||
**Triggers:**
|
||||
- Enskilt inköp >50,000 kr utan förklaring
|
||||
- Negativ intäkt (rättelse?)
|
||||
- Valutakurs utanför rimligt intervall
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Beloppet verkar ovanligt.
|
||||
- Bokfört belopp: [X] kr
|
||||
- Normalt intervall: [Y-Z] kr
|
||||
Kontrollera att beloppet är korrekt.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Momsvarningar
|
||||
|
||||
### 2.1 Periodisk sammanställning saknas
|
||||
|
||||
**Flagga:** EU-försäljning utan periodisk sammanställning
|
||||
|
||||
**Triggers:**
|
||||
- Försäljning bokförd i Ruta 39
|
||||
- Ingen periodisk sammanställning inlämnad för perioden
|
||||
- Periodisk sammanställning ≠ Ruta 39
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Periodisk sammanställning krävs.
|
||||
Du har bokfört [X] kr i EU-försäljning under [kvartal].
|
||||
Deadline för periodisk sammanställning: [datum]
|
||||
Förseningsavgift vid miss: 1,250 kr
|
||||
|
||||
Kunder att rapportera:
|
||||
- [Företag 1]: [VAT-nummer], [belopp]
|
||||
- [Företag 2]: [VAT-nummer], [belopp]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Felaktig momshantering
|
||||
|
||||
**Flagga:** Moms hanterad inkonsekvent med kundtyp
|
||||
|
||||
**Triggers:**
|
||||
- Svensk moms debiterad till EU-företag med VAT-nummer
|
||||
- Ingen moms debiterad till svensk kund
|
||||
- Export-moms på EU-försäljning
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Momshanteringen verkar felaktig.
|
||||
Kund: [Kundnamn]
|
||||
Kundens säte: [Land]
|
||||
VAT-nummer: [Om finns]
|
||||
|
||||
Korrekt momshantering:
|
||||
- [Regel som borde gälla]
|
||||
- Ruta i momsdeklaration: [X]
|
||||
|
||||
Vill du korrigera?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 OSS-tröskel överskriden
|
||||
|
||||
**Flagga:** EU-privatpersonförsäljning överstiger 99,680 kr
|
||||
|
||||
**Triggers:**
|
||||
- Summa B2C EU-försäljning >99,680 kr under året
|
||||
- Fortsatt svensk moms efter överskridande
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: OSS-tröskel överskriden.
|
||||
Total EU-privatpersonsförsäljning: [X] kr
|
||||
Tröskel: 99,680 kr
|
||||
|
||||
Konsekvens:
|
||||
- Obligatoriskt att debitera mottagarlandets moms
|
||||
- Registrera för OSS hos Skatteverket
|
||||
- Lämna kvartalsvis OSS-deklaration
|
||||
|
||||
Tidigare försäljning under året kan behöva korrigeras.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Ogiltigt VAT-nummer
|
||||
|
||||
**Flagga:** VAT-nummer kunde inte verifieras
|
||||
|
||||
**Triggers:**
|
||||
- VAT-nummer angivet men validering misslyckades
|
||||
- Format stämmer ej med landskod
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: VAT-nummer kunde inte verifieras.
|
||||
Angivet nummer: [VAT-nummer]
|
||||
Land: [Landkod]
|
||||
|
||||
Verifiera på VIES: https://ec.europa.eu/taxation_customs/vies/
|
||||
|
||||
Om ogiltigt:
|
||||
- Kunden behandlas som privatperson
|
||||
- Svensk moms eller OSS gäller
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SGI-varningar
|
||||
|
||||
### 3.1 Låg SGI-grund
|
||||
|
||||
**Flagga:** SGI-grundande inkomst under rekommenderad nivå
|
||||
|
||||
**Triggers:**
|
||||
- EF: Överskott <200,000 kr/år
|
||||
- AB: Lön <200,000 kr/år
|
||||
- Total SGI-grund <400,000 kr/år
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Lågt socialförsäkringsskydd.
|
||||
Din SGI-grund: [X] kr/år
|
||||
Rekommenderad miniminivå: 400,000 kr/år
|
||||
|
||||
Vid sjukdom:
|
||||
- Nuvarande sjukpenning: ~[Y] kr/dag
|
||||
- Med rekommenderad SGI: ~[Z] kr/dag
|
||||
|
||||
Vid föräldraledighet (390 dagar):
|
||||
- Nu: [A] kr totalt
|
||||
- Rekommenderat: [B] kr totalt
|
||||
- Skillnad: [C] kr
|
||||
|
||||
Överväg att:
|
||||
- EF: Minska avdrag / öka överskott
|
||||
- AB: Höja löneuttag
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Hög utdelning, låg lön (AB)
|
||||
|
||||
**Flagga:** Obalans mellan lön och utdelning
|
||||
|
||||
**Triggers:**
|
||||
- Utdelning >3x lön
|
||||
- Lön under 300,000 kr med vinst >500,000 kr
|
||||
- SGI-konsekvens ej beaktad
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Skatteoptimering påverkar ditt sociala skydd.
|
||||
Uttagen lön: [X] kr
|
||||
Planerad utdelning: [Y] kr
|
||||
Din SGI: [X] kr (endast lön räknas)
|
||||
|
||||
Konsekvens:
|
||||
- Sjukpenning baseras på [X] kr
|
||||
- Föräldrapenning baseras på [X] kr
|
||||
- Utdelning [Y] kr ger INGET socialt skydd
|
||||
|
||||
Rekommendation: Balansera lön och utdelning utifrån ditt behov av trygghet.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Planerad föräldraledighet
|
||||
|
||||
**Flagga:** Användare nämner barn/föräldraledighet med låg SGI
|
||||
|
||||
**Triggers:**
|
||||
- Nyckelord: "gravid", "barn", "föräldraledigt", "VAB"
|
||||
- Kombinerat med låg SGI-grund
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VIKTIGT: Föräldrapenning baseras på din SGI.
|
||||
Nuvarande SGI-grund: [X] kr
|
||||
|
||||
Om barn planeras inom 12 månader:
|
||||
- SGI fastställs vid barnets födelse
|
||||
- Varje månad med högre inkomst höjer SGI
|
||||
|
||||
Åtgärd NU:
|
||||
- EF: Redovisa högre överskott
|
||||
- AB: Höj månadslönen
|
||||
- Sikta på SGI ≥599,250 kr för maximal föräldrapenning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Strukturvarningar (dubbel struktur)
|
||||
|
||||
### 4.1 Internprissättning avviker
|
||||
|
||||
**Flagga:** Pris mellan EF och AB verkar icke-marknadsmässigt
|
||||
|
||||
**Triggers:**
|
||||
- Timpris <300 kr eller >2,000 kr
|
||||
- Avvikelse >20% från jämförbar extern prissättning
|
||||
- Pris utan dokumenterad grund
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Internprissättningen kan ifrågasättas.
|
||||
Transaktion: [Beskrivning]
|
||||
Pris: [X] kr
|
||||
Marknadsmässigt intervall: [Y-Z] kr
|
||||
|
||||
Skatteverket kräver armlängdspris.
|
||||
Vid avvikelse:
|
||||
- Risk för omklassificering
|
||||
- Förtäckt lön/utdelning beskattas hårdare
|
||||
|
||||
Rekommendation: Dokumentera varför priset är marknadsmässigt.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Avtal saknas för interntransaktion
|
||||
|
||||
**Flagga:** Faktura mellan EF och AB utan avtal
|
||||
|
||||
**Triggers:**
|
||||
- Internfaktura bokförd
|
||||
- Inget avtal registrerat i systemet
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Avtal saknas för interntransaktion.
|
||||
Transaktion: [Beskrivning]
|
||||
Belopp: [X] kr
|
||||
|
||||
Krav för godkänd interntransaktion:
|
||||
1. Skriftligt avtal
|
||||
2. Marknadsmässig prissättning
|
||||
3. Verklig tjänst/vara levererad
|
||||
4. Affärsmässig motivering
|
||||
|
||||
Upprätta avtal FÖRE fakturering.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Alla kostnader i ett företag, alla intäkter i annat
|
||||
|
||||
**Flagga:** Misstänkt resultatförflyttning
|
||||
|
||||
**Triggers:**
|
||||
- EF har endast kostnader (förlust)
|
||||
- AB har endast intäkter (hög vinst)
|
||||
- Eller vice versa
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Obalanserad resultatfördelning.
|
||||
EF resultat: [X] kr
|
||||
AB resultat: [Y] kr
|
||||
|
||||
Detta mönster kan tyda på:
|
||||
- Felaktig allokering av kostnader
|
||||
- Resultatförflyttning (ej tillåtet utan affärsmässig grund)
|
||||
|
||||
Kontrollera:
|
||||
- Är kostnaderna bokförda i rätt entitet?
|
||||
- Har rätt entitet fakturerat kunderna?
|
||||
- Finns underlag för fördelningen?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Juridiska varningar
|
||||
|
||||
### 5.1 Reklammarkering saknas
|
||||
|
||||
**Flagga:** Betalt samarbete utan tydlig reklammarkering
|
||||
|
||||
**Triggers:**
|
||||
- Inkomst från varumärke/plattform
|
||||
- Ingen notering om reklammarkering
|
||||
- Samarbetsavtal registrerat
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
PÅMINNELSE: Kontrollera reklammarkering.
|
||||
Samarbete med: [Företag]
|
||||
Ersättning: [X] kr
|
||||
|
||||
Krav enligt Marknadsföringslagen:
|
||||
- "REKLAM" eller "ANNONS" överst i inlägg
|
||||
- Tydlig och omedelbart synlig
|
||||
- I video: Muntligt OCH/ELLER textgrafik
|
||||
|
||||
Underlåtenhet kan leda till:
|
||||
- Varning från Konsumentverket
|
||||
- Förbud vid vite
|
||||
- Marknadsstörningsavgift
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Upphovsrättskänsligt material
|
||||
|
||||
**Flagga:** Risk för upphovsrättsintrång
|
||||
|
||||
**Triggers:**
|
||||
- Video/musik-content i kommersiellt sammanhang
|
||||
- Ingen licensdokumentation
|
||||
- Känd låt/artist nämnd
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Kontrollera upphovsrätt.
|
||||
Om du använder musik/bilder/video:
|
||||
- Har du licens?
|
||||
- Är materialet royalty-free?
|
||||
|
||||
Utan licens:
|
||||
- Risk för skadestånd (referens: ABBA-fallet 200,000 kr)
|
||||
- Innehåll kan tas ner
|
||||
- Plattformskonto kan stängas
|
||||
|
||||
Säkra alternativ:
|
||||
- Epidemic Sound
|
||||
- Artlist
|
||||
- Creative Commons
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Administrativa varningar
|
||||
|
||||
### 6.1 Deadline närmar sig
|
||||
|
||||
**Flagga:** Viktig deadline inom 7 dagar
|
||||
|
||||
**Triggers:**
|
||||
- Momsdeklaration oinlämnad
|
||||
- Periodisk sammanställning oinlämnad
|
||||
- F-skatt obetald
|
||||
- Inkomstdeklaration oinlämnad
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
DEADLINE-VARNING: [Uppgift]
|
||||
Deadline: [Datum] ([X] dagar kvar)
|
||||
|
||||
Status: [Ej påbörjad / Påbörjad / Klar]
|
||||
|
||||
Förseningsavgift vid miss: [Belopp] kr
|
||||
|
||||
[Länk/instruktion för att slutföra]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Saknad kontrolluppgift
|
||||
|
||||
**Flagga:** Inkomst utan motsvarande kontrolluppgift
|
||||
|
||||
**Triggers:**
|
||||
- Intäkt bokförd från svenskt företag
|
||||
- Ingen kontrolluppgift i förväg
|
||||
- Deklarationsperiod närmar sig
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Kontrolluppgift kan saknas.
|
||||
Inkomst från: [Företag]
|
||||
Belopp: [X] kr
|
||||
Period: [År/Månad]
|
||||
|
||||
Kontrollera:
|
||||
- Fick du kontrolluppgift?
|
||||
- Om nej, kontakta [Företag]
|
||||
|
||||
Kontrolluppgifter ska finnas i e-tjänsten senast [datum].
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6.3 Valutakurs ej dokumenterad
|
||||
|
||||
**Flagga:** Utländsk transaktion utan kursdokumentation
|
||||
|
||||
**Triggers:**
|
||||
- Belopp i USD/EUR/annan valuta
|
||||
- Ingen valutakurs angiven
|
||||
- Stor skillnad mellan möjliga kurser
|
||||
|
||||
**AI-åtgärd:**
|
||||
```
|
||||
VARNING: Valutakurs behöver dokumenteras.
|
||||
Transaktion: [Beskrivning]
|
||||
Belopp: [Valuta] [Originalbelopp]
|
||||
Datum: [Inbetalningsdatum]
|
||||
|
||||
Ange valutakurs eller hämta automatiskt:
|
||||
- Riksbankens kurs [datum]: [X.XX]
|
||||
- Beräknat SEK-belopp: [Y] kr
|
||||
|
||||
Kursen ska vara den som gällde vid inbetalningsdatum.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Automatiska kontroller
|
||||
|
||||
### 7.1 Periodiska avstämningar
|
||||
|
||||
```
|
||||
MÅNATLIG KONTROLL:
|
||||
□ Summa intäkter plattformar = Summa bokförda intäkter?
|
||||
□ Banksaldo = Bokfört saldo?
|
||||
□ Ingående moms rimlig i förhållande till kostnader?
|
||||
□ Utgående moms rimlig i förhållande till intäkter?
|
||||
|
||||
KVARTALSVIS KONTROLL:
|
||||
□ Periodisk sammanställning = Ruta 39?
|
||||
□ SGI-grund tillräcklig?
|
||||
□ Internfakturor avstämda mellan EF och AB?
|
||||
|
||||
ÅRSVIS KONTROLL:
|
||||
□ Alla kontrolluppgifter mottagna?
|
||||
□ Preliminärskatt vs faktisk skatt?
|
||||
□ Periodiseringsfonder inom gränser?
|
||||
□ 3:12-gränsbelopp beräknat?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7.2 Flaggningströsklar
|
||||
|
||||
| Kontroll | Flagga om |
|
||||
|----------|-----------|
|
||||
| SGI-grund | <200,000 kr/år |
|
||||
| Lön vs utdelning (AB) | Utdelning >3x lön |
|
||||
| Internpris | Avviker >20% från marknad |
|
||||
| Periodisk sammanställning | Saknas vid EU-försäljning |
|
||||
| Momsavvikelse | >5% differens |
|
||||
| Kostnad utan verifikation | Alltid |
|
||||
| Privat kostnad i företag | Alltid |
|
||||
| Deadline | <7 dagar kvar |
|
||||
|
||||
---
|
||||
|
||||
## 8. Eskaleringsregler
|
||||
|
||||
### När ska AI rekommendera extern rådgivning?
|
||||
|
||||
```
|
||||
REKOMMENDERA REVISOR/REDOVISNINGSKONSULT:
|
||||
- Omsättning >3 MSEK/år
|
||||
- Komplex dubbel struktur
|
||||
- Internationella transaktioner med osäker momshantering
|
||||
- Skatteverket har skickat förfrågan
|
||||
|
||||
REKOMMENDERA JURIST:
|
||||
- Upphovsrättskrav
|
||||
- Avtalsförhandling med stort värde (>100,000 kr)
|
||||
- Tvist med uppdragsgivare
|
||||
- Marknadsföringsrättslig varning
|
||||
|
||||
REKOMMENDERA FÖRSÄKRINGSKASSAN:
|
||||
- SGI-relaterade frågor
|
||||
- Uppbyggnadsskede-bedömning
|
||||
- Omprövning av beslut
|
||||
```
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType } from '@/types'
|
||||
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
|
||||
import { getVatRate } from './vat-entries'
|
||||
|
||||
/**
|
||||
* Maps TransactionCategory to BAS accounts for journal entry creation
|
||||
@@ -45,7 +46,8 @@ export function getCategoryAccountMapping(
|
||||
category: TransactionCategory,
|
||||
amount: number,
|
||||
isBusiness: boolean,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
vatTreatment?: VatTreatment
|
||||
): CategoryAccountMapping {
|
||||
// Private/owner transactions use entity-specific accounts
|
||||
if (!isBusiness) {
|
||||
@@ -77,7 +79,7 @@ export function getCategoryAccountMapping(
|
||||
// Business income categories
|
||||
const incomeMapping: Record<string, string> = {
|
||||
income_services: '3001', // Försäljning tjänster 25%
|
||||
income_products: '3001', // Försäljning varor 25%
|
||||
income_products: '3002', // Försäljning varor 25%
|
||||
income_other: '3900', // Övriga rörelseintäkter
|
||||
}
|
||||
|
||||
@@ -89,11 +91,14 @@ export function getCategoryAccountMapping(
|
||||
const vatExemptCategories = ['expense_bank_fees', 'expense_card_fees', 'expense_currency_exchange']
|
||||
const isVatExempt = vatExemptCategories.includes(category)
|
||||
|
||||
// Use provided vatTreatment, or default based on category
|
||||
const resolvedVat = vatTreatment ?? (isVatExempt ? null : 'standard_25')
|
||||
|
||||
return {
|
||||
debitAccount: expenseAccount,
|
||||
creditAccount: BANK_ACCOUNT,
|
||||
vatTreatment: isVatExempt ? null : 'standard_25',
|
||||
vatDebitAccount: isVatExempt ? null : '2641', // Debiterad ingående moms
|
||||
vatTreatment: resolvedVat,
|
||||
vatDebitAccount: resolvedVat ? '2641' : null, // Debiterad ingående moms
|
||||
vatCreditAccount: null,
|
||||
}
|
||||
}
|
||||
@@ -101,12 +106,33 @@ export function getCategoryAccountMapping(
|
||||
// Check if it's an income category
|
||||
if (category.startsWith('income_')) {
|
||||
const incomeAccount = incomeMapping[category] || '3900'
|
||||
|
||||
// Use provided vatTreatment, or default to standard_25
|
||||
const resolvedVat = vatTreatment ?? 'standard_25'
|
||||
|
||||
// Determine output VAT account based on rate
|
||||
let outputVatAccount: string | null = null
|
||||
switch (resolvedVat) {
|
||||
case 'standard_25':
|
||||
outputVatAccount = '2611' // Utgående moms försäljning 25%
|
||||
break
|
||||
case 'reduced_12':
|
||||
outputVatAccount = '2621' // Utgående moms försäljning 12%
|
||||
break
|
||||
case 'reduced_6':
|
||||
outputVatAccount = '2631' // Utgående moms försäljning 6%
|
||||
break
|
||||
default:
|
||||
outputVatAccount = null
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
debitAccount: BANK_ACCOUNT,
|
||||
creditAccount: incomeAccount,
|
||||
vatTreatment: 'standard_25',
|
||||
vatTreatment: resolvedVat,
|
||||
vatDebitAccount: null,
|
||||
vatCreditAccount: '2610', // Utgående moms
|
||||
vatCreditAccount: outputVatAccount,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,34 +164,38 @@ export function buildMappingResultFromCategory(
|
||||
category: TransactionCategory,
|
||||
transaction: Transaction,
|
||||
isBusiness: boolean,
|
||||
entityType: EntityType = 'enskild_firma'
|
||||
entityType: EntityType = 'enskild_firma',
|
||||
vatTreatment?: VatTreatment
|
||||
): MappingResult {
|
||||
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType)
|
||||
const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType, vatTreatment)
|
||||
|
||||
const vatLines: VatJournalLine[] = []
|
||||
|
||||
// Calculate VAT if applicable (Swedish standard: gross amount includes 25% VAT)
|
||||
// VAT = gross * 0.25 / 1.25 = gross * 0.20
|
||||
if (isBusiness && mapping.vatTreatment === 'standard_25') {
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
const vatAmount = Math.round((grossAmount * 0.20) * 100) / 100 // Round to öre
|
||||
// Calculate VAT if applicable using the resolved treatment from mapping
|
||||
const treatment = mapping.vatTreatment as VatTreatment | null
|
||||
if (isBusiness && treatment) {
|
||||
const vatRate = getVatRate(treatment)
|
||||
if (vatRate > 0) {
|
||||
const grossAmount = Math.abs(transaction.amount)
|
||||
const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100
|
||||
|
||||
if (transaction.amount < 0 && mapping.vatDebitAccount) {
|
||||
// Expense: Ingående moms (deductible VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatDebitAccount,
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
description: 'Ingående moms 25%',
|
||||
})
|
||||
} else if (transaction.amount > 0 && mapping.vatCreditAccount) {
|
||||
// Income: Utgående moms (output VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatCreditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
description: 'Utgående moms 25%',
|
||||
})
|
||||
if (transaction.amount < 0 && mapping.vatDebitAccount) {
|
||||
// Expense: Ingående moms (deductible VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatDebitAccount,
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
description: `Ingående moms ${vatRate * 100}%`,
|
||||
})
|
||||
} else if (transaction.amount > 0 && mapping.vatCreditAccount) {
|
||||
// Income: Utgående moms (output VAT)
|
||||
vatLines.push({
|
||||
account_number: mapping.vatCreditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
description: `Utgående moms ${vatRate * 100}%`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,9 +125,8 @@ function buildLineInserts(
|
||||
amount_in_currency: line.amount_in_currency ? Math.round(line.amount_in_currency * 100) / 100 : null,
|
||||
exchange_rate: line.exchange_rate || null,
|
||||
line_description: line.line_description || null,
|
||||
tax_code: line.tax_code || null,
|
||||
cost_center: line.cost_center || null,
|
||||
project: line.project || null,
|
||||
cost_center_id: line.cost_center || null,
|
||||
project_id: line.project || null,
|
||||
sort_order: index,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -187,10 +187,71 @@ export async function createCreditNoteJournalEntry(
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entry for kontantmetoden (cash method) when payment is received.
|
||||
* Combined entry: revenue + VAT recognised at payment.
|
||||
*
|
||||
* Debit 1930 Företagskonto [total]
|
||||
* Credit 30xx Försäljning [subtotal]
|
||||
* Credit 26xx Utgående moms [vat_amount] (if applicable)
|
||||
*/
|
||||
export async function createInvoiceCashEntry(
|
||||
userId: string,
|
||||
invoice: Invoice,
|
||||
paymentDate: string
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for payment date:', paymentDate)
|
||||
return null
|
||||
}
|
||||
|
||||
const revenueAccount = getRevenueAccount(invoice.vat_treatment)
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Debit: Företagskonto (total received)
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: invoice.total,
|
||||
credit_amount: 0,
|
||||
line_description: `Betalning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
// Credit: Revenue account (subtotal excl VAT)
|
||||
lines.push({
|
||||
account_number: revenueAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.subtotal,
|
||||
line_description: `Försäljning faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
|
||||
// Credit: Output VAT (if applicable)
|
||||
if (invoice.vat_amount > 0) {
|
||||
const vatAccount = getOutputVatAccount(invoice.vat_treatment)
|
||||
lines.push({
|
||||
account_number: vatAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: invoice.vat_amount,
|
||||
line_description: `Utgående moms faktura ${invoice.invoice_number}`,
|
||||
})
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: `Betalning faktura ${invoice.invoice_number} (kontantmetoden)`,
|
||||
source_type: 'invoice_cash_payment',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate revenue account based on VAT treatment
|
||||
*/
|
||||
function getRevenueAccount(vatTreatment: VatTreatment): string {
|
||||
export function getRevenueAccount(vatTreatment: VatTreatment): string {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25':
|
||||
return '3001' // Försäljning 25%
|
||||
@@ -212,7 +273,7 @@ function getRevenueAccount(vatTreatment: VatTreatment): string {
|
||||
/**
|
||||
* Get the output VAT account based on VAT treatment
|
||||
*/
|
||||
function getOutputVatAccount(vatTreatment: VatTreatment): string {
|
||||
export function getOutputVatAccount(vatTreatment: VatTreatment): string {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25':
|
||||
return '2611'
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { createJournalEntry, findFiscalPeriod } from './engine'
|
||||
import { generateReverseChargeLines } from './vat-entries'
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
JournalEntry,
|
||||
SupplierInvoice,
|
||||
SupplierInvoiceItem,
|
||||
} from '@/types'
|
||||
|
||||
/**
|
||||
* Create journal entry when a supplier invoice is registered (accrual method)
|
||||
*
|
||||
* Swedish domestic (25% VAT):
|
||||
* Debit 5xxx/6xxx (per item's account_number) [item line_total]
|
||||
* Debit 2641 Ingående moms [total VAT]
|
||||
* Credit 2440 Leverantörsskulder [total incl VAT]
|
||||
*
|
||||
* EU reverse charge (supplier_type = 'eu_business'):
|
||||
* Debit 5xxx/6xxx (per item) [total]
|
||||
* Debit 2645 Beräknad ingående moms [fiktiv VAT]
|
||||
* Credit 2614 Utgående moms omvänd [fiktiv VAT]
|
||||
* Credit 2440 Leverantörsskulder [total]
|
||||
*/
|
||||
export async function createSupplierInvoiceRegistrationEntry(
|
||||
userId: string,
|
||||
invoice: SupplierInvoice,
|
||||
items: SupplierInvoiceItem[],
|
||||
supplierType: string
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, invoice.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for invoice date:', invoice.invoice_date)
|
||||
return null
|
||||
}
|
||||
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
const desc = `Lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + item.line_total)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
debit_amount: Math.round(amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
} else if (invoice.vat_amount > 0) {
|
||||
// Domestic: Debit ingående moms
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Credit: Leverantörsskulder
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(invoice.total * 100) / 100,
|
||||
line_description: desc,
|
||||
currency: invoice.currency !== 'SEK' ? invoice.currency : undefined,
|
||||
amount_in_currency: invoice.currency !== 'SEK' ? invoice.total : undefined,
|
||||
exchange_rate: invoice.exchange_rate || undefined,
|
||||
})
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: invoice.invoice_date,
|
||||
description: desc,
|
||||
source_type: 'supplier_invoice_registered',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entry when a supplier invoice is paid (accrual method)
|
||||
*
|
||||
* Debit 2440 Leverantörsskulder [payment amount]
|
||||
* Credit 1930 Företagskonto [payment amount]
|
||||
*
|
||||
* With exchange rate difference:
|
||||
* Debit 2440 Leverantörsskulder [original SEK amount]
|
||||
* Credit 1930 Företagskonto [actual SEK paid]
|
||||
* Credit/Debit 3960/7960 [difference]
|
||||
*/
|
||||
export async function createSupplierInvoicePaymentEntry(
|
||||
userId: string,
|
||||
invoice: SupplierInvoice,
|
||||
paymentAmount: number,
|
||||
paymentDate: string,
|
||||
exchangeRateDifference?: number
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for payment date:', paymentDate)
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (ankomst ${invoice.arrival_number})`
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (exchangeRateDifference && exchangeRateDifference !== 0) {
|
||||
// Foreign currency with exchange rate difference
|
||||
const originalSekAmount = paymentAmount
|
||||
const actualSekPaid = paymentAmount - exchangeRateDifference
|
||||
|
||||
// Debit: Clear leverantörsskulder at original booked SEK amount
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: Math.round(originalSekAmount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Credit: Bank at actual SEK paid
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(actualSekPaid * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Exchange rate difference
|
||||
if (exchangeRateDifference > 0) {
|
||||
// Gain: Credit 3960
|
||||
lines.push({
|
||||
account_number: '3960',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||||
line_description: 'Valutakursvinst',
|
||||
})
|
||||
} else {
|
||||
// Loss: Debit 7960
|
||||
lines.push({
|
||||
account_number: '7960',
|
||||
debit_amount: Math.round(Math.abs(exchangeRateDifference) * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: 'Valutakursförlust',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Standard SEK payment
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: Math.round(paymentAmount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(paymentAmount * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: desc,
|
||||
source_type: 'supplier_invoice_paid',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entry for cash method (kontantmetoden)
|
||||
* Combined entry at payment time:
|
||||
*
|
||||
* Debit 5xxx/6xxx (per item) [line_total]
|
||||
* Debit 2641 Ingående moms [total VAT]
|
||||
* Credit 1930 Företagskonto [total incl VAT]
|
||||
*/
|
||||
export async function createSupplierInvoiceCashEntry(
|
||||
userId: string,
|
||||
invoice: SupplierInvoice,
|
||||
items: SupplierInvoiceItem[],
|
||||
paymentDate: string,
|
||||
supplierType: string
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for payment date:', paymentDate)
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = `Betalning lev.faktura ${invoice.supplier_invoice_number} (kontantmetoden)`
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
// Aggregate expense amounts by account number
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + item.line_total)
|
||||
}
|
||||
|
||||
// Debit: Expense accounts
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
debit_amount: Math.round(amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && invoice.reverse_charge) {
|
||||
// EU reverse charge: fiktiv moms entries
|
||||
const vatRate = getDefaultVatRate(invoice.vat_treatment)
|
||||
const reverseChargeLines = generateReverseChargeLines(invoice.subtotal, vatRate)
|
||||
lines.push(...reverseChargeLines)
|
||||
} else if (invoice.vat_amount > 0) {
|
||||
// Domestic: Debit ingående moms
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: Math.round(invoice.vat_amount * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Credit: Företagskonto
|
||||
lines.push({
|
||||
account_number: '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(invoice.total * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: desc,
|
||||
source_type: 'supplier_invoice_cash_payment',
|
||||
source_id: invoice.id,
|
||||
lines,
|
||||
}
|
||||
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create journal entry for a supplier credit note (reversal of registration)
|
||||
*
|
||||
* Debit 2440 Leverantörsskulder [total]
|
||||
* Credit 5xxx/6xxx (per item) [line_total]
|
||||
* Credit 2641 Ingående moms [total VAT]
|
||||
*/
|
||||
export async function createSupplierCreditNoteEntry(
|
||||
userId: string,
|
||||
creditNote: SupplierInvoice,
|
||||
items: SupplierInvoiceItem[],
|
||||
supplierType: string
|
||||
): Promise<JournalEntry | null> {
|
||||
const fiscalPeriodId = await findFiscalPeriod(userId, creditNote.invoice_date)
|
||||
if (!fiscalPeriodId) {
|
||||
console.warn('No open fiscal period found for credit note date:', creditNote.invoice_date)
|
||||
return null
|
||||
}
|
||||
|
||||
const desc = `Kreditfaktura lev. ${creditNote.supplier_invoice_number} (ankomst ${creditNote.arrival_number})`
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
const absTotal = Math.abs(creditNote.total)
|
||||
const absVat = Math.abs(creditNote.vat_amount)
|
||||
|
||||
// Debit: Leverantörsskulder
|
||||
lines.push({
|
||||
account_number: '2440',
|
||||
debit_amount: Math.round(absTotal * 100) / 100,
|
||||
credit_amount: 0,
|
||||
line_description: desc,
|
||||
})
|
||||
|
||||
// Credit: Expense accounts (reverse)
|
||||
const expenseByAccount = new Map<string, number>()
|
||||
for (const item of items) {
|
||||
const current = expenseByAccount.get(item.account_number) || 0
|
||||
expenseByAccount.set(item.account_number, current + Math.abs(item.line_total))
|
||||
}
|
||||
|
||||
for (const [accountNumber, amount] of expenseByAccount) {
|
||||
lines.push({
|
||||
account_number: accountNumber,
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(amount * 100) / 100,
|
||||
line_description: desc,
|
||||
})
|
||||
}
|
||||
|
||||
if (supplierType === 'eu_business' && creditNote.reverse_charge) {
|
||||
// Reverse the fiktiv moms (swap debit/credit from registration)
|
||||
const vatRate = getDefaultVatRate(creditNote.vat_treatment)
|
||||
const vatAmount = Math.round(Math.abs(creditNote.subtotal) * vatRate * 100) / 100
|
||||
lines.push({
|
||||
account_number: '2645',
|
||||
debit_amount: 0,
|
||||
credit_amount: vatAmount,
|
||||
line_description: `Omvänd fiktiv ingående moms ${desc}`,
|
||||
})
|
||||
lines.push({
|
||||
account_number: '2614',
|
||||
debit_amount: vatAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Omvänd fiktiv utgående moms ${desc}`,
|
||||
})
|
||||
} else if (absVat > 0) {
|
||||
// Credit: Ingående moms (reverse)
|
||||
lines.push({
|
||||
account_number: '2641',
|
||||
debit_amount: 0,
|
||||
credit_amount: Math.round(absVat * 100) / 100,
|
||||
line_description: `Ingående moms ${desc}`,
|
||||
})
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: creditNote.invoice_date,
|
||||
description: desc,
|
||||
source_type: 'supplier_credit_note',
|
||||
source_id: creditNote.id,
|
||||
lines,
|
||||
}
|
||||
|
||||
return createJournalEntry(userId, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default VAT rate from treatment string
|
||||
*/
|
||||
function getDefaultVatRate(vatTreatment: string): number {
|
||||
switch (vatTreatment) {
|
||||
case 'standard_25': return 0.25
|
||||
case 'reduced_12': return 0.12
|
||||
case 'reduced_6': return 0.06
|
||||
default: return 0.25
|
||||
}
|
||||
}
|
||||
@@ -50,16 +50,16 @@ export async function createTransactionJournalEntry(
|
||||
const lines: CreateJournalEntryLineInput[] = []
|
||||
|
||||
if (mappingResult.default_private) {
|
||||
// Private expense
|
||||
// Private expense — use entity-specific account from mappingResult
|
||||
lines.push(
|
||||
{
|
||||
account_number: '2013', // Övriga egna uttag
|
||||
account_number: mappingResult.debit_account,
|
||||
debit_amount: absAmount,
|
||||
credit_amount: 0,
|
||||
line_description: `Privat: ${transaction.description}`,
|
||||
},
|
||||
{
|
||||
account_number: '1930', // Företagskonto
|
||||
account_number: mappingResult.credit_account || '1930',
|
||||
debit_amount: 0,
|
||||
credit_amount: absAmount,
|
||||
line_description: transaction.description,
|
||||
@@ -116,20 +116,53 @@ export async function createTransactionJournalEntry(
|
||||
const debitAccount = mappingResult.debit_account || '1930'
|
||||
const creditAccount = mappingResult.credit_account
|
||||
|
||||
lines.push(
|
||||
{
|
||||
if (mappingResult.vat_lines.length > 0) {
|
||||
// Has output VAT
|
||||
const vatCredit = mappingResult.vat_lines
|
||||
.filter(l => l.credit_amount > 0)
|
||||
.reduce((sum, l) => sum + l.credit_amount, 0)
|
||||
const netAmount = Math.round((absAmount - vatCredit) * 100) / 100
|
||||
|
||||
// Debit bank for gross amount
|
||||
lines.push({
|
||||
account_number: debitAccount,
|
||||
debit_amount: absAmount,
|
||||
credit_amount: 0,
|
||||
line_description: transaction.description,
|
||||
},
|
||||
{
|
||||
})
|
||||
// Credit revenue for net amount
|
||||
lines.push({
|
||||
account_number: creditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: absAmount,
|
||||
credit_amount: netAmount,
|
||||
line_description: transaction.description,
|
||||
})
|
||||
// Credit output VAT
|
||||
for (const vatLine of mappingResult.vat_lines) {
|
||||
lines.push({
|
||||
account_number: vatLine.account_number,
|
||||
debit_amount: vatLine.debit_amount,
|
||||
credit_amount: vatLine.credit_amount,
|
||||
line_description: vatLine.description,
|
||||
})
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// No VAT - simple two-line entry
|
||||
lines.push(
|
||||
{
|
||||
account_number: debitAccount,
|
||||
debit_amount: absAmount,
|
||||
credit_amount: 0,
|
||||
line_description: transaction.description,
|
||||
},
|
||||
{
|
||||
account_number: creditAccount,
|
||||
debit_amount: 0,
|
||||
credit_amount: absAmount,
|
||||
line_description: transaction.description,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const input: CreateJournalEntryInput = {
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { DocumentAttachment, CreateDocumentAttachmentInput, DocumentUploadS
|
||||
/**
|
||||
* Compute SHA-256 hash of a file buffer
|
||||
*/
|
||||
async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||||
export async function computeSHA256(buffer: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
@@ -91,6 +91,11 @@ export async function uploadDocument(
|
||||
|
||||
/**
|
||||
* Create a new version of an existing document (WORM: old version is superseded)
|
||||
*
|
||||
* Uses the create_document_version RPC for atomic versioning with:
|
||||
* - Row-level locking (prevents concurrent versioning race condition)
|
||||
* - Cryptographic hash chain (prev_version_hash links to previous version)
|
||||
* - Single transaction (insert new + mark old superseded)
|
||||
*/
|
||||
export async function createNewVersion(
|
||||
userId: string,
|
||||
@@ -99,28 +104,12 @@ export async function createNewVersion(
|
||||
): Promise<DocumentAttachment> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Fetch the original/current version
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('document_attachments')
|
||||
.select('*')
|
||||
.eq('id', originalId)
|
||||
.eq('user_id', userId)
|
||||
.eq('is_current_version', true)
|
||||
.single()
|
||||
|
||||
if (fetchError || !current) {
|
||||
throw new Error('Original document not found or not the current version')
|
||||
}
|
||||
|
||||
const rootOriginalId = current.original_id || current.id
|
||||
const newVersion = current.version + 1
|
||||
|
||||
// Compute SHA-256 hash
|
||||
const sha256Hash = await computeSHA256(file.buffer)
|
||||
|
||||
// Upload new file
|
||||
// Upload new file to Storage
|
||||
const timestamp = Date.now()
|
||||
const storagePath = `documents/${userId}/${timestamp}_v${newVersion}_${file.name}`
|
||||
const storagePath = `documents/${userId}/${timestamp}_${file.name}`
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('documents')
|
||||
@@ -133,41 +122,33 @@ export async function createNewVersion(
|
||||
throw new Error(`Failed to upload new version: ${uploadError.message}`)
|
||||
}
|
||||
|
||||
// Create new version record
|
||||
const { data: newDoc, error: insertError } = await supabase
|
||||
.from('document_attachments')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
storage_path: storagePath,
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.buffer.byteLength,
|
||||
mime_type: file.type || null,
|
||||
sha256_hash: sha256Hash,
|
||||
version: newVersion,
|
||||
original_id: rootOriginalId,
|
||||
is_current_version: true,
|
||||
uploaded_by: userId,
|
||||
upload_source: current.upload_source,
|
||||
digitization_date: new Date().toISOString(),
|
||||
journal_entry_id: current.journal_entry_id,
|
||||
journal_entry_line_id: current.journal_entry_line_id,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
// Atomic version creation via RPC (row lock + hash chain + supersede in one tx)
|
||||
const { data: newDocId, error: rpcError } = await supabase.rpc('create_document_version', {
|
||||
p_user_id: userId,
|
||||
p_original_doc_id: originalId,
|
||||
p_storage_path: storagePath,
|
||||
p_file_name: file.name,
|
||||
p_file_size_bytes: file.buffer.byteLength,
|
||||
p_mime_type: file.type || null,
|
||||
p_sha256_hash: sha256Hash,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
if (rpcError) {
|
||||
// Clean up uploaded file on RPC failure
|
||||
await supabase.storage.from('documents').remove([storagePath])
|
||||
throw new Error(`Failed to create new version record: ${insertError.message}`)
|
||||
throw new Error(`Failed to create new version: ${rpcError.message}`)
|
||||
}
|
||||
|
||||
// Mark old version as superseded
|
||||
await supabase
|
||||
// Fetch the complete new version record
|
||||
const { data: newDoc, error: fetchError } = await supabase
|
||||
.from('document_attachments')
|
||||
.update({
|
||||
is_current_version: false,
|
||||
superseded_by_id: newDoc.id,
|
||||
})
|
||||
.eq('id', current.id)
|
||||
.select('*')
|
||||
.eq('id', newDocId)
|
||||
.single()
|
||||
|
||||
if (fetchError || !newDoc) {
|
||||
throw new Error('Failed to fetch new version record')
|
||||
}
|
||||
|
||||
return newDoc as DocumentAttachment
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export interface SupplierLedgerEntry {
|
||||
supplier_id: string
|
||||
supplier_name: string
|
||||
current: number
|
||||
days_1_30: number
|
||||
days_31_60: number
|
||||
days_61_90: number
|
||||
days_90_plus: number
|
||||
total_outstanding: number
|
||||
}
|
||||
|
||||
export interface SupplierLedgerReport {
|
||||
entries: SupplierLedgerEntry[]
|
||||
total_outstanding: number
|
||||
total_current: number
|
||||
total_overdue: number
|
||||
unpaid_count: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate supplier ledger (leverantörsreskontra) with aging analysis
|
||||
*/
|
||||
export async function generateSupplierLedger(
|
||||
userId: string,
|
||||
asOfDate?: string
|
||||
): Promise<SupplierLedgerReport> {
|
||||
const supabase = await createClient()
|
||||
const refDate = asOfDate ? new Date(asOfDate) : new Date()
|
||||
|
||||
// Fetch all unpaid/partially_paid supplier invoices
|
||||
const { data: invoices, error } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('*, supplier:suppliers(id, name)')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
|
||||
if (error || !invoices) {
|
||||
return {
|
||||
entries: [],
|
||||
total_outstanding: 0,
|
||||
total_current: 0,
|
||||
total_overdue: 0,
|
||||
unpaid_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Group by supplier and calculate aging
|
||||
const bySupplier = new Map<string, SupplierLedgerEntry>()
|
||||
|
||||
for (const inv of invoices) {
|
||||
const supplierId = inv.supplier_id
|
||||
const supplierName = inv.supplier?.name || 'Okänd leverantör'
|
||||
|
||||
if (!bySupplier.has(supplierId)) {
|
||||
bySupplier.set(supplierId, {
|
||||
supplier_id: supplierId,
|
||||
supplier_name: supplierName,
|
||||
current: 0,
|
||||
days_1_30: 0,
|
||||
days_31_60: 0,
|
||||
days_61_90: 0,
|
||||
days_90_plus: 0,
|
||||
total_outstanding: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const entry = bySupplier.get(supplierId)!
|
||||
const dueDate = new Date(inv.due_date)
|
||||
const daysOverdue = Math.floor((refDate.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
const amount = inv.remaining_amount || 0
|
||||
|
||||
if (daysOverdue <= 0) {
|
||||
entry.current += amount
|
||||
} else if (daysOverdue <= 30) {
|
||||
entry.days_1_30 += amount
|
||||
} else if (daysOverdue <= 60) {
|
||||
entry.days_31_60 += amount
|
||||
} else if (daysOverdue <= 90) {
|
||||
entry.days_61_90 += amount
|
||||
} else {
|
||||
entry.days_90_plus += amount
|
||||
}
|
||||
|
||||
entry.total_outstanding += amount
|
||||
}
|
||||
|
||||
const entries = Array.from(bySupplier.values())
|
||||
.sort((a, b) => b.total_outstanding - a.total_outstanding)
|
||||
|
||||
const total_outstanding = entries.reduce((sum, e) => sum + e.total_outstanding, 0)
|
||||
const total_current = entries.reduce((sum, e) => sum + e.current, 0)
|
||||
const total_overdue = total_outstanding - total_current
|
||||
|
||||
return {
|
||||
entries,
|
||||
total_outstanding: Math.round(total_outstanding * 100) / 100,
|
||||
total_current: Math.round(total_current * 100) / 100,
|
||||
total_overdue: Math.round(total_overdue * 100) / 100,
|
||||
unpaid_count: invoices.length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
export interface ReconciliationResult {
|
||||
supplier_ledger_total: number
|
||||
account_2440_balance: number
|
||||
difference: number
|
||||
is_reconciled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare sum of open supplier invoices against account 2440 balance
|
||||
*/
|
||||
export async function generateReconciliation(
|
||||
userId: string,
|
||||
periodId: string
|
||||
): Promise<ReconciliationResult> {
|
||||
const supabase = await createClient()
|
||||
|
||||
// Get total outstanding from supplier invoices
|
||||
const { data: invoices } = await supabase
|
||||
.from('supplier_invoices')
|
||||
.select('remaining_amount')
|
||||
.eq('user_id', userId)
|
||||
.in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
|
||||
|
||||
const supplierLedgerTotal = (invoices || [])
|
||||
.reduce((sum, inv) => sum + (inv.remaining_amount || 0), 0)
|
||||
|
||||
// Get account 2440 balance from journal entry lines
|
||||
const { data: journalLines } = await supabase
|
||||
.from('journal_entry_lines')
|
||||
.select('debit_amount, credit_amount, journal_entry_id')
|
||||
.eq('account_number', '2440')
|
||||
|
||||
// Filter to posted entries in the period
|
||||
let account2440Balance = 0
|
||||
if (journalLines) {
|
||||
// Account 2440 is a liability: credit normal balance
|
||||
// Balance = credits - debits
|
||||
for (const line of journalLines) {
|
||||
account2440Balance += (line.credit_amount || 0) - (line.debit_amount || 0)
|
||||
}
|
||||
}
|
||||
|
||||
const difference = Math.round((supplierLedgerTotal - account2440Balance) * 100) / 100
|
||||
|
||||
return {
|
||||
supplier_ledger_total: Math.round(supplierLedgerTotal * 100) / 100,
|
||||
account_2440_balance: Math.round(account2440Balance * 100) / 100,
|
||||
difference,
|
||||
is_reconciled: Math.abs(difference) < 0.01,
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: ["nonpedagogically-sphinxlike-dalila.ngrok-free.dev"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 843 B |