Add/stripe skv (#1004)
* fix(salary): align pain.001 salary file with the Swedish domestic bank dialect Verified against the Swedish Common Interpretation of ISO 20022 (Bankforeningen, Common Payment Types in Sweden, Appendix 1 Example 4: Salaries) and Nordea Corporate Access pain.001 examples v2.6 (2026-06-22), and XSD-validated against the official pain.001.001.03 schema: - drop SvcLvl SEPA (SEPA credit transfers are EUR-only; omitting SvcLvl gets the domestic NURG default) - drop RmtInf (not allowed for SALA salary payments; the beneficiary statement text comes from the Dataclearing LON code) - address employees domestically: clearing as CdtrAgt ClrSysMmbId SESBA, account WITHOUT clearing as CdtrAcct Othr with SchmeNm BBAN - share the clearing/account split (Swedbank 5-digit shift, Nordea personkonto prefix dedup) between the LB and pain.001 generators via splitDomesticBankAccount, fixing pain.001 duplicating the personkonto clearing - clamp MsgId/PmtInfId/InstrId/EndToEndId to Max35Text with the per-tx counter surviving truncation; carry the org number on Dbtr - return 400 from the pain001 route on an invalid clearing instead of emitting a broken file Also includes two unrelated decision-log lines from the parallel revisor-review session (DECISIONS.md is a shared append-only log). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(nav): surface the year-end chain in the sidebar Add Periodiseringar, Arsredovisning (aktiebolag only) and Inkomstdeklaration (INK2 for AB, NE-bilaga for EF) to the Skatt & bokslut group, in workflow order. Entity gating via a new entityOnly flag on NavItem; isActive carve-outs extended so exactly one row lights up for the new routes. Driven by an external revisor review that concluded these features did not exist because none of them were reachable from the nav. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): Stripe Connect integration behind config gate Connect OAuth per company (only the acct_ id is stored), automatic single-use Payment Links on invoice send, deterministic payment settlement against 1686 (BAS moved acquirer receivables 1580 -> 1686), payout booking with reverse-charge fees (6570 + 4535/4598 + 2645/2614), and a 15-minute sync cron. Non-deterministic events land as needs_review, never guessed at. Fully dark without STRIPE_CONNECT_CLIENT_ID: connect returns 503, the send hook and cron no-op, and the settings page shows 'Kommer snart' (hosted) until the Connect platform is verified. Self-hosted keeps the honest not-configured message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(deadlines): add shared completeTaxDeadline and fix dead AGI deadline auto-complete generate-declaration.ts has updated non-existent columns (type/period/ status) since inception, so the arbetsgivardeklaration deadline was never auto-completed. Replace with a shared helper targeting the real schema (tax_deadline_type/tax_period/is_completed), also used by the kvittens crons and moms handlers in the follow-up commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(rot-rut): import Skatteverket beslutsfil and record decisions on payout requests Parse the beslutsfil JSON from Skatteverkets rot/rut e-tjanst and record godkant belopp on the matching begaran: matched by stored skv_referensnummer first, then exact name among active undecided requests; arenden by fakturanummer then personnummer, exactly-one or the beslut errors (all-or-nothing). Never auto-settles: recording the beslut and booking the payout are separate acts. Exposed as an API route and the gnubok_import_rot_rut_beslut MCP tool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skatteverket): system auth for background reads, one-click VAT submit, kvittens notifications Hybrid auth program: system CCG (org certificate) for background reads while personal BankID stays for interactive submissions, since SKV per-flow refresh tokens live 65 min and crons structurally cannot run on them. All system-auth code sits behind SKATTEVERKET_SYSTEM_AUTH_MODE (default off) with a stub transport until the Expisoft cert and CCG avtal land; auth resolution is centralized in resolve-auth.ts. Also in this change: - One-click VAT submit chaining kontrollera -> utkast -> las server-side with a stage discriminator; step-by-step buttons demoted to the overflow menu. - Kvittens crons (AGI + new VAT schedule) with email-only notifications, deduped in notification_log under the new skv_kvittens type. - Ombud grant probe + verification UI in the connect panel, and a dashboard promo card for unconnected companies. - skatteverket_company_connections table with pg-real coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(salary): auto-settle AGI tax payment from skattekonto and surface SKV reconnect on the tax card The "Skatt att betala" card only cleared via the manual mark-paid button on the run detail page; the promised automatic flip from the Skattekonto sync was never implemented, so paid periods stayed red. - settleAgiTaxPayments: during every skattekonto sync, a booked "Arbetsgivardeklaration YYYYMM" debit row settles the matching agi_declarations.tax_paid_at, but only when the amount equals the declared total to the ore and the account is not in deficit (deterministic; drift or deficit falls back to manual). - Salary overview card: reconnect hint when the SKV token needs re-consent (link to /settings/tax, silent when the extension is off), plus an inline "Markera som betald" button reusing the existing endpoint and salary_payments strings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add cloud backup scheduling and alerting features - Implement unit tests for scheduling logic in `schedule.test.ts`, covering various scenarios for determining if a backup schedule is due. - Create a new module `backup-alert.ts` to handle failure alerts for cloud backup auto-sync, including email notifications for reauthentication and repeated failures. - Introduce `schedule.ts` to manage scheduling logic, including handling local time zones and converting between local and UTC hours. - Add CSV report generation functions in `archive-csv.ts` for trial balance, income statement, balance sheet, and general ledger, ensuring compatibility with Swedish Excel formats. - Create a README generator for the archive structure in `archive-readme.ts`, providing clear documentation for users accessing backup files. - Implement tests for CSV report generation in `archive-csv.test.ts`, ensuring correct formatting and content. - Establish a full-archive coverage contract test in `full-archive-coverage.pg.test.ts` to ensure all company-scoped tables are properly classified for backup. * fix(stripe): correct invoice clearing reference and improve type safety in sync logic * fix(invoices): narrow accountingMethod before resolveInvoicePaymentSourceType settleInvoicePayment takes accountingMethod as a raw settings string, but resolveInvoicePaymentSourceType requires the 'accrual' | 'cash' union. Normalize at the call site (anything but 'cash' books as accrual), matching the existing useCashEntry semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address CodeRabbit review findings and nitpicks on PR #1004 Review findings: - backup settings redirect: always force view=export over incoming params - AGI/VAT kvittens crons: isolate best-effort post-submit calls, check the signed-state persist error, guard recovery calls in catch blocks so one company cannot abort the rest; surface grant_revoked in the run summary - kvittens notifications: atomic claim-first dedup with a partial unique index; map non-uuid reference keys to deterministic uuids - grant probe: record the actual 2xx status; mTLS transport: handle response-stream errors - stripe: amount-aware idempotency keys for payment links; emit stripe.disconnected on upstream revocations - ROT/RUT beslut import: mutate in-memory request state after apply, move item + header writes into an atomic apply_rot_rut_beslut RPC, add rot_rut_payout to JournalEntrySourceTypeSchema - migrations: use NOT VALID + VALIDATE CONSTRAINT for CHECK constraints on journal_entries, notification_log and rot_rut_payout_requests - cloud backup: hour_utc-only schedule updates clear stale hour_local Nitpicks: - stripe sync: enforce the cron time budget inside per-connection event processing with idempotent cursor progress; maybeSingle for settings; honest partial-customer DTO shared with the settlement boundary - shared applyPaymentLinkToInvoice helper for both invoice send routes, v1 docblock documents step 6b and PAYMENT_LINK_FAILED - settings panel: drop redundant decodeURIComponent - cloud backup: document worst-case archive memory headroom Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ee3c33c7a4
commit
98d0c7f2d0
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
@@ -12,6 +12,15 @@ vi.mock('@/extensions/general/cloud-backup/lib/sync', () => ({
|
||||
saveExtensionData: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/cloud-backup/lib/backup-alert', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/extensions/general/cloud-backup/lib/backup-alert')>()
|
||||
return {
|
||||
...actual,
|
||||
sendBackupFailureAlert: vi.fn().mockResolvedValue({ sent: true }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
@@ -22,13 +31,18 @@ import {
|
||||
performSync,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { sendBackupFailureAlert } from '@/extensions/general/cloud-backup/lib/backup-alert'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockPerformSync = vi.mocked(performSync)
|
||||
const mockSaveExtensionData = vi.mocked(saveExtensionData)
|
||||
const mockSendBackupFailureAlert = vi.mocked(sendBackupFailureAlert)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
|
||||
// All tests run at a frozen 2026-07-12 12:30 UTC.
|
||||
const NOW = new Date('2026-07-12T12:30:00.000Z')
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/cloud-backup/auto-sync/cron', {
|
||||
headers: { authorization: 'Bearer test-secret' },
|
||||
@@ -75,13 +89,49 @@ function makeSupabaseStub(
|
||||
return { from } as any
|
||||
}
|
||||
|
||||
function scheduleRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: 12,
|
||||
last_auto_sync_at: null,
|
||||
last_auto_sync_status: null,
|
||||
last_auto_sync_error: null,
|
||||
...overrides,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function okSyncResult() {
|
||||
return {
|
||||
ok: true as const,
|
||||
lastSync: {
|
||||
at: '2026-07-12T12:30:00Z',
|
||||
folder_id: 'folder-1',
|
||||
files: [],
|
||||
total_size_bytes: 1000,
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/drive/folders/folder-1',
|
||||
uploadedCount: 1,
|
||||
skippedCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('cloud-backup auto-sync cron', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers({ now: NOW })
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
process.env.NEXT_PUBLIC_APP_URL = 'https://app.test'
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockSendBackupFailureAlert.mockResolvedValue({ sent: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('returns 401 when cron auth fails', async () => {
|
||||
@@ -95,13 +145,43 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
})
|
||||
|
||||
it('skips schedules that are disabled', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ enabled: false })])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips schedules whose slot is later today', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow({ hour_utc: 13 })]))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches up companies whose earlier slot was missed', async () => {
|
||||
// 03:00 slot, never synced: still due at 12:30 (e.g. after a time-budget overrun).
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow({ hour_utc: 3 })]))
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.successes).toBe(1)
|
||||
expect(mockPerformSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips schedules that already ran since today\'s slot', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: { enabled: false, hour_utc: new Date().getUTCHours() },
|
||||
},
|
||||
scheduleRow({ hour_utc: 3, last_auto_sync_at: '2026-07-12T03:05:00.000Z' }),
|
||||
])
|
||||
)
|
||||
|
||||
@@ -112,73 +192,25 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips schedules whose hour does not match the current UTC hour', async () => {
|
||||
const offHour = (new Date().getUTCHours() + 5) % 24
|
||||
it('runs again when the last attempt was yesterday', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: { enabled: true, hour_utc: offHour },
|
||||
},
|
||||
scheduleRow({ hour_utc: 12, last_auto_sync_at: '2026-07-11T12:05:00.000Z' }),
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.successes).toBe(1)
|
||||
})
|
||||
|
||||
it('skips schedules whose last_auto_sync_at is less than 20h ago', async () => {
|
||||
const recent = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||
it('runs sync with document fallback and persists success state', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: recent,
|
||||
},
|
||||
},
|
||||
])
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs sync and persists success for qualifying schedules', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
lastSync: {
|
||||
at: '2026-04-20T03:00:00Z',
|
||||
file_id: 'f-1',
|
||||
file_name: 'arkiv.zip',
|
||||
file_size_bytes: 1000,
|
||||
folder_id: 'folder-1',
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/file/d/f-1/view',
|
||||
})
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
@@ -188,92 +220,162 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
companyId: 'c-1',
|
||||
userId: 'u-1',
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
})
|
||||
)
|
||||
expect(body.successes).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
|
||||
// Persisted the success state on the schedule
|
||||
const [, , , key, value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect(key).toBe('google_drive_schedule')
|
||||
expect((value as any).last_auto_sync_status).toBe('success')
|
||||
expect((value as any).last_auto_sync_error).toBeNull()
|
||||
// Success resets the failure counter.
|
||||
expect((value as any).consecutive_failures).toBe(0)
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records error status when performSync returns ok=false', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
it('increments the failure counter without alerting below the threshold', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow()]))
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'archive_too_large',
|
||||
message: 'Archive exceeds size limit',
|
||||
size_bytes: 100 * 1024 * 1024,
|
||||
size_limit_bytes: 80 * 1024 * 1024,
|
||||
reason: 'upload_failed',
|
||||
message: 'Drive upload failed: 500',
|
||||
})
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.successes).toBe(0)
|
||||
expect(body.errors).toBe(1)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_auto_sync_status).toBe('error')
|
||||
expect((value as any).last_auto_sync_error).toBe('Archive exceeds size limit')
|
||||
expect((value as any).consecutive_failures).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches thrown errors and records them against the schedule', async () => {
|
||||
it('alerts when the consecutive failure threshold is reached', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'upload_failed',
|
||||
message: 'Drive upload failed: 500',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: 'c-1',
|
||||
kind: 'repeated_failures',
|
||||
consecutiveFailures: 3,
|
||||
errorMessage: 'Drive upload failed: 500',
|
||||
})
|
||||
)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).consecutive_failures).toBe(3)
|
||||
expect((value as any).last_alert_at).toEqual(expect.any(String))
|
||||
})
|
||||
|
||||
it('throttles repeat alerts via last_alert_at', async () => {
|
||||
const recentAlert = new Date(NOW.getTime() - 24 * 60 * 60 * 1000).toISOString()
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
scheduleRow({ consecutive_failures: 5, last_alert_at: recentAlert }),
|
||||
])
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'upload_failed',
|
||||
message: 'still failing',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_alert_at).toBe(recentAlert)
|
||||
})
|
||||
|
||||
it('alerts immediately when the token dies during the sync', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(makeSupabaseStub([scheduleRow()]))
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'needs_reauth',
|
||||
message: 'Google Drive authorization expired; reconnect required',
|
||||
})
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth' })
|
||||
)
|
||||
})
|
||||
|
||||
it('catches thrown errors, counts them and records them against the schedule', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub([scheduleRow({ consecutive_failures: 2 })])
|
||||
)
|
||||
mockPerformSync.mockRejectedValueOnce(new Error('Drive quota exceeded'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.errors).toBe(1)
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'repeated_failures', consecutiveFailures: 3 })
|
||||
)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_auto_sync_status).toBe('error')
|
||||
expect((value as any).last_auto_sync_error).toContain('Drive quota exceeded')
|
||||
expect((value as any).consecutive_failures).toBe(3)
|
||||
})
|
||||
|
||||
it('skips connections flagged needs_reauth without syncing or touching the schedule', async () => {
|
||||
it('skips pre-flagged needs_reauth connections and alerts once per incident', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionRows: [
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
// The incident had not been alerted yet: one alert, persisted on the schedule.
|
||||
expect(mockSendBackupFailureAlert).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ kind: 'needs_reauth' })
|
||||
)
|
||||
expect(mockSaveExtensionData).toHaveBeenCalledTimes(1)
|
||||
const [, , , , value] = mockSaveExtensionData.mock.calls[0]
|
||||
expect((value as any).last_alert_at).toEqual(expect.any(String))
|
||||
// last_auto_sync_* stays untouched: it keeps showing the original failure.
|
||||
expect((value as any).last_auto_sync_at).toBeNull()
|
||||
})
|
||||
|
||||
it('does not re-alert an already alerted needs_reauth incident', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[scheduleRow({ last_alert_at: '2026-07-10T04:00:00.000Z' })],
|
||||
{
|
||||
connectionRows: [
|
||||
{ company_id: 'c-1', value: { status: 'needs_reauth' } },
|
||||
{
|
||||
company_id: 'c-1',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -282,58 +384,30 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(mockPerformSync).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
expect(body.skipped).toBe(1)
|
||||
expect(body.successes).toBe(0)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results).toEqual([
|
||||
{ companyId: 'c-1', status: 'skipped', error: 'needs_reauth' },
|
||||
])
|
||||
expect(mockSendBackupFailureAlert).not.toHaveBeenCalled()
|
||||
expect(mockSaveExtensionData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('only skips the flagged company when others are due', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
company_id: 'c-live',
|
||||
user_id: 'u-2',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
{ ...scheduleRow(), company_id: 'c-dead' },
|
||||
{ ...scheduleRow(), company_id: 'c-live', user_id: 'u-2' },
|
||||
],
|
||||
{
|
||||
connectionRows: [
|
||||
{ company_id: 'c-dead', value: { status: 'needs_reauth' } },
|
||||
{
|
||||
company_id: 'c-dead',
|
||||
value: { status: 'needs_reauth', needs_reauth_at: '2026-07-10T03:00:00.000Z' },
|
||||
},
|
||||
{ company_id: 'c-live', value: { status: 'active' } },
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
lastSync: {
|
||||
at: '2026-07-10T03:00:00Z',
|
||||
file_id: 'f-1',
|
||||
file_name: 'arkiv.zip',
|
||||
file_size_bytes: 1000,
|
||||
folder_id: 'folder-1',
|
||||
},
|
||||
webViewLink: 'https://drive.google.com/file/d/f-1/view',
|
||||
})
|
||||
mockPerformSync.mockResolvedValueOnce(okSyncResult())
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
@@ -348,20 +422,9 @@ describe('cloud-backup auto-sync cron', () => {
|
||||
|
||||
it('fails open and attempts the sync when the connection lookup errors', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub(
|
||||
[
|
||||
{
|
||||
company_id: 'c-1',
|
||||
user_id: 'u-1',
|
||||
value: {
|
||||
enabled: true,
|
||||
hour_utc: new Date().getUTCHours(),
|
||||
last_auto_sync_at: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
{ connectionError: { message: 'connection query failed' } }
|
||||
)
|
||||
makeSupabaseStub([scheduleRow()], {
|
||||
connectionError: { message: 'connection query failed' },
|
||||
})
|
||||
)
|
||||
mockPerformSync.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
|
||||
@@ -8,6 +8,12 @@ import {
|
||||
SCHEDULE_KEY,
|
||||
saveExtensionData,
|
||||
} from '@/extensions/general/cloud-backup/lib/sync'
|
||||
import { isScheduleDue } from '@/extensions/general/cloud-backup/lib/schedule'
|
||||
import {
|
||||
sendBackupFailureAlert,
|
||||
shouldSendBackupAlert,
|
||||
type BackupAlertKind,
|
||||
} from '@/extensions/general/cloud-backup/lib/backup-alert'
|
||||
import type {
|
||||
GoogleDriveConnection,
|
||||
GoogleDriveSchedule,
|
||||
@@ -16,10 +22,15 @@ import type {
|
||||
/**
|
||||
* GET /api/extensions/cloud-backup/auto-sync/cron
|
||||
*
|
||||
* Runs hourly. Finds all companies with `google_drive_schedule.enabled = true`
|
||||
* whose `hour_utc` matches the current UTC hour, and whose `last_auto_sync_at`
|
||||
* is either unset or more than 20 hours old. Triggers a full Drive backup for
|
||||
* each qualifying company via the shared `performSync()` helper.
|
||||
* Runs hourly. Finds all companies whose auto-sync is due (daily slot has
|
||||
* passed and no attempt has run since it: see `isScheduleDue`) and triggers a
|
||||
* full Drive backup for each via the shared `performSync()` helper. Companies
|
||||
* left over when a run hits its time budget stay due and are picked up by the
|
||||
* next hourly run instead of losing the day.
|
||||
*
|
||||
* Failures increment `consecutive_failures` on the schedule; alert emails go
|
||||
* out on dead tokens (once per incident) and repeated failures (threshold in
|
||||
* `backup-alert.ts`), throttled per company.
|
||||
*
|
||||
* Uses the service role client: no user session, no RLS. Each row in
|
||||
* `extension_data` carries its own `user_id` (the user who configured the
|
||||
@@ -37,7 +48,7 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
const currentHourUtc = new Date().getUTCHours()
|
||||
const now = new Date()
|
||||
const origin = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
|
||||
const { data: rows, error } = await supabase
|
||||
@@ -58,16 +69,9 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
return NextResponse.json({ message: 'No schedules configured', processed: 0 })
|
||||
}
|
||||
|
||||
const candidates = rows.filter((r) => {
|
||||
const schedule = r.value as GoogleDriveSchedule | null
|
||||
if (!schedule || !schedule.enabled) return false
|
||||
if (schedule.hour_utc !== currentHourUtc) return false
|
||||
if (schedule.last_auto_sync_at) {
|
||||
const ageMs = Date.now() - new Date(schedule.last_auto_sync_at).getTime()
|
||||
if (ageMs < 20 * 60 * 60 * 1000) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
const candidates = rows.filter((r) =>
|
||||
isScheduleDue(r.value as GoogleDriveSchedule | null, now)
|
||||
)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return NextResponse.json({
|
||||
@@ -98,13 +102,11 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
})
|
||||
}
|
||||
|
||||
const needsReauthCompanyIds = new Set(
|
||||
(connectionRows ?? [])
|
||||
.filter(
|
||||
(r) => (r.value as GoogleDriveConnection | null)?.status === 'needs_reauth'
|
||||
)
|
||||
.map((r) => r.company_id as string)
|
||||
)
|
||||
const connectionByCompany = new Map<string, GoogleDriveConnection>()
|
||||
for (const r of connectionRows ?? []) {
|
||||
const value = r.value as GoogleDriveConnection | null
|
||||
if (value) connectionByCompany.set(r.company_id as string, value)
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 250_000 // 4m10s: leaves 50s margin below Vercel's 300s Pro limit
|
||||
@@ -115,6 +117,40 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
error?: string
|
||||
}[] = []
|
||||
|
||||
/**
|
||||
* Send a failure alert if warranted and return the new last_alert_at.
|
||||
* Best-effort: alert failures are logged inside sendBackupFailureAlert.
|
||||
*/
|
||||
const maybeAlert = async (params: {
|
||||
companyId: string
|
||||
userId: string
|
||||
kind: BackupAlertKind
|
||||
consecutiveFailures: number
|
||||
errorMessage: string | null
|
||||
lastAlertAt: string | null | undefined
|
||||
}): Promise<string | null> => {
|
||||
const prior = params.lastAlertAt ?? null
|
||||
if (
|
||||
!shouldSendBackupAlert({
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
lastAlertAt: prior,
|
||||
now: new Date(),
|
||||
})
|
||||
) {
|
||||
return prior
|
||||
}
|
||||
const sent = await sendBackupFailureAlert(supabase, {
|
||||
companyId: params.companyId,
|
||||
userId: params.userId,
|
||||
kind: params.kind,
|
||||
consecutiveFailures: params.consecutiveFailures,
|
||||
errorMessage: params.errorMessage,
|
||||
origin,
|
||||
})
|
||||
return sent.sent ? new Date().toISOString() : prior
|
||||
}
|
||||
|
||||
for (const row of candidates) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
ctx.log.info('time budget reached', {
|
||||
@@ -128,9 +164,35 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
const userId = row.user_id as string
|
||||
const schedule = row.value as GoogleDriveSchedule
|
||||
|
||||
if (needsReauthCompanyIds.has(companyId)) {
|
||||
const connection = connectionByCompany.get(companyId)
|
||||
if (connection?.status === 'needs_reauth') {
|
||||
// Do not touch last_auto_sync_* here: the schedule keeps showing the
|
||||
// failure from the night the dead token was detected.
|
||||
// failure from the night the dead token was detected. But make sure the
|
||||
// incident has been alerted once: a token can go dead via a manual sync
|
||||
// (which never emails) and would otherwise stay silent forever.
|
||||
const alertedSinceIncident =
|
||||
schedule.last_alert_at &&
|
||||
connection.needs_reauth_at &&
|
||||
new Date(schedule.last_alert_at).getTime() >=
|
||||
new Date(connection.needs_reauth_at).getTime()
|
||||
if (!alertedSinceIncident) {
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: 'needs_reauth',
|
||||
consecutiveFailures: schedule.consecutive_failures ?? 0,
|
||||
errorMessage: null,
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
if (lastAlertAt !== (schedule.last_alert_at ?? null)) {
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, {
|
||||
...schedule,
|
||||
last_alert_at: lastAlertAt,
|
||||
}).catch((persistErr) => {
|
||||
ctx.log.error('failed to persist alert state', persistErr as Error, { companyId })
|
||||
})
|
||||
}
|
||||
}
|
||||
results.push({ companyId, status: 'skipped', error: 'needs_reauth' })
|
||||
continue
|
||||
}
|
||||
@@ -142,13 +204,31 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
userId,
|
||||
origin,
|
||||
includeDocuments: true,
|
||||
allowDocumentFallback: true,
|
||||
})
|
||||
|
||||
const consecutiveFailures = syncResult.ok
|
||||
? 0
|
||||
: (schedule.consecutive_failures ?? 0) + 1
|
||||
let lastAlertAt = schedule.last_alert_at ?? null
|
||||
if (!syncResult.ok) {
|
||||
lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: syncResult.reason === 'needs_reauth' ? 'needs_reauth' : 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: syncResult.message,
|
||||
lastAlertAt,
|
||||
})
|
||||
}
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: syncResult.ok ? 'success' : 'error',
|
||||
last_auto_sync_error: syncResult.ok ? null : syncResult.message,
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated)
|
||||
|
||||
@@ -163,11 +243,23 @@ export const GET = withCronContext('cron.cloud_backup_auto_sync', async (_reques
|
||||
companyId,
|
||||
})
|
||||
|
||||
const consecutiveFailures = (schedule.consecutive_failures ?? 0) + 1
|
||||
const lastAlertAt = await maybeAlert({
|
||||
companyId,
|
||||
userId,
|
||||
kind: 'repeated_failures',
|
||||
consecutiveFailures,
|
||||
errorMessage: message.slice(0, 200),
|
||||
lastAlertAt: schedule.last_alert_at,
|
||||
})
|
||||
|
||||
const updated: GoogleDriveSchedule = {
|
||||
...schedule,
|
||||
last_auto_sync_at: new Date().toISOString(),
|
||||
last_auto_sync_status: 'error',
|
||||
last_auto_sync_error: message.slice(0, 200),
|
||||
consecutive_failures: consecutiveFailures,
|
||||
last_alert_at: lastAlertAt,
|
||||
}
|
||||
await saveExtensionData(supabase, companyId, userId, SCHEDULE_KEY, updated).catch(
|
||||
(persistErr) => {
|
||||
|
||||
@@ -30,7 +30,11 @@ vi.mock('@/extensions/general/skatteverket/lib/api-client', () => {
|
||||
this.name = 'SkatteverketAuthError'
|
||||
}
|
||||
}
|
||||
return { SkatteverketAuthError }
|
||||
return {
|
||||
SkatteverketAuthError,
|
||||
// resolve-auth's currentSkvEnvironment (grant-revoked path) reads this.
|
||||
getSkatteverketEnvironment: vi.fn().mockReturnValue('test'),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/token-store', () => ({
|
||||
@@ -49,17 +53,40 @@ vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
// The route's real connection-store hits the DB via its own service client;
|
||||
// mocking keeps the grant-revoked path deterministic. resolve-auth also
|
||||
// imports getConnection from here (only used when system auth mode is on,
|
||||
// which is off in tests), so export it too.
|
||||
vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({
|
||||
getConnection: vi.fn().mockResolvedValue(null),
|
||||
markGrantRevoked: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/deadlines/complete-tax-deadline', () => ({
|
||||
completeTaxDeadline: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/kvittens-notification', () => ({
|
||||
sendKvittensNotification: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockAgiGetKvittenser = vi.mocked(agiGetKvittenser)
|
||||
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
||||
const mockMarkGrantRevoked = vi.mocked(markGrantRevoked)
|
||||
const mockCompleteTaxDeadline = vi.mocked(completeTaxDeadline)
|
||||
const mockSendKvittensNotification = vi.mocked(sendKvittensNotification)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/skatteverket/agi/kvittenser/cron', {
|
||||
@@ -87,7 +114,7 @@ function makeSupabaseStub(tables: Record<string, { data: unknown; error?: unknow
|
||||
const result = tables[table] ?? { data: null, error: null }
|
||||
const resolved = { data: result.data, error: result.error ?? null }
|
||||
const chain: any = {}
|
||||
for (const method of ['select', 'eq', 'order', 'limit', 'update', 'delete']) {
|
||||
for (const method of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete', 'insert']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
|
||||
@@ -160,10 +187,105 @@ describe('AGI kvittenser cron', () => {
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.grantRevoked).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports signed and sends the notification when completeTaxDeadline throws', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
kvittenser: [
|
||||
{
|
||||
uuidKvittens: 'uuid-1',
|
||||
signeradAv: '191212121212',
|
||||
signeradTid: '2026-06-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)
|
||||
mockCompleteTaxDeadline.mockRejectedValueOnce(new Error('deadline table unavailable'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
// The filing succeeded before the best-effort step failed: the row must
|
||||
// still land as signed, never as error (the next run only revisits
|
||||
// pending_signature rows, so an error here would be lost permanently).
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
|
||||
// The notification still goes out even though deadline completion threw.
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledWith(expect.anything(), {
|
||||
companyId: 'comp-1',
|
||||
userId: 'user-1',
|
||||
kind: 'agi',
|
||||
period: expect.any(String),
|
||||
kvittensnummer: 'uuid-1',
|
||||
referenceId: 'decl-1',
|
||||
})
|
||||
|
||||
// The failure is a warning, not an error.
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('completeTaxDeadline failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports signed when sendKvittensNotification throws', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
kvittenser: [
|
||||
{
|
||||
uuidKvittens: 'uuid-1',
|
||||
signeradAv: '191212121212',
|
||||
signeradTid: '2026-06-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any)
|
||||
mockSendKvittensNotification.mockRejectedValueOnce(new Error('smtp down'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0].status).toBe('signed')
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
const warnMessages = warnSpy.mock.calls.map(c => String(c[0]))
|
||||
expect(warnMessages.some(m => m.includes('sendKvittensNotification failed'))).toBe(true)
|
||||
})
|
||||
|
||||
it('counts grant_revoked in the run summary', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockAgiGetKvittenser.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('Ombud grant missing.', 'OMBUD_GRANT_MISSING'),
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(1)
|
||||
expect(body.grantRevoked).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.apigwConfig).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ status: 'grant_revoked', error: 'OMBUD_GRANT_MISSING' })
|
||||
expect(mockMarkGrantRevoked).toHaveBeenCalledWith('comp-1', expect.any(String), 'lasombud', 'OMBUD_GRANT_MISSING')
|
||||
|
||||
const summaryLine = logSpy.mock.calls.map(c => String(c[0])).find(m => m.includes('Processed'))
|
||||
expect(summaryLine).toContain('1 grants revoked')
|
||||
})
|
||||
|
||||
it('logs a warn (not error) and records apigw_config on ACCESS_DENIED', async () => {
|
||||
|
||||
@@ -5,7 +5,11 @@ import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { agiGetKvittenser } from '@/extensions/general/skatteverket/lib/agi-client'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { formatRedovisare, formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
|
||||
@@ -78,7 +82,7 @@ export async function GET(request: Request) {
|
||||
declarationId: string
|
||||
companyId: string
|
||||
period: string
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'apigw_config' | 'error'
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'no_company_settings' | 'expired_token' | 'grant_revoked' | 'apigw_config' | 'error'
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
@@ -103,26 +107,21 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
// The token table is user-scoped (one BankID identity per user) but
|
||||
// also carries company_id. Match on company_id so a multi-company
|
||||
// operator's token is reused only for the company that owns the AGI.
|
||||
const { data: token } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, status')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!token?.user_id) {
|
||||
results.push({ declarationId, companyId, period, status: 'no_token' })
|
||||
continue
|
||||
}
|
||||
|
||||
// A connection flagged needs_reconsent cannot heal on its own (SKV's
|
||||
// per-flow refresh tokens live 65 minutes) — skip quietly instead of
|
||||
// failing the same pending declaration every run until the user
|
||||
// re-consents.
|
||||
if (token.status === 'needs_reconsent') {
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
// Auth resolution prefers system credentials (verified lasombud grant)
|
||||
// and falls back to the company's user token: kvittens polling is the
|
||||
// canonical case for the hybrid model, since the user signed at SKV
|
||||
// and their 65-minute session is usually long dead by the time the
|
||||
// kvittens exists.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'lasombud' })
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
// A connection flagged needs_reconsent cannot heal on its own
|
||||
// (SKV's per-flow refresh tokens live 65 minutes): skip quietly
|
||||
// instead of failing the same declaration every run.
|
||||
results.push({ declarationId, companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
} else {
|
||||
results.push({ declarationId, companyId, period, status: 'no_token' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -142,7 +141,7 @@ export async function GET(request: Request) {
|
||||
settings.entity_type as 'enskild_firma' | 'aktiebolag',
|
||||
)
|
||||
|
||||
const kvittRes = await agiGetKvittenser(supabase, token.user_id as string, arbetsgivare, period)
|
||||
const kvittRes = await agiGetKvittenser(resolved.auth, arbetsgivare, period)
|
||||
if (!kvittRes.ok) {
|
||||
results.push({
|
||||
declarationId, companyId, period,
|
||||
@@ -186,7 +185,7 @@ export async function GET(request: Request) {
|
||||
status: 'submitted',
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
submitted_at: submittedAt,
|
||||
submitted_by: token.user_id,
|
||||
submitted_by: resolved.tokenUserId,
|
||||
response_data: {
|
||||
signeradAv: kvittens.signeradAv ?? null,
|
||||
signeradTid: kvittens.signeradTid ?? null,
|
||||
@@ -216,10 +215,62 @@ export async function GET(request: Request) {
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', `agi_submission_${period}`)
|
||||
|
||||
// The declaration is already flipped to submitted above, and the next
|
||||
// run only revisits pending_signature rows: from here on everything is
|
||||
// best-effort. Each step gets its own try/catch so a failure is logged
|
||||
// as a warning without masking the successful filing or skipping the
|
||||
// remaining confirmation steps.
|
||||
|
||||
// The kvittens is the canonical filing receipt: confirm the period's
|
||||
// arbetsgivardeklaration deadline (terminal state).
|
||||
try {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['arbetsgivardeklaration'],
|
||||
`${decl.period_year}-${String(decl.period_month).padStart(2, '0')}`,
|
||||
'confirmed'
|
||||
)
|
||||
} catch (deadlineErr) {
|
||||
console.warn('[agi-kvittenser-cron] completeTaxDeadline failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: deadlineErr instanceof Error ? deadlineErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
|
||||
// Tell the user: signing happened at Skatteverket, often long after
|
||||
// they closed our tab, so this is the only confirmation they get.
|
||||
if (resolved.tokenUserId) {
|
||||
try {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'agi',
|
||||
period,
|
||||
kvittensnummer: kvittens.uuidKvittens,
|
||||
referenceId: declarationId,
|
||||
})
|
||||
} catch (notifyErr) {
|
||||
console.warn('[agi-kvittenser-cron] sendKvittensNotification failed after successful filing', {
|
||||
declarationId, companyId, period,
|
||||
message: notifyErr instanceof Error ? notifyErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
results.push({ declarationId, companyId, period, status: 'signed' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// System-mode read rejected: the company withdrew the behorighet.
|
||||
// Downgrade the connection row so the next run falls back to the
|
||||
// user token (if any). Never touches skatteverket_tokens.
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
|
||||
results.push({ declarationId, companyId, period, status: 'grant_revoked', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
@@ -272,11 +323,12 @@ export async function GET(request: Request) {
|
||||
const signed = results.filter(r => r.status === 'signed').length
|
||||
const stillPending = results.filter(r => r.status === 'still_pending').length
|
||||
const expired = results.filter(r => r.status === 'expired_token').length
|
||||
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
|
||||
const apigwConfig = results.filter(r => r.status === 'apigw_config').length
|
||||
const errors = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
`[agi-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${grantRevoked} grants revoked, ${apigwConfig} apigw config gaps, ${errors} errors`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -284,6 +336,7 @@ export async function GET(request: Request) {
|
||||
signed,
|
||||
stillPending,
|
||||
expired,
|
||||
grantRevoked,
|
||||
apigwConfig,
|
||||
errors,
|
||||
results,
|
||||
|
||||
@@ -7,9 +7,12 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { syncSkattekonto, SKATTEKONTO_LAST_SYNCED_AT_KEY } from '@/extensions/general/skatteverket/lib/skattekonto-sync'
|
||||
import { computeSkattekontoDrift, maybeAlertDrift } from '@/extensions/general/skatteverket/lib/skattekonto-drift'
|
||||
import { SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketAuthError, type SkvAuth } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { SkatteverketSkattekontoError } from '@/extensions/general/skatteverket/lib/skattekonto-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
|
||||
import { listVerifiedCompanies, markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { currentSkvEnvironment, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -19,16 +22,29 @@ export const maxDuration = 60
|
||||
* GET /api/extensions/skatteverket/skattekonto/sync/cron
|
||||
*
|
||||
* Daily skattekonto sync (cron 0 4 * * *: 04:00 UTC, 06:00 Swedish time).
|
||||
* Pulls saldo + transactions for every company that has a connected
|
||||
* Skatteverket token, and persists the results to skattekonto_transactions.
|
||||
* Pulls saldo + transactions and persists them to skattekonto_transactions.
|
||||
*
|
||||
* Work list is a union keyed by company:
|
||||
* 1. System-mode entries: companies with a verified lasombud grant, synced
|
||||
* on Accounted's own CCG credentials (no user token involved, so the
|
||||
* 65-minute personal-token lifetime stops mattering here). Active only
|
||||
* when SKATTEVERKET_SYSTEM_AUTH_MODE=on.
|
||||
* 2. User-mode entries: companies with an active personal token, exactly
|
||||
* the pre-hybrid behavior. Fallback during the transition and for
|
||||
* companies that never grant the behorighet.
|
||||
*
|
||||
* Shadow mode logs per user-mode company whether a verified grant exists,
|
||||
* without any behavior change: the rollout confidence signal.
|
||||
*
|
||||
* Skips a company if it was synced within the last hour (cooldown),
|
||||
* to keep manual + cron triggers from racing each other.
|
||||
*
|
||||
* Time budget: 50s (Vercel default 60s function timeout, 10s margin).
|
||||
*
|
||||
* Per-company errors are logged but do not abort the run: one expired
|
||||
* token shouldn't block 49 other working syncs.
|
||||
* Per-company errors are logged but do not abort the run. A system-token
|
||||
* minting failure (SYSTEM_AUTH_FAILED) short-circuits the remaining
|
||||
* system-mode entries (one config problem, not fifty) but user-mode entries
|
||||
* still run.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
@@ -48,12 +64,10 @@ export async function GET(request: Request) {
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// Find all companies with a connected, believed-working token. The token
|
||||
// row is keyed by user_id but carries company_id (multi-tenant refactor).
|
||||
// Rows flagged needs_reconsent are excluded: SKV's per-flow refresh tokens
|
||||
// live 65 minutes, so a connection that failed with a terminal auth error
|
||||
// can never heal on its own — retrying it every night only produced a
|
||||
// per-company error log until the user re-consents (which resets status).
|
||||
// User-token entries. The token row is keyed by user_id but carries
|
||||
// company_id (multi-tenant refactor). Rows flagged needs_reconsent are
|
||||
// excluded: SKV's per-flow refresh tokens live 65 minutes, so a connection
|
||||
// that failed with a terminal auth error can never heal on its own.
|
||||
const { data: tokens, error: tokensError } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id, company_id, expires_at, refresh_count')
|
||||
@@ -69,36 +83,72 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: 'Failed to fetch tokens' }, { status: 500 })
|
||||
}
|
||||
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return NextResponse.json({ message: 'No connected tokens', processed: 0 })
|
||||
const systemAuthActive = getSystemAuthMode() === 'on' && isSystemAuthConfigured()
|
||||
const systemCompanies = systemAuthActive
|
||||
? await listVerifiedCompanies(currentSkvEnvironment(), 'lasombud')
|
||||
: []
|
||||
|
||||
type WorkItem = { companyId: string; userId: string; source: 'system' | 'user' }
|
||||
const tokenByCompany = new Map<string, string>()
|
||||
for (const token of tokens ?? []) {
|
||||
if (token.company_id) tokenByCompany.set(token.company_id as string, token.user_id as string)
|
||||
}
|
||||
|
||||
const work: WorkItem[] = []
|
||||
const systemCompanyIds = new Set<string>()
|
||||
for (const company of systemCompanies) {
|
||||
// ctx still needs a user identity (events, drift emails). Prefer the
|
||||
// token owner, fall back to whoever verified the grant.
|
||||
const userId = tokenByCompany.get(company.company_id) ?? company.created_by
|
||||
if (!userId) continue
|
||||
systemCompanyIds.add(company.company_id)
|
||||
work.push({ companyId: company.company_id, userId, source: 'system' })
|
||||
}
|
||||
for (const token of tokens ?? []) {
|
||||
const companyId = token.company_id as string | null
|
||||
if (!companyId) {
|
||||
console.warn('[skattekonto-sync-cron] token without company_id skipped', {
|
||||
userId: token.user_id,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (systemCompanyIds.has(companyId)) continue
|
||||
work.push({ companyId, userId: token.user_id as string, source: 'user' })
|
||||
}
|
||||
|
||||
if (work.length === 0) {
|
||||
return NextResponse.json({ message: 'No connected companies', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 50_000
|
||||
const SYNC_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour
|
||||
const shadowMode = getSystemAuthMode() === 'shadow'
|
||||
|
||||
type Result = {
|
||||
userId: string
|
||||
companyId: string
|
||||
status: 'synced' | 'skipped_cooldown' | 'expired' | 'error'
|
||||
source: 'system' | 'user'
|
||||
status: 'synced' | 'skipped_cooldown' | 'expired' | 'grant_revoked' | 'system_auth_failed' | 'error'
|
||||
booked?: number
|
||||
upcoming?: number
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
// One config problem, not one per company: after the first minting
|
||||
// failure, skip the remaining system-mode entries this run.
|
||||
let systemAuthFailed = false
|
||||
|
||||
for (const token of tokens) {
|
||||
for (const item of work) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} tokens`)
|
||||
console.log(`[skattekonto-sync-cron] Time budget reached after ${results.length} companies`)
|
||||
break
|
||||
}
|
||||
|
||||
const userId = token.user_id as string
|
||||
const companyId = token.company_id as string | null
|
||||
const { companyId, userId, source } = item
|
||||
|
||||
if (!companyId) {
|
||||
// Pre-multi-tenant tokens may lack company_id. Skip: cannot scope.
|
||||
results.push({ userId, companyId: '(missing)', status: 'error', error: 'No company_id on token' })
|
||||
if (source === 'system' && systemAuthFailed) {
|
||||
results.push({ userId, companyId, source, status: 'system_auth_failed', error: 'skipped after first failure' })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -121,13 +171,21 @@ export async function GET(request: Request) {
|
||||
if (lastSyncedAt) {
|
||||
const elapsed = Date.now() - new Date(lastSyncedAt).getTime()
|
||||
if (elapsed < SYNC_COOLDOWN_MS) {
|
||||
results.push({ userId, companyId, status: 'skipped_cooldown' })
|
||||
results.push({ userId, companyId, source, status: 'skipped_cooldown' })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (shadowMode) {
|
||||
// Rollout signal only: would this company have run on system auth?
|
||||
const grantReady = await hasVerifiedGrant(companyId, 'lasombud')
|
||||
console.info('[skattekonto-sync-cron] shadow: grant state', { companyId, grantReady })
|
||||
}
|
||||
|
||||
const ctx = createExtensionContext(supabase, userId, companyId, 'skatteverket')
|
||||
const syncResult = await syncSkattekonto(ctx)
|
||||
const auth: SkvAuth =
|
||||
source === 'system' ? { mode: 'system' } : { mode: 'user', supabase, userId }
|
||||
const syncResult = await syncSkattekonto(ctx, auth)
|
||||
|
||||
// Drift check: compare the fresh SKV saldo against GL 1630 sum. Emits
|
||||
// `skattekonto.drift_detected` when |drift| > tolerance and not throttled.
|
||||
@@ -145,6 +203,7 @@ export async function GET(request: Request) {
|
||||
results.push({
|
||||
userId,
|
||||
companyId,
|
||||
source,
|
||||
status: 'synced',
|
||||
booked: syncResult.booked,
|
||||
upcoming: syncResult.upcoming,
|
||||
@@ -152,22 +211,45 @@ export async function GET(request: Request) {
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
// Terminal auth states are a known outcome: surface them distinctly
|
||||
// so ops can dashboard "X companies need to reconnect", persist the
|
||||
// health flag so this cron stops retrying the row, and let the UI
|
||||
// prompt for re-consent proactively.
|
||||
if (source === 'system' && err instanceof SkatteverketAuthError) {
|
||||
// System-mode failures never touch skatteverket_tokens.
|
||||
if (err.code === 'SYSTEM_AUTH_FAILED') {
|
||||
if (!systemAuthFailed) {
|
||||
systemAuthFailed = true
|
||||
console.warn(
|
||||
'[skattekonto-sync-cron] system token minting/authentication failed; skipping remaining system-mode entries this run',
|
||||
{ companyId, message },
|
||||
)
|
||||
}
|
||||
results.push({ userId, companyId, source, status: 'system_auth_failed', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// The company withdrew the behorighet: downgrade the connection
|
||||
// row so the next run falls back to the user token (if any). The
|
||||
// sync cron doubles as the ongoing grant liveness probe.
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'lasombud', err.code)
|
||||
results.push({ userId, companyId, source, status: 'grant_revoked', error: err.code })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal user-token auth states are a known outcome: surface them
|
||||
// distinctly, persist the health flag so this cron stops retrying the
|
||||
// row, and let the UI prompt for re-consent proactively.
|
||||
if (
|
||||
source === 'user' &&
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
await markNeedsReconsent(supabase, userId, err.code)
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
results.push({ userId, companyId, source, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
// TOKEN_REVOKED auto-deletes the row inside skvRequest — treat it as
|
||||
// the same quiet "reconnect needed" outcome, not a runtime error.
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
results.push({ userId, companyId, status: 'expired', error: err.code })
|
||||
if (source === 'user' && err instanceof SkatteverketAuthError && err.code === 'TOKEN_REVOKED') {
|
||||
results.push({ userId, companyId, source, status: 'expired', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -175,20 +257,23 @@ export async function GET(request: Request) {
|
||||
console.error('[skattekonto-sync-cron] Sync failed', {
|
||||
userId,
|
||||
companyId,
|
||||
source,
|
||||
message,
|
||||
felkod,
|
||||
})
|
||||
results.push({ userId, companyId, status: 'error', error: message })
|
||||
results.push({ userId, companyId, source, status: 'error', error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const synced = results.filter(r => r.status === 'synced').length
|
||||
const skipped = results.filter(r => r.status === 'skipped_cooldown').length
|
||||
const expired = results.filter(r => r.status === 'expired').length
|
||||
const grantRevoked = results.filter(r => r.status === 'grant_revoked').length
|
||||
const systemAuthFailures = results.filter(r => r.status === 'system_auth_failed').length
|
||||
const errors = results.filter(r => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${errors} errors`,
|
||||
`[skattekonto-sync-cron] Processed ${results.length}: ${synced} synced, ${skipped} cooldown, ${expired} expired, ${grantRevoked} grant revoked, ${systemAuthFailures} system-auth failures, ${errors} errors`,
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -196,6 +281,8 @@ export async function GET(request: Request) {
|
||||
synced,
|
||||
skipped,
|
||||
expired,
|
||||
grantRevoked,
|
||||
systemAuthFailures,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.mock('@supabase/supabase-js', () => ({
|
||||
createClient: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/init', () => ({
|
||||
ensureInitialized: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/cron', () => ({
|
||||
verifyCronSecret: vi.fn().mockReturnValue(null),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/api-client', () => {
|
||||
class SkatteverketAuthError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SkatteverketAuthError'
|
||||
}
|
||||
}
|
||||
return {
|
||||
SkatteverketAuthError,
|
||||
skvRequest: vi.fn(),
|
||||
skvRequestWithAuth: vi.fn(),
|
||||
getSkatteverketEnvironment: vi.fn(() => 'test'),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/connection-store', () => ({
|
||||
getConnection: vi.fn().mockResolvedValue(null),
|
||||
markGrantRevoked: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/token-store', () => ({
|
||||
RECONSENT_ERROR_CODES: [
|
||||
'SESSION_EXPIRED',
|
||||
'REFRESH_EXHAUSTED',
|
||||
'MISSING_SCOPE',
|
||||
'TOKEN_CORRUPTED',
|
||||
] as const,
|
||||
markNeedsReconsent: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/skatteverket/lib/kvittens-notification', () => ({
|
||||
sendKvittensNotification: vi.fn().mockResolvedValue({ sent: true }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/deadlines/complete-tax-deadline', () => ({
|
||||
completeTaxDeadline: vi.fn().mockResolvedValue({ completed: 1 }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/entitlements/has-capability', () => ({
|
||||
hasCapability: vi.fn().mockResolvedValue(true),
|
||||
}))
|
||||
|
||||
import { GET } from '../route'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { skvRequestWithAuth, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { markNeedsReconsent } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
|
||||
const mockCreateClient = vi.mocked(createClient)
|
||||
const mockVerifyCronSecret = vi.mocked(verifyCronSecret)
|
||||
const mockSkvRequest = vi.mocked(skvRequestWithAuth)
|
||||
const mockSendKvittensNotification = vi.mocked(sendKvittensNotification)
|
||||
const mockCompleteTaxDeadline = vi.mocked(completeTaxDeadline)
|
||||
const mockMarkNeedsReconsent = vi.mocked(markNeedsReconsent)
|
||||
const mockMarkGrantRevoked = vi.mocked(markGrantRevoked)
|
||||
|
||||
function makeRequest() {
|
||||
return new Request('http://localhost/api/extensions/skatteverket/vat/kvittenser/cron', {
|
||||
headers: { authorization: 'Bearer test-secret' },
|
||||
})
|
||||
}
|
||||
|
||||
const LOCKED_STATE = {
|
||||
status: 'draft_locked',
|
||||
redovisare: '165560000000',
|
||||
redovisningsperiod: '202606',
|
||||
periodType: 'monthly',
|
||||
year: 2026,
|
||||
period: 6,
|
||||
signeringsLank: 'https://skv.test/sign/abc',
|
||||
}
|
||||
|
||||
function makeSupabaseStub(
|
||||
tables: Record<string, { data: unknown; error?: unknown; updateError?: unknown }>,
|
||||
) {
|
||||
return {
|
||||
from: vi.fn((table: string) => {
|
||||
const result = tables[table] ?? { data: null, error: null }
|
||||
const resolved = { data: result.data, error: result.error ?? null }
|
||||
const chain: any = {}
|
||||
let isUpdate = false
|
||||
for (const method of ['select', 'eq', 'in', 'like', 'order', 'limit', 'delete', 'insert']) {
|
||||
chain[method] = vi.fn(() => chain)
|
||||
}
|
||||
chain.update = vi.fn(() => {
|
||||
isUpdate = true
|
||||
return chain
|
||||
})
|
||||
chain.maybeSingle = vi.fn().mockResolvedValue(resolved)
|
||||
chain.single = vi.fn().mockResolvedValue(resolved)
|
||||
chain.then = (resolve: (v: unknown) => void) =>
|
||||
resolve(isUpdate && result.updateError ? { data: null, error: result.updateError } : resolved)
|
||||
return chain
|
||||
}),
|
||||
} as any
|
||||
}
|
||||
|
||||
function stubHappyTables(state: Record<string, unknown> = LOCKED_STATE) {
|
||||
return makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(state) }],
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
})
|
||||
}
|
||||
|
||||
describe('VAT kvittenser cron', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.SKATTEVERKET_ENABLED = 'true'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'
|
||||
mockVerifyCronSecret.mockReturnValue(null)
|
||||
mockMarkNeedsReconsent.mockResolvedValue(undefined)
|
||||
mockMarkGrantRevoked.mockResolvedValue(undefined)
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore()
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('returns 401 when cron auth fails', async () => {
|
||||
mockVerifyCronSecret.mockReturnValueOnce(
|
||||
new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }) as any,
|
||||
)
|
||||
const res = await GET(makeRequest())
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when the extension flag is off', async () => {
|
||||
process.env.SKATTEVERKET_ENABLED = 'false'
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockCreateClient).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('marks the filing signed, completes the moms deadline, and notifies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123', tidpunkt: '2026-07-01T10:00:00Z' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(body.errors).toBe(0)
|
||||
expect(body.results[0]).toMatchObject({ companyId: 'comp-1', period: '202606', status: 'signed' })
|
||||
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'comp-1',
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
'2026-06',
|
||||
'confirmed',
|
||||
)
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: 'comp-1',
|
||||
userId: 'user-1',
|
||||
kind: 'vat',
|
||||
kvittensnummer: 'KV-123',
|
||||
}),
|
||||
)
|
||||
expect(errorSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('quarterly picker params produce a YYYY-QN tax period', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
stubHappyTables({ ...LOCKED_STATE, periodType: 'quarterly', period: 2 }),
|
||||
)
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-456' }),
|
||||
} as any)
|
||||
|
||||
await GET(makeRequest())
|
||||
|
||||
expect(mockCompleteTaxDeadline).toHaveBeenCalledWith(
|
||||
expect.anything(), 'comp-1', ['moms_monthly', 'moms_quarterly'], '2026-Q2', 'confirmed',
|
||||
)
|
||||
})
|
||||
|
||||
it('legacy state without picker params still flips status but skips the deadline', async () => {
|
||||
const { periodType: _pt, year: _y, period: _p, ...legacyState } = LOCKED_STATE
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables(legacyState))
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-789' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(1)
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records still_pending on 404 without touching state', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockResolvedValueOnce({ ok: false, status: 404 } as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.stillPending).toBe(1)
|
||||
expect(body.signed).toBe(0)
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips rows that are not draft_locked', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables({ ...LOCKED_STATE, status: 'draft_saved' }))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(0)
|
||||
expect(mockSkvRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('flags reconsent codes as expired_token and marks the connection', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockRejectedValueOnce(
|
||||
new SkatteverketAuthError('Sessionen har gått ut.', 'SESSION_EXPIRED'),
|
||||
)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.expired).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'expired_token', error: 'SESSION_EXPIRED' })
|
||||
expect(mockMarkNeedsReconsent).toHaveBeenCalledWith(expect.anything(), 'user-1', 'SESSION_EXPIRED')
|
||||
})
|
||||
|
||||
it('records error for generic failures without aborting the run', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubHappyTables())
|
||||
mockSkvRequest.mockRejectedValueOnce(new Error('fetch failed'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({ status: 'error', error: 'fetch failed' })
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a failing signed-state update yields an error row and skips deadline + notification', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(
|
||||
makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) }],
|
||||
updateError: { message: 'connection reset', code: '08006' },
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
}),
|
||||
)
|
||||
mockSkvRequest.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123' }),
|
||||
} as any)
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.signed).toBe(0)
|
||||
expect(body.errors).toBe(1)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
period: '202606',
|
||||
status: 'error',
|
||||
error: 'Failed to persist signed state: connection reset',
|
||||
})
|
||||
expect(mockCompleteTaxDeadline).not.toHaveBeenCalled()
|
||||
expect(mockSendKvittensNotification).not.toHaveBeenCalled()
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
function stubTwoCompanyTables() {
|
||||
return makeSupabaseStub({
|
||||
extension_data: {
|
||||
data: [
|
||||
{ company_id: 'comp-1', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) },
|
||||
{ company_id: 'comp-2', key: 'submission_202606', value: JSON.stringify(LOCKED_STATE) },
|
||||
],
|
||||
},
|
||||
skatteverket_tokens: { data: { user_id: 'user-1', status: 'active' } },
|
||||
})
|
||||
}
|
||||
|
||||
it('a throwing markGrantRevoked does not abort the remaining companies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubTwoCompanyTables())
|
||||
mockSkvRequest
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('Ombud saknas.', 'OMBUD_GRANT_MISSING'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-123' }),
|
||||
} as any)
|
||||
mockMarkGrantRevoked.mockRejectedValueOnce(new Error('db outage'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
status: 'error',
|
||||
error: 'OMBUD_GRANT_MISSING',
|
||||
})
|
||||
expect(body.results[1]).toMatchObject({ companyId: 'comp-2', status: 'signed' })
|
||||
expect(mockMarkGrantRevoked).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a throwing markNeedsReconsent does not abort the remaining companies', async () => {
|
||||
mockCreateClient.mockReturnValueOnce(stubTwoCompanyTables())
|
||||
mockSkvRequest
|
||||
.mockRejectedValueOnce(new SkatteverketAuthError('Sessionen har gått ut.', 'SESSION_EXPIRED'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ kvittensnummer: 'KV-456' }),
|
||||
} as any)
|
||||
mockMarkNeedsReconsent.mockRejectedValueOnce(new Error('network blip'))
|
||||
|
||||
const res = await GET(makeRequest())
|
||||
const body = await res.json()
|
||||
|
||||
expect(body.processed).toBe(2)
|
||||
expect(body.results[0]).toMatchObject({
|
||||
companyId: 'comp-1',
|
||||
status: 'expired_token',
|
||||
error: 'SESSION_EXPIRED',
|
||||
})
|
||||
expect(body.results[1]).toMatchObject({ companyId: 'comp-2', status: 'signed' })
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { verifyCronSecret } from '@/lib/auth/cron'
|
||||
import { skvRequestWithAuth, SkatteverketAuthError } from '@/extensions/general/skatteverket/lib/api-client'
|
||||
import { markNeedsReconsent, RECONSENT_ERROR_CODES } from '@/extensions/general/skatteverket/lib/token-store'
|
||||
import { sendKvittensNotification } from '@/extensions/general/skatteverket/lib/kvittens-notification'
|
||||
import { resolveReadAuth, currentSkvEnvironment } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { markGrantRevoked } from '@/extensions/general/skatteverket/lib/connection-store'
|
||||
import { completeTaxDeadline } from '@/lib/deadlines/complete-tax-deadline'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import type { SkatteverketInlamnatResponse } from '@/extensions/general/skatteverket/types'
|
||||
import type { VatPeriodType } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
export const maxDuration = 60
|
||||
|
||||
/**
|
||||
* GET /api/extensions/skatteverket/vat/kvittenser/cron
|
||||
*
|
||||
* VAT filing reconciliation, mirroring the AGI kvittens cron. A locked VAT
|
||||
* draft (`submission_{period}` extension_data row with status
|
||||
* 'draft_locked') means the user was handed a BankID signing link at
|
||||
* Skatteverket. If they sign and never come back to the panel, nothing on
|
||||
* our side records that the declaration was filed: the submission state
|
||||
* stays "awaiting signature", the moms deadline stays open, and the user
|
||||
* gets no confirmation. This cron polls /inlamnat for those periods and on
|
||||
* a hit flips the stored state to 'signed', completes the period's moms
|
||||
* deadline, and emails the filing confirmation.
|
||||
*
|
||||
* Per-row errors are logged and skipped: one expired token must not block
|
||||
* other companies' reconciliation.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const authError = verifyCronSecret(request)
|
||||
if (authError) return authError
|
||||
|
||||
if (process.env.SKATTEVERKET_ENABLED !== 'true') {
|
||||
return NextResponse.json({ message: 'Skatteverket extension disabled', processed: 0 })
|
||||
}
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return NextResponse.json({ error: 'Missing Supabase configuration' }, { status: 500 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
// AGI submission state uses the distinct `agi_submission_` prefix, so the
|
||||
// `submission_` filter below cannot match AGI rows.
|
||||
const { data: rows, error: rowsError } = await supabase
|
||||
.from('extension_data')
|
||||
.select('company_id, key, value')
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.like('key', 'submission\\_%')
|
||||
.order('updated_at', { ascending: true })
|
||||
.limit(200)
|
||||
|
||||
if (rowsError) {
|
||||
console.error('[vat-kvittenser-cron] Failed to fetch submission states', {
|
||||
message: rowsError.message,
|
||||
code: rowsError.code,
|
||||
})
|
||||
return NextResponse.json({ error: 'Failed to fetch submission states' }, { status: 500 })
|
||||
}
|
||||
|
||||
interface SubmissionState {
|
||||
status?: string
|
||||
redovisare?: string
|
||||
redovisningsperiod?: string
|
||||
periodType?: VatPeriodType
|
||||
year?: number
|
||||
period?: number
|
||||
signeringsLank?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const locked = (rows ?? []).flatMap((row) => {
|
||||
try {
|
||||
const state: SubmissionState =
|
||||
typeof row.value === 'string' ? JSON.parse(row.value) : (row.value as SubmissionState)
|
||||
if (state?.status !== 'draft_locked' || !state.redovisare || !state.redovisningsperiod) {
|
||||
return []
|
||||
}
|
||||
return [{ companyId: row.company_id as string, key: row.key as string, state }]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
if (locked.length === 0) {
|
||||
return NextResponse.json({ message: 'No locked drafts', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 50_000
|
||||
|
||||
type Result = {
|
||||
companyId: string
|
||||
period: string
|
||||
status: 'signed' | 'still_pending' | 'no_token' | 'expired_token' | 'error'
|
||||
error?: string
|
||||
}
|
||||
const results: Result[] = []
|
||||
|
||||
for (const item of locked) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log(`[vat-kvittenser-cron] Time budget reached after ${results.length} rows`)
|
||||
break
|
||||
}
|
||||
|
||||
const { companyId, key, state } = item
|
||||
const period = state.redovisningsperiod as string
|
||||
|
||||
if (!(await hasCapability(supabase, companyId, CAPABILITY.skatteverket))) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// Prefers system credentials (verified moms_ombud grant), falls back
|
||||
// to the company's user token: post-signing checks are exactly where
|
||||
// the 65-minute personal session is usually already dead.
|
||||
const resolved = await resolveReadAuth(supabase, companyId, { requires: 'moms_ombud' })
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === 'needs_reconsent') {
|
||||
results.push({ companyId, period, status: 'expired_token', error: 'needs_reconsent' })
|
||||
} else {
|
||||
results.push({ companyId, period, status: 'no_token' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const response = await skvRequestWithAuth(
|
||||
resolved.auth,
|
||||
'GET',
|
||||
`/inlamnat/${state.redovisare}/${period}`
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
results.push({ companyId, period, status: 'still_pending' })
|
||||
continue
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '')
|
||||
results.push({ companyId, period, status: 'error', error: `${response.status}: ${text}` })
|
||||
continue
|
||||
}
|
||||
|
||||
const inlamnat = (await response.json()) as SkatteverketInlamnatResponse
|
||||
|
||||
// Flip the stored state so the panel shows "inlämnad" instead of a
|
||||
// stale "awaiting signature" view. Keep the row (unlike AGI, the VAT
|
||||
// panel reads it for kvittens display on revisit).
|
||||
const { error: updateError } = await supabase
|
||||
.from('extension_data')
|
||||
.update({
|
||||
value: JSON.stringify({
|
||||
...state,
|
||||
status: 'signed',
|
||||
kvittensnummer: inlamnat.kvittensnummer ?? null,
|
||||
tidpunkt: inlamnat.tidpunkt ?? null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
})
|
||||
.eq('company_id', companyId)
|
||||
.eq('extension_id', 'skatteverket')
|
||||
.eq('key', key)
|
||||
|
||||
if (updateError) {
|
||||
// The row is still draft_locked, so the next run retries it. Skip
|
||||
// the deadline completion and the notification: doing them now
|
||||
// would double-fire when the retry succeeds.
|
||||
console.error('[vat-kvittenser-cron] Failed to persist signed state', {
|
||||
companyId,
|
||||
period,
|
||||
message: updateError.message,
|
||||
code: updateError.code,
|
||||
})
|
||||
results.push({
|
||||
companyId,
|
||||
period,
|
||||
status: 'error',
|
||||
error: `Failed to persist signed state: ${updateError.message}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Complete the period's moms deadline. Only possible when the state
|
||||
// carries the picker params (written by the one-click chain; states
|
||||
// persisted by the older step-by-step routes lack them).
|
||||
if (state.periodType && state.year && state.period) {
|
||||
const taxPeriod =
|
||||
state.periodType === 'monthly'
|
||||
? `${state.year}-${String(state.period).padStart(2, '0')}`
|
||||
: state.periodType === 'quarterly'
|
||||
? `${state.year}-Q${state.period}`
|
||||
: null
|
||||
if (taxPeriod) {
|
||||
await completeTaxDeadline(
|
||||
supabase,
|
||||
companyId,
|
||||
['moms_monthly', 'moms_quarterly'],
|
||||
taxPeriod,
|
||||
'confirmed'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (resolved.tokenUserId) {
|
||||
await sendKvittensNotification(supabase, {
|
||||
companyId,
|
||||
userId: resolved.tokenUserId,
|
||||
kind: 'vat',
|
||||
period,
|
||||
kvittensnummer: inlamnat.kvittensnummer ?? period,
|
||||
referenceId: `vat_${companyId}_${period}`,
|
||||
})
|
||||
}
|
||||
|
||||
results.push({ companyId, period, status: 'signed' })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
|
||||
if (err instanceof SkatteverketAuthError && err.code === 'OMBUD_GRANT_MISSING') {
|
||||
// System-mode read rejected: downgrade the connection row so the
|
||||
// next run falls back to the user token (if any). Best-effort: a
|
||||
// failure here must not abort the remaining companies' rows.
|
||||
try {
|
||||
await markGrantRevoked(companyId, currentSkvEnvironment(), 'moms_ombud', err.code)
|
||||
} catch (revokeErr) {
|
||||
console.warn('[vat-kvittenser-cron] Failed to mark grant revoked', {
|
||||
companyId,
|
||||
period,
|
||||
message: revokeErr instanceof Error ? revokeErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
results.push({ companyId, period, status: 'error', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
err instanceof SkatteverketAuthError &&
|
||||
(RECONSENT_ERROR_CODES as readonly string[]).includes(err.code)
|
||||
) {
|
||||
// Persist the health flag so the cron stops retrying this
|
||||
// connection. Best-effort: a failure here must not abort the
|
||||
// remaining companies' rows.
|
||||
try {
|
||||
const { data: tokenRow } = await supabase
|
||||
.from('skatteverket_tokens')
|
||||
.select('user_id')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (tokenRow?.user_id) {
|
||||
await markNeedsReconsent(supabase, tokenRow.user_id as string, err.code)
|
||||
}
|
||||
} catch (reconsentErr) {
|
||||
console.warn('[vat-kvittenser-cron] Failed to persist reconsent flag', {
|
||||
companyId,
|
||||
period,
|
||||
message: reconsentErr instanceof Error ? reconsentErr.message : 'Unknown error',
|
||||
})
|
||||
}
|
||||
results.push({ companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
if (err instanceof SkatteverketAuthError) {
|
||||
results.push({ companyId, period, status: 'expired_token', error: err.code })
|
||||
continue
|
||||
}
|
||||
|
||||
console.error('[vat-kvittenser-cron] Reconciliation failed', { companyId, period, message })
|
||||
results.push({ companyId, period, status: 'error', error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const signed = results.filter((r) => r.status === 'signed').length
|
||||
const stillPending = results.filter((r) => r.status === 'still_pending').length
|
||||
const expired = results.filter((r) => r.status === 'expired_token').length
|
||||
const errors = results.filter((r) => r.status === 'error').length
|
||||
|
||||
console.log(
|
||||
`[vat-kvittenser-cron] Processed ${results.length}: ${signed} signed, ${stillPending} still pending, ${expired} expired, ${errors} errors`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
processed: results.length,
|
||||
signed,
|
||||
stillPending,
|
||||
expired,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { hashAuthCode } from '@/lib/auth/oauth-codes'
|
||||
import {
|
||||
exchangeCodeForAccount,
|
||||
fetchAccountDisplayName,
|
||||
} from '@/extensions/general/stripe/lib/connect'
|
||||
|
||||
// This route emits stripe.connected (audit trail). ensureInitialized() must
|
||||
// run at module load so the event_log handler has subscribed before the first
|
||||
// emit on a cold instance.
|
||||
ensureInitialized()
|
||||
|
||||
/**
|
||||
* GET /api/extensions/stripe/callback
|
||||
*
|
||||
* OAuth callback for Stripe Connect authorization. Must be a real Next.js
|
||||
* route (not an extension dispatcher handler) because Stripe redirects the
|
||||
* user's browser to this URL directly.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
const code = searchParams.get('code')
|
||||
const state = searchParams.get('state')
|
||||
const error = searchParams.get('error')
|
||||
const errorDescription = searchParams.get('error_description')
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
|
||||
const settingsUrl = `${baseUrl}/settings/payments`
|
||||
|
||||
if (error) {
|
||||
const errorMessage = errorDescription || error
|
||||
// access_denied is the user cancelling at Stripe: an expected outcome.
|
||||
const logDenied = error === 'access_denied' ? console.warn : console.error
|
||||
logDenied('[stripe] Connect authorization denied', {
|
||||
error,
|
||||
error_description: errorDescription,
|
||||
has_state: !!state,
|
||||
})
|
||||
|
||||
if (state) {
|
||||
try {
|
||||
const supabase = await createServiceClient()
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ status: 'error', error_message: errorMessage, oauth_state: null })
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
} catch (cleanupError) {
|
||||
console.error('[stripe] Failed to clean up pending connection:', cleanupError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent(errorMessage)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return NextResponse.redirect(`${settingsUrl}?stripe_error=missing_parameters`)
|
||||
}
|
||||
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
try {
|
||||
// Locate the connection awaiting this callback by oauth_state (CSRF-safe:
|
||||
// the token is a single-use random UUID written before the redirect and
|
||||
// cleared below).
|
||||
const { data: pendingConnection, error: findError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select('id, user_id, company_id')
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
.single()
|
||||
|
||||
if (findError || !pendingConnection) {
|
||||
console.error('[stripe] No pending connection for oauth_state', {
|
||||
findError: findError
|
||||
? { message: findError.message, code: findError.code }
|
||||
: null,
|
||||
hasCode: !!code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replay protection (OAuth 2.1 §4.1.2): a code may be exchanged once.
|
||||
// The PRIMARY KEY on oauth_used_codes rejects a second insert.
|
||||
const { error: replayError } = await supabase
|
||||
.from('oauth_used_codes')
|
||||
.insert({ code_hash: hashAuthCode(code) })
|
||||
if (replayError) {
|
||||
console.error('[stripe] Authorization code already used', {
|
||||
connectionId: pendingConnection.id,
|
||||
code: replayError.code,
|
||||
})
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('invalid_state')}`,
|
||||
)
|
||||
}
|
||||
|
||||
const { stripeAccountId, livemode } = await exchangeCodeForAccount(code)
|
||||
const displayName = await fetchAccountDisplayName(stripeAccountId)
|
||||
|
||||
const { data: updatedConnection, error: updateError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
stripe_account_id: stripeAccountId,
|
||||
livemode,
|
||||
display_name: displayName,
|
||||
status: 'active',
|
||||
connected_at: new Date().toISOString(),
|
||||
error_message: null,
|
||||
oauth_state: null, // Clear to prevent replay
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
.select('id, company_id, user_id, stripe_account_id, livemode')
|
||||
.single()
|
||||
|
||||
if (updateError || !updatedConnection) {
|
||||
// 23505 = one of the partial unique indexes: this Stripe account is
|
||||
// already actively connected (to this or another company), or the
|
||||
// company connected in a parallel tab. Both are user-facing conflicts.
|
||||
const isConflict = updateError?.code === '23505'
|
||||
console.error('[stripe] Failed to activate connection', {
|
||||
connectionId: pendingConnection.id,
|
||||
error: updateError
|
||||
? { message: updateError.message, code: updateError.code }
|
||||
: null,
|
||||
})
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: isConflict
|
||||
? 'Stripe-kontot är redan anslutet till ett företag.'
|
||||
: 'Anslutningen kunde inte slutföras.',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('id', pendingConnection.id)
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent(
|
||||
isConflict ? 'account_already_connected' : 'activation_failed',
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'stripe.connected',
|
||||
payload: {
|
||||
connectionId: updatedConnection.id,
|
||||
stripeAccountId: updatedConnection.stripe_account_id!,
|
||||
livemode: updatedConnection.livemode,
|
||||
userId: updatedConnection.user_id,
|
||||
companyId: updatedConnection.company_id,
|
||||
},
|
||||
})
|
||||
} catch (emitError) {
|
||||
// Non-fatal: the DB state (source of truth) is already committed.
|
||||
console.error('[stripe] Failed to emit stripe.connected event', {
|
||||
connectionId: updatedConnection.id,
|
||||
error: emitError instanceof Error ? emitError.message : String(emitError),
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.redirect(`${settingsUrl}?stripe_connected=true`)
|
||||
} catch (error) {
|
||||
console.error('[stripe] Callback error', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
name: error instanceof Error ? error.name : undefined,
|
||||
hasCode: !!code,
|
||||
})
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({
|
||||
status: 'error',
|
||||
error_message: 'Anslutningen kunde inte slutföras.',
|
||||
oauth_state: null,
|
||||
})
|
||||
.eq('oauth_state', state)
|
||||
.eq('status', 'pending')
|
||||
} catch (cleanupError) {
|
||||
console.error('[stripe] Callback cleanup failed:', cleanupError)
|
||||
}
|
||||
|
||||
return NextResponse.redirect(
|
||||
`${settingsUrl}?stripe_error=${encodeURIComponent('connection_failed')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withCronContext } from '@/lib/api/with-cron-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { hasCapability } from '@/lib/entitlements/has-capability'
|
||||
import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
import { syncStripeConnection } from '@/extensions/general/stripe/lib/sync'
|
||||
import type { StripeConnection } from '@/extensions/general/stripe/types'
|
||||
|
||||
// settleInvoicePayment emits invoice.paid; the Stripe extension's own
|
||||
// link-deactivation handler (and webhook fan-out) must be wired before the
|
||||
// first emit on a cold instance.
|
||||
ensureInitialized()
|
||||
|
||||
export const maxDuration = 300
|
||||
|
||||
/**
|
||||
* GET /api/extensions/stripe/sync/cron
|
||||
* Polls each active Stripe connection's event stream every 15 minutes and
|
||||
* applies checkout payments to invoices (deterministic match, 1686 clearing).
|
||||
*
|
||||
* Processes up to 50 connections per run, oldest-synced first. Idempotent:
|
||||
* event claims are unique per (connection, event), so overlapping windows and
|
||||
* re-runs are no-ops.
|
||||
*/
|
||||
export const GET = withCronContext('cron.stripe_sync', async (_request, ctx) => {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseServiceKey) {
|
||||
return errorResponseFromCode('INTERNAL_ERROR', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: { reason: 'Missing Supabase configuration' },
|
||||
})
|
||||
}
|
||||
if (!process.env.STRIPE_SECRET_KEY || !process.env.STRIPE_CONNECT_CLIENT_ID) {
|
||||
return NextResponse.json({ message: 'Stripe Connect not configured', processed: 0 })
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey)
|
||||
|
||||
const { data: connections, error: connError } = await supabase
|
||||
.from('stripe_connections')
|
||||
.select('*')
|
||||
.eq('status', 'active')
|
||||
.order('last_event_created_at', { ascending: true, nullsFirst: true })
|
||||
.limit(50)
|
||||
|
||||
if (connError) {
|
||||
ctx.log.error('failed to fetch stripe connections', connError, {
|
||||
message: connError.message,
|
||||
code: connError.code,
|
||||
})
|
||||
return errorResponse(connError, ctx.log, { requestId: ctx.requestId })
|
||||
}
|
||||
|
||||
if (!connections || connections.length === 0) {
|
||||
return NextResponse.json({ message: 'No active connections to sync', processed: 0 })
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIME_BUDGET_MS = 240_000 // leave a minute of margin inside maxDuration
|
||||
// Absolute deadline shared with syncStripeConnection so a single
|
||||
// connection's event batch cannot blow the budget on its own: the sync
|
||||
// stops between events and persists its cursor up to what it processed.
|
||||
const deadlineMs = startTime + TIME_BUDGET_MS
|
||||
|
||||
const results: Array<{
|
||||
connectionId: string
|
||||
settled: number
|
||||
needsReview: number
|
||||
ignored: number
|
||||
status: 'synced' | 'revoked' | 'error'
|
||||
}> = []
|
||||
|
||||
for (const connection of connections as StripeConnection[]) {
|
||||
if (Date.now() >= deadlineMs) {
|
||||
ctx.log.info('time budget reached', { processedSoFar: results.length })
|
||||
break
|
||||
}
|
||||
|
||||
if (!(await hasCapability(supabase, connection.company_id, CAPABILITY.stripe_payments))) {
|
||||
ctx.log.info('skip: capability not entitled', { companyId: connection.company_id })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const summary = await syncStripeConnection(supabase, connection, ctx.log, deadlineMs)
|
||||
if (summary.deadlineReached) {
|
||||
ctx.log.info('connection stopped early on time budget; remaining events resume next run', {
|
||||
connectionId: connection.id,
|
||||
})
|
||||
}
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
settled: summary.settled,
|
||||
needsReview: summary.needsReview,
|
||||
ignored: summary.ignored,
|
||||
status: summary.revoked ? 'revoked' : 'synced',
|
||||
})
|
||||
} catch (error) {
|
||||
ctx.log.error('stripe sync failed for connection', error as Error, {
|
||||
connectionId: connection.id,
|
||||
companyId: connection.company_id,
|
||||
})
|
||||
await supabase
|
||||
.from('stripe_connections')
|
||||
.update({ error_message: 'Synkroniseringen misslyckades. Försöker igen automatiskt.' })
|
||||
.eq('id', connection.id)
|
||||
results.push({
|
||||
connectionId: connection.id,
|
||||
settled: 0,
|
||||
needsReview: 0,
|
||||
ignored: 0,
|
||||
status: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const totals = results.reduce(
|
||||
(acc, r) => ({
|
||||
settled: acc.settled + r.settled,
|
||||
needsReview: acc.needsReview + r.needsReview,
|
||||
}),
|
||||
{ settled: 0, needsReview: 0 },
|
||||
)
|
||||
ctx.log.info('stripe sync summary', {
|
||||
processed: results.length,
|
||||
totalSettled: totals.settled,
|
||||
totalNeedsReview: totals.needsReview,
|
||||
failed: results.filter((r) => r.status === 'error').length,
|
||||
})
|
||||
|
||||
return NextResponse.json({ processed: results.length, ...totals, results })
|
||||
})
|
||||
@@ -172,7 +172,9 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
undefined,
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
undefined, // paymentAmount: full settle
|
||||
undefined // settlementAccountNumber: default 1930
|
||||
)
|
||||
})
|
||||
|
||||
@@ -239,7 +241,8 @@ describe('POST /api/invoices/[id]/mark-paid', () => {
|
||||
expect.objectContaining({ id: 'inv-1' }),
|
||||
expect.any(String),
|
||||
'enskild_firma',
|
||||
expect.anything()
|
||||
expect.anything(),
|
||||
undefined // settlementAccountNumber: default 1930
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import {
|
||||
createInvoicePaymentJournalEntry,
|
||||
createInvoiceCashEntry,
|
||||
} from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createJournalEntry, findFiscalPeriod } from '@/lib/bookkeeping/engine'
|
||||
import { resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { isBookkeepingError } from '@/lib/bookkeeping/errors'
|
||||
import { cancelOrphanedPaymentEntry } from '@/lib/bookkeeping/cancel-orphaned-entry'
|
||||
import { MarkInvoicePaidSchema } from '@/lib/api/schemas'
|
||||
import { ensureInitialized } from '@/lib/init'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { findDuplicatePaymentCandidatesForInvoice } from '@/lib/invoices/duplicate-payment-candidates'
|
||||
import { planInvoicePayment } from '@/lib/invoices/apply-invoice-payment'
|
||||
import { eventBus } from '@/lib/events'
|
||||
import { settleInvoicePayment } from '@/lib/invoices/settle-invoice-payment'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import type { CreateJournalEntryInput, EntityType, Invoice } from '@/types'
|
||||
import type { EntityType, Invoice } from '@/types'
|
||||
|
||||
ensureInitialized()
|
||||
|
||||
@@ -26,6 +17,10 @@ ensureInitialized()
|
||||
*
|
||||
* Faktureringsmetoden (accrual): Debit 1930, Credit 1510 (clearing entry)
|
||||
* Kontantmetoden (cash): Debit 1930, Credit 30xx, Credit 26xx
|
||||
*
|
||||
* The booking + status transition live in settleInvoicePayment (shared with
|
||||
* the Stripe payment sync); this route owns request parsing, the payable
|
||||
* guard, and the duplicate-payment advisory.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'invoice.mark_paid',
|
||||
@@ -142,23 +137,10 @@ export const POST = withRouteContext(
|
||||
const accountingMethod = settings?.accounting_method || 'accrual'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
// Drive the JE shape from the invoice's actual booking state, not from
|
||||
// the current accounting_method setting. If the invoice was booked at
|
||||
// send (Dr 1510 / Cr 30xx + VAT), the payment MUST clear 1510:
|
||||
// otherwise the receivable orphans and 30xx + VAT double-count. Only
|
||||
// when there is no prior JE (pure kontantmetoden) do we recognise
|
||||
// revenue + VAT here.
|
||||
const invoiceAlreadyBooked = !!(invoice as { journal_entry_id?: string | null }).journal_entry_id
|
||||
const useCashEntry = !invoiceAlreadyBooked && accountingMethod === 'cash'
|
||||
|
||||
// Ledger math + overpayment guard via the shared planInvoicePayment helper:
|
||||
// the same single source of truth the match-invoice flow uses, so the three
|
||||
// mark-paid surfaces (this route, the v1 API, and the agent commit path)
|
||||
// cannot drift again. Runs BEFORE any journal entry is created so a doomed
|
||||
// overpayment never burns a voucher number. paymentAmount and the
|
||||
// duplicate-payment guard above operate in the booking currency (SEK for
|
||||
// custom lines); convert to invoice currency for the ledger comparison so a
|
||||
// foreign-currency invoice isn't falsely rejected as overpaid.
|
||||
// paymentAmount and the duplicate-payment guard above operate in the
|
||||
// booking currency (SEK for custom lines); convert to invoice currency for
|
||||
// the ledger comparison so a foreign-currency invoice isn't falsely
|
||||
// rejected as overpaid.
|
||||
const fxRate =
|
||||
invoice.currency && invoice.currency !== 'SEK' && invoice.exchange_rate
|
||||
? invoice.exchange_rate
|
||||
@@ -166,168 +148,49 @@ export const POST = withRouteContext(
|
||||
const paymentAmountInInvoiceCurrency = customLines
|
||||
? roundOre(paymentAmount / fxRate)
|
||||
: paymentAmount
|
||||
const payment = planInvoicePayment(invoice, paymentAmountInInvoiceCurrency)
|
||||
if (!payment.ok) {
|
||||
return errorResponseFromCode('MATCH_AMOUNT_EXCEEDS_REMAINING', opLog, {
|
||||
requestId,
|
||||
details: payment.details,
|
||||
})
|
||||
}
|
||||
const { newPaidAmount, newRemaining, newStatus } = payment.plan
|
||||
|
||||
const isRealInvoice = !invoice.document_type || invoice.document_type === 'invoice'
|
||||
let journalEntryId: string | null = null
|
||||
const result = await settleInvoicePayment(supabase, companyId!, user.id, {
|
||||
invoice: invoice as Invoice & { customer?: { name?: string | null } | null },
|
||||
paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
accountingMethod,
|
||||
entityType,
|
||||
exchangeRateDifference,
|
||||
customLines,
|
||||
})
|
||||
|
||||
if (isRealInvoice) {
|
||||
try {
|
||||
if (customLines) {
|
||||
const totalDebit = customLines.reduce((s, l) => s + l.debit_amount, 0)
|
||||
const totalCredit = customLines.reduce((s, l) => s + l.credit_amount, 0)
|
||||
if (Math.round((totalDebit - totalCredit) * 100) !== 0 || totalDebit <= 0) {
|
||||
return errorResponseFromCode('INVOICE_PAID_LINES_UNBALANCED', opLog, {
|
||||
requestId,
|
||||
details: { totalDebit, totalCredit },
|
||||
})
|
||||
}
|
||||
|
||||
const fiscalPeriodId = await findFiscalPeriod(supabase, companyId!, paymentDate)
|
||||
if (!fiscalPeriodId) {
|
||||
return errorResponseFromCode('INVOICE_PAID_NO_FISCAL_PERIOD', opLog, {
|
||||
requestId,
|
||||
details: { paymentDate },
|
||||
})
|
||||
}
|
||||
const sourceType = resolveInvoicePaymentSourceType({
|
||||
invoiceAlreadyBooked,
|
||||
accountingMethod,
|
||||
if (!result.ok) {
|
||||
switch (result.code) {
|
||||
case 'BOOKKEEPING_ERROR':
|
||||
return errorResponse(result.error, opLog, { requestId })
|
||||
case 'UPDATE_FAILED':
|
||||
opLog.error('failed to update invoice status', result.error as Error)
|
||||
return errorResponse(result.error, opLog, { requestId })
|
||||
case 'INVOICE_PAID_BOOK_FAILED':
|
||||
opLog.error('failed to create payment journal entry', undefined, {
|
||||
details: result.details,
|
||||
})
|
||||
return errorResponseFromCode(result.code, opLog, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
})
|
||||
case 'INVOICE_PAID_RACE':
|
||||
return errorResponseFromCode(result.code, opLog, { requestId })
|
||||
default:
|
||||
return errorResponseFromCode(result.code, opLog, {
|
||||
requestId,
|
||||
details: result.details,
|
||||
})
|
||||
const input: CreateJournalEntryInput = {
|
||||
fiscal_period_id: fiscalPeriodId,
|
||||
entry_date: paymentDate,
|
||||
description: invoice.customer?.name
|
||||
? `Inbetalning kundfaktura ${invoice.invoice_number}, ${invoice.customer.name}`
|
||||
: `Inbetalning kundfaktura ${invoice.invoice_number}`,
|
||||
source_type: sourceType,
|
||||
source_id: invoice.id,
|
||||
lines: customLines,
|
||||
}
|
||||
const journalEntry = await createJournalEntry(supabase, companyId!, user.id, input)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else if (useCashEntry) {
|
||||
const journalEntry = await createInvoiceCashEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
entityType, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
} else {
|
||||
const journalEntry = await createInvoicePaymentJournalEntry(
|
||||
supabase, companyId!, user.id, invoice as Invoice, paymentDate,
|
||||
exchangeRateDifference, invoice.customer?.name,
|
||||
)
|
||||
journalEntryId = journalEntry?.id ?? null
|
||||
}
|
||||
} catch (err) {
|
||||
if (isBookkeepingError(err)) {
|
||||
return errorResponse(err, opLog, { requestId })
|
||||
}
|
||||
opLog.error('failed to create payment journal entry', err as Error)
|
||||
return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: err instanceof Error ? err.message : 'unknown' },
|
||||
})
|
||||
}
|
||||
|
||||
// Fail closed: a real invoice must produce a payment voucher. If a helper
|
||||
// returned null without throwing (e.g. a closed/locked fiscal period),
|
||||
// refuse to mark the invoice paid: flipping status with no journal entry
|
||||
// orphans the receivable and diverges the GL from the sub-ledger.
|
||||
if (!journalEntryId) {
|
||||
opLog.error('mark-paid produced no journal entry; refusing to mark paid', undefined, {
|
||||
invoiceId: id,
|
||||
})
|
||||
return errorResponseFromCode('INVOICE_PAID_BOOK_FAILED', opLog, {
|
||||
requestId,
|
||||
details: { reason: 'no_journal_entry_created' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// CAS guard: only update if status is still in a payable state.
|
||||
const { data: updateResult, error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
.update({
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
...(newStatus === 'paid' ? { paid_at: now } : {}),
|
||||
})
|
||||
.eq('id', id)
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue', 'partially_paid'])
|
||||
.select('id')
|
||||
|
||||
if (updateError) {
|
||||
opLog.error('failed to update invoice status', updateError)
|
||||
// The payment voucher already posted but the invoice row did not flip to
|
||||
// paid; cancel the orphan so the GL doesn't diverge from the sub-ledger.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: fakturauppdatering misslyckades efter bokförd betalning',
|
||||
)
|
||||
}
|
||||
return errorResponse(updateError, opLog, { requestId })
|
||||
}
|
||||
|
||||
if (!updateResult || updateResult.length === 0) {
|
||||
// Status changed between read and write (concurrent settle): cancel the
|
||||
// orphaned payment voucher and document the voucher gap before reporting.
|
||||
if (journalEntryId) {
|
||||
await cancelOrphanedPaymentEntry(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
journalEntryId,
|
||||
'Automatiskt makulerad: dubblettbokning förhindrad av samtidighetsskydd',
|
||||
)
|
||||
}
|
||||
return errorResponseFromCode('INVOICE_PAID_RACE', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Notify subscribers: invoice.paid fans out to registered webhooks
|
||||
// (lib/webhooks/handler.ts). Best-effort: the payment is already committed,
|
||||
// so an emit failure must not fail the request. Mirrors the v1 route.
|
||||
try {
|
||||
await eventBus.emit({
|
||||
type: 'invoice.paid',
|
||||
payload: {
|
||||
invoice: {
|
||||
...(invoice as Invoice),
|
||||
status: newStatus,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
paid_at: newStatus === 'paid' ? now : (invoice as Invoice).paid_at,
|
||||
} as Invoice,
|
||||
companyId: companyId!,
|
||||
userId: user.id,
|
||||
paymentAmount: paymentAmountInInvoiceCurrency,
|
||||
paymentDate,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
opLog.error('invoice.paid emit failed', err as Error, { invoiceId: id })
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
status: newStatus,
|
||||
paid_at: newStatus === 'paid' ? now : null,
|
||||
paid_amount: newPaidAmount,
|
||||
remaining_amount: newRemaining,
|
||||
journal_entry_id: journalEntryId,
|
||||
status: result.newStatus,
|
||||
paid_at: result.paidAt,
|
||||
paid_amount: result.newPaidAmount,
|
||||
remaining_amount: result.newRemaining,
|
||||
journal_entry_id: result.journalEntryId,
|
||||
})
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
|
||||
import { createSchedulesForCustomerInvoice } from '@/lib/bookkeeping/accruals/from-invoices'
|
||||
import { uploadDocument } from '@/lib/core/documents/document-service'
|
||||
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { guardSandbox } from '@/lib/sandbox/guard'
|
||||
@@ -131,6 +132,18 @@ export const POST = withRouteContext(
|
||||
return errorResponseFromCode('INVOICE_SEND_NUMBER_ASSIGN_FAILED', opLog, { requestId })
|
||||
}
|
||||
|
||||
// Auto-create an online payment link (extension-provided, e.g. Stripe) now
|
||||
// that the number exists, so the email button and PDF QR carry it. A
|
||||
// failure never blocks the send: the faktura is legally valid without a
|
||||
// link, so it degrades to a PARTIAL warning instead.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
supabase,
|
||||
companyId!,
|
||||
user.id,
|
||||
invoice as Invoice,
|
||||
opLog,
|
||||
)
|
||||
|
||||
// Final render with the assigned number: this is the buffer attached to
|
||||
// the email and later archived as underlag. Override status to 'sent' on
|
||||
// the in-memory copy: the DB flip happens after email delivery (line
|
||||
@@ -206,6 +219,10 @@ export const POST = withRouteContext(
|
||||
// which sub-step broke.
|
||||
const partialFailures: Array<{ step: string; reason: string }> = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
partialFailures.push({ step: 'payment_link', reason: paymentLinkFailure })
|
||||
}
|
||||
|
||||
{
|
||||
const { error: updateError } = await supabase
|
||||
.from('invoices')
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import {
|
||||
createMockRequest,
|
||||
parseJsonResponse,
|
||||
createQueuedMockSupabase,
|
||||
} from '@/tests/helpers'
|
||||
import { encryptPersonnummer } from '@/lib/salary/personnummer'
|
||||
|
||||
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/company/context', () => ({
|
||||
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth/require-write', () => ({
|
||||
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}))
|
||||
|
||||
import { POST as importPOST } from '../import/route'
|
||||
|
||||
const REQUEST_ID = '22222222-2222-4222-8222-222222222222'
|
||||
// Skatteverket official example personnummer (synthetic).
|
||||
const PNR = '193610058590'
|
||||
const mockUser = { id: 'user-1', email: 'test@test.se' }
|
||||
|
||||
function makeBeslutFile(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
version: '1',
|
||||
utforare: '168780003656',
|
||||
beslut: [
|
||||
{
|
||||
namn: 'ROT 2026-07-02',
|
||||
referensnummer: '20260000185-01',
|
||||
arenden: [{ personnummer: PNR, fakturanummer: '96458', godkantBelopp: 2000 }],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reset()
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
|
||||
})
|
||||
|
||||
describe('POST /api/rot-rut/beslut/import', () => {
|
||||
it('returns 401 when not authenticated', async () => {
|
||||
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
expect(response.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 400 on a malformed beslutsfil', async () => {
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', {
|
||||
method: 'POST',
|
||||
body: { version: '1', utforare: 'not-digits', beslut: [] },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 ROT_RUT_BESLUT_WRONG_COMPANY when utforare mismatches', async () => {
|
||||
enqueue({ data: { org_number: '556123-4567' } }) // company_settings
|
||||
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(body.error.code).toBe('ROT_RUT_BESLUT_WRONG_COMPANY')
|
||||
})
|
||||
|
||||
it('imports a matching beslut and returns per-beslut outcomes', async () => {
|
||||
enqueue({ data: { org_number: '878000-3656' } }) // company_settings
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: REQUEST_ID,
|
||||
name: 'ROT 2026-07-02',
|
||||
status: 'submitted',
|
||||
requested_total: 3000,
|
||||
decided_total: null,
|
||||
decided_at: null,
|
||||
skv_referensnummer: null,
|
||||
},
|
||||
],
|
||||
}) // requests
|
||||
enqueue({
|
||||
data: [
|
||||
{
|
||||
id: 'item-1',
|
||||
invoice_id: 'inv-1',
|
||||
requested_amount: 3000,
|
||||
invoice: {
|
||||
invoice_number: '96458',
|
||||
deduction_personnummer_encrypted: encryptPersonnummer(PNR),
|
||||
},
|
||||
},
|
||||
],
|
||||
}) // items
|
||||
enqueue({ data: null }) // item update
|
||||
enqueue({ data: null }) // request update
|
||||
|
||||
const response = await importPOST(
|
||||
createMockRequest('/api/rot-rut/beslut/import', { method: 'POST', body: makeBeslutFile() }),
|
||||
)
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { imported: number; errors: number; results: Array<Record<string, unknown>> }
|
||||
}>(response)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.imported).toBe(1)
|
||||
expect(body.data.errors).toBe(0)
|
||||
expect(body.data.results[0]).toMatchObject({
|
||||
status: 'imported',
|
||||
request_id: REQUEST_ID,
|
||||
decided_total: 2000,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { RotRutBeslutFileSchema } from '@/lib/api/schemas'
|
||||
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
|
||||
import { importRotRutBeslutFile } from '@/lib/invoices/rot-rut-beslut-import'
|
||||
|
||||
/**
|
||||
* POST /api/rot-rut/beslut/import
|
||||
*
|
||||
* Import Skatteverkets beslutsfil (the decision JSON downloaded from the
|
||||
* rot/rut e-tjänst) and record godkänt belopp on the matching payout
|
||||
* requests: per-item decided_amount, request decided_total/decided_at, the
|
||||
* SKV referensnummer, and the rejected status for 0-kr avslag.
|
||||
*
|
||||
* The body is the beslutsfil content verbatim. Matching is exact-only and
|
||||
* each beslut applies all-or-nothing; unmatched beslut are reported per
|
||||
* entry in the response instead of failing the whole import. Booking the
|
||||
* actual utbetalning stays with POST /payout-requests/{id}/settle.
|
||||
*/
|
||||
export const POST = withRouteContext(
|
||||
'rot_rut.beslut.import',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, log, requestId } = ctx
|
||||
|
||||
const validation = await validateBody(request, RotRutBeslutFileSchema)
|
||||
if (!validation.success) return validation.response
|
||||
|
||||
const result = await importRotRutBeslutFile(supabase, companyId!, validation.data)
|
||||
|
||||
if (!result.ok) {
|
||||
return errorResponseFromCode(result.code, log, { requestId })
|
||||
}
|
||||
|
||||
log.info('rot/rut beslutsfil imported', {
|
||||
imported: result.imported,
|
||||
alreadyImported: result.already_imported,
|
||||
errors: result.errors,
|
||||
})
|
||||
|
||||
return NextResponse.json({ data: result })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
)
|
||||
@@ -133,11 +133,17 @@ export const GET = withRouteContext<{ params: Promise<{ id: string }> }>(
|
||||
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
|
||||
const messageId = `${getBranding().appName.toUpperCase()}-${company.org_number?.replace('-', '')}-${periodLabel}`
|
||||
|
||||
const xml = generatePain001(companyData, employees, {
|
||||
messageId,
|
||||
paymentDate: run.payment_date,
|
||||
periodLabel,
|
||||
})
|
||||
let xml: string
|
||||
try {
|
||||
xml = generatePain001(companyData, employees, {
|
||||
messageId,
|
||||
paymentDate: run.payment_date,
|
||||
periodLabel,
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Kunde inte generera betalfil'
|
||||
return NextResponse.json({ error: msg }, { status: 400 })
|
||||
}
|
||||
|
||||
await supabase
|
||||
.from('salary_runs')
|
||||
|
||||
@@ -7,9 +7,10 @@ ensureInitialized()
|
||||
/**
|
||||
* Mark the AGI period's tax payment (skatt + avgifter) as paid.
|
||||
*
|
||||
* This is a manual confirmation by the user: bank reconciliation against
|
||||
* Skattekontot transactions can also flip this flag automatically (handled
|
||||
* elsewhere via the Skattekonto sync).
|
||||
* This is a manual confirmation by the user. The Skattekonto sync also flips
|
||||
* the flag automatically when the period's AGI debit row is booked with the
|
||||
* exact declared amount and the account is not in deficit (see
|
||||
* extensions/general/skatteverket/lib/agi-tax-settlement.ts).
|
||||
*/
|
||||
export const POST = withRouteContext<{ params: Promise<{ period: string }> }>(
|
||||
'tax_payment.mark_paid',
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
* 500 INVOICE_SEND_PDF_RENDER_FAILED, no number burned.
|
||||
* 6. ensureInvoiceNumber allocates the F-series number atomically.
|
||||
* Fail → 500 INVOICE_SEND_NUMBER_ASSIGN_FAILED.
|
||||
* 6b. Auto-create an online payment link (extension-provided, e.g. Stripe)
|
||||
* and persist it on the invoice row. Best-effort: a provider or persist
|
||||
* failure never blocks the send; it surfaces as a PAYMENT_LINK_FAILED
|
||||
* warning on the response once the email is delivered.
|
||||
* 7. Final PDF render with the real number.
|
||||
* 8. Email send via Resend (the email extension). Fail → 502
|
||||
* INVOICE_SEND_PROVIDER_FAILED. The number IS consumed at this point;
|
||||
@@ -43,7 +47,8 @@ import { registerEndpoint, dataEnvelope } from '@/lib/api/v1/registry'
|
||||
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { InvoicePDF } from '@/lib/invoices/pdf-template'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { prepareInvoicePdfRender, buildSwishQrDataUrl, buildPaymentLinkQrDataUrl } from '@/lib/invoices/pdf-render-helpers'
|
||||
import { applyPaymentLinkToInvoice } from '@/lib/extensions/payment-links'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import {
|
||||
generateInvoiceEmailHtml,
|
||||
@@ -357,6 +362,26 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
}
|
||||
const finalInvoiceNumber =
|
||||
(numbered as { invoice_number?: string } | null)?.invoice_number ?? typed.invoice_number
|
||||
|
||||
// Step 6b: auto-create an online payment link (extension-provided, e.g.
|
||||
// Stripe) now that the number exists, so the email button and PDF QR
|
||||
// carry it. Failure degrades to a PAYMENT_LINK_FAILED warning: the
|
||||
// faktura is legally valid without a link. The helper mirrors the link
|
||||
// onto the in-memory row only after a successful persist so a link on
|
||||
// the PDF can always be matched back to the row.
|
||||
const { failure: paymentLinkFailure } = await applyPaymentLinkToInvoice(
|
||||
ctx.supabase,
|
||||
ctx.companyId!,
|
||||
ctx.userId,
|
||||
typed as Invoice,
|
||||
ctx.log,
|
||||
{
|
||||
invoiceNumber: finalInvoiceNumber,
|
||||
logPrefix: 'invoices.send: ',
|
||||
logContext: { invoiceId },
|
||||
},
|
||||
)
|
||||
|
||||
// Also override `status` to 'sent' on the in-memory copy. The actual DB
|
||||
// flip happens at step 9a (after email delivery), but if we render with
|
||||
// the stale 'draft' status the customer receives a PDF stamped
|
||||
@@ -371,6 +396,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
try {
|
||||
const { branding, company: renderCompany } = await prepareInvoicePdfRender(settings)
|
||||
const swishQrDataUrl = await buildSwishQrDataUrl(settings, renderableInvoice)
|
||||
const paymentLinkQrDataUrl = await buildPaymentLinkQrDataUrl(renderableInvoice)
|
||||
pdfBuffer = await renderToBuffer(
|
||||
InvoicePDF({
|
||||
invoice: renderableInvoice,
|
||||
@@ -380,6 +406,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
originalInvoiceNumber,
|
||||
branding,
|
||||
swishQrDataUrl,
|
||||
paymentLinkQrDataUrl,
|
||||
}),
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -435,6 +462,10 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
|
||||
// Email has been delivered. Subsequent failures surface as warnings.
|
||||
const warnings: { code: string; message: string }[] = []
|
||||
|
||||
if (paymentLinkFailure) {
|
||||
warnings.push({ code: 'PAYMENT_LINK_FAILED', message: paymentLinkFailure })
|
||||
}
|
||||
|
||||
// Step 9a: status flip to 'sent'. The `.eq('status', 'draft')` is an
|
||||
// optimistic-lock guard against a concurrent state change between fetch
|
||||
// and write. PostgREST returns `{ error: null }` for 0-row updates, so
|
||||
|
||||
Reference in New Issue
Block a user