diff --git a/packages/core/src/ksef/ksef.adapter.ts b/packages/core/src/ksef/ksef.adapter.ts index fbd5271..3f37638 100644 --- a/packages/core/src/ksef/ksef.adapter.ts +++ b/packages/core/src/ksef/ksef.adapter.ts @@ -52,6 +52,7 @@ export class KsefAdapterImpl implements KsefAdapter { subjectType, pageSize: opts.pageSize, pageOffset: opts.pageOffset, + includeXml: opts.includeXml, }) return result.invoices.map((raw) => ({ diff --git a/packages/core/src/ksef/types.ts b/packages/core/src/ksef/types.ts index 1e046f4..0130d67 100644 --- a/packages/core/src/ksef/types.ts +++ b/packages/core/src/ksef/types.ts @@ -38,6 +38,7 @@ export interface KsefClient { subjectType?: KsefSubjectType pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> sendInvoice(params: { token: string diff --git a/packages/http/src/client.ts b/packages/http/src/client.ts index 757114f..ccc2e10 100644 --- a/packages/http/src/client.ts +++ b/packages/http/src/client.ts @@ -15,7 +15,7 @@ import { shouldRefresh, type ActiveSession, } from './session.js' -import { fetchInvoices as fetchInvoicesHttp } from './invoices.js' +import { fetchInvoices as fetchInvoicesHttp, fetchInvoiceXml as fetchInvoiceXmlHttp } from './invoices.js' import { fetchUpoXml } from './upo.js' import { KsefApiError } from './errors.js' import { fetchKsefTokenEncryptionKey } from './public-key.js' @@ -129,6 +129,7 @@ export class KsefHttpClient implements KsefClient { subjectType?: 'Subject1' | 'Subject2' | 'Subject3' pageSize?: number pageOffset?: number + includeXml?: boolean }): Promise<{ invoices: KsefRawInvoice[]; total: number }> { let session = decodeSessionToken(params.token) if (!session) { @@ -151,12 +152,31 @@ export class KsefHttpClient implements KsefClient { pageSize: params.pageSize, pageOffset: params.pageOffset, subjectType: params.subjectType ?? 'Subject2', + includeXml: params.includeXml, }), this.retryOpts, ) return result } + async fetchInvoiceXml(params: { token: string; ksefNumber: string }): Promise { + let session = decodeSessionToken(params.token) + if (!session) { + throw new Error('KsefHttpClient.fetchInvoiceXml: invalid session token') + } + if (shouldRefresh(session)) { + session = await withHttpRetry( + () => refreshAccessToken(this.http, session as ActiveSession), + this.retryOpts, + ) + } + return withHttpRetry( + () => + fetchInvoiceXmlHttp(this.http, (session as ActiveSession).accessToken, params.ksefNumber), + this.retryOpts, + ) + } + async sendInvoice(): Promise<{ ksefReferenceNumber: string; timestamp: string }> { throw new Error( 'KsefHttpClient.sendInvoice: not implemented in HTTP client MVP — see http_plan.md §H05.5', diff --git a/packages/mcp/src/tools/sync-invoices.ts b/packages/mcp/src/tools/sync-invoices.ts index 8f393c0..f529e6a 100644 --- a/packages/mcp/src/tools/sync-invoices.ts +++ b/packages/mcp/src/tools/sync-invoices.ts @@ -1,4 +1,6 @@ import type { Ksefnik } from '@ksefnik/core' +import { writeFile, mkdir } from 'node:fs/promises' +import { resolve, isAbsolute, join } from 'node:path' import { z } from 'zod' export const syncInvoicesSchema = z.object({ @@ -11,17 +13,48 @@ export const syncInvoicesSchema = z.object({ * Defaults to `cost` for backwards compatibility with the MVP. */ subject: z.enum(['sales', 'cost']).optional(), + /** + * When provided, downloads the full FA(2)/FA(3) XML for each invoice and + * saves it to `${saveDir}/${ksefReference}.xml`. Path must be absolute. + * Creates the directory if it doesn't exist. + */ + saveDir: z.string().optional(), }) export type SyncInvoicesInput = z.infer export async function syncInvoices(ksef: Ksefnik, input: SyncInvoicesInput) { const subjectType = input.subject === 'sales' ? 'Subject1' : 'Subject2' + const includeXml = Boolean(input.saveDir) + const invoices = await ksef.invoices.fetch({ from: input.dateFrom, to: input.dateTo, nip: input.nip, subjectType, + includeXml, }) - return { invoices, count: invoices.length, subject: input.subject ?? 'cost' } + + let savedFiles: string[] = [] + if (input.saveDir) { + const dir = isAbsolute(input.saveDir) ? input.saveDir : resolve(input.saveDir) + await mkdir(dir, { recursive: true }) + savedFiles = await Promise.all( + invoices + .filter((inv) => inv.ksefReference && inv.rawXml) + .map(async (inv) => { + const path = join(dir, `${inv.ksefReference}.xml`) + await writeFile(path, inv.rawXml ?? '', 'utf8') + return path + }), + ) + } + + return { + invoices, + count: invoices.length, + subject: input.subject ?? 'cost', + savedFiles: input.saveDir ? savedFiles : undefined, + savedCount: input.saveDir ? savedFiles.length : undefined, + } } diff --git a/packages/shared/src/types/ksef-adapter.ts b/packages/shared/src/types/ksef-adapter.ts index 4054d52..8b62868 100644 --- a/packages/shared/src/types/ksef-adapter.ts +++ b/packages/shared/src/types/ksef-adapter.ts @@ -14,6 +14,12 @@ export interface FetchInvoicesOpts { subjectType?: InvoiceSubjectType pageSize?: number pageOffset?: number + /** + * When true, also fetch the full FA(2)/FA(3) XML body for each invoice via + * `GET /invoices/ksef/{ksefNumber}` and put it on `Invoice.rawXml`. Off by + * default — metadata alone covers grossAmount/NIPs/dates. + */ + includeXml?: boolean } export interface SendInvoiceInput {