* feat(api): v1 invoice :mark-paid + :credit action verbs (Phase 2 PR-B-2b combined)
Bigger PR per the user's request. Lands the remaining two journal-entry-
centric action verbs together — they share the same lifecycle pattern
established in :mark-sent (idempotent, dry-runnable, scope-gated,
warnings on partial-state failures).
POST /api/v1/companies/:companyId/invoices/:id/mark-paid
- Books a payment against a sent / overdue invoice. Updates status to
paid (or partially_paid when remaining_amount > 0). Three booking paths:
- Faktureringsmetoden (accrual default): Debit 1930 / Credit 1510 via
createInvoicePaymentJournalEntry — settles AR.
- Kontantmetoden (cash): Debit 1930 / Credit revenue + Credit VAT via
createInvoiceCashEntry — revenue recognition happens HERE under cash.
- Custom lines (partial payment): caller-supplied balanced journal lines
via createJournalEntry directly. Validated for balance (sum debits ==
sum credits, both > 0) → 400 INVOICE_PAID_LINES_UNBALANCED otherwise.
- Optional body: { payment_date?, exchange_rate_difference?, lines? }
- Race-condition guard: status update matches .in(['sent','overdue',
'partially_paid']) so a concurrent payment returns 409 INVOICE_PAID_RACE.
- Emits invoice.paid (new event type, added to lib/events/types.ts with
paymentAmount + paymentDate in the payload).
POST /api/v1/companies/:companyId/invoices/:id/credit
- Issues a kreditfaktura against a sent / paid / overdue invoice
(ML 17 kap 22–23§). Creates a NEW invoice row with:
- invoice_number = "KR-<original>"
- credited_invoice_id = original id
- status = 'sent'
- All amounts negated (subtotal, vat_amount, total, items quantities/totals)
- Items mirror the original with negated values; inserted in a separate
step with company-scoped rollback DELETE on failure.
- Flips original invoice to status='credited'. Warns ORIGINAL_NOT_FLIPPED
if the flip fails (the credit note still exists; operator reconciles).
- Posts reverse journal entry via createCreditNoteJournalEntry (accrual
only; cash basis defers to refund time).
- Emits credit_note.created (existing event in the bus).
Both endpoints:
- Use the established wrapper + Idempotency-Key + dry-run + warnings
pattern from :mark-sent.
- Validate document_type (no delivery_notes), credited_invoice_id (no
recursive credits), and status before any mutation.
- Use explicit column projections (no SELECT *).
- Sanitize pg_message from client responses (kept in logs).
- Emit error-level logs on partial-state failures + surface warnings to
the caller via meta.warnings.
Event types union (lib/events/types.ts) gains invoice.paid; credit
uses the existing credit_note.created event.
URL convention: plain /verb subpaths (e.g. /invoices/:id/mark-paid),
consistent with :mark-sent. Stripe/QuickBooks pattern, not the
AIP-style :verb that Next.js routing fights.
17 new tests covering happy paths (accrual + cash for mark-paid),
custom-lines balance validation, dry-run preview, document-shape
guards, scope, idempotency, race conditions, and credit-of-credit /
delivery-note rejection.
3194/3194 vitest pass; build clean; lint clean on v1 paths.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): address PR #455 review + include password-recovery fixes
PR #455 review fixes:
- Greptile P1 (CLAUDE.md architecture rule): API routes that emit events
via eventBus must call ensureInitialized() at module level to wire
extension event handlers. Neither :mark-paid (invoice.paid) nor :credit
(credit_note.created) had it — nor did the already-merged :mark-sent,
POST /invoices, POST /customers, etc. Fixed once at the wrapper layer:
ensureInitialized() now runs at module import of lib/api/v1/with-api-v1.ts,
so EVERY v1 route gets the init at import time. Single source of truth
prevents future routes from forgetting (idempotent guard makes the
repeated call safe). Cleaner than per-route copy of the call.
- Swarm PI1.3 (low): 0.005 epsilon in mark-paid was undocumented. Added
a comment explaining: after rounding to 2 decimals, newRemaining is in
steps of 0.01; values ≤ half-an-öre only arise from float artefacts.
Pushing back (recurring triage, consistent with prior PRs):
- V8.2.1 + CC6.3 × 4 "ctx.companyId vs params.companyId mismatch" —
impossible by construction. The wrapper sets ctx.companyId FROM the URL
params after the membership check. They are guaranteed equal.
- V2.3 + A.8.15 + A.8.28 atomicity / floating-point / partial-failure
alerts — same architectural / cross-surface deferred work as prior PRs;
matches internal /api/invoices pattern precisely.
- V4.5 account_number allowlist — engine validates it.
- V2.4 idempotency TOCTOU — wrapper handles via DB unique constraint.
- Art.5(1)(f) PII in logs, A.8.11 dry-run preview scope, A.8.15 partial-
failure naming, test scope coverage — all recurring triage.
Password-recovery flow fixes (included per request — pre-existing
working-tree changes the user authored):
- app/(auth)/auth/callback/route.ts: when the callback exchanges a
recovery token (type='recovery' or next='/reset-password'), redirect
directly to /reset-password instead of running onboarding/MFA/
dashboard checks. Previously users clicking the password-reset email
got bounced through onboarding.
- lib/supabase/middleware.ts: /reset-password no longer bounces
authenticated users to / (the recovery flow lands here with a fresh
session by design — the user is *supposed* to call updateUser({
password }) on this page).
- app/(auth)/login/page.tsx: shows an error banner when ?error=auth_error
is set (expired/used recovery link), with a button to request a new
one. Wrapped the page in <Suspense> because useSearchParams() now
forces dynamic rendering (Next.js 16 static-prerender bail-out
otherwise).
- app/(auth)/auth/callback/__tests__/route.test.ts: new test file
covering the recovery callback path.
3197/3197 vitest pass (3194 prior + 3 from the new auth-callback tests).
Build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(api): mark-paid uses remaining_amount as default payment, not total
Real correctness fix from Swedish-compliance review on PR #455. When no
customLines is supplied, mark-paid previously defaulted paymentAmount to
typed.total. Combined with the race-condition guard that allows the
status UPDATE to flip a partially_paid invoice to paid, this could
over-credit AR in a race scenario:
1. Invoice in 'sent' status, total=12500, remaining=12500.
2. Concurrent partial payment lands first → status='partially_paid',
remaining=7500.
3. The full-payment request's pre-flight saw 'sent' and passed; its
UPDATE matches partially_paid (race guard allows it). With the old
logic the journal entry was for total=12500 against an AR balance
of only 7500 — a 5000 over-credit.
Using remaining_amount as the default eliminates this. Same end state
in the common case (no prior partial); correct booking in the race.
3197/3197 vitest pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
10 KiB
TypeScript
168 lines
10 KiB
TypeScript
import type {
|
|
JournalEntry,
|
|
Invoice,
|
|
Transaction,
|
|
Customer,
|
|
Supplier,
|
|
FiscalPeriod,
|
|
DocumentAttachment,
|
|
Receipt,
|
|
CreditNote,
|
|
ReconciliationMethod,
|
|
InvoiceInboxItem,
|
|
SupplierInvoice,
|
|
} from '@/types'
|
|
|
|
// ============================================================
|
|
// Core Event Types — discriminated union of all system events
|
|
// ============================================================
|
|
|
|
export type CoreEvent =
|
|
// Bookkeeping
|
|
| { type: 'journal_entry.drafted'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.committed'; payload: { entry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.corrected'; payload: { original: JournalEntry; storno: JournalEntry; corrected: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.reversed'; payload: { originalEntry: JournalEntry; reversalEntry: JournalEntry; userId: string; companyId: string } }
|
|
| { type: 'journal_entry.deleted'; payload: { entryId: string; voucherSeries: string; voucherNumber: number; userId: string; companyId: string } }
|
|
// Documents
|
|
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
|
|
| { type: 'document.accessed'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
|
|
// Invoicing
|
|
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
|
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
|
|
| { type: 'invoice.paid'; payload: { invoice: Invoice; paymentAmount: number; paymentDate: string; userId: string; companyId: string } }
|
|
| { type: 'credit_note.created'; payload: { creditNote: CreditNote; userId: string; companyId: string } }
|
|
// Banking
|
|
| { type: 'transaction.synced'; payload: { transactions: Transaction[]; userId: string; companyId: string } }
|
|
| { type: 'transaction.categorized'; payload: { transaction: Transaction; account: string; taxCode: string; userId: string; companyId: string } }
|
|
| { type: 'transaction.reconciled'; payload: { transaction: Transaction; journalEntryId: string; method: ReconciliationMethod; userId: string; companyId: string } }
|
|
// Bank connection lifecycle — consent + account selection are the
|
|
// GDPR/PSD2 audit points; emitted to event_log for compliance trail.
|
|
| { type: 'bank_connection.consent_granted'; payload: { connectionId: string; bankName: string | null; accountCount: number; consentExpiresAt: string | null; userId: string; companyId: string } }
|
|
| { type: 'bank_connection.account_selection_changed'; payload: { connectionId: string; bankName: string | null; previousStatus: string; newStatus: string; enabledCount: number; totalCount: number; userId: string; companyId: string } }
|
|
| { type: 'bank_connection.revoked'; payload: { connectionId: string; bankName: string | null; userId: string; companyId: string } }
|
|
// Periods
|
|
| { type: 'period.locked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
| { type: 'period.unlocked'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
| { type: 'period.year_closed'; payload: { period: FiscalPeriod; userId: string; companyId: string } }
|
|
// Customers
|
|
| { type: 'customer.created'; payload: { customer: Customer; userId: string; companyId: string } }
|
|
// Suppliers
|
|
| { type: 'supplier.created'; payload: { supplier: Supplier; userId: string; companyId: string } }
|
|
// Receipts
|
|
| { type: 'receipt.extracted'; payload: {
|
|
receipt: Receipt;
|
|
documentId: string | null;
|
|
confidence: number;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
| { type: 'receipt.matched'; payload: {
|
|
receipt: Receipt;
|
|
transaction: Transaction;
|
|
confidence: number;
|
|
autoMatched: boolean;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
| { type: 'receipt.confirmed'; payload: {
|
|
receipt: Receipt;
|
|
businessTotal: number;
|
|
privateTotal: number;
|
|
userId: string;
|
|
companyId: string;
|
|
}}
|
|
// Supplier Invoice Lifecycle
|
|
| { type: 'supplier_invoice.registered'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.approved'; payload: { supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.paid'; payload: { supplierInvoice: SupplierInvoice; paymentAmount: number; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.credited'; payload: { supplierInvoice: SupplierInvoice; creditNote: SupplierInvoice; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.uncredited'; payload: { supplierInvoice: SupplierInvoice; reversedCreditNoteId: string; reversalEntryId: string | null; userId: string; companyId: string } }
|
|
// Payment Matching
|
|
| { type: 'invoice.match_confirmed'; payload: { invoice: Invoice; transaction: Transaction; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.match_confirmed'; payload: { supplierInvoice: SupplierInvoice; transaction: Transaction; userId: string; companyId: string } }
|
|
// Supplier Invoice Inbox
|
|
| { type: 'supplier_invoice.received'; payload: { inboxItem: InvoiceInboxItem; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.extracted'; payload: { inboxItem: InvoiceInboxItem; confidence: number; userId: string; companyId: string } }
|
|
| { type: 'supplier_invoice.confirmed'; payload: { inboxItem: InvoiceInboxItem; supplierInvoice: SupplierInvoice; userId: string; companyId: string } }
|
|
// Salary
|
|
| { type: 'salary_run.created'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
| { type: 'salary_run.approved'; payload: { salaryRunId: string; approvedBy: string; userId: string; companyId: string } }
|
|
| { type: 'salary_run.booked'; payload: { salaryRunId: string; entryIds: string[]; userId: string; companyId: string } }
|
|
| { type: 'agi.generated'; payload: { agiId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
| { type: 'agi.submitted'; payload: { salaryRunId: string; periodYear: number; periodMonth: number; userId: string; companyId: string } }
|
|
// Skatteverket — Skattekonto sync
|
|
| { type: 'skattekonto.synced'; payload: { booked: number; upcoming: number; balanceSkv: number; balanceKfm: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.balance.changed'; payload: { previousBalance: number; currentBalance: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.transaction.upcoming'; payload: { transaktionsdatum: string; forfallodatum: string; transaktionstext: string; beloppSkatteverket: number; userId: string; companyId: string } }
|
|
| { type: 'skattekonto.connection.expired'; payload: { reason: 'REFRESH_EXHAUSTED' | 'SESSION_EXPIRED' | 'TOKEN_CORRUPTED'; userId: string; companyId: string } }
|
|
// Company & account lifecycle
|
|
| { type: 'company.deleted'; payload: { companyId: string; userId: string; archivedAt: string } }
|
|
| { type: 'account.deleted'; payload: { userId: string; deletedAt: string } }
|
|
// MCP telemetry — fired from the MCP dispatcher.
|
|
// Persisted to event_log (30-day TTL) for hot-tool / error-rate / latency analytics.
|
|
// Intentionally lightweight: no args, no result body — only metadata.
|
|
| { type: 'mcp.tool_called'; payload: {
|
|
tool: string // e.g. 'gnubok_create_invoice'
|
|
requiredScope: string | null // from TOOL_SCOPE_MAP, null if unscoped
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null // api_key id, oauth client, etc.
|
|
actorLabel: string | null // human-readable actor label
|
|
latencyMs: number // wall-clock time inside execute()
|
|
success: boolean // true iff the tool returned without throwing AND was invoked (not denied)
|
|
isError: boolean // matches the JSON-RPC tool-result isError flag returned to the client
|
|
errorCode: string | null // structured error code from tool-result.toToolError when applicable
|
|
errorKind: 'execution' | 'scope_denied' | 'unknown_tool' | null
|
|
requestId: string | number | null // JSON-RPC request id (helps correlate with client-side logs)
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
// tools/list — informs us whether agents are using progressive discovery
|
|
// (gnubok_search_tools) or pulling the full list. Tool counts vary with
|
|
// the caller's scope set.
|
|
| { type: 'mcp.tools_list_called'; payload: {
|
|
toolCount: number // tools actually returned (post scope filter)
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
latencyMs: number
|
|
requestId: string | number | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
// resources/read — informs us which skills/widgets/data resources actually
|
|
// get loaded by agents. `kind` discriminates by URI scheme so we can
|
|
// GROUP BY skill vs widget vs data without parsing URIs.
|
|
| { type: 'mcp.resource_read'; payload: {
|
|
uri: string // e.g. 'gnubok://skill/month-end-close'
|
|
kind: 'widget' | 'skill' | 'data' | 'unknown'
|
|
success: boolean
|
|
errorCode: string | null
|
|
latencyMs: number
|
|
actorType: 'user' | 'api_key' | 'mcp_oauth' | 'cron'
|
|
actorId: string | null
|
|
actorLabel: string | null
|
|
requestId: string | number | null
|
|
userId: string
|
|
companyId: string
|
|
}}
|
|
|
|
// ============================================================
|
|
// Helper Types
|
|
// ============================================================
|
|
|
|
/** All possible event type strings */
|
|
export type CoreEventType = CoreEvent['type']
|
|
|
|
/** Extract the payload type for a given event type */
|
|
export type EventPayload<T extends CoreEventType> = Extract<CoreEvent, { type: T }>['payload']
|
|
|
|
/** Handler function for a specific event type */
|
|
export type EventHandler<T extends CoreEventType> = (payload: EventPayload<T>) => Promise<void> | void
|
|
|
|
/** Subscription: event type + handler */
|
|
export interface EventSubscription<T extends CoreEventType = CoreEventType> {
|
|
eventType: T
|
|
handler: EventHandler<T>
|
|
}
|