Bug/resend and invoices (#1192)

* fix(invoices): anchor the PDF logo to the top-left of its header cell

The logo box is always the full 240x80pt reserved area (any larger logo is
clamped to exactly that), so objectFit: 'contain' placed the image inside it
with the default 50% 50% centering. A wide banner logo fills the width and
lands on the left margin, but a near-square logo scaled down to the 80pt
height cap is only ~117pt wide and got pushed ~60pt in from the margin, which
reads as a misaligned logo and forced companies to reshape their artwork.

Anchor the image top-left so every aspect ratio starts at the margin.

Covered by a test that renders the real PDF and reads the image placement
matrix out of the content stream, for both a wide and a near-square logo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(invoices): show the real delivery outcome in the send history

"Skickad" only meant the email provider accepted the message, so a bounced
invoice looked identical to one that arrived. Resend reports the outcome
asynchronously; that report now lands on the delivery row and drives the
history: green is reserved for a confirmed delivery, bounce/blocked reads
red, delayed and spam-marked read amber, and an accepted-but-unconfirmed
send is neutral instead of falsely green.

The report arrives on a signed webhook and may only touch the three new
provider status columns of an already sent, unredacted row: the WORM trigger
proves nothing else changed, and a lower ranked or older report can never
downgrade an observed failure. The provider reason text can quote the failing
address, so it is masked on read and cleared by the daily PII redaction job.

Timestamps also formatted in Europe/Stockholm instead of falling back to the
runtime zone, which rendered a 14:05 send as 12:05 on Vercel.

Delivery reports are per message, never per recipient: Resend sends one event
for the whole message, so splitting a send per recipient would be the only way
to get finer granularity, at the cost of CC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(stripe): make the integration feed-only

Stripe sync now only imports balance transactions into the transactions
inbox, like any bank feed; nothing auto-books. The event/settlement sync
(lib/sync.ts, lib/payouts.ts) stays in the repo but is no longer wired to
any route or cron: the 15-min sync cron is removed from vercel.json.
Payment links on invoice send are unchanged; their payments arrive as
feed rows and are matched manually.

- /sync runs only syncStripeBalanceTransactions; response is { success,
  transactions }
- connecting via OAuth enables the nightly feed by default (toggle stays
  as opt-out)
- panel: needs-review section and plumbing removed, copy rewritten to
  transactions-first (sv + en), toast reports fetched/imported/linked
  and calls out an empty result instead of silent all-zeros

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): return the article currency from the v1 article list

