feat: consolidate /expenses into /supplier-invoices, dual-channel support feedback, compliance skills (#417)

* feat(supplier-invoices): consolidate /expenses into /supplier-invoices and add bank matching

Collapse the duplicate AP entry points by redirecting /expenses, /expenses/new,
and /expenses/[id] into the canonical /supplier-invoices routes, and absorb the
expense-entry flow into /supplier-invoices/new. Add a BankTransactionPicker so
a supplier invoice can be registered and matched to an existing outgoing bank
transaction in one step. Extend the transaction → invoice match dialog to
handle supplier invoices alongside customer invoices, including the new
/match-supplier-invoice endpoint and Swedish copy variants. Update the sidebar
to point Leverantörsfakturor at /supplier-invoices and hide the legacy entry.

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

* feat(support): send feedback to both Recapt and email channels

Previously submitFeedback used Recapt when present and only fell back to email
on failure, so feedback captured by the SDK never reached the support inbox.
Always POST to /api/support/contact in parallel with the Recapt call and
return the list of channels that succeeded; feedback is considered delivered
if either channel succeeds.

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

* chore(skills): add compliance skill references for GDPR, ISO 27001, OSS, OWASP ASVS, SOC 2

Add reference material for five compliance domains alongside the existing
.claude/skills/ set so future audits and CI gating work has a documented
mapping to controls, violation patterns, tool orchestration, and cross-framework
crosswalks.

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

* fix(supplier-invoices): address review issues in register-and-match flow

- BankTransactionPicker: move createClient() inside the effect and drop the
  supabase client from the deps array. Calling createClient() in the component
  body produced a fresh reference on every render, which combined with the
  setIsLoading(true) inside the effect to fire an infinite re-fetch loop while
  the dialog was open.
- /supplier-invoices/new: replace the submitMode useState with a useRef.
  setSubmitMode() in the button onClick and the read in the form onSubmit run
  in the same React event batch, so onSubmit always saw the previous render's
  value and the bank picker never opened on first use.
- /supplier-invoices/new: route AB companies through the existing review
  dialog before booking the register-and-match flow. handlePickTransaction now
  stores the picked transaction and opens the review dialog for AB; on
  confirm, handleConfirm posts the create and then matches the stored
  transaction. EF retains the one-step create+approve+match path.

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

* fix(transactions): populate potential_supplier_invoice so the match flow is reachable

fetchTransactions and loadMoreTransactions previously joined potential_invoice
from the invoices table but never looked up potential_supplier_invoice, so
TransactionInboxCard's hasSupplierInvoiceMatch check was always false and
handleConfirmInvoiceMatch's supplier branch was unreachable. Mirror the
existing customer-invoice pipeline: collect potential_supplier_invoice_id
values, batch-fetch the matching supplier_invoices (with their supplier),
build a map, and spread the result onto each TransactionWithInvoice in
parallel with the customer-invoice fetch.

Update uncategorizedTransactions sort and transactionsWithMatches filter to
also recognize supplier-invoice candidates so they bubble to the top of the
inbox and aren't excluded from match-driven views.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-07 18:13:43 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8a1c7f0714
commit 15d4f429f3
52 changed files with 8103 additions and 2043 deletions
+54 -35
View File
@@ -19,86 +19,105 @@ describe('submitFeedback', () => {
vi.stubGlobal('window', {})
}
it('uses Recapt when SDK is present and prepends subject', async () => {
function stubFetchOk() {
const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })
vi.stubGlobal('fetch', fetchSpy)
return fetchSpy
}
it('sends to both Recapt and email when SDK is present', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
const fetchSpy = vi.fn()
vi.stubGlobal('fetch', fetchSpy)
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
expect(result).toEqual({ ok: true, channel: 'recapt' })
expect(result.ok).toBe(true)
expect(result.channels.sort()).toEqual(['email', 'recapt'])
expect(recapt).toHaveBeenCalledWith('feedback', { message: '[Hjälpsida]\n\nHjälp tack' })
expect(fetchSpy).not.toHaveBeenCalled()
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ subject: 'Hjälpsida', message: 'Hjälp tack' }),
})
)
})
it('uses Recapt without subject prefix when subject omitted', async () => {
it('omits subject prefix in Recapt payload when subject not provided', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
stubFetchOk()
await submitFeedback({ message: 'plain' })
expect(recapt).toHaveBeenCalledWith('feedback', { message: 'plain' })
})
it('falls back to email when Recapt throws', async () => {
it('still reports success via email when Recapt throws', async () => {
stubRecapt(() => {
throw new Error('boom')
})
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
})
vi.stubGlobal('fetch', fetchSpy)
stubFetchOk()
const result = await submitFeedback({ subject: 'X', message: 'msg' })
expect(result).toEqual({ ok: true, channel: 'email' })
expect(fetchSpy).toHaveBeenCalledWith(
'/api/support/contact',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ subject: 'X', message: 'msg' }),
})
)
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
})
it('falls back to email when Recapt SDK is absent', async () => {
it('uses email only when Recapt SDK is absent', async () => {
stubNoRecapt()
const fetchSpy = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({}),
})
vi.stubGlobal('fetch', fetchSpy)
const fetchSpy = stubFetchOk()
const result = await submitFeedback({ message: 'msg' })
expect(result).toEqual({ ok: true, channel: 'email' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['email'])
expect(fetchSpy).toHaveBeenCalledOnce()
})
it('returns failure with error from email when fetch returns non-ok', async () => {
stubNoRecapt()
const fetchSpy = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Mailtjänsten är inte konfigurerad' }),
it('reports success when Recapt succeeds even if email fails', async () => {
const recapt = vi.fn()
stubRecapt(recapt)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) })
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(true)
expect(result.channels).toEqual(['recapt'])
})
it('returns failure with email error when both channels fail', async () => {
stubRecapt(() => {
throw new Error('boom')
})
vi.stubGlobal('fetch', fetchSpy)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: 'Mailtjänsten är inte konfigurerad' }),
})
)
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(false)
expect(result.channel).toBe('email')
expect(result.channels).toEqual([])
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
})
it('returns failure when fetch itself throws', async () => {
it('returns failure when fetch itself throws and Recapt is absent', async () => {
stubNoRecapt()
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
const result = await submitFeedback({ message: 'msg' })
expect(result.ok).toBe(false)
expect(result.channels).toEqual([])
expect(result.error).toBe('Network down')
})
})
+35 -15
View File
@@ -3,9 +3,11 @@ export interface SubmitFeedbackInput {
subject?: string
}
export type SupportChannel = 'recapt' | 'email'
export interface SubmitFeedbackResult {
ok: boolean
channel: 'recapt' | 'email'
channels: SupportChannel[]
error?: string
}
@@ -14,7 +16,9 @@ function composeMessage({ message, subject }: SubmitFeedbackInput): string {
return `[${subject}]\n\n${message}`
}
async function submitViaEmail({ message, subject }: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
async function submitViaEmail(
{ message, subject }: SubmitFeedbackInput
): Promise<{ ok: true } | { ok: false; error: string }> {
try {
const res = await fetch('/api/support/contact', {
method: 'POST',
@@ -23,26 +27,42 @@ async function submitViaEmail({ message, subject }: SubmitFeedbackInput): Promis
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
return { ok: false, channel: 'email', error: data.error || 'Kunde inte skicka meddelandet' }
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
}
return { ok: true, channel: 'email' }
return { ok: true }
} catch (err) {
return { ok: false, channel: 'email', error: err instanceof Error ? err.message : 'Nätverksfel' }
return { ok: false, error: err instanceof Error ? err.message : 'Nätverksfel' }
}
}
function submitViaRecapt(
input: SubmitFeedbackInput
): { ok: true } | { ok: false; error: string } | null {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
if (typeof recapt !== 'function') return null
try {
recapt('feedback', { message: composeMessage(input) })
return { ok: true }
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Recapt-fel' }
}
}
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
const recapt = typeof window !== 'undefined' ? window.recapt : undefined
const fullMessage = composeMessage(input)
const recaptResult = submitViaRecapt(input)
const emailResult = await submitViaEmail(input)
if (typeof recapt === 'function') {
try {
recapt('feedback', { message: fullMessage })
return { ok: true, channel: 'recapt' }
} catch {
// fall through to email
}
const channels: SupportChannel[] = []
if (recaptResult?.ok) channels.push('recapt')
if (emailResult.ok) channels.push('email')
if (channels.length > 0) {
return { ok: true, channels }
}
return submitViaEmail(input)
return {
ok: false,
channels: [],
error: emailResult.ok ? undefined : emailResult.error,
}
}