From f27fdcd1060533bcd87aef41561961e09cbe8aa1 Mon Sep 17 00:00:00 2001 From: Luke Cichocki <999622+luke-cf@users.noreply.github.com> Date: Wed, 27 May 2026 11:07:36 +0200 Subject: [PATCH 1/3] ci: migrate docs build/deploy to cfnode1 + vps-codeformers runner Build & push (docs.yml, prod-release.yml) now run on self-hosted [self-hosted, codeformers] (cfnode1) with GHCR auth via GITHUB_TOKEN instead of a long-lived PAT mounted on the host - no more ~/.env.ksefnik dependency, no more macmini-specific paths. Deploy step moved from `ssh codeformers "..."` to a separate job pinned to [self-hosted, vps-codeformers] which runs the deploy.sh on the docs VPS directly. That removes the SSH key on the build host and lets us add more VPSes later by just registering more org runners with the codeformers-vps / vps-shared / vps- label triple. VPS-side .env (REPO_TOKEN_GITHUB_PAT) is untouched - kept for `docker pull` after the job ends (consistent with OxStudio's vps-prod convention). --- .github/workflows/docs.yml | 78 ++++++++++++++++++-- .github/workflows/prod-release.yml | 114 +++++++++++++++++++++-------- 2 files changed, 157 insertions(+), 35 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 002ba25..f11fcde 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,19 +14,87 @@ on: type: boolean default: true +permissions: + contents: read + +concurrency: + group: ksefnik-docs + cancel-in-progress: false + +env: + IMAGE: ghcr.io/codeformers-it/ksefnik-docs + PLATFORM: linux/amd64 + jobs: build: + name: Build & push image runs-on: [self-hosted, codeformers] + timeout-minutes: 20 + permissions: + contents: read + packages: write + outputs: + sha: ${{ steps.meta.outputs.sha }} + steps: - uses: actions/checkout@v4 - - name: Build & push Docker image - run: ./scripts/build-and-push.sh + - id: meta + run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + run: echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Ensure buildx builder exists + run: | + docker buildx inspect ksefnik-builder >/dev/null 2>&1 \ + || docker buildx create --name ksefnik-builder --driver docker-container --bootstrap + + - name: Build & push + env: + PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} + run: | + BUILD_ARGS="" + if [ -n "$PRO_REPO_TOKEN" ]; then + BUILD_ARGS="--build-arg PRO_REPO_TOKEN=${PRO_REPO_TOKEN}" + fi + docker buildx build \ + --builder ksefnik-builder \ + --platform "${PLATFORM}" \ + --push \ + -f docker/docs.Dockerfile \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:${{ steps.meta.outputs.sha }}" \ + $BUILD_ARGS \ + . + + - name: Logout from GHCR + if: always() + run: docker logout ghcr.io || true deploy: + name: Deploy docs on VPS needs: build if: github.event_name == 'workflow_dispatch' && inputs.deploy - runs-on: [self-hosted, codeformers] + runs-on: [self-hosted, vps-codeformers] + timeout-minutes: 5 + steps: - - name: Deploy to VPS - run: ssh codeformers "cd /home/ubuntu/ksefnik.pl && ./scripts/deploy.sh" + - uses: actions/checkout@v4 + + - name: Sync compose + deploy script into deploy dir + run: | + set -e + DEPLOY_DIR=/home/ubuntu/ksefnik.pl + install -m 644 docker-compose.prod.yml "${DEPLOY_DIR}/docker-compose.prod.yml" + install -m 755 scripts/deploy.sh "${DEPLOY_DIR}/scripts/deploy.sh" + + - name: Run deploy.sh + run: /home/ubuntu/ksefnik.pl/scripts/deploy.sh + + - name: Smoke test + run: | + set -e + status=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1:7020/) + echo "http://127.0.0.1:7020/ -> HTTP ${status}" + [ "${status}" = "200" ] || exit 1 diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 71157fd..06b5f25 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -2,7 +2,8 @@ name: Release to npm (prod) # Merging into prod bumps the minor version of all @ksefnik/* packages, # builds everything, publishes to npm with provenance, tags the release, -# and opens a sync PR back to main. +# builds the docs Docker image, deploys it to the VPS, and opens a sync PR +# back to main. # # The merge to prod IS the "I want to release" decision. # Only @luke-cf can approve and merge PRs to prod. @@ -12,14 +13,29 @@ on: branches: [prod] permissions: - contents: write - id-token: write - pull-requests: write + contents: read + +concurrency: + group: ksefnik-release + cancel-in-progress: false + +env: + IMAGE: ghcr.io/codeformers-it/ksefnik-docs + PLATFORM: linux/amd64 jobs: release: if: github.actor != 'github-actions[bot]' runs-on: [self-hosted, codeformers] + timeout-minutes: 30 + permissions: + contents: write + id-token: write + pull-requests: write + packages: write + outputs: + version: ${{ steps.tag.outputs.version }} + sha: ${{ steps.tag.outputs.sha }} steps: - uses: actions/checkout@v4 with: @@ -69,6 +85,7 @@ jobs: git tag -f "v$VERSION" git push origin prod git push origin "v$VERSION" --force + echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -77,47 +94,84 @@ jobs: name: v${{ steps.tag.outputs.version }} generate_release_notes: true - - name: Build docs Docker image (tagged with npm version) + - name: Log in to GHCR + run: echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin + + - name: Ensure buildx builder exists run: | - VERSION="${{ steps.tag.outputs.version }}" - GIT_SHA=$(git rev-parse --short HEAD) - - DOCKER_CONFIG_DIR="$(mktemp -d)" - trap 'rm -rf "$DOCKER_CONFIG_DIR"' EXIT - AUTH_TOKEN=$(printf 'codeformers-it:%s' "${{ secrets.REPO_TOKEN_GITHUB_PAT }}" | base64) - printf '{"auths":{"ghcr.io":{"auth":"%s"}},"credsStore":""}' "$AUTH_TOKEN" > "$DOCKER_CONFIG_DIR/config.json" - if [ -d "$HOME/.docker/cli-plugins" ]; then - ln -sf "$HOME/.docker/cli-plugins" "$DOCKER_CONFIG_DIR/cli-plugins" - fi - export DOCKER_CONFIG="$DOCKER_CONFIG_DIR" + docker buildx inspect ksefnik-builder >/dev/null 2>&1 \ + || docker buildx create --name ksefnik-builder --driver docker-container --bootstrap + - name: Build & push docs Docker image + env: + PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} + run: | BUILD_ARGS="" - if [ -n "${PRO_REPO_TOKEN:-}" ]; then + if [ -n "$PRO_REPO_TOKEN" ]; then BUILD_ARGS="--build-arg PRO_REPO_TOKEN=${PRO_REPO_TOKEN}" fi - docker buildx build \ - --platform linux/amd64 \ + --builder ksefnik-builder \ + --platform "${PLATFORM}" \ --push \ -f docker/docs.Dockerfile \ - -t "ghcr.io/codeformers-it/ksefnik-docs:latest" \ - -t "ghcr.io/codeformers-it/ksefnik-docs:v${VERSION}" \ - -t "ghcr.io/codeformers-it/ksefnik-docs:${GIT_SHA}" \ + -t "${IMAGE}:latest" \ + -t "${IMAGE}:v${{ steps.tag.outputs.version }}" \ + -t "${IMAGE}:${{ steps.tag.outputs.sha }}" \ $BUILD_ARGS \ . - env: - PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} - - name: Deploy docs to VPS - run: ssh codeformers "cd /home/ubuntu/ksefnik.pl && ./scripts/deploy.sh" + - name: Logout from GHCR + if: always() + run: docker logout ghcr.io || true + + deploy: + name: Deploy docs on VPS + needs: release + runs-on: [self-hosted, vps-codeformers] + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: prod + + - name: Sync compose + deploy script into deploy dir + run: | + set -e + DEPLOY_DIR=/home/ubuntu/ksefnik.pl + install -m 644 docker-compose.prod.yml "${DEPLOY_DIR}/docker-compose.prod.yml" + install -m 755 scripts/deploy.sh "${DEPLOY_DIR}/scripts/deploy.sh" + + - name: Run deploy.sh + run: /home/ubuntu/ksefnik.pl/scripts/deploy.sh + + - name: Smoke test + run: | + set -e + status=$(curl -sS -o /dev/null -w '%{http_code}' -m 10 http://127.0.0.1:7020/) + echo "http://127.0.0.1:7020/ -> HTTP ${status}" + [ "${status}" = "200" ] || exit 1 + + sync-back: + name: Open sync PR prod → main + needs: release + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Open sync PR prod → main + - name: Create sync PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.release.outputs.version }} run: | gh pr create \ --base main \ --head prod \ - --title "chore: sync versions from prod v${{ steps.tag.outputs.version }}" \ - --body "Automated version sync after npm release of v${{ steps.tag.outputs.version }}. Merge to bring main in line with published versions." \ - || echo "sync PR already exists or no diff — skipping" + --title "chore: sync versions from prod v${VERSION}" \ + --body "Automated version sync after npm release of v${VERSION}. Merge to bring main in line with published versions." \ + || echo "sync PR already exists or no diff - skipping" From 8aa94b607e94eae448f6e9b75013ac107f946b34 Mon Sep 17 00:00:00 2001 From: Luke Cichocki <999622+luke-cf@users.noreply.github.com> Date: Wed, 27 May 2026 11:25:49 +0200 Subject: [PATCH 2/3] ci(release): split prod release into 4 atomic jobs + dry-run dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous pipeline crammed everything (install, build, test, bump, npm publish, git tag, GH release, docker build, GHCR push, VPS deploy) into one job. If any step past 'npm publish' failed, the release was left in an inconsistent state (packages on npm, no tag in git, no docker image, no VPS deploy) with no clean way to retry just the failed stage. New shape: verify - install/build/typecheck/test on the current SHA, uploads dist. Pure validation, mutates nothing, safe to rerun. publish-npm - downloads dist, bumps versions, publishes to npm, commits + tags + GH release. After this job the new vX.Y.Z tag exists on origin/prod. build-docs - checks out the v${VERSION} tag, builds the docs image, pushes to GHCR. Re-running this job is idempotent (same tag → same image content), so a flaky GHCR push can be retried alone. deploy - pulls the new image on the VPS, recreates the container, smoke-tests. Re-running just pulls latest again. sync-back - opens a PR prod → main so the bump lands back in main. Added workflow_dispatch with dry_run=true (default). Lets us validate the whole verify path without publishing or tagging - useful for testing pipeline changes without burning a real release version. --- .github/workflows/prod-release.yml | 114 ++++++++++++++++++++++++----- 1 file changed, 95 insertions(+), 19 deletions(-) diff --git a/.github/workflows/prod-release.yml b/.github/workflows/prod-release.yml index 06b5f25..b0e3eae 100644 --- a/.github/workflows/prod-release.yml +++ b/.github/workflows/prod-release.yml @@ -7,10 +7,27 @@ name: Release to npm (prod) # # The merge to prod IS the "I want to release" decision. # Only @luke-cf can approve and merge PRs to prod. +# +# Pipeline shape (4 atomic jobs + sync-back): +# verify - install/build/typecheck/test on the current SHA; uploads dist artifacts. +# Safe to re-run - mutates nothing. +# publish-npm - downloads dist, bumps versions, publishes to npm, commits + tags + GH release. +# After this job the new vX.Y.Z tag exists on origin/prod. +# build-docs - checks out the v${VERSION} tag, builds the docs Docker image, pushes to GHCR. +# Re-runnable: same tag → same image content; tag overwrite is safe. +# deploy - pulls the new image on the VPS, recreates the container, smoke-tests. +# Re-runnable: just pulls latest again. +# sync-back - opens a PR prod → main so version bumps land back in main. on: push: branches: [prod] + workflow_dispatch: + inputs: + dry_run: + description: 'Verify only (no npm publish / git tag / docker push / deploy)' + type: boolean + default: true permissions: contents: read @@ -24,22 +41,14 @@ env: PLATFORM: linux/amd64 jobs: - release: + verify: + name: Verify (install / build / typecheck / test) if: github.actor != 'github-actions[bot]' runs-on: [self-hosted, codeformers] - timeout-minutes: 30 - permissions: - contents: write - id-token: write - pull-requests: write - packages: write - outputs: - version: ${{ steps.tag.outputs.version }} - sha: ${{ steps.tag.outputs.sha }} + timeout-minutes: 20 steps: - uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - uses: pnpm/action-setup@v4 @@ -48,7 +57,6 @@ jobs: with: node-version: 22 cache: pnpm - registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile @@ -63,10 +71,61 @@ jobs: env: CI: 'true' + - name: Upload dist artifacts + # publish-npm consumes these instead of rebuilding from scratch. + uses: actions/upload-artifact@v4 + with: + name: packages-dist + path: packages/*/dist + if-no-files-found: error + retention-days: 1 + + publish-npm: + name: Publish to npm + tag release + needs: verify + # Skip when dispatched manually with dry_run=true. push:prod events have no + # 'inputs' object, so the check evaluates to false and the job runs as normal. + if: github.event_name != 'workflow_dispatch' || inputs.dry_run == false + runs-on: [self-hosted, codeformers] + timeout-minutes: 15 + permissions: + contents: write + id-token: write + outputs: + version: ${{ steps.tag.outputs.version }} + sha: ${{ steps.tag.outputs.sha }} + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + registry-url: https://registry.npmjs.org + + # We need node_modules so pnpm publish can resolve workspace:* refs into + # concrete versions when it rewrites package.json files on publish. + - run: pnpm install --frozen-lockfile + + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: packages-dist + path: packages + - name: Bump minor versions in all packages run: node scripts/bump-version.mjs minor - name: Publish to npm + # --no-git-checks lets pnpm publish from a dirty tree (we just bumped versions). + # If a partial publish fails mid-way (some packages on npm, some not), the next + # rerun will skip already-published versions thanks to npm's own dedup, but the + # version bump commit will not be in git yet - rerun is safe. run: pnpm -r publish --access public --provenance --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -94,6 +153,23 @@ jobs: name: v${{ steps.tag.outputs.version }} generate_release_notes: true + build-docs: + name: Build & push docs Docker image + needs: publish-npm + runs-on: [self-hosted, codeformers] + timeout-minutes: 20 + permissions: + contents: read + packages: write + steps: + - name: Checkout published tag + # Building from the exact tag publish-npm just pushed guarantees the docs image + # version matches the npm release. If this job is rerun, the same tag is checked + # out and the same image content is produced. + uses: actions/checkout@v4 + with: + ref: v${{ needs.publish-npm.outputs.version }} + - name: Log in to GHCR run: echo '${{ secrets.GITHUB_TOKEN }}' | docker login ghcr.io -u '${{ github.actor }}' --password-stdin @@ -102,7 +178,7 @@ jobs: docker buildx inspect ksefnik-builder >/dev/null 2>&1 \ || docker buildx create --name ksefnik-builder --driver docker-container --bootstrap - - name: Build & push docs Docker image + - name: Build & push env: PRO_REPO_TOKEN: ${{ secrets.PRO_REPO_TOKEN }} run: | @@ -116,8 +192,8 @@ jobs: --push \ -f docker/docs.Dockerfile \ -t "${IMAGE}:latest" \ - -t "${IMAGE}:v${{ steps.tag.outputs.version }}" \ - -t "${IMAGE}:${{ steps.tag.outputs.sha }}" \ + -t "${IMAGE}:v${{ needs.publish-npm.outputs.version }}" \ + -t "${IMAGE}:${{ needs.publish-npm.outputs.sha }}" \ $BUILD_ARGS \ . @@ -127,13 +203,13 @@ jobs: deploy: name: Deploy docs on VPS - needs: release + needs: [publish-npm, build-docs] runs-on: [self-hosted, vps-codeformers] timeout-minutes: 5 steps: - uses: actions/checkout@v4 with: - ref: prod + ref: v${{ needs.publish-npm.outputs.version }} - name: Sync compose + deploy script into deploy dir run: | @@ -154,7 +230,7 @@ jobs: sync-back: name: Open sync PR prod → main - needs: release + needs: publish-npm runs-on: ubuntu-latest permissions: pull-requests: write @@ -167,7 +243,7 @@ jobs: - name: Create sync PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - VERSION: ${{ needs.release.outputs.version }} + VERSION: ${{ needs.publish-npm.outputs.version }} run: | gh pr create \ --base main \ From f592355f2ffdd3fac8a8b848d4aa550854da4727 Mon Sep 17 00:00:00 2001 From: Luke Cichocki <999622+luke-cf@users.noreply.github.com> Date: Wed, 27 May 2026 11:33:57 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(mcp):=20saveDir=20option=20for=20sync?= =?UTF-8?q?=5Finvoices=20tool=20=E2=80=94=20writes=20FA=20XML=20to=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in `saveDir` parameter to the sync_invoices MCP tool. When set, the tool fetches the full FA(2)/FA(3) XML for each invoice (via the existing fetchInvoiceXml HTTP path) and writes one file per invoice to ${saveDir}/${ksefReferenceNumber}.xml. mkdir -p semantics, absolute or relative paths accepted (relative resolved against cwd of the MCP host). Plumbing for the new option: shared FetchInvoicesOpts.includeXml: documents the flag + that rawXml lands on Invoice.rawXml when set (was already handled by adapter). core KsefClient.fetchInvoices gains optional includeXml. KsefAdapter forwards it to the underlying client. http KsefHttpClient.fetchInvoices forwards includeXml. Adds fetchInvoiceXml(token, ksefNumber) public method backed by the existing http/invoices.ts helper so non-MCP consumers can also grab single XMLs. Default behavior unchanged — fetchInvoices without includeXml still returns metadata only. --- packages/core/src/ksef/ksef.adapter.ts | 1 + packages/core/src/ksef/types.ts | 1 + packages/http/src/client.ts | 22 +++++++++++++- packages/mcp/src/tools/sync-invoices.ts | 35 ++++++++++++++++++++++- packages/shared/src/types/ksef-adapter.ts | 6 ++++ 5 files changed, 63 insertions(+), 2 deletions(-) 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 {