The dashboard, importer, export and MCP article surfaces all learned to
carry a non-SEK article price (#1166, #1183, #1184), but the v1
projection still omitted currency. An API or agent caller therefore read
price_excl_vat with nothing marking it as EUR and would copy the number
straight onto a SEK invoice line, at a nine-to-one error.

Adds currency to the projection, the response shape and the example, plus
a pitfall stating the price is not always SEK and that this endpoint does
no FX conversion.

Additive field only; no migration (articles.currency already exists).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(settings): replace the settings modal with a routed panel sheet

Settings now renders as a sheet that fills the main panel, sliding up over the
page the user came from and back down on close, with the sidebar and frame left
visible and usable. Behind it sits one shared master-detail surface: underline
search across every section and subsection, the grouped section rail, and the
active section as a direct-editing accordion. All 11 sections are decomposed
into subsections, and the legacy *SettingsContent components compose the same
pieces so the stacked and accordion layouts cannot drift.

The sheet is the only presentation, on every entry path. The intercepting route
handles in-app navigation and closes by popping the history entry, landing back
on the page underneath. @settingsModal/default.tsx handles cold loads (refresh,
deep link, new tab), where interception never fires; nothing is mounted
underneath there, so it closes to the dashboard. Both branch on one shared
predicate, isSheetSection, together with the settings layout, which must render
nothing for those sections or the surface would stack twice behind the sheet
and run every section's fetches twice.

Closing is deliberate rather than incidental: the X, Esc, or navigating away.
The dialog is non-modal so the sidebar's account popover and company switcher
keep working with settings up, and an outside click no longer dismisses it.
Sections land fully collapsed, and the scroll position of the page behind
survives opening and closing the sheet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: enhance article management and settings UI

- Add PATCH test for toggling article active state without other fields.
- Remove unused MessageCircle icon from DashboardContent.
- Refactor AccountingFrameworkForm to use SettingsFieldRow for better help text display.
- Update CompanyInfoForm, DimensionsToggle, and various settings forms to replace description with help text.
- Remove redundant headings and intros in several settings components to streamline UI.
- Improve help text for various settings in English and Swedish translations.
- Update structured error messages for better clarity on article deletion.

* refactor(ArticleDetailPage): remove unused imports and duplicate state variable

* fix(settings): own deep-linked settings routes by route list, not nav visibility

Review fixes from the settings panel sheet work:
* isSheetSection reads the full settings route list so a hidden-but-deep-linked
  section (assistant before BankID, banking in sandbox, api without MCP) is
  claimed by the sheet instead of rendering the legacy shell around an empty panel
* keep 503 on the Resend delivery webhook when the signing secret is unset, with
  a test pinning the behaviour
* stripe callback route test coverage

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: update salary, tax, and templates settings components

- Refactored SalarySettingsContent to use a form wrapper and improved payment settings UI.
- Enhanced TaxSettingsContent with new signals for EU sales, KU obligations, and ROT/RUT deductions.
- Updated TemplatesSettingsContent to remove legacy comments and improve readability.
- Simplified navigation items by removing unnecessary constants and directly using hrefs.
- Cleaned up translation files by removing deprecated keys and adding new descriptions for clarity.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-07-25 22:56:17 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f07a34c51b
commit d54b43f80f
89 changed files with 6399 additions and 4682 deletions
@@ -0,0 +1,289 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WebhookEventPayload } from 'resend'
const verifyMock = vi.fn()
const rpcMock = vi.fn()
vi.mock('resend', () => ({
Resend: class {
webhooks = { verify: verifyMock }
},
}))
vi.mock('@/lib/auth/api-keys', () => ({
createServiceClientNoCookies: () => ({ rpc: rpcMock }),
}))
import { emailExtension } from '@/extensions/general/email'
import {
ResendDeliverySignatureError,
toDeliveryReport,
verifyDeliveryWebhook,
} from '@/extensions/general/email/lib/delivery-webhook'
function baseData(overrides: Record<string, unknown> = {}) {
return {
created_at: '2026-07-24T08:00:00.000Z',
email_id: 'msg-1',
from: 'noreply@example.com',
to: ['customer@example.com'],
subject: 'Faktura F-1001',
...overrides,
}
}
const deliveryRoute = emailExtension.apiRoutes!.find(
(route) => route.path === '/delivery-status',
)!
function webhookRequest(body: unknown = { type: 'email.delivered' }): Request {
return new Request('https://example.test/api/extensions/ext/email/delivery-status', {
method: 'POST',
headers: {
'svix-id': 'msg_1',
'svix-timestamp': '1753344000',
'svix-signature': 'v1,signature',
},
body: JSON.stringify(body),
})
}
describe('toDeliveryReport', () => {
it('maps arrival outcomes to a provider status', () => {
const cases: Array<[string, string]> = [
['email.delivered', 'delivered'],
['email.delivery_delayed', 'delayed'],
['email.complained', 'complained'],
['email.bounced', 'bounced'],
['email.failed', 'failed'],
['email.suppressed', 'suppressed'],
]
for (const [type, expected] of cases) {
const event = {
type,
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: { message: 'Mailbox unavailable', subType: 'General', type: 'Permanent' },
failed: { reason: 'Rejected by upstream' },
suppressed: { message: 'On suppression list', type: 'bounce' },
}),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)?.status).toBe(expected)
}
})
it('ignores events that say nothing about arrival', () => {
for (const type of ['email.sent', 'email.scheduled', 'email.opened', 'email.clicked']) {
const event = {
type,
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toBeNull()
}
})
it('keeps the provider reason text for a bounce', () => {
const event = {
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: {
message: '550 5.1.1 Recipient address rejected',
subType: 'General',
type: 'Permanent',
},
}),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toEqual({
providerMessageId: 'msg-1',
status: 'bounced',
occurredAt: '2026-07-24T08:00:00.000Z',
detail: '550 5.1.1 Recipient address rejected Permanent/General',
})
})
it('leaves the reason empty for a plain delivery', () => {
const event = {
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)?.detail).toBeNull()
})
it('falls back to ingestion time when the provider clock is unusable', () => {
const event = {
type: 'email.delivered',
created_at: 'not-a-date',
data: baseData(),
} as unknown as WebhookEventPayload
const report = toDeliveryReport(event)
expect(report).not.toBeNull()
expect(Number.isNaN(new Date(report!.occurredAt).getTime())).toBe(false)
})
it('degrades to no reason when the provider ships an unexpected payload shape', () => {
const event = {
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toEqual({
providerMessageId: 'msg-1',
status: 'bounced',
occurredAt: '2026-07-24T08:00:00.000Z',
detail: null,
})
})
it('drops an event without a provider message id', () => {
const event = {
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({ email_id: undefined }),
} as unknown as WebhookEventPayload
expect(toDeliveryReport(event)).toBeNull()
})
})
describe('verifyDeliveryWebhook', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
})
it('passes the Svix headers through to the provider verifier', () => {
verifyMock.mockReturnValue({ type: 'email.delivered' })
const headers = new Headers({
'svix-id': 'msg_1',
'svix-timestamp': '1753344000',
'svix-signature': 'v1,signature',
})
verifyDeliveryWebhook('{"type":"email.delivered"}', headers)
expect(verifyMock).toHaveBeenCalledWith({
payload: '{"type":"email.delivered"}',
headers: { id: 'msg_1', timestamp: '1753344000', signature: 'v1,signature' },
webhookSecret: 'whsec_test',
})
})
it('raises a signature error when verification fails', () => {
verifyMock.mockImplementation(() => {
throw new Error('No matching signature found')
})
expect(() => verifyDeliveryWebhook('{}', new Headers())).toThrow(ResendDeliverySignatureError)
})
})
describe('POST /api/extensions/ext/email/delivery-status', () => {
beforeEach(() => {
vi.clearAllMocks()
process.env.RESEND_API_KEY = 'test-key'
process.env.RESEND_DELIVERY_WEBHOOK_SECRET = 'whsec_test'
})
it('is unauthenticated: the signature is the credential', () => {
expect(deliveryRoute.skipAuth).toBe(true)
expect(deliveryRoute.method).toBe('POST')
})
it('returns 503 when the webhook secret is not configured', async () => {
delete process.env.RESEND_DELIVERY_WEBHOOK_SECRET
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(503)
expect(rpcMock).not.toHaveBeenCalled()
})
it('returns 401 on an invalid signature', async () => {
verifyMock.mockImplementation(() => {
throw new Error('No matching signature found')
})
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(401)
expect(rpcMock).not.toHaveBeenCalled()
})
it('applies a verified bounce to the matching delivery', async () => {
verifyMock.mockReturnValue({
type: 'email.bounced',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({
bounce: { message: 'Mailbox unavailable', subType: 'General', type: 'Permanent' },
}),
})
rpcMock.mockResolvedValue({ data: 'delivery-1', error: null })
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: true })
expect(rpcMock).toHaveBeenCalledWith('apply_invoice_delivery_provider_status', {
p_provider: 'resend',
p_provider_message_id: 'msg-1',
p_status: 'bounced',
p_occurred_at: '2026-07-24T08:00:00.000Z',
p_detail: 'Mailbox unavailable Permanent/General',
})
})
it('acknowledges events that are not about arrival without touching the database', async () => {
verifyMock.mockReturnValue({
type: 'email.opened',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
})
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: false, reason: 'ignored_event' })
expect(rpcMock).not.toHaveBeenCalled()
})
it('acknowledges mail that is not a tracked invoice delivery', async () => {
verifyMock.mockReturnValue({
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData({ email_id: 'payslip-mail' }),
})
rpcMock.mockResolvedValue({ data: null, error: null })
const response = await deliveryRoute.handler(webhookRequest())
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data).toEqual({ applied: false, reason: 'no_matching_delivery' })
})
it('fails loudly on a database error so the provider retries', async () => {
verifyMock.mockReturnValue({
type: 'email.delivered',
created_at: '2026-07-24T08:00:00.000Z',
data: baseData(),
})
rpcMock.mockResolvedValue({ data: null, error: { message: 'connection reset' } })
const response = await deliveryRoute.handler(webhookRequest())
expect(response.status).toBe(500)
})
})
+74
View File
@@ -1,12 +1,86 @@
import { NextResponse } from 'next/server'
import type { Extension } from '@/lib/extensions/types'
import { registerEmailService } from '@/lib/email/service'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { createLogger } from '@/lib/logger'
import { ResendEmailService } from './lib/resend-service'
import {
ResendDeliverySignatureError,
isDeliveryWebhookConfigured,
toDeliveryReport,
verifyDeliveryWebhook,
} from './lib/delivery-webhook'
// Register the Resend implementation immediately when this extension is loaded
registerEmailService(new ResendEmailService())
const log = createLogger('email-delivery-webhook')
export const emailExtension: Extension = {
id: 'email',
name: 'E-post (Resend)',
version: '1.0.0',
apiRoutes: [
// ── Resend delivery webhook (Svix-signed, no user auth) ──
// Reports whether a sent invoice email actually arrived. Resend pushes
// every event for the account to this endpoint, including mail that is not
// a tracked invoice delivery: unmatched reports are acknowledged and
// dropped so they are not retried forever.
{
method: 'POST',
path: '/delivery-status',
skipAuth: true,
handler: async (request: Request) => {
if (!isDeliveryWebhookConfigured()) {
log.error('RESEND_DELIVERY_WEBHOOK_SECRET is not configured', undefined)
return NextResponse.json({ error: 'Delivery webhook not configured' }, { status: 503 })
}
const rawBody = await request.text()
let event
try {
event = verifyDeliveryWebhook(rawBody, request.headers)
} catch (err) {
if (err instanceof ResendDeliverySignatureError) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
log.error('delivery webhook verification failed', err)
return NextResponse.json({ error: 'Verification failed' }, { status: 500 })
}
const report = toDeliveryReport(event)
if (!report) {
return NextResponse.json({ data: { applied: false, reason: 'ignored_event' } })
}
const { data, error } = await createServiceClientNoCookies().rpc(
'apply_invoice_delivery_provider_status',
{
p_provider: 'resend',
p_provider_message_id: report.providerMessageId,
p_status: report.status,
p_occurred_at: report.occurredAt,
p_detail: report.detail,
},
)
// A failed apply must not be acknowledged: Svix retries non-2xx with
// backoff, which is exactly the recovery wanted for a transient
// database error.
if (error) {
log.error('failed to apply delivery status', error, { status: report.status })
return NextResponse.json({ error: 'Failed to record delivery status' }, { status: 500 })
}
if (!data) {
return NextResponse.json({ data: { applied: false, reason: 'no_matching_delivery' } })
}
log.info('delivery status applied', { deliveryId: data, status: report.status })
return NextResponse.json({ data: { applied: true } })
},
},
],
}
@@ -0,0 +1,145 @@
/**
* Resend outbound delivery webhook.
*
* "Accepted by Resend" and "the recipient's server took it" are two different
* facts, and only the first one is known when a send returns. Resend reports
* the second one asynchronously, per message: one report covers every
* recipient on that message, and the reason text names the address that
* failed. This module verifies the signed payload and maps it onto the
* provider status stored on the invoice delivery row.
*/
import { Resend } from 'resend'
import type { WebhookEventPayload } from 'resend'
import type { InvoiceDeliveryProviderStatus } from '@/types'
export class ResendDeliverySignatureError extends Error {
constructor(message: string) {
super(message)
this.name = 'ResendDeliverySignatureError'
}
}
export interface ProviderDeliveryReport {
providerMessageId: string
status: InvoiceDeliveryProviderStatus
occurredAt: string
detail: string | null
}
/**
* Events that say something about whether the message arrived. `email.sent`
* and `email.scheduled` only repeat what the send call already told us, and
* open/click tracking is not enabled: both are ignored on purpose.
*/
const STATUS_BY_EVENT: Record<string, InvoiceDeliveryProviderStatus> = {
'email.delivered': 'delivered',
'email.delivery_delayed': 'delayed',
'email.complained': 'complained',
'email.bounced': 'bounced',
'email.failed': 'failed',
'email.suppressed': 'suppressed',
}
export function isDeliveryWebhookConfigured(): boolean {
return !!process.env.RESEND_DELIVERY_WEBHOOK_SECRET
}
/**
* Verifies the Svix-signed payload against RESEND_DELIVERY_WEBHOOK_SECRET.
* This is a separate Resend endpoint from the inbound document mailbox, so it
* carries its own signing secret.
*/
export function verifyDeliveryWebhook(
rawBody: string,
requestHeaders: Headers,
): WebhookEventPayload {
const secret = process.env.RESEND_DELIVERY_WEBHOOK_SECRET
if (!secret) throw new Error('RESEND_DELIVERY_WEBHOOK_SECRET is required')
const apiKey = process.env.RESEND_API_KEY
if (!apiKey) throw new Error('RESEND_API_KEY is required')
const svixHeaders = {
id: requestHeaders.get('svix-id') ?? '',
timestamp: requestHeaders.get('svix-timestamp') ?? '',
signature: requestHeaders.get('svix-signature') ?? '',
}
try {
return new Resend(apiKey).webhooks.verify({
payload: rawBody,
headers: svixHeaders,
webhookSecret: secret,
})
} catch (err) {
throw new ResendDeliverySignatureError(
err instanceof Error ? err.message : 'Invalid signature',
)
}
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
/**
* The reason is read defensively: the payload is external input, and a
* provider that ships a new event shape must degrade to "no reason given"
* rather than throw, which would turn every retry into another failed
* delivery report.
*/
function reasonText(event: WebhookEventPayload): string | null {
const data = event.data as {
bounce?: { message?: unknown; subType?: unknown; type?: unknown }
failed?: { reason?: unknown }
suppressed?: { message?: unknown; type?: unknown }
}
if (event.type === 'email.bounced') {
const classification = [text(data.bounce?.type), text(data.bounce?.subType)]
.filter(Boolean)
.join('/')
return [text(data.bounce?.message), classification || null].filter(Boolean).join(' ') || null
}
if (event.type === 'email.failed') {
return text(data.failed?.reason)
}
if (event.type === 'email.suppressed') {
return [text(data.suppressed?.message), text(data.suppressed?.type)]
.filter(Boolean)
.join(' ') || null
}
return null
}
/**
* Maps a verified event onto a delivery report, or null when the event says
* nothing about arrival. The provider clock wins over ingestion time: webhooks
* can be retried hours later, and the status timestamp must stay the moment
* the outcome actually happened.
*/
export function toDeliveryReport(event: WebhookEventPayload): ProviderDeliveryReport | null {
const status = STATUS_BY_EVENT[event.type]
if (!status) return null
const data = event.data as { email_id?: string }
if (!data.email_id) return null
const occurredAt = parseTimestamp(event.created_at)
return {
providerMessageId: data.email_id,
status,
occurredAt,
detail: reasonText(event),
}
}
function parseTimestamp(value: unknown): string {
if (typeof value === 'string') {
const parsed = new Date(value)
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
}
return new Date().toISOString()
}
+1 -1
View File
@@ -5,7 +5,7 @@
"entryPoint": "@/extensions/general/email",
"workspace": null,
"requiredEnvVars": ["RESEND_API_KEY", "RESEND_FROM_EMAIL"],
"optionalEnvVars": [],
"optionalEnvVars": ["RESEND_DELIVERY_WEBHOOK_SECRET"],
"npmDependencies": ["resend"],
"definition": {
"name": "E-post (Resend)",
@@ -10,21 +10,10 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatDate } from '@/lib/utils'
import { cn, formatDate } from '@/lib/utils'
import { getDaysUntilExpiry, isConsentExpiringSoon } from '../lib/api-client'
import Link from 'next/link'
import {
CreditCard,
AlertTriangle,
RefreshCw,
Settings,
Trash2,
Loader2,
CheckCircle,
ChevronDown,
XCircle,
Upload,
} from 'lucide-react'
import { ChevronDown, Loader2 } from 'lucide-react'
import type { BankConnection } from '@/types'
interface BankConnectionStatusProps {
@@ -36,6 +25,13 @@ interface BankConnectionStatusProps {
isSyncing?: boolean
}
/**
* One bank connection as a flat hairline row (Fönster settings language):
* bank name + state on one line with quiet actions on the right, live
* warnings as compact warning-tone lines underneath, and the accounts as an
* indented sub-list. Normal state (Aktiv) is muted text; a Badge appears
* only when the row deviates (expired/error/pending).
*/
export function BankConnectionStatus({
connection,
onSync,
@@ -47,48 +43,19 @@ export function BankConnectionStatus({
const daysUntilExpiry = getDaysUntilExpiry(connection.consent_expires)
const isExpiring = isConsentExpiringSoon(connection.consent_expires)
type StatusEntry = {
icon: typeof CheckCircle
color: string
label: string
variant: 'success' | 'warning' | 'destructive' | 'secondary'
}
type StatusEntry =
| { kind: 'text'; label: string }
| { kind: 'badge'; label: string; variant: 'warning' | 'destructive' | 'secondary' }
const statusConfig: Record<string, StatusEntry> = {
active: {
icon: CheckCircle,
color: 'text-success',
label: 'Aktiv',
variant: 'success',
},
pending: {
icon: Loader2,
color: 'text-warning',
label: 'Väntar',
variant: 'warning',
},
expired: {
icon: AlertTriangle,
color: 'text-warning',
label: 'Utgånget samtycke',
variant: 'warning',
},
error: {
icon: XCircle,
color: 'text-destructive',
label: 'Fel',
variant: 'destructive',
},
revoked: {
icon: XCircle,
color: 'text-gray-600',
label: 'Bortkopplad',
variant: 'secondary',
},
active: { kind: 'text', label: 'Aktiv' },
pending: { kind: 'badge', label: 'Väntar', variant: 'warning' },
expired: { kind: 'badge', label: 'Utgånget samtycke', variant: 'warning' },
error: { kind: 'badge', label: 'Fel', variant: 'destructive' },
revoked: { kind: 'badge', label: 'Bortkopplad', variant: 'secondary' },
}
const status = statusConfig[connection.status] || statusConfig.error
const StatusIcon = status.icon
// Parse accounts from connection
const accounts = (connection.accounts_data as Array<{
@@ -118,32 +85,32 @@ export function BankConnectionStatus({
const errorMessage = connection.error_message ?? ''
return (
<div className="border rounded-lg p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<CreditCard className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{connection.bank_name}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<StatusIcon className={`h-3 w-3 ${status.color}`} />
<span>{status.label}</span>
{connection.last_synced_at && (
<>
<span>-</span>
<span>Synkad {formatDate(connection.last_synced_at)}</span>
</>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="border-b border-border px-1 py-3">
{/* Main line: identity + state left, quiet actions right */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium">{connection.bank_name}</span>
{status.kind === 'badge' ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<span className="text-xs text-muted-foreground">{status.label}</span>
)}
{connection.last_synced_at && (
<span className="text-xs text-muted-foreground tabular-nums">
Synkad {formatDate(connection.last_synced_at)}
</span>
)}
{/* Consent renewal date as quiet metadata; the expired state already
carries its own warning line below. */}
{connection.consent_expires && !isConnectionExpired && (
<span className="text-xs text-muted-foreground tabular-nums">
Samtycke till {formatDate(connection.consent_expires)}
</span>
)}
<span className="ml-auto flex shrink-0 flex-wrap items-center gap-1">
{(isConnectionExpired || isConnectionError) && onReconnect && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5">
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground hover:text-foreground">
Förnya anslutning
<ChevronDown className="h-3.5 w-3.5" />
</Button>
@@ -170,98 +137,81 @@ export function BankConnectionStatus({
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<RefreshCw className="h-4 w-4 mr-1" />
Försök igen
</>
)}
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Försök igen
</Button>
)}
{connection.status === 'active' && (
<Button
variant="outline"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onSync(connection.id)}
disabled={isSyncing}
>
{isSyncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
Synka
</Button>
)}
{onManageAccounts && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onManageAccounts(connection.id)}
title="Hantera konton"
>
<Settings className="h-4 w-4" />
Välj konton
</Button>
)}
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-destructive"
onClick={() => onDisconnect(connection.id)}
>
<Trash2 className="h-4 w-4 text-destructive" />
Koppla från
</Button>
</div>
</span>
</div>
{/* Error message */}
{/* Error message: live warning, compact warning-tone lines */}
{isConnectionError && errorMessage && (
<>
<div className="flex items-center gap-2 p-3 bg-destructive/10 rounded-lg">
<XCircle className="h-4 w-4 text-destructive flex-shrink-0" />
<span className="text-sm text-destructive">
{errorMessage}
</span>
</div>
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Du kan också <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">{errorMessage}</p>
<p className="mt-1 text-xs text-muted-foreground">
Du kan också{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
{/* Expired consent notice */}
{isConnectionExpired && (
<>
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning flex-shrink-0" />
<span className="text-sm">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</span>
</div>
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border">
<Upload className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Medan du väntar kan du <Link href="/import?mode=bank" className="underline hover:text-foreground">importera transaktioner via bankfil</Link>
</span>
</div>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
PSD2-samtycket har löpt ut. Förnya anslutningen för att återuppta synkroniseringen.
</p>
<p className="mt-1 text-xs text-muted-foreground">
Medan du väntar kan du{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner via bankfil
</Link>
</p>
</>
)}
{/* Consent expiry warning (for active connections) */}
{!isConnectionExpired && isExpiring && daysUntilExpiry !== null && (
<div className="flex items-center gap-2 p-3 bg-warning/10 rounded-lg">
<AlertTriangle className="h-4 w-4 text-warning" />
<span className="text-sm">
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
Förnya genom att ansluta igen.
</span>
</div>
<p className="mt-1 text-[12.5px] leading-relaxed text-attn">
Samtycket går ut om {daysUntilExpiry} {daysUntilExpiry === 1 ? 'dag' : 'dagar'}.
Förnya genom att ansluta igen.
</p>
)}
{/* Initial backfill summary: shows what the bank actually returned vs what we asked for. */}
@@ -278,7 +228,7 @@ export function BankConnectionStatus({
truncated = (minTime - requestedTime) > 7 * 24 * 60 * 60 * 1000
}
return (
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>
Initial historik:{' '}
<span className="tabular-nums">
@@ -295,59 +245,58 @@ export function BankConnectionStatus({
)
})()}
{/* Accounts list */}
{/* Accounts: indented flat sub-list instead of boxed rows */}
{accounts.length > 0 && (
<div className="space-y-2">
<div className="ml-3 mt-3 border-l border-border pl-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">Konton</p>
<p className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Konton
</p>
<p className="text-xs text-muted-foreground tabular-nums">
{enabledCount} av {accounts.length} synkas
</p>
</div>
<div className="space-y-2">
{accounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={`flex items-center justify-between p-3 rounded-lg ${isDisabled ? 'bg-muted/20 opacity-60' : 'bg-muted/50'}`}
>
<div>
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{account.name || account.iban || 'Okänt konto'}
</p>
{isDisabled && (
<span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1.5 py-0.5">
Synkas ej
</span>
)}
</div>
{account.iban && (
<p className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</p>
{accounts.map((account) => {
const isDisabled = account.enabled === false
return (
<div
key={account.uid}
className={cn(
'flex flex-wrap items-center gap-x-3 gap-y-1 py-2',
isDisabled && 'opacity-60',
)}
>
<span className="text-sm">
{account.name || account.iban || 'Okänt konto'}
</span>
{isDisabled && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide text-muted-foreground">
Synkas ej
</Badge>
)}
{account.iban && (
<span className="text-xs text-muted-foreground">
{account.iban.replace(/(.{4})/g, '$1 ').trim()}
</span>
)}
{account.balance !== undefined && (
<span className="ml-auto inline-flex shrink-0 items-baseline gap-2">
{account.balance_updated_at && (
<span className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</span>
)}
</div>
{account.balance !== undefined && (
<div className="text-right">
<p className="text-sm font-medium tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</p>
{account.balance_updated_at && (
<p className="text-[10px] text-muted-foreground">
{formatBalanceAge(account.balance_updated_at)}
</p>
)}
</div>
)}
</div>
)
})}
</div>
<span className="text-sm tabular-nums">
{new Intl.NumberFormat('sv-SE', {
style: 'currency',
currency: account.currency,
}).format(account.balance)}
</span>
</span>
)}
</div>
)
})}
</div>
)}
</div>
@@ -3,17 +3,16 @@
import { useState, useEffect, useRef } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import { AlertTriangle, CheckCircle, Loader2, Upload } from 'lucide-react'
import { cn } from '@/lib/utils'
import { CheckCircle, Loader2, Upload } from 'lucide-react'
import { createClient } from '@/lib/supabase/client'
import { notifyBankSyncUpdated } from '@/lib/transactions/bank-sync-signal'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { SettingsGroup, SettingsRow, SettingsSeg } from '@/components/settings/SettingsRows'
import { BankSelector, type Bank } from './BankSelector'
import { BankConnectionStatus } from './BankConnectionStatus'
import { AccountPickerDialog } from './AccountPickerDialog'
@@ -431,23 +430,21 @@ export default function BankingSettingsPanel() {
// keeps the already-loaded connections visible instead of wiping them.
if (loadError && bankConnections.length === 0) {
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle>Kunde inte ladda bankanslutningar</CardTitle>
<CardDescription>
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
och transaktioner är oförändrade.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-3">
<div className="px-1 pt-8">
<p className="text-sm font-medium">Kunde inte ladda bankanslutningar</p>
<p className="mt-1 max-w-[56ch] text-xs leading-relaxed text-muted-foreground">
Något gick fel när dina bankanslutningar skulle hämtas. Dina anslutningar
och transaktioner är oförändrade.
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" onClick={() => fetchConnections()}>
Försök igen
</Button>
<Button variant="ghost" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil istället</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
)
}
@@ -463,7 +460,7 @@ export default function BankingSettingsPanel() {
: []
return (
<div className="space-y-6">
<div>
<DestructiveConfirmDialog {...dialogProps} />
{pickerConnection && (
@@ -480,194 +477,146 @@ export default function BankingSettingsPanel() {
/>
)}
{/* Persistent CSV fallback after connection/sync failure */}
{/* Persistent CSV fallback after connection/sync failure: a live hint,
kept visible as a compact line instead of a boxed strip. */}
{showCsvFallback && (
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/50 p-4">
<Upload className="h-5 w-5 shrink-0 text-muted-foreground" />
<p className="flex-1 text-sm text-muted-foreground">
Har du problem med bankanslutningen? Du kan importera transaktioner manuellt via bankfil.
<div className="flex items-start gap-2 px-1 pt-6">
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<p className="text-[12.5px] leading-relaxed text-muted-foreground">
Har du problem med bankanslutningen? Du kan{' '}
<Link href="/import?mode=bank" className="underline underline-offset-2 hover:text-foreground">
importera transaktioner manuellt via bankfil
</Link>
.
</p>
<Button variant="outline" size="sm" asChild>
<Link href="/import?mode=bank">Importera bankfil</Link>
</Button>
</div>
)}
{/* Pending account selection: new connections waiting for the user to pick accounts */}
{pendingSelectionConnections.length > 0 && (
<Card className="border-warning/30">
<CardHeader>
<CardTitle>Välj konton att synka</CardTitle>
<CardDescription>
Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingSelectionConnections.map((connection) => {
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
return (
<div
key={connection.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div className="flex items-center gap-3">
<AlertTriangle className="h-5 w-5 shrink-0 text-warning" />
<div>
<p className="font-medium">{connection.bank_name}</p>
<p className="text-sm text-muted-foreground">
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => setPickerConnectionId(connection.id)}
>
Välj konton
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</div>
</div>
)
})}
</CardContent>
</Card>
<SettingsGroup
label="Välj konton att synka"
help="Banken har gett åtkomst till flera konton. Välj vilka du vill synka innan några transaktioner hämtas."
>
{pendingSelectionConnections.map((connection) => {
const accountsList = (connection.accounts_data as StoredAccount[] | null) || []
return (
<div
key={connection.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border px-1 py-3"
>
<span className="text-sm font-medium">{connection.bank_name}</span>
<span className="text-xs text-muted-foreground">
{accountsList.length} konton tillgängliga: inga transaktioner synkas ännu
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<Button size="sm" onClick={() => setPickerConnectionId(connection.id)}>
Välj konton
</Button>
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => handleDisconnectBank(connection.id)}
>
Avbryt
</Button>
</span>
</div>
)
})}
</SettingsGroup>
)}
{/* Action required: expired/error connections */}
{actionRequiredConnections.length > 0 && (
<Card className="border-warning/30">
<CardHeader>
<CardTitle>Åtgärd krävs</CardTitle>
<CardDescription>
Dessa anslutningar behöver uppmärksamhet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{actionRequiredConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleReconnect}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
</Card>
<SettingsGroup label="Åtgärd krävs" help="Dessa anslutningar behöver uppmärksamhet.">
{actionRequiredConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onReconnect={handleReconnect}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</SettingsGroup>
)}
{/* Connected banks */}
{activeConnections.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Anslutna banker</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</CardContent>
</Card>
<SettingsGroup label="Anslutna banker">
{activeConnections.map((connection) => (
<BankConnectionStatus
key={connection.id}
connection={connection}
onSync={handleSyncTransactions}
onDisconnect={handleDisconnectBank}
onManageAccounts={() => setPickerConnectionId(connection.id)}
isSyncing={syncingConnectionId === connection.id}
/>
))}
</SettingsGroup>
)}
{/* Connect new bank. Non-payers keep seeing the card (conversion
{/* Connect new bank. Non-payers keep seeing the group (conversion
surface) but the bank list is replaced by an upgrade note: the
server gate would 403 the connect anyway. */}
<Card>
<CardHeader>
<CardTitle>Anslut ny bank</CardTitle>
<CardDescription>
Välj din bank nedan för att koppla ditt konto via PSD2.
</CardDescription>
</CardHeader>
server gate would 403 the connect anyway. The former "Om
bankintegration (PSD2)" card lives on as group-level help. */}
<SettingsGroup
label="Anslut ny bank"
help={
<div className="space-y-2">
<p>Välj din bank nedan för att koppla ditt konto via PSD2.</p>
<p className="font-medium">Om bankintegration (PSD2)</p>
<p>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</p>
<p>
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</div>
}
>
{!hasBankSync ? (
<CardContent>
<div className="px-1 pt-3">
<UpgradeNote>
Automatisk banksynk kräver ett abonnemang. Du kan fortfarande importera
transaktioner manuellt via bankfiler på importsidan.
</UpgradeNote>
</CardContent>
) : (
<CardContent className="space-y-4">
{/* Account type selector */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Kontotyp:</span>
<div className="inline-flex rounded-lg border border-border p-0.5">
<button
type="button"
onClick={() => setPsuType('business')}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
psuType === 'business'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Företagskonto
</button>
<button
type="button"
onClick={() => setPsuType('personal')}
className={cn(
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
psuType === 'personal'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Privatkonto
</button>
</div>
</div>
{psuType === 'personal' && (
<p className="text-xs text-muted-foreground">
Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma).
</p>
)}
<BankSelector
onConnect={(bank) => handleConnectBank(bank, psuType)}
onPsuTypeDetected={setPsuType}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
</CardContent>
) : (
<>
<SettingsRow
label="Kontotyp"
help="Välj Privatkonto om du använder ditt personliga bankkonto för din verksamhet (vanligt för enskild firma)."
>
<SettingsSeg
value={psuType}
onChange={setPsuType}
aria-label="Kontotyp"
options={[
{ value: 'business', label: 'Företagskonto' },
{ value: 'personal', label: 'Privatkonto' },
]}
/>
</SettingsRow>
<div className="px-1 pt-4">
<BankSelector
onConnect={(bank) => handleConnectBank(bank, psuType)}
onPsuTypeDetected={setPsuType}
isConnecting={isConnecting}
connectingBankName={connectingBankName}
/>
</div>
</>
)}
</Card>
{/* Info about PSD2 */}
<Card>
<CardHeader>
<CardTitle>Om bankintegration (PSD2)</CardTitle>
<CardDescription>
Automatisk import av transaktioner via PSD2 open banking.
Samtycket gäller i 90 dagar och behöver sedan förnyas.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Vi använder säker bankintegration (PSD2). Vi kan endast läsa transaktioner,
aldrig flytta pengar. Du kan också importera transaktioner manuellt via
bankfiler på importsidan.
</p>
</CardContent>
</Card>
</SettingsGroup>
</div>
)
}
@@ -10,20 +10,11 @@ import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { useFormat } from '@/lib/hooks/use-format'
import { formatCurrency, formatDate } from '@/lib/utils'
import { CreditCard, Link2, Loader2, RefreshCw, Unlink } from 'lucide-react'
import type { StripeReviewEvent, StripeStatusResponse } from '../types'
import type { StripeStatusResponse } from '../types'
type ConnectionInfo = NonNullable<StripeStatusResponse['connection']>
const KNOWN_REVIEW_REASONS = new Set([
'invoice_not_found',
'invoice_already_paid',
'amount_mismatch',
'currency_mismatch',
'non_sek_invoice',
])
const STATUS_VARIANT: Record<ConnectionInfo['status'], 'success' | 'secondary' | 'destructive' | 'warning'> = {
active: 'success',
pending: 'secondary',
@@ -46,8 +37,6 @@ export default function StripeSettingsPanel() {
const [confirmDisconnect, setConfirmDisconnect] = useState(false)
const [syncing, setSyncing] = useState(false)
const [togglingTransactionSync, setTogglingTransactionSync] = useState(false)
const [needsReviewCount, setNeedsReviewCount] = useState(0)
const [needsReview, setNeedsReview] = useState<StripeReviewEvent[]>([])
const loadStatus = useCallback(async () => {
try {
@@ -56,8 +45,6 @@ export default function StripeSettingsPanel() {
const data = (await res.json()) as StripeStatusResponse
setConfigured(data.configured)
setConnection(data.connection)
setNeedsReviewCount(data.needs_review_count ?? 0)
setNeedsReview(data.needs_review ?? [])
} finally {
setLoading(false)
}
@@ -128,9 +115,7 @@ export default function StripeSettingsPanel() {
body: JSON.stringify({}),
})
const data = (await res.json().catch(() => ({}))) as {
settled?: number
needsReview?: number
transactions?: { imported?: number; linked?: number }
transactions?: { fetched?: number; imported?: number; linked?: number }
error?: string
}
if (!res.ok) {
@@ -141,18 +126,20 @@ export default function StripeSettingsPanel() {
})
return
}
const paymentsLine = t('sync_done_description', {
settled: data.settled ?? 0,
review: data.needsReview ?? 0,
})
// Honest summary: report what Stripe actually returned. An all-zero
// run is a real answer ("the account had nothing in the window"), not
// a silent success.
const fetched = data.transactions?.fetched ?? 0
toast({
title: t('sync_done_title'),
description: data.transactions
? `${paymentsLine} ${t('sync_done_transactions', {
imported: data.transactions.imported ?? 0,
linked: data.transactions.linked ?? 0,
})}`
: paymentsLine,
description:
fetched === 0
? t('sync_done_empty')
: t('sync_done_feed', {
fetched,
imported: data.transactions?.imported ?? 0,
linked: data.transactions?.linked ?? 0,
}),
})
await loadStatus()
} finally {
@@ -362,40 +349,6 @@ export default function StripeSettingsPanel() {
</div>
)}
{isActive && needsReviewCount > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('needs_review_title')}
</h2>
<Badge variant="warning">{needsReviewCount}</Badge>
</div>
<p className="text-sm text-muted-foreground">{t('needs_review_hint')}</p>
<ul className="divide-y divide-border rounded-lg border border-border">
{needsReview.map((event) => (
<li key={event.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<p className="text-sm">
{event.reason && KNOWN_REVIEW_REASONS.has(event.reason)
? t(`reason_${event.reason}`)
: event.reason || t('reason_unknown')}
</p>
{event.event_created_at && (
<p className="text-xs text-muted-foreground tabular-nums">
{formatDate(event.event_created_at)}
</p>
)}
</div>
{event.amount != null && (
<span className="shrink-0 text-sm tabular-nums">
{formatCurrency(event.amount, event.currency ?? 'SEK')}
</span>
)}
</li>
))}
</ul>
</div>
)}
</CardContent>
</Card>
)
+14 -38
View File
@@ -15,7 +15,6 @@ import {
handleCreditNoteCreated,
handleInvoicePaid,
} from './lib/payment-links'
import { syncStripeConnection } from './lib/sync'
import { syncStripeBalanceTransactions } from './lib/transaction-sync'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import type { StripeConnection, StripeStatusResponse } from './types'
@@ -38,9 +37,11 @@ const NOT_CONFIGURED_MESSAGE =
* Stripe Connect extension
*
* Connects a company's Stripe account via Connect OAuth (Standard accounts).
* Auto-creates a Stripe Payment Link when an invoice is sent, marks invoices
* paid from Stripe checkout events, and books payouts (gross/fees/net) against
* the 1686 clearing account.
* Auto-creates a Stripe Payment Link when an invoice is sent, and imports the
* account's balance transactions into the transactions inbox as a bank feed
* for the Stripe balance (1686). Feed-only by decision 2026-07-24: nothing is
* auto-booked; the event/settlement sync (lib/sync.ts, lib/payouts.ts) is
* retained in the repo but not wired to any cron or route.
*
* Required environment variables:
* - STRIPE_SECRET_KEY (the platform account key, shared with billing)
@@ -97,35 +98,9 @@ export const stripeExtension: Extension = {
const connection =
rows?.find((r) => r.status === 'active') ?? rows?.[0] ?? null
// Events + payouts the deterministic matcher refused to auto-apply.
// Members can read both ledgers under RLS; the panel lists them for
// manual handling.
const { data: reviewRows, count: reviewCount } = await supabase
.from('stripe_payment_events')
.select('id, reason, amount, currency, invoice_id, event_created_at', {
count: 'exact',
})
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const { data: payoutRows, count: payoutCount } = await supabase
.from('stripe_payouts')
.select('id, reason, amount, currency, event_created_at', { count: 'exact' })
.eq('company_id', ctx.companyId)
.eq('status', 'needs_review')
.order('event_created_at', { ascending: false })
.limit(5)
const payload: StripeStatusResponse = {
configured: isStripeConnectConfigured(),
connection,
needs_review_count: (reviewCount ?? 0) + (payoutCount ?? 0),
needs_review: [
...(reviewRows ?? []),
...(payoutRows ?? []).map((p) => ({ ...p, invoice_id: null })),
],
}
return NextResponse.json(payload)
},
@@ -175,14 +150,15 @@ export const stripeExtension: Extension = {
try {
const serviceClient = createServiceClientNoCookies()
const typedConnection = connection as StripeConnection
const summary = await syncStripeConnection(serviceClient, typedConnection)
// The manual button covers both feeds: when the balance-transaction
// feed is enabled, "Synka nu" also pulls it (same module as the
// nightly cron, no separate rate limit needed: one user action).
const transactions = typedConnection.transaction_sync_enabled
? await syncStripeBalanceTransactions(serviceClient, typedConnection)
: undefined
return NextResponse.json({ success: true, ...summary, transactions })
// Feed-only (2026-07-24): Stripe sync imports balance transactions
// into the inbox, nothing more. The event/settlement sync
// (syncStripeConnection in lib/sync.ts) stays in the repo but is
// deliberately not called: booking is a user decision in the inbox,
// like any bank feed. The manual button ignores
// transaction_sync_enabled (that flag gates the nightly cron):
// pressing it IS the opt-in.
const transactions = await syncStripeBalanceTransactions(serviceClient, typedConnection)
return NextResponse.json({ success: true, transactions })
} catch (error) {
log.error('[stripe] Manual sync failed', {
message: error instanceof Error ? error.message : String(error),
-12
View File
@@ -21,16 +21,6 @@ export interface StripeConnection {
updated_at: string
}
/** Row shape of public.stripe_payment_events (needs_review projection). */
export interface StripeReviewEvent {
id: string
reason: string | null
amount: number | null
currency: string | null
invoice_id: string | null
event_created_at: string | null
}
/** Status payload returned by GET /api/extensions/ext/stripe/status. */
export interface StripeStatusResponse {
configured: boolean
@@ -47,6 +37,4 @@ export interface StripeStatusResponse {
| 'transaction_sync_enabled'
| 'last_balance_txn_synced_at'
> | null
needs_review_count?: number
needs_review?: StripeReviewEvent[]
}