* fix(enable-banking): flatten Enable Banking's bank_transaction_code object to a string
Enable Banking serializes bank_transaction_code as {description, code,
sub_code}; three places declared it a string. The direct path passed the
object through, so PostgREST wrote its JSON text into
transactions.bank_transaction_code for every Enable Banking row since
2026-08-09 (6,356 rows, 78 companies) and the label/method derivation never
matched. The Connect service forwarded the same object and the wire contract
rejected it, so every connector-canary sync failed from 2026-09-03 (Capstone
support case 2026-09-07, "banksynken mot Nordea").
One rule, one place: normalizeBankTransactionCode in the connect-contract
file (code, code/sub_code, else description, else null), applied by
convertTransaction here and by Connect's normalizeBookedTransaction in the
mirrored contract. The wire schema stays z.string().nullable();
CONTRACT_VERSION bumps to 2026-09-08. A repair migration rewrites the stored
JSON text with the same rule and touches nothing else.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* fix(enable-banking): skip reset-source rows in the repair and read "Kortköp/uttag" as card
Skeptic findings on 4a30bb3f3:
- The repair migration would have aborted on prod: 110 of the 6,356 rows
belong to a migration-reset source company, whose transactions are
immutable by trigger (transactions_block_migration_reset_source_mutation).
Same failure as 20260903170000. Those rows are now excluded; nothing reads
the column back for an archived company.
- With the code description reaching the keyword tables as a string,
"Kortköp/uttag" (SEB/Swedbank wording for an ordinary card purchase)
matched UTTAG before KORT in both CODE_KEYWORD_METHODS and KEYWORD_LABELS,
so 256 card rows a month would have shown "Betalsätt: Uttag". Card now
precedes withdrawal in both tables (and in the Connect mirror), matching
what TRAILING_PHRASES already says about the same phrase.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
* chore(migrations): annotate the repair as pg-test skip and state why no rattelse log is owed
coverage-gate flagged the migration because it creates a function; the only
function is a pg_temp helper dropped in the same statement batch, and a
one-shot UPDATE cannot be re-exercised after apply, so the annotation is the
honest disposition. The header also answers the Swedish compliance review:
the column is a write-once ingest projection with no reader, the underlag is
the archived raw PSD2 page (untouched), and the verifikat lives in
journal_entries, which the statement never reads or writes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnbRMhwvUigizrPar5hW47
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
/**
|
|
* @accounted/connect-contract
|
|
*
|
|
* The wire contract between an Accounted ledger installation (hosted, or a
|
|
* self-hosted instance) and the Accounted Connect service that operates the
|
|
* provider integrations only Accounted can run: bank feeds through its PSD2
|
|
* credentials, the Skatteverket API client, the Peppol access point, company
|
|
* lookup, the migration sources.
|
|
*
|
|
* Everything here is shape, never behaviour: constants, Zod schemas and the
|
|
* TypeScript types inferred from them, plus the one normalizer that keeps a
|
|
* provider field on that shape (normalizeBankTransactionCode). Both sides
|
|
* validate with the same schemas so they cannot drift apart. The package is MIT so that anyone may
|
|
* implement either side of it: a self-hosted ledger talking to Accounted
|
|
* Connect, or an alternative connector service talking to the open ledger.
|
|
*
|
|
* Versioning: `CONTRACT_VERSION` is a date. Fields are only ever added; a
|
|
* breaking change is a new operation or family name, never a changed one.
|
|
*/
|
|
|
|
export const CONTRACT_VERSION = '2026-09-08'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keys, headers and paths
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Connector keys start with this prefix; the rest is 32 random bytes, base64url. */
|
|
export const CONNECTOR_KEY_PREFIX = 'gnubok_ck_'
|
|
|
|
/** Header alternative to `Authorization: Bearer`, for proxied calls where Authorization carries an upstream token. */
|
|
export const CONNECTOR_KEY_HEADER = 'x-connector-key'
|
|
|
|
export const CONNECTOR_ENTITLEMENTS_PATH = '/api/connect/entitlements'
|
|
|
|
/** Default origin of the connector service. Installations that pointed at the hosted app's copy of the routes set GNUBOK_CONNECT_URL explicitly. */
|
|
export const DEFAULT_CONNECT_BASE_URL = 'https://connect.accounted.se'
|
|
|
|
/**
|
|
* Request headers an installation sends alongside its key. The company header
|
|
* is the installation's own opaque company reference: the service never
|
|
* resolves it to anything and only uses it to scope quotas and ownership.
|
|
*/
|
|
export const CONNECTOR_HEADERS = {
|
|
company: 'X-Connector-Company',
|
|
upstreamAuthorization: 'X-Connector-Upstream-Authorization',
|
|
upstreamContentType: 'X-Connector-Upstream-Content-Type',
|
|
} as const
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entitlements (installation <-> service)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export const connectorKeyStatusSchema = z.enum(['active', 'suspended', 'revoked'])
|
|
export type ConnectorKeyStatus = z.infer<typeof connectorKeyStatusSchema>
|
|
|
|
/** What the service tells an installation about its key. */
|
|
export const connectorEntitlementsSchema = z.object({
|
|
status: connectorKeyStatusSchema,
|
|
/** Capability keys the subscription covers. */
|
|
scopes: z.array(z.string()),
|
|
/** End of the paid period, ISO; null for an open-ended (manually issued) key. */
|
|
current_period_end: z.string().nullable(),
|
|
org_number: z.string(),
|
|
/** The installation origin this key is pinned to; null until the first sync claims it. */
|
|
instance_url: z.string().nullable(),
|
|
server_time: z.string(),
|
|
})
|
|
export type ConnectorEntitlements = z.infer<typeof connectorEntitlementsSchema>
|
|
|
|
/** What an installation reports on every sync (quantity billing input). */
|
|
export const connectorSyncReportSchema = z.object({
|
|
active_company_count: z.number().int().min(0),
|
|
instance_url: z.string().optional(),
|
|
app_version: z.string().optional(),
|
|
})
|
|
export type ConnectorSyncReport = z.infer<typeof connectorSyncReportSchema>
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Every refusal from the service is this shape. `code` is stable and machine
|
|
* readable; `retryable` tells the installation whether backing off helps.
|
|
*/
|
|
export const connectorErrorSchema = z.object({
|
|
error: z.string(),
|
|
code: z.string(),
|
|
retryable: z.boolean().optional(),
|
|
detail: z.string().nullable().optional(),
|
|
})
|
|
export type ConnectorError = z.infer<typeof connectorErrorSchema>
|
|
|
|
/** Codes the service may answer with, in addition to upstream-specific ones. */
|
|
export const CONNECTOR_ERROR_CODES = [
|
|
'BAD_REQUEST',
|
|
'CONNECTOR_SCOPE_MISSING',
|
|
'CONNECTOR_COMPANY_MISSING',
|
|
'CONNECTOR_PATH_NOT_ALLOWED',
|
|
'CONNECTOR_NOT_OWNED',
|
|
'CONNECTOR_QUOTA_EXCEEDED',
|
|
'CONNECTOR_RATE_LIMITED',
|
|
'CONNECTOR_STATE_INVALID',
|
|
'CONNECTOR_STATE_CONSUMED',
|
|
'CONNECTOR_REDIRECT_INVALID',
|
|
'CONNECTOR_LEDGER_FAILED',
|
|
'CONNECTOR_UPSTREAM_ERROR',
|
|
'CONNECTOR_UPSTREAM_UNCONFIGURED',
|
|
'CONNECTOR_PEPPOL_PARTICIPANT_TAKEN',
|
|
'CONNECTOR_PEPPOL_PARTICIPANT_NOT_ALLOWED',
|
|
'CONNECTOR_PEPPOL_SENDER_NOT_REGISTERED',
|
|
'PEPPOL_RECEIVING_UNSUPPORTED',
|
|
'PEPPOL_REGISTRATION_CAP_REACHED',
|
|
] as const
|
|
export type ConnectorErrorCode = (typeof CONNECTOR_ERROR_CODES)[number]
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bank sync operation (installation -> service, POST /api/connect/bank/sync)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The installation holds the PSD2 session and the account; the service does
|
|
* the provider paging, the booked-only filter and the normalization, and
|
|
* returns what the installation ingests plus the raw provider pages it
|
|
* archives. Stored keys (external ids) stay computed on the installation from
|
|
* booking_date, amount and its own account scope, exactly as before.
|
|
*/
|
|
export const bankSyncRequestSchema = z.object({
|
|
/** The Enable Banking session id the installation obtained (ownership is checked). */
|
|
session_id: z.string().trim().min(1).max(200),
|
|
account_uid: z.string().trim().min(1).max(200),
|
|
account_currency: z.string().trim().length(3),
|
|
date_from: z.iso.date().optional(),
|
|
date_to: z.iso.date().optional(),
|
|
strategy: z.enum(['default', 'longest']).optional(),
|
|
})
|
|
export type BankSyncRequest = z.infer<typeof bankSyncRequestSchema>
|
|
|
|
export const normalizedBankTransactionSchema = z.object({
|
|
/** A real calendar date: the installation's stored keys and ledger date derive from it. */
|
|
booking_date: z.iso.date(),
|
|
amount: z.number(),
|
|
currency: z.string(),
|
|
description: z.string(),
|
|
counterparty_name: z.string().nullable(),
|
|
counterparty_account: z.string().nullable(),
|
|
reference: z.string().nullable(),
|
|
merchant_category_code: z.string().nullable(),
|
|
bank_transaction_code: z.string().nullable(),
|
|
proprietary_bank_transaction_code: z.string().nullable(),
|
|
})
|
|
export type NormalizedBankTransaction = z.infer<typeof normalizedBankTransactionSchema>
|
|
|
|
/**
|
|
* Enable Banking sends `bank_transaction_code` (and, in principle,
|
|
* `proprietary_bank_transaction_code`) as an object
|
|
* `{ description, code, sub_code }`, not as the string both sides declared.
|
|
* Swedish ASPSPs leave `code` null and put the only signal in `description`
|
|
* ("Card purchase", "Swish", "Kortköp/uttag"). The wire field is a string, so
|
|
* every producer (the Connect service and a ledger's direct Enable Banking
|
|
* path) flattens with this one rule before the value reaches
|
|
* `normalizedBankTransactionSchema` or a ledger column:
|
|
*
|
|
* object with `code` -> `code`, or `code/sub_code` when a sub-code exists
|
|
* object without code -> `description`
|
|
* string -> trimmed as is
|
|
* anything else -> null
|
|
*
|
|
* Shared here rather than copied per producer so the two sides cannot drift:
|
|
* the 2026-09-03 outage was exactly that drift (Connect forwarded the object,
|
|
* the ledger rejected it, and the direct path had been storing the object's
|
|
* JSON text since 2026-08-09).
|
|
*/
|
|
export function normalizeBankTransactionCode(input: unknown): string | null {
|
|
if (typeof input === 'string') {
|
|
const trimmed = input.trim()
|
|
return trimmed.length > 0 ? trimmed : null
|
|
}
|
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
const record = input as Record<string, unknown>
|
|
const text = (key: string): string | null => {
|
|
const value = record[key]
|
|
if (typeof value !== 'string') return null
|
|
const trimmed = value.trim()
|
|
return trimmed.length > 0 ? trimmed : null
|
|
}
|
|
const code = text('code')
|
|
if (code) {
|
|
const subCode = text('sub_code')
|
|
return subCode ? `${code}/${subCode}` : code
|
|
}
|
|
return text('description')
|
|
}
|
|
return null
|
|
}
|
|
|
|
export const bankSyncResponseSchema = z.object({
|
|
transactions: z.array(normalizedBankTransactionSchema),
|
|
/** Raw provider pages, verbatim, for the installation's archive. */
|
|
raw_pages: z.array(z.string()),
|
|
skipped_pending: z.number().int().min(0),
|
|
returned_min_booking_date: z.string().nullable(),
|
|
returned_max_booking_date: z.string().nullable(),
|
|
/** Set when the provider rejected the window and a narrower date_from was used. */
|
|
effective_date_from: z.string().nullable(),
|
|
pages: z.number().int().min(0),
|
|
})
|
|
export type BankSyncResponse = z.infer<typeof bankSyncResponseSchema>
|
|
|
|
/** Error codes specific to the bank sync operation. */
|
|
export const BANK_SYNC_ERROR_CODES = ['CONNECTOR_BANK_SESSION_EXPIRED', 'CONNECTOR_BANK_UPSTREAM_ERROR'] as const
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Peppol operations (installation -> service, /api/connect/peppol/*)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* The Peppol upstream speaks transport operations, not provider paths: the
|
|
* access-point account is shared, so the service scopes every read to what
|
|
* the calling key and company own. These schemas mirror the ledger's
|
|
* PeppolTransport interface one to one.
|
|
*/
|
|
|
|
export const PEPPOL_MAX_DOCUMENT_CHARS = 5_000_000
|
|
|
|
const peppolFourDigitScheme = z
|
|
.string()
|
|
.length(4)
|
|
.regex(/^\d+$/, 'ISO 6523 ICD scheme: four digits')
|
|
|
|
export const peppolParticipantSchema = z.object({
|
|
scheme: peppolFourDigitScheme,
|
|
identifier: z.string().trim().min(1).max(64),
|
|
})
|
|
export type PeppolParticipant = z.infer<typeof peppolParticipantSchema>
|
|
|
|
export const peppolDocumentTypeSchema = z.enum(['Invoice', 'CreditNote'])
|
|
export type PeppolInboundDocumentType = z.infer<typeof peppolDocumentTypeSchema>
|
|
|
|
export const peppolDeliveryStatusSchema = z.enum([
|
|
'staged',
|
|
'recipient_verified',
|
|
'submitting',
|
|
'retryable_failure',
|
|
'submission_accepted',
|
|
'transport_succeeded',
|
|
'recipient_acknowledged',
|
|
'business_accepted',
|
|
'business_rejected',
|
|
'no_route',
|
|
'failed',
|
|
])
|
|
export type PeppolDeliveryStatus = z.infer<typeof peppolDeliveryStatusSchema>
|
|
|
|
export const peppolRecipientCapabilitySchema = z.object({
|
|
documentTypeId: z.string(),
|
|
processId: z.string(),
|
|
})
|
|
|
|
export const peppolLookupRequestSchema = z.object({ participant: peppolParticipantSchema })
|
|
export type PeppolLookupRequest = z.infer<typeof peppolLookupRequestSchema>
|
|
|
|
export const peppolLookupResultSchema = z.discriminatedUnion('reachable', [
|
|
z.object({
|
|
reachable: z.literal(true),
|
|
participant: peppolParticipantSchema,
|
|
capabilities: z.array(peppolRecipientCapabilitySchema),
|
|
checkedAt: z.string(),
|
|
}),
|
|
z.object({
|
|
reachable: z.literal(false),
|
|
participant: peppolParticipantSchema,
|
|
reasonCode: z.string(),
|
|
checkedAt: z.string(),
|
|
}),
|
|
])
|
|
export type PeppolLookupResult = z.infer<typeof peppolLookupResultSchema>
|
|
|
|
export const peppolSubmissionSchema = z.object({
|
|
idempotencyKey: z.string().trim().min(1).max(128),
|
|
/** The installation's own company reference; the service overrides it with the company header. */
|
|
tenantReference: z.string().trim().min(1).max(128),
|
|
sender: peppolParticipantSchema,
|
|
recipient: peppolParticipantSchema,
|
|
documentTypeId: z.string().trim().min(1).max(512),
|
|
processId: z.string().trim().min(1).max(512),
|
|
filename: z.string().trim().min(1).max(255),
|
|
contentType: z.literal('application/xml'),
|
|
document: z.string().min(1).max(PEPPOL_MAX_DOCUMENT_CHARS),
|
|
documentSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
})
|
|
export type PeppolSubmission = z.infer<typeof peppolSubmissionSchema>
|
|
|
|
export const peppolSubmissionReceiptSchema = z.object({
|
|
provider: z.string(),
|
|
providerSubmissionId: z.string(),
|
|
idempotencyKey: z.string(),
|
|
tenantReference: z.string(),
|
|
acceptedAt: z.string(),
|
|
})
|
|
export type PeppolSubmissionReceipt = z.infer<typeof peppolSubmissionReceiptSchema>
|
|
|
|
export const peppolSubmissionRefSchema = z.object({
|
|
providerSubmissionId: z.string().trim().min(1).max(128),
|
|
})
|
|
export type PeppolSubmissionRef = z.infer<typeof peppolSubmissionRefSchema>
|
|
|
|
export const peppolVerifiedEventSchema = z.object({
|
|
provider: z.string(),
|
|
providerTenantId: z.string().nullable(),
|
|
providerSubmissionId: z.string().nullable(),
|
|
providerEventId: z.string().nullable(),
|
|
idempotencyKey: z.string().nullable(),
|
|
eventCode: z.string(),
|
|
normalizedStatus: peppolDeliveryStatusSchema,
|
|
isTerminal: z.boolean(),
|
|
detail: z.string().nullable(),
|
|
occurredAt: z.string(),
|
|
rawPayload: z.record(z.string(), z.unknown()),
|
|
eventSha256: z.string(),
|
|
verificationMethod: z.string(),
|
|
})
|
|
export type PeppolVerifiedEvent = z.infer<typeof peppolVerifiedEventSchema>
|
|
|
|
export const peppolDeliveryEvidenceSchema = z.object({
|
|
provider: z.string(),
|
|
evidenceType: z.string(),
|
|
payload: z.record(z.string(), z.unknown()),
|
|
exactDocument: z.string().nullable(),
|
|
exactDocumentSha256: z.string().nullable(),
|
|
evidenceSha256: z.string(),
|
|
retrievedAt: z.string(),
|
|
})
|
|
export type PeppolDeliveryEvidence = z.infer<typeof peppolDeliveryEvidenceSchema>
|
|
|
|
export const peppolBusinessCardSchema = z.object({
|
|
companyName: z.string().trim().min(1).max(200),
|
|
countryCode: z.string().trim().length(2),
|
|
geographicalInformation: z.string().max(500).nullish(),
|
|
vatNumber: z.string().max(64).nullish(),
|
|
orgNumber: z.string().max(64).nullish(),
|
|
})
|
|
|
|
export const peppolRecipientRegistrationRequestSchema = z.object({
|
|
participant: peppolParticipantSchema,
|
|
businessCard: peppolBusinessCardSchema,
|
|
documentTypes: z
|
|
.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) }))
|
|
.min(1)
|
|
.max(20),
|
|
description: z.string().max(200).nullish(),
|
|
/** The installation's own company reference; the service uses the company header. */
|
|
tenantReference: z.string().max(128).nullish(),
|
|
})
|
|
export type PeppolRecipientRegistrationRequest = z.infer<typeof peppolRecipientRegistrationRequestSchema>
|
|
|
|
export const peppolRecipientRegistrationResultSchema = z.object({
|
|
status: z.enum(['registered', 'updated']),
|
|
participant: peppolParticipantSchema,
|
|
/** Opaque on the connector: the service never reveals its provider account reference. */
|
|
providerAccountReference: z.string().nullable(),
|
|
raw: z.record(z.string(), z.unknown()),
|
|
})
|
|
export type PeppolRecipientRegistrationResult = z.infer<typeof peppolRecipientRegistrationResultSchema>
|
|
|
|
export const peppolInboundListRequestSchema = z.object({
|
|
documentType: peppolDocumentTypeSchema,
|
|
limit: z.number().int().min(1).max(100).optional(),
|
|
includeRead: z.boolean().optional(),
|
|
})
|
|
export type PeppolInboundListRequest = z.infer<typeof peppolInboundListRequestSchema>
|
|
|
|
export const peppolInboundMessageSchema = z.object({
|
|
provider: z.string(),
|
|
providerDocumentId: z.string(),
|
|
documentType: peppolDocumentTypeSchema,
|
|
payload: z.record(z.string(), z.unknown()),
|
|
receivedAt: z.string().nullable(),
|
|
})
|
|
export type PeppolInboundMessage = z.infer<typeof peppolInboundMessageSchema>
|
|
|
|
export const peppolInboundXmlRequestSchema = z.object({
|
|
providerDocumentId: z.string().trim().min(1).max(128),
|
|
documentType: peppolDocumentTypeSchema,
|
|
})
|
|
export type PeppolInboundXmlRequest = z.infer<typeof peppolInboundXmlRequestSchema>
|
|
|
|
export const peppolInboundXmlResultSchema = z.object({ xml: z.string().nullable() })
|
|
export type PeppolInboundXmlResult = z.infer<typeof peppolInboundXmlResultSchema>
|
|
|
|
/**
|
|
* The operation table: method, path under `/api/connect/peppol`, whether the
|
|
* company header is required, and the request and response schemas.
|
|
*/
|
|
export const PEPPOL_OPERATIONS = {
|
|
lookup: { method: 'POST', path: '/lookup', company: false, request: peppolLookupRequestSchema, response: peppolLookupResultSchema },
|
|
submit: { method: 'POST', path: '/submit', company: true, request: peppolSubmissionSchema, response: peppolSubmissionReceiptSchema },
|
|
status: { method: 'POST', path: '/status', company: true, request: peppolSubmissionRefSchema, response: z.array(peppolVerifiedEventSchema) },
|
|
evidence: { method: 'POST', path: '/evidence', company: true, request: peppolSubmissionRefSchema, response: z.array(peppolDeliveryEvidenceSchema) },
|
|
register: { method: 'PUT', path: '/recipient', company: true, request: peppolRecipientRegistrationRequestSchema, response: peppolRecipientRegistrationResultSchema },
|
|
unregister: { method: 'DELETE', path: '/recipient', company: true, request: peppolParticipantSchema, response: z.null() },
|
|
inboundList: { method: 'POST', path: '/inbound/list', company: false, request: peppolInboundListRequestSchema, response: z.array(peppolInboundMessageSchema) },
|
|
inboundXml: { method: 'POST', path: '/inbound/xml', company: false, request: peppolInboundXmlRequestSchema, response: peppolInboundXmlResultSchema },
|
|
} as const
|
|
export type PeppolOperation = keyof typeof PEPPOL_OPERATIONS
|