Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1a5fba9
feat(infakt): register Infakt plugin in API/worker + wire webhook ing…
norbert-kulus-blockydevs Jul 1, 2026
918228b
fix(infakt): address PR #1293 tech review suggestions
norbert-kulus-blockydevs Jul 1, 2026
a185d93
fix(infakt): correct field names, KSeF submission, and handshake stat…
norbert-kulus-blockydevs Jul 1, 2026
89f220c
fix(infakt): disambiguate verification handshake from signed events
norbert-kulus-blockydevs Jul 1, 2026
c0b0926
fix(infakt): resolve rebase conflict duplicating taxRateNumeric
norbert-kulus-blockydevs Jul 1, 2026
17e4279
fix(infakt): address PR #1293 tech-review findings on KSeF submission
norbert-kulus-blockydevs Jul 1, 2026
4017f18
fix(infakt): register ConnectionTesterPort for infakt.accounting.v1
norbert-kulus-blockydevs Jul 1, 2026
651cac2
fix(infakt): send invoice amounts as plain-integer groszy, not decima…
norbert-kulus-blockydevs Jul 1, 2026
0bca26e
fix(infakt): polish remaining review suggestions on PR #1293
norbert-kulus-blockydevs Jul 1, 2026
c003797
fix(infakt): map KSeF success status to accepted, not cleared
norbert-kulus-blockydevs Jul 1, 2026
271b0cb
Merge branch 'main' into 1281-infakt-plugin-registration-webhook
norbert-kulus-blockydevs Jul 2, 2026
31f0854
Merge branch 'main' into 1281-infakt-plugin-registration-webhook
norbert-kulus-blockydevs Jul 2, 2026
d88f99a
docs(plans): implementation plan for inFakt FE plugin + invoice-secti…
norbert-kulus-blockydevs Jul 1, 2026
3b87a21
docs(plans): add E2E Playwright evidence phase to inFakt FE plan
norbert-kulus-blockydevs Jul 1, 2026
33b5cfa
feat(web): inFakt FE plugin — connection setup + invoice detail + KOR…
norbert-kulus-blockydevs Jul 1, 2026
04db003
test(web): add inFakt E2E screenshot-evidence scripts
norbert-kulus-blockydevs Jul 1, 2026
a0c6008
docs(infakt): add first 4 manual inFakt-dashboard walkthrough screens…
norbert-kulus-blockydevs Jul 1, 2026
0531e9f
docs(infakt): add real inFakt E2E screenshots (connection, issuance, …
norbert-kulus-blockydevs Jul 1, 2026
1289e1b
docs(infakt): drop broken invoice-detail-page screenshot (route missi…
norbert-kulus-blockydevs Jul 1, 2026
6a4a8b1
docs(infakt): re-capture E2E screenshots with the money-format fix ve…
norbert-kulus-blockydevs Jul 1, 2026
36c474a
docs(infakt): move E2E screenshots to libs/integrations/infakt/docs/a…
norbert-kulus-blockydevs Jul 1, 2026
205d721
docs(infakt): add clean not-issued/submitted screenshots + fresh Part…
norbert-kulus-blockydevs Jul 1, 2026
3006134
fix(web): address inFakt FE plugin tech-review findings
norbert-kulus-blockydevs Jul 1, 2026
22bd27b
docs(infakt): re-capture screenshots with the cleared->accepted fix v…
norbert-kulus-blockydevs Jul 1, 2026
fa2f194
feat(web): Infakt default-payment-method picker (wizard + edit disclo…
norbert-kulus-blockydevs Jul 2, 2026
d24ff95
docs(infakt): re-verify screenshots against current code post tech-re…
norbert-kulus-blockydevs Jul 2, 2026
927817f
docs(infakt): re-capture connection wizard screenshots with payment-m…
norbert-kulus-blockydevs Jul 2, 2026
d441dbe
fix(web): address inFakt FE plugin PR #1300 tech-review findings
norbert-kulus-blockydevs Jul 2, 2026
323f6ad
Merge branch 'main' into 1282-infakt-fe-plugin
norbert-kulus-blockydevs Jul 2, 2026
adb755e
Merge branch 'main' into 1282-infakt-fe-plugin
norbert-kulus-blockydevs Jul 2, 2026
356b293
fix(infakt): implement real PDF download via RegulatoryDocumentReader…
norbert-kulus-blockydevs Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions apps/web/e2e/infakt-connection.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* inFakt setup-guide screenshot capture — connection walkthrough.
*
* Drives the running web app (:4173): opens /connections/new, selects the
* inFakt card, fills out the guided setup form with the real sandbox API
* key, submits, and runs "Test connection". Captures libs/integrations/infakt/docs/assets/.
*
* Presentation requirement (these screenshots are reused verbatim in the
* operator-facing setup guide): the API-key input stays `type="password"`
* throughout — this script never switches it to reveal the raw key, and
* never logs the key to stdout. Use a realistic connection name, not
* placeholder junk.
*
* Usage: node apps/web/e2e/infakt-connection.mjs
* Env:
* WEB_BASE web preview base (default http://localhost:4173)
* INFAKT_SANDBOX_API_KEY required — real inFakt sandbox API key
* INFAKT_CONN_NAME connection name (default "inFakt Sandbox")
*/
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SHOTS = resolve(__dirname, '../../../libs/integrations/infakt/docs/assets');
const BASE = process.env.WEB_BASE ?? 'http://localhost:4173';
const API_KEY = process.env.INFAKT_SANDBOX_API_KEY ?? '';
const CONN_NAME = process.env.INFAKT_CONN_NAME ?? 'inFakt Sandbox';
// inFakt's adapter defaults to the production base URL — sandbox key holders
// must override it, or every call (including the connection-test probe) 401s.
const INFAKT_BASE_URL = process.env.INFAKT_BASE_URL ?? 'https://api.sandbox-infakt.pl/api/v3';

if (!API_KEY) {
console.error('INFAKT_CONNECTION_ERROR: INFAKT_SANDBOX_API_KEY is required (not committed, not logged).');
process.exit(1);
}

async function forceLightTheme(page) {
// These screenshots are reused in the tutorial — always capture OpenLinker's
// light theme regardless of the OS/browser color-scheme preference.
await page.addInitScript(() => {
window.localStorage.setItem('openlinker.theme', 'light');
});
}

async function login(page) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.getByPlaceholder('Enter your username').fill('operator');
await page.getByPlaceholder('Enter your password').fill('infakt-e2e-pw');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 });
}

async function shot(page, name) {
await page.waitForTimeout(700);
await page.screenshot({ path: resolve(SHOTS, `${name}.png`), fullPage: true });
console.log('captured', name);
}

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await forceLightTheme(page);
await login(page);

// 1. Platform picker — inFakt card visible.
await page.goto(`${BASE}/connections/new`, { waitUntil: 'networkidle' });
await shot(page, '00-platform-picker');

// 2. Guided setup form (empty).
const infaktCard = page.locator('.platform-picker__card', { hasText: 'inFakt' });
await infaktCard.click();
await page.waitForURL(/\/connections\/new\/infakt/, { timeout: 10000 });
await shot(page, '01-infakt-wizard-empty');

// 3. Filled form — realistic name, real sandbox key (masked on screen).
await page.getByLabel('Connection name').fill(CONN_NAME);
await page.getByLabel('API key').fill(API_KEY);
await page.getByLabel(/Base URL/i).fill(INFAKT_BASE_URL);
await shot(page, '02-infakt-wizard-filled');

// 4. Submit — connection created.
await page.getByRole('button', { name: 'Connect inFakt' }).click();
await page.waitForTimeout(1500);
await shot(page, '03-infakt-connection-created');

// 5. Test connection.
const testBtn = page.getByRole('button', { name: 'Test connection' });
if (await testBtn.count()) {
await testBtn.click();
await page.waitForTimeout(3000);
await shot(page, '04-infakt-connection-test-ok');
}

// 6. Connections list — the new inFakt connection visible.
await page.goto(`${BASE}/connections`, { waitUntil: 'networkidle' });
await shot(page, '05-connections-list-with-infakt');

console.log('DONE');
} catch (err) {
console.error('INFAKT_CONNECTION_ERROR', err.message);
await page.screenshot({ path: resolve(SHOTS, 'error-connection.png'), fullPage: true }).catch(() => {});
process.exitCode = 1;
} finally {
await browser.close();
}
176 changes: 176 additions & 0 deletions apps/web/e2e/infakt-invoice.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* inFakt setup-guide screenshot capture — issue an invoice through inFakt.
*
* Drives the running web app (:4173): opens a real ingested order, issues an
* invoice through the order-detail Invoice panel, waits for the real
* OL -> InfaktInvoicingAdapter -> inFakt sandbox -> KSeF round-trip, then
* captures the submitted (pending) and accepted (cleared) reg-card states,
* and drives one KOR correction. Captures into libs/integrations/infakt/docs/assets/.
*
* The document is created end-to-end against the real inFakt sandbox — this
* script only drives + screenshots the OL browser side.
*
* Presentation requirement: use a real seeded order, let every toast/alert
* settle before each shot, and re-take any frame that shows a dev artifact
* (raw JSON, console error toast, stray localhost URL) rather than shipping
* it — these screenshots are reused verbatim in the operator setup guide.
*
* Usage: node apps/web/e2e/infakt-invoice.mjs
* Env:
* WEB_BASE web preview base (default http://localhost:4173)
* ORDER_ID OL order id to issue against — required (no order picked automatically,
* to avoid accidentally issuing against the wrong seeded fixture)
* INFAKT_CONN_NAME connection label to pick when >1 invoicing connection
* CLEARANCE_POLL_MS max time to poll for KSeF acceptance (default 120000 — sandbox clears
* in ~90s per the feasibility POC)
*/
import { chromium } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SHOTS = resolve(__dirname, '../../../libs/integrations/infakt/docs/assets');
const BASE = process.env.WEB_BASE ?? 'http://localhost:4173';
const ORDER_ID = process.env.ORDER_ID ?? '';
const CONN_NAME = process.env.INFAKT_CONN_NAME ?? 'inFakt';
const CONNECTION_ID = process.env.INFAKT_CONNECTION_ID ?? '';
const CLEARANCE_POLL_MS = Number(process.env.CLEARANCE_POLL_MS ?? 120000);

if (!ORDER_ID) {
console.error('INFAKT_INVOICE_ERROR: ORDER_ID is required — pick a real seeded order id.');
process.exit(1);
}

async function forceLightTheme(page) {
// These screenshots are reused in the tutorial — always capture OpenLinker's
// light theme regardless of the OS/browser color-scheme preference.
await page.addInitScript(() => {
window.localStorage.setItem('openlinker.theme', 'light');
});
}

async function login(page) {
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
await page.getByPlaceholder('Enter your username').fill('operator');
await page.getByPlaceholder('Enter your password').fill('infakt-e2e-pw');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL((u) => !u.pathname.startsWith('/login'), { timeout: 15000 });
}

async function shot(page, name) {
await page.waitForTimeout(700);
await page.screenshot({ path: resolve(SHOTS, `${name}.png`), fullPage: true });
console.log('captured', name);
}

async function pickConnectionIfNeeded(page) {
const picker = page.getByLabel(/Invoicing connection/i).first();
if (await picker.count()) {
// Select by connection id (option value) — the fragile label-text/regex
// match previously fell through to selectOption({index:1}), silently
// picking the WRONG provider (Subiekt) instead of inFakt.
if (CONNECTION_ID) {
await picker.selectOption({ value: CONNECTION_ID });
} else {
await picker.selectOption({ label: CONN_NAME }).catch(() => picker.selectOption({ index: 1 }));
}
await page.waitForTimeout(800);
}
}

async function waitForAccepted(page, deadlineMs) {
const start = Date.now();
while (Date.now() - start < deadlineMs) {
// A full reload drops the client-side connection-picker selection —
// re-pick it every time or the panel falls back to "Select a
// connection…" and the reg-card never renders at all (looked like a
// false "rejected" before this fix).
await page.reload({ waitUntil: 'networkidle' });
await pickConnectionIfNeeded(page);
const accepted = await page.locator('.reg-card--success').count();
if (accepted > 0) return true;
const rejected = await page.locator('.reg-card--error').count();
if (rejected > 0) return false;
await page.waitForTimeout(5000);
}
return false;
}

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
try {
await forceLightTheme(page);
await login(page);

// 1. Order detail — not-issued state.
await page.goto(`${BASE}/orders/${ORDER_ID}`, { waitUntil: 'networkidle' });
await pickConnectionIfNeeded(page);
await shot(page, '10-order-detail-not-issued');

// 2. Issue the invoice. Shoot BEFORE any reload — the mutation's own
// query-invalidation updates the panel in place, and a reload here would
// just drop the connection-picker selection for no benefit.
const issueBtn = page.getByRole('button', { name: /Issue invoice/i }).first();
if (await issueBtn.count()) {
await issueBtn.click();
await page.waitForTimeout(3000);
}
await shot(page, '11-invoice-issued-submitted');
const pendingCard = await page.locator('.reg-card--info').count();
console.log('pending reg-card visible:', pendingCard > 0);

// 3. Poll until KSeF clears (accepted) or rejects.
const accepted = await waitForAccepted(page, CLEARANCE_POLL_MS);
await shot(page, accepted ? '12-invoice-accepted-cleared' : '12-invoice-rejected');
if (!accepted) {
console.log('KSeF did not reach accepted within the poll window — skipping correction step.');
}

// 4. Issue a KOR correction (only if accepted) — still on the order page,
// where the "Issue correction" button actually lives (no PDF link exists
// for inFakt today, so there is no separate "view invoice" affordance to
// detour through first).
if (accepted) {
const correctionBtn = page.getByRole('button', { name: /Issue correction/i }).first();
if (await correctionBtn.count()) {
await correctionBtn.click();
await page.waitForTimeout(500);
await shot(page, '14-correction-modal-empty');

await page.getByLabel(/Line number 1/i).fill('1');
await page.getByLabel(/New qty, line 1/i).fill('1');
await page.getByLabel(/New price, line 1/i).fill('99.99');
await shot(page, '15-correction-modal-filled');

await page.getByRole('button', { name: /Issue KOR/i }).click();
await page.waitForTimeout(4000);
await shot(page, '16-correction-issued');
}
}

// 5. Invoices list — both the original and (if issued) the correction visible.
// Filtered to our connection so the shared dev DB's unrelated rows (other
// providers' test fixtures) don't clutter a screenshot meant for the tutorial.
const invoicesListUrl = CONNECTION_ID
? `${BASE}/invoices?connectionId=${CONNECTION_ID}`
: `${BASE}/invoices`;
await page.goto(invoicesListUrl, { waitUntil: 'networkidle' });
await shot(page, '17-invoices-list');

// 6. Full invoice detail page (screen 06 parity) — reached by clicking the
// row for our order, the only navigation path (no PDF link on the panel).
const invoiceRow = page.getByText(ORDER_ID.slice(0, 24)).first();
if (await invoiceRow.count()) {
await invoiceRow.click();
await page.waitForTimeout(1200);
await shot(page, '13-invoice-detail-page');
}

console.log('DONE');
} catch (err) {
console.error('INFAKT_INVOICE_ERROR', err.message);
await page.screenshot({ path: resolve(SHOTS, 'error-invoice.png'), fullPage: true }).catch(() => {});
process.exitCode = 1;
} finally {
await browser.close();
}
9 changes: 5 additions & 4 deletions apps/web/src/app/routes/route-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,21 @@ const lazyRoutes = collectLazyRoutes([
* route reverted to eager `element:` form, which is exactly the regression
* the parameterized test below is meant to catch.
*
* Today's breakdown (46 total):
* Today's breakdown (49 total):
* - 36 authenticated children (under `coreChildren`, counting per-children-node
* because grouped routes like orders/customers/inventory expose multiple
* lazy nodes — includes `/dev/ui` design-system page (#775), `/shipments` (#770),
* `/users` user-management page (#1125), and `/invoices/:invoiceId` detail (#1240))
* - 3 guest routes (forgot-password, reset-password, register — login stays eager)
* - 9 plugin routes (allegro callback + setup, prestashop setup, dpd setup,
* woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup)
* - 10 plugin routes (allegro callback + setup, prestashop setup, dpd setup,
* woocommerce setup, erli setup, subiekt setup (#1199), ksef setup, inpost setup,
* infakt setup (#1282))
*
* Routes that are intentionally eager (no page module to defer):
* - login (first-paint optimization — see `login.route.tsx`)
* - prompt-templates-legacy-redirects (inline `<Navigate>` element)
*/
const EXPECTED_LAZY_ROUTE_COUNT = 48;
const EXPECTED_LAZY_ROUTE_COUNT = 49;

describe('route lazy contract', () => {
it(`the registered route tree contains exactly ${EXPECTED_LAZY_ROUTE_COUNT} lazy routes`, () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ type StructuredField =
| 'sellerCountryIso2'
| 'contextIdentifier'
| 'inpostEnvironment'
| 'inpostOrganizationId';
| 'inpostOrganizationId'
| 'infaktPaymentMethod';

function readString(config: Record<string, unknown>, key: string): string {
const value = config[key];
Expand Down Expand Up @@ -104,6 +105,16 @@ function readInpostEnvironment(config: Record<string, unknown>): '' | 'sandbox'
return value === 'sandbox' || value === 'production' ? value : '';
}

/**
* Read the Infakt default payment method out of `config.defaultPaymentMethod`
* (#1303). Absent keys hydrate to `'cash'` — the adapter's own fallback — so
* the select (and the disclosure summary reading it) always reflects what
* will actually be sent, never a blank/unset state.
*/
function readInfaktPaymentMethod(config: Record<string, unknown>): 'cash' | 'transfer' {
return config.defaultPaymentMethod === 'transfer' ? 'transfer' : 'cash';
}

/**
* Read the InPost sender address out of `config.senderAddress` (#771). Returns
* a fully-populated form shape — empty-string fields where the operator hasn't
Expand Down Expand Up @@ -333,6 +344,8 @@ export function EditConnectionForm({ connection }: EditConnectionFormProps): Rea
inpostEnvironment: readInpostEnvironment(connection.config),
inpostOrganizationId: readString(connection.config, 'organizationId'),
inpostSenderAddress: readInpostSenderAddress(connection.config),
// Infakt default payment method (#1303) — `config.defaultPaymentMethod`.
infaktPaymentMethod: readInfaktPaymentMethod(connection.config),
},
resolver: zodResolver(editConnectionSchema),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,9 @@ export const editConnectionSchema = z
// flat `config.sellerNip`). All optional client-side so the operator can save
// incremental progress; the server is the strict gate.
ksefEnvironment: z.union([z.enum(['test', 'demo', 'prod']), z.literal('')]).optional(),
// Infakt-only structured field surfacing `config.defaultPaymentMethod`
// (#1303). Empty allowed for unset — the adapter falls back to `'cash'`.
infaktPaymentMethod: z.union([z.enum(['cash', 'transfer']), z.literal('')]).optional(),
// NIP normalization mirrors the setup wizard (`ksef-setup.schema.ts`): strip
// dashes/spaces the operator may paste, then enforce 10 digits. Without this
// parity a value saved with separators on create fails the edit-schema check.
Expand Down Expand Up @@ -364,6 +367,11 @@ export interface StructuredConfigPatch {
subiektCapabilities?: Record<string, boolean>;
/** KSeF environment — `config.env` (#1152). Empty string clears the key. */
ksefEnvironment?: string;
/**
* Infakt default payment method — `config.defaultPaymentMethod` (#1303).
* Empty string clears the key (adapter falls back to `'cash'`).
*/
infaktPaymentMethod?: string;
/**
* KSeF seller-profile sub-fields (#1223). Each maps onto a leaf of the nested
* `config.seller.{nip,name,address}` shape the adapter's `resolveSeller`
Expand Down Expand Up @@ -589,6 +597,16 @@ export function mergeStructuredIntoConfig(
}
}
}
// Infakt default payment method (#1303) — `config.defaultPaymentMethod`.
// The form field is named `infaktPaymentMethod` to avoid colliding with a
// future generic `paymentMethod` field on another platform.
if (structured.infaktPaymentMethod !== undefined) {
if (structured.infaktPaymentMethod.length === 0) {
delete next.defaultPaymentMethod;
} else {
next.defaultPaymentMethod = structured.infaktPaymentMethod;
}
}
return next;
}

Expand Down
Loading
Loading