* 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>
198 lines
7.4 KiB
TypeScript
198 lines
7.4 KiB
TypeScript
import { createServerClient } from '@supabase/ssr'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { hashInviteToken } from '@/lib/auth/invite-tokens'
|
|
|
|
export async function GET(request: NextRequest) {
|
|
const { searchParams, origin } = new URL(request.url)
|
|
const code = searchParams.get('code')
|
|
const token_hash = searchParams.get('token_hash')
|
|
const type = searchParams.get('type')
|
|
const next = searchParams.get('next') ?? '/'
|
|
|
|
// Collect cookies that Supabase sets during auth so we can
|
|
// explicitly forward them on the redirect response.
|
|
const pendingCookies: { name: string; value: string; options: Record<string, unknown> }[] = []
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
pendingCookies.length = 0
|
|
cookiesToSet.forEach((cookie) => {
|
|
// Mirror the cookie into request.cookies so subsequent getAll()
|
|
// calls within this request lifecycle return the updated values
|
|
// (matches the pattern used in middleware.ts).
|
|
request.cookies.set(cookie.name, cookie.value)
|
|
pendingCookies.push(cookie)
|
|
})
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
let authenticated = false
|
|
|
|
// Handle PKCE flow (code exchange)
|
|
if (code) {
|
|
const { error } = await supabase.auth.exchangeCodeForSession(code)
|
|
authenticated = !error
|
|
}
|
|
// Handle token hash flow (email verification / magic link)
|
|
else if (token_hash && type) {
|
|
const { error } = await supabase.auth.verifyOtp({
|
|
token_hash,
|
|
type: type as 'signup' | 'invite' | 'magiclink' | 'recovery' | 'email_change' | 'email',
|
|
})
|
|
authenticated = !error
|
|
}
|
|
|
|
if (authenticated) {
|
|
let redirectPath = next
|
|
|
|
// Password recovery flow: the user just exchanged a recovery token, so they
|
|
// have a fresh session whose only purpose is to call updateUser({ password })
|
|
// on /reset-password. Skip onboarding / team setup / dashboard redirect.
|
|
// The token-hash flow signals this via type=recovery; PKCE has no type, so
|
|
// also gate on next === '/reset-password' (only the reset request sets it).
|
|
if (type === 'recovery' || next === '/reset-password') {
|
|
const response = NextResponse.redirect(new URL('/reset-password', origin))
|
|
for (const { name, value, options } of pendingCookies) {
|
|
response.cookies.set({ name, value, ...options })
|
|
}
|
|
return response
|
|
}
|
|
|
|
const { data: { user } } = await supabase.auth.getUser()
|
|
if (user) {
|
|
// Check MFA status — redirect to verify if factor is enrolled but session is AAL1
|
|
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
|
|
if (aal?.nextLevel === 'aal2' && aal?.currentLevel === 'aal1') {
|
|
const response = NextResponse.redirect(new URL('/mfa/verify', origin))
|
|
for (const { name, value, options } of pendingCookies) {
|
|
response.cookies.set({ name, value, ...options })
|
|
}
|
|
return response
|
|
}
|
|
|
|
// Check for pending invite token (set by invite page before redirecting to register)
|
|
const inviteToken = request.cookies.get('gnubok-invite-token')?.value
|
|
if (inviteToken) {
|
|
try {
|
|
const tokenHash = hashInviteToken(inviteToken)
|
|
|
|
// Use the service role client to bypass RLS for invite acceptance
|
|
const serviceClient = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
{ cookies: { getAll: () => [], setAll: () => {} } }
|
|
)
|
|
|
|
// Look up company invitation
|
|
const { data: invite } = await serviceClient
|
|
.from('company_invitations')
|
|
.select('id, company_id, email, role, status, expires_at')
|
|
.eq('token_hash', tokenHash)
|
|
.single()
|
|
|
|
if (
|
|
invite &&
|
|
invite.status === 'pending' &&
|
|
new Date(invite.expires_at) > new Date() &&
|
|
user.email?.toLowerCase() === invite.email.toLowerCase()
|
|
) {
|
|
// Add user to company
|
|
await serviceClient.from('company_members').insert({
|
|
company_id: invite.company_id,
|
|
user_id: user.id,
|
|
role: invite.role,
|
|
source: 'direct',
|
|
})
|
|
|
|
// Set active company
|
|
await serviceClient.from('user_preferences').upsert({
|
|
user_id: user.id,
|
|
active_company_id: invite.company_id,
|
|
}, { onConflict: 'user_id' })
|
|
|
|
// Mark invite as accepted
|
|
await serviceClient
|
|
.from('company_invitations')
|
|
.update({ status: 'accepted' })
|
|
.eq('id', invite.id)
|
|
|
|
// Invited user goes straight to dashboard — no onboarding needed
|
|
redirectPath = '/'
|
|
|
|
// Clear invite cookie and set company cookie on response
|
|
const response = NextResponse.redirect(new URL(redirectPath, origin))
|
|
for (const { name, value, options } of pendingCookies) {
|
|
response.cookies.set({ name, value, ...options })
|
|
}
|
|
response.cookies.set('gnubok-company-id', invite.company_id, {
|
|
path: '/',
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: 60 * 60 * 24 * 365,
|
|
})
|
|
response.cookies.delete('gnubok-invite-token')
|
|
return response
|
|
}
|
|
} catch (err) {
|
|
console.error('[auth/callback] invite acceptance failed:', err)
|
|
// Fall through to normal onboarding check
|
|
}
|
|
}
|
|
|
|
// Ensure user has a silent team (for new signups and existing users without one)
|
|
const { data: teamMembership } = await supabase
|
|
.from('team_members')
|
|
.select('team_id')
|
|
.eq('user_id', user.id)
|
|
.limit(1)
|
|
.maybeSingle()
|
|
|
|
if (!teamMembership) {
|
|
// Create team via service client (RPC requires auth.uid() which isn't available here)
|
|
const serviceClient = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
|
{ cookies: { getAll: () => [], setAll: () => {} } }
|
|
)
|
|
|
|
const teamId = crypto.randomUUID()
|
|
await serviceClient.from('teams').insert({
|
|
id: teamId,
|
|
name: 'Personal',
|
|
created_by: user.id,
|
|
})
|
|
await serviceClient.from('team_members').insert({
|
|
team_id: teamId,
|
|
user_id: user.id,
|
|
role: 'owner',
|
|
})
|
|
}
|
|
|
|
// Always redirect to dashboard — it handles zero-company and incomplete states
|
|
redirectPath = '/'
|
|
}
|
|
|
|
// Create redirect and explicitly set auth cookies on the response
|
|
const response = NextResponse.redirect(new URL(redirectPath, origin))
|
|
for (const { name, value, options } of pendingCookies) {
|
|
response.cookies.set({ name, value, ...options })
|
|
}
|
|
// Keep the invite cookie alive so the onboarding page fallback can
|
|
// retry acceptance (only clear it when successfully processed above).
|
|
return response
|
|
}
|
|
|
|
// Authentication failed — redirect to login with error
|
|
return NextResponse.redirect(new URL('/login?error=auth_error', origin))
|
|
}
|