rossaddison / invoice
Builds the invoice folder for rossaddison/yii3-i
Requires
- php: 8.4 - 9.0
- ext-apcu: *
- ext-ctype: *
- ext-curl: *
- ext-dom: *
- ext-fileinfo: *
- ext-filter: *
- ext-gd: *
- ext-intl: *
- ext-json: *
- ext-libxml: *
- ext-mbstring: *
- ext-openssl: *
- ext-pdo: *
- ext-pdo_sqlite: *
- ext-simplexml: *
- ext-sodium: *
- ext-zend-opcache: *
- ext-zlib: *
- adyen/php-api-library: ^30.0.1
- amzn/amazon-pay-api-sdk-php: ^2.7.2
- braintree/braintree_php: >=6.37.0
- brick/math: >=0.18.0
- brick/money: >=0.14.1
- cebe/markdown: ^1.2.1@dev
- checkout/checkout-sdk-php: ^5.3
- chillerlan/php-qrcode: ^6.0.1
- cycle/annotated: ^4.6.0
- cycle/database: ^2.21.0
- cycle/entity-behavior: ^1.7.1
- cycle/orm: ^2.18.0
- cycle/schema-provider: ^1.0
- digitalbazaar/json-ld: ^0.4.8
- doctrine/collections: >2.6.0
- doctrine/inflector: >=2.2.0
- fakerphp/faker: ^1.24.1
- gocardless/gocardless-pro: ^8.0
- google/cloud-translate: ^2.3.1
- guzzlehttp/guzzle: >=7.15.3
- guzzlehttp/psr7: >=2.13.0
- httpsoft/http-message: ^1.1.6
- league/iso3166: ^4.4.0
- maglnet/composer-require-checker: ^4.24.0
- mollie/mollie-api-php: >=3.13.1
- mpdf/mpdf: ^8.3.1
- nyholm/psr7: ^1.8.2
- php-http/curl-client: ^2.4.0
- php-http/guzzle7-adapter: ^1.1
- php-standard-library/php-standard-library: ^6.2.1
- phpseclib/phpseclib: >=3.0.56
- phptg/bot-api: >=0.21.2
- promphp/prometheus_client_php: >=2.15.1
- psalm/plugin-symfony: ^5.3.0
- psr/container: ^2.0.2
- psr/http-client: >=1.0.3
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
- psr/log: ^3.0.2
- ramsey/uuid: >=4.9.3
- robrichards/xmlseclibs: ^3.1.5
- rossaddison/openbanking-client: dev-main
- rossaddison/requirements: dev-master
- rossaddison/yii-auth-client: dev-invoice
- rossaddison/yii-cycle-1: dev-master
- rossaddison/yii-gii: *
- sabre/xml: ^4.1.0
- sebastian/comparator: >=8.2.1
- sebastian/diff: >=8.3
- setasign/fpdf: ^1.9.0
- spomky-labs/otphp: ^11.5.0
- stripe/stripe-php: >=19
- symfony/console: >=8.1.4
- symfony/process: >=8.1.0
- truelayer/client: ^3.3
- vlucas/phpdotenv: ^5.6.4
- yiisoft/access: ^2.0.1
- yiisoft/aliases: ^3.1.1
- yiisoft/arrays: ^3.2.1
- yiisoft/assets: ^5.1.2
- yiisoft/auth: ^3.3.0
- yiisoft/bootstrap5: ^1.2
- yiisoft/cache: ^3.2
- yiisoft/cache-apcu: ^1.1
- yiisoft/cache-file: ^3.2
- yiisoft/config: ^1.6.2
- yiisoft/cookies: ^1.2.4
- yiisoft/csrf: ^2.2.3
- yiisoft/data: ^2.0.0
- yiisoft/data-cycle: dev-master
- yiisoft/data-response: ^2.2.0
- yiisoft/db: ^2.0.1
- yiisoft/db-mysql: ^2
- yiisoft/definitions: ^3.4.1
- yiisoft/di: ^1.4.1
- yiisoft/error-handler: ^4.3.2
- yiisoft/factory: ^1.3.1
- yiisoft/files: ^2.1
- yiisoft/form: ^1.5.2
- yiisoft/form-model: ^1.1.1
- yiisoft/friendly-exception: ^1.2
- yiisoft/html: >=4.2
- yiisoft/http: ^1.3
- yiisoft/i18n: ^1.2.2
- yiisoft/injector: ^1.2.1
- yiisoft/input-http: >=1.0.1
- yiisoft/json: ^1.1
- yiisoft/log: >=2.2.1
- yiisoft/log-target-file: >=3.1
- yiisoft/mailer: ^6.1.0
- yiisoft/mailer-symfony: ^4.0.1
- yiisoft/middleware-dispatcher: ^5.4
- yiisoft/rate-limiter: dev-master
- yiisoft/rbac: ^2.1.2
- yiisoft/rbac-cycle-db: >=3
- yiisoft/rbac-php: >=2.1
- yiisoft/rbac-rules-container: >=2.1
- yiisoft/request-provider: ^1.3
- yiisoft/router: ^4.0.2
- yiisoft/router-fastroute: ^4.0.3
- yiisoft/security: ^1.2.0
- yiisoft/session: ^3.0.1
- yiisoft/strings: ^2.7
- yiisoft/translator: ^3.2.1
- yiisoft/translator-message-php: ^1.1.2
- yiisoft/user: ^2.3.2
- yiisoft/validator: ^2.6.0
- yiisoft/var-dumper: ^1.7.1
- yiisoft/view: ^12.2.4
- yiisoft/widget: ^2.2.2
- yiisoft/yii-console: ^2.4.2
- yiisoft/yii-dataview: dev-fix/csp-dropdownfilter-onchange
- yiisoft/yii-event: ^2.2
- yiisoft/yii-http: ^1.1.1
- yiisoft/yii-middleware: ^1.1.2
- yiisoft/yii-runner-console: ^2.2.1
- yiisoft/yii-runner-http: ^3.2.1
- yiisoft/yii-view-renderer: ^7.4.1
- zircote/swagger-php: >=6.5.3
Requires (Dev)
- codeception/c3: ^2.9
- codeception/codeception: >=5.3
- codeception/module-asserts: ^3.3.0
- codeception/module-cli: ^2.0.1
- codeception/module-phpbrowser: ^4.1.0
- dg/bypass-finals: ^1.11.0
- friendsofphp/php-cs-fixer: ^3.95.19
- google/protobuf: >=5.35.1
- guzzlehttp/promises: >=2.5.2
- mockery/mockery: ^1.6.12
- phpunit/php-code-coverage: >=14.2.2
- phpunit/phpunit: >=13.1.4
- rector/rector: ^2.6.2
- roave/better-reflection: ^6.72
- roave/infection-static-analysis-plugin: >=1.44
- roave/security-advisories: dev-latest
- rossaddison/translator-extractor: dev-master
- shipmonk/composer-dependency-analyser: ^1.8.4
- spatie/phpunit-watcher: >=1.24.4
- squizlabs/php_codesniffer: ^4.0.4
- testo/bridge-mockery: ^0.1.2
- testo/facade: >=0.1.1
- testo/testo: ^0.10.41
- vimeo/psalm: >=6.16.1
This package is auto-updated.
Last update: 2026-08-18 12:49:08 UTC
README
(Place the contents of this download into the yii3-i invoice folder or run as a separate repository.)
Yii3-i (Rossaddison/Invoice)
A professional Open Source E-Invoicing System for PHP (Yii3) with UBL 2.4 and Peppol support.
Features
Vat Support
Multi-Currency Billing
Peppol UBL 2.4 E-Invoicing
Automated generation and transmission of compliant UBL 2.4 documents via the Peppol network.
Recent Implementations
TrueLayer Gateway — UK Open Banking, Confirmed Live End-to-End — added TrueLayer, a smaller UK-founded Open Banking payment-initiation gateway, as this app's 13th online payment method, built against the current Payments V3 API via the official truelayer/client SDK (chosen over the original first pick, Ordo, after confirming it had ceased trading). Beneficiary resolution is currency-aware — GBP uses CompanyPrivate::getBacsSortCode()/getBacsAccountNumber(), EUR uses getIban() — since TrueLayer rejects IBAN-identified beneficiaries for GBP outright. A long live debugging chain against a real production server, each step root-caused from runtime/logs/app.log rather than guessed, fixed: a 'password'-type Settings field silently stripping the signing PEM's newlines (fixed by adding a genuine new 'textarea' field type), a real pre-existing bug where the BACS sort code silently never saved at all (a CSP-blocked inline <script>), the stored XX-XX-XX sort code format needing sanitizing to TrueLayer's plain-6-digit requirement, a PSR-7 getHeaders()-returns-arrays-of-values mismatch crashing the SDK's own JWS signer on every webhook delivery, the Locale middleware silently rewriting the incoming webhook's path to prepend /en/ and breaking TrueLayer's path-based signature verification, and a Console Webhook URI casing mismatch. After all of those, TrueLayer still stopped attempting webhook delivery entirely (payments confirmed Executed in TrueLayer's own dashboard, zero delivery attempts ever reaching this app) — resolved pragmatically by also marking the invoice paid synchronously on the customer's own redirect back, still gated by TrueLayer's own authenticated GET /v3/payments/{id} response rather than the browser's say-so. Confirmed live end-to-end: a real sandbox payment through the full Hosted Payments Page → mock bank → consent flow correctly marked the invoice paid. Verified: php -l clean, full-project Psalm clean at every step, full Testo suite green throughout (855/855, 19348 assertions) (August 2026)
2FA Six-Box OTP Entry — Shipped, Broke Login on Production, Reverted — redesigned the single free-text 2FA code input on setup.php/verify.php into six auto-advancing single-digit boxes (plus an 8-character hex box group for backup recovery codes on verify.php), live-verified end-to-end via Playwright and merged as PR #1082 — then locked every user out of login once deployed to yii3i.online (production): no auto-advance, the recovery-code toggle did nothing, and Submit failed native "please fill in this field" validation because the real, hidden #code field the backend validates never received a value. All three symptoms traced to one shared cause — AuthAegisTotpKeypadAsset's JS bundle never rendered in the live page at all (confirmed via document.querySelectorAll('script') showing only 2 of the expected 3 <script> tags), despite the file being correctly built, published to public/assets/, and registered unconditionally in the same layout call site as an adjacent bundle that did render. Ruled out: stale git checkout (git pull was already up to date), stale public/assets cache (cleared, re-published, still missing), file permissions (correct), and both the Apache and application (runtime/logs/app.log) error logs (clean). A genuine, separate quirks-mode-causing HTML bug (a stray <div data-authchoice> rendering before <!doctype html>, from the unrelated yii-auth-client widget) was found alongside it but not proven causal. Root cause not conclusively identified before the user was locked out of login under time pressure — reverted setup.php/verify.php/keypad-copy-to-clipboard.ts to their exact pre-feature state instead of continuing to debug live. Deliberately deferred, not abandoned — next attempt planned after more payment gateways are registered. Verified: php -l clean, vitest 176/176, live Playwright re-check confirms the plain-input login flow works end-to-end again with a real generated TOTP code (August 2026)
Rate Limit "Too Many Requests" — Bare 429 to Clean Redirect — hitting the login rate limiter showed a raw, unstyled "Too Many Requests" page (black text on white, browser default serif font) instead of anything resembling the rest of the app — traced directly to vendor/yiisoft/rate-limiter's LimitRequestsMiddleware::createErrorResponse(), which writes the bare status text into the body with no Content-Type at all. New RateLimitRedirectMiddleware wraps the shared RateLimiter::global()/perIp() builders (used by every rate-limited auth route — login, forgotpassword, resetpassword, signup, change) and turns a 429 into a plain redirect back to the page instead, mirroring AuthController::login()'s own separate, controller-level rate-limit check, which already did exactly this for its own layer. Live testing against the real running app (not just the unit tests) caught a second, more serious regression the fix itself introduced: /login's GET and POST share one rate-limit counter, so once 429s started redirecting, a GET page-load that itself tripped the limit would redirect back to that same over-budget GET — an infinite loop, reproduced live as a genuine ERR_TOO_MANY_REDIRECTS. Fixed by skipping the inner limiter entirely for safe methods (GET/HEAD/OPTIONS), matching a POST-only scoping convention that already existed elsewhere in the same controller. Verified: full-project Psalm clean, Testo Unit 833/833 (5 new tests, including one reproducing the exact live GET-loop regression), full PHPUnit Unit 3,854/3,854, and live-confirmed end-to-end via Playwright — 5 rapid login POSTs return 200/200/200/200/302 with no redirect loop across 10 repeated attempts (August 2026)
Resizable Columns Rollout + SonarCloud Test-Classification Fix — extended the drag-to-resize/auto-fit/reset column feature from inv/index alone to all six grids (inv/guest, quote/index, quote/guest, salesorder/index, salesorder/guest), extracting the shared logic into a new column-resizer.ts module so every page opts in without duplicating it. Found and fixed a real pre-existing bug along the way: salesorder/guest.php's GridView table id was hardcoded to 'table-quote' (a copy-paste artifact), which would have bled column-resize state between the two unrelated pages. Pushing the rollout's PR failed SonarCloud's quality gate on new_coverage (33.5%, required 80%) — tracing the per-file breakdown via the SonarCloud API turned up a real, project-wide sonar-project.properties bug: TypeScript *.test.ts files were never declared as test code, so every one was scored as ordinary source requiring its own coverage (confirmed pre-existing: inv-index.test.ts, untouched by this PR, already sat at "0% coverage, 111 uncovered lines"). Fixed by declaring src/typescript as both a source and a test root with sonar.test.inclusions=**/*.test.ts — new_coverage jumped to 84.0% immediately. That fix then surfaced 4 more genuine findings in unrelated pre-existing test files, now getting test-specific SonarQube rules applied for the first time: a BLOCKER S2699 test with zero assertions (cron.test.ts), two S5976 "3 tests → parameterize" consolidations, and one more S5906 .length→toHaveLength() swap — all fixed in a follow-up PR. Verified: full-project Psalm clean, full PHPUnit 3,907/3,907, full Testo 828/828, Vitest 176/176, both PRs' SonarCloud quality gates OK (August 2026)
HomeCare Dwelling Redesign — Product Stops Meaning "House" — working backward from a persistent difficulty linking HomeCare addresses to this app's CategoryPrimary → CategorySecondary → Family run hierarchy surfaced a real modeling smell: Product (a catalog concept) had been repurposed to represent a physical house, one Product row per address. New Dwelling entity takes that role over entirely — split house_number_numeric/house_number_suffix columns for correct natural sort ("12" before "12A" before "13"), family_id/Client.dwelling_id deliberately plain scalar FKs rather than Cycle #[BelongsTo] relations, sidestepping a confirmed real nullable: true-column-vs-nullable: false-relation gotcha already live in Product.php. One Client → at most one Dwelling (no join table — a deliberate simplification over the many-to-many ProductClient precedent), and every HomeCare invoice line now references one shared "HomeCare Service" catalog Product instead of one per house. HomeCareSignupController::confirm() retargeted to resolve/create the Dwelling before creating the Client (so dwelling_id is set at creation, not backfilled), and a new inv/index house-number column (Inv → Client → Dwelling, mirroring the existing current-run column's Inv → InvItem → Product → Family chain shape). ProductClient and the existing commalist bulk-generator deliberately left untouched. Spreadsheet import, a worker doorstep-canvassing tool, Google Maps/geofencing, and backfilling dwelling_id for pre-existing HomeCare clients are explicitly not built yet. Verified: full-project Psalm clean at every stage, full PHPUnit 3,896/3,896 passing throughout, new DwellingEntityTest (19 tests), schema changes confirmed directly against the live database — not yet tested against a real end-to-end signup click-through (August 2026)
Checkout.com Gateway — added as this app's 12th online payment gateway, built against the Payment Links API (POST /payment-links, a hosted checkout page matching Square/Razorpay/Mercado Pago/PayPal's existing redirect pattern) via the official checkout/checkout-sdk-php package. Unlike every gateway added since Razorpay, this one is installed as a real composer dependency, not just read for ground-truthing — its own HTTP layer genuinely is guzzlehttp/guzzle (^7.4, confirmed from the SDK's own composer.json), the same client every other mockable gateway here (Stripe, Mollie, GoCardless) is already built and tested against, so the same "no mockable double" reasoning that kept Razorpay/PayPal/Square/Mercado Pago SDK-free cuts the other way for this one. Every endpoint, auth scheme, and the Cko-Signature webhook HMAC formula were ground-truthed directly against the SDK's real executable source. Hit a genuine SDK-typing gotcha along the way — every one of the SDK's own fluent builder methods declares no return type at all, degrading a direct method chain to mixed throughout — resolved with two small typed accessor methods rather than any @psalm-suppress, confirmed correct when Psalm itself flagged the extra annotations as unnecessary once the real fix was in place. Not yet tested against a real Checkout.com account; deliberately left as an open, unconfirmed question in gateways.json (sandbox_status: null, not 'untested') rather than assumed blocked the way Paystack/Razorpay/YooKassa's genuine registration walls are. Verified: full-project Psalm clean (100% type inference), Testo 828/828 (18 new tests against a real Guzzle MockHandler), full PHPUnit 3,877/3,877 (August 2026)
Adyen Webhook HMAC Key — Root Cause Was an Unsaved Dashboard Change — real Adyen sandbox payments kept failing to mark their invoice paid, every webhook coming back 401 invalid signature, even after the original malformed-key bug (base64 stored instead of hex) was found and fixed. Exhaustive code-level debugging (a self-signed test payload accepted by the real production route, temporary logging confirming every single field feeding the signature computation was correct, ruling out a PHP boolean-vs-string implode() gotcha specifically) fully exonerated this app's own code — the stored key was independently confirmed valid 64-character hex, yet the signature still never matched. The decisive check: independently computing the stored key's own KCV (Key Check Value) and comparing it against Adyen's own displayed KCV for that webhook — they didn't match, proving the key was correctly shaped but not the right one. Root cause: Adyen shows a freshly-generated HMAC key's raw value exactly once; the key had been generated and copied correctly, but the webhook's configuration page was navigated away from without clicking Save, so Adyen kept signing with whatever key was active before that regeneration — a classic "shown once" dashboard trap, not a code defect anywhere in this app. Fixed by regenerating once more and explicitly saving, verified via the same KCV cross-check before spending another test-payment cycle. Confirmed live end-to-end: a real Pay by Bank (Tink Demo Bank) sandbox payment correctly flipped its invoice to paid on the very next webhook delivery. Also independently nails down the KCV algorithm Adyen actually uses (HMAC-SHA256("00000000", key-packed-as-binary), last 3 bytes, uppercase hex) for anyone debugging this again (August 2026)
Payment Record Channel Emoji — Charset Regression Fix — the user reported "The paid was appearing beforehand automatically via other payment_gateways. Now it is not," which a git log/git diff comparison against the last known-good commit (Mollie's webhook, 2026-08-05) traced to f05c501f (2026-08-06): the 🪝 emoji prefix added to every webhook-channel Payment.note is a genuinely 4-byte UTF-8 character, and PaymentRecordContext.channel defaults to Webhook for 9 of this app's 11 gateways — so this one commit silently broke automatic paid-marking across almost every gateway, not just PayPal, the one that happened to surface it first. Rather than depend on the still-unresolved question of why the MySQL DSN charset fix below hasn't provably taken effect against real webhook traffic on production, this swaps the trigger itself: PaymentRecordChannel::Webhook->emoji() now returns ⚡ (U+26A1, Basic Multilingual Plane, at most 3 bytes in UTF-8) instead of 🪝 — same distinguishing feature, incapable of tripping the connection-charset gap regardless of its actual cause. Two new tests assert every codepoint in both channel emoji is BMP-safe, so this exact class of regression can't silently return. Explicitly not a fix for the underlying connection-charset issue itself, which remains real, unexplained, and a latent risk for any other 4-byte character written anywhere else in the app. Verified: php -l clean, full-project Psalm clean, Testo 807/807 (August 2026)
MySQL Connection Charset Bug — Second Layer Under the PayPal Webhook Fix — deploying the decode() fix below got PayPal's webhook past signature verification for the first time, immediately exposing a second, genuinely separate bug one line further into the same request: a real PayPal sandbox payment (INV119) still didn't get marked paid, this time with a PDOException — MySQL 1366 rejecting the 🪝 emoji (PaymentRecordChannel::Webhook->emoji(), prefixed onto every webhook-driven Payment.note) as an invalid value for that column. The obvious suspect — a narrow-charset column — turned out to be wrong: a direct information_schema.COLUMNS query against production showed every column, including payment.note, and the database's own default already declared utf8mb4. The real cause was one level up: neither of this app's two MySQL DSN strings (config/common/di/db.php, and Cycle ORM's own connection in config/common/params.php) ever specified charset=utf8mb4, so PDO/mysqlnd negotiated its own compiled-in default independent of the schema, and MySQL converted incoming bytes to that narrower charset before they ever reached the column. Fixed by adding ;charset=utf8mb4 to both DSNs — purely additive, no schema/data migration needed, since the schema was already correct throughout. Not PayPal-specific: PaymentRecordContext's channel defaults to Webhook for every gateway's webhook handler, so any gateway's webhook-driven payment would have hit the identical crash the first time it ran; PayPal was simply the one under live test when it first fired. Verified: reproduced locally with a standalone before/after script round-tripping the real emoji byte-for-byte through a temporary utf8mb4 column (this WAMP's MariaDB already defaults to utf8mb4 so the failure itself doesn't reproduce locally, but the fix behaves identically either way), php -l clean, full-project Psalm clean, Testo 805/805 (August 2026)
PayPal Webhook decode() Bug — Root Cause of Every Live Webhook 500 — the same live sandbox testing session that found the captureOrder() logging gap below also hit a real end-to-end blocker: two genuinely-paid PayPal sandbox invoices (INV119, INV120) never got marked paid, because every incoming PAYMENT.CAPTURE.COMPLETED webhook 500'd. Two red herrings chased first — a wrong /invoice/-prefixed webhook URL (my own mistake, wrongly generalized from paypalComplete's redirect URL, which genuinely is inside that route group) and route-cache staleness (production's APCu-cached FastRoute table) — neither was the actual cause. Direct production log analysis (grep'd over SSH) showed the real one: PaypalWebhookHandler::handle() called SettingRepository::decode() — meant only for 'password'-type Settings, genuinely encrypted at rest — on gateway_paypal_webhookId, which is declared 'text' in SettingPaymentTrait::paypalGatewayFields(), a plain unencrypted value. Feeding a plain string like PayPal's own webhook ID format into Cryptor::Decrypt() isn't valid ciphertext and threw, unhandled, before signature verification even ran. Fixed by reading the setting directly, matching how the equally-'text'-typed clientId is already read elsewhere in the class. Audited every other decode() call site across all 13 other gateways against their Settings' declared field types afterward — PayPal's webhookId was the only mismatch found anywhere in the codebase. Verified: php -l clean, full-project Psalm clean, Testo 805/805 (no regressions; no dedicated new test, since the existing webhook tests construct $webhookId directly rather than through SettingRepository). Still needs a production deploy + real webhook retest to confirm INV119/INV120 actually get marked paid end-to-end (August 2026)
PayPal Structured Error Logging — live sandbox testing hit a real captureOrder() 422 rejection (DUPLICATE_INVOICE_ID — a retried payment on an invoice already captured once, because an unrelated webhook-URL misconfiguration meant this app never learned the first payment had gone through). The only way to find out why was manually digging through PayPal's own Developer Dashboard error log; this app's own logs just said "response missing capture id or status." Root cause: parseCaptureOrderResponse() only knew how to read a successful capture's shape, so PayPal's actual error body (name/message/debug_id/details[].issue) was silently discarded — and the same gap existed to varying degrees in refund(), createPayment(), verifyPayment(), verifyWebhookSignature(), and accessToken(). Fixed with one shared extractErrorDetail() helper wired into every failure path in the class — issue/debug_id are what actually pinpoint a cause; PayPal's message field is frequently just generic wrapper text regardless of what went wrong. Purely additive — no method's return behavior changed, failures just leave a diagnosable trail in this app's own logs now instead of requiring a trip to PayPal's dashboard. Verified: full-project Psalm clean, Testo 805/805 (3 new tests, each asserting via a real Mockery expectation — not just a spy — that issue/debug_id land in the logged context for a mocked error response shaped exactly like the real DUPLICATE_INVOICE_ID one hit live) (August 2026)
Mercado Pago — First South-America-Priority Gateway — added Mercado Pago (Argentina/Brazil/Chile/Colombia/Mexico/Peru/Uruguay, tied to Mercado Livre, the region's largest marketplace) as this app's first South-America gateway, built against its Checkout Pro / Preferences API — same hosted-checkout redirect pattern as Razorpay/Square/PayPal. Every URL, auth scheme (single Bearer access token, unlike Razorpay's key id + secret pair), and the webhook HMAC formula were ground-truthed directly against mercadopago/sdk-php's real source via gh api — deliberately not installed as a dependency (bespoke CurlRequest transport, no mockable double), same reasoning as Razorpay/PayPal/Square. One structural difference from every prior gateway: Mercado Pago's webhook body carries no invoice reference at all, just a payment id, so the handler always makes one authenticated lookup for both status and external_reference together. Caught a real bug before shipping — the webhook handler set the Merchant audit record's driver field to a human-readable 'Mercado Pago' (space) instead of the exact 'Mercado_Pago' (underscore) key the refund dropdown's exact-string DB lookup requires, which would have silently broken every refund for this gateway; fixed by cross-checking the existing Amazon_Pay precedent. Verified: full-project Psalm clean, Testo 802/802 (26 new tests), full PHPUnit 3,824/3,824, Functional SiteControllerCest 25/25, and the new gateway confirmed live on the public /gateway-status page after syncing gateways.json into its SQLite projection. Same-day correction: this was originally chosen partly because its sandbox looked accessible without a registered company, unlike Paystack/Razorpay/YooKassa/PayPal Sandbox — confirmed wrong the same day. Creating a Mercado Pago account requires a local tax ID tied to residency (CUIT/CUIL+DNI in Argentina, CPF in Brazil), the same category of wall as those four gateways, just via personal tax residency rather than company registration. sandbox_status stays untested permanently for this maintainer, not a temporary state; the code and sandbox-check wiring are kept regardless, since a future Mercado-Pago-eligible contributor could still supply real credentials (August 2026)
Gateway Sandbox Account Expiry + Telegram Alert — added a human-curated sandbox_expiry_date field to gateways.json/the gateway_status SQLite table (own index, synced via the usual BUILD_DATABASE=true cycle and verified directly against the real local DB), plus a Telegram notification when one's passed. Shipped first as expiry_date, framed around API key expiry — renamed the same day after checking Adyen's own docs turned up that API keys generally don't expire on a fixed schedule at all (Adyen's stay valid indefinitely until manually rotated); it's sandbox/trial accounts that actually have a real expiry, a different thing entirely. The rename needed a second schema-sync pass, since Cycle's SyncTables adds a differently-named column rather than detecting a true rename — the orphaned original column (confirmed all-NULL first) was dropped by hand. Deliberately scoped to sandbox accounts only — this app's encrypted production gateway credentials (Setting table) are a separate trust boundary, not touched. The notification comes from PHP code, not a CI-workflow curl step: reuses the existing TelegramHelper (phptg/bot-api wrapper, previously used only for client-facing invoice delivery), which gained one new sendMessage() method, constructed with two new GitHub repo secrets (TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID) read via getenv() — deliberately a different bot from the in-app client-facing one, which is Setting-table-driven and unreachable from CI anyway. CheckGatewaySandboxesCommand checks every row's sandbox_expiry_date independent of whether a sandbox check ran for it, and sends one summary message listing every currently-expired gateway — stateless and re-sent every weekly run for as long as a gateway stays expired (no "already notified" tracking; that's the intended nag-until-fixed behavior). Skips silently when the two secrets aren't set yet, same incremental-rollout philosophy as every sandbox credential secret. Verified: full-project Psalm clean before and after the rename, Testo 776/776 (4 new tests covering isExpired()'s date-boundary logic), Functional SiteControllerCest 25/25. The Telegram send itself isn't yet verified live — no secrets configured, no gateway has a real sandbox_expiry_date set — flagged as the real end-to-end check still to do once both exist (August 2026)
Gateway Status CI Fixes — Three Bugs Deep on the First Real Run — the first real run of the gateway-status GitHub Actions workflow (after repo secrets were added) hit three separate fatal errors in sequence, each only surfacing once the previous one was fixed. (1) .env is gitignored, so a fresh CI checkout never has one, and autoload.php — required by every php yii ... console call — calls Dotenv::load(), not safeLoad(), throwing hard when the file's simply missing; fixed with a cp .env.example .env step. (2) That exposed a deeper one: every php yii ... call builds the full DI container, which eagerly compiles the Cycle ORM schema for every registered connection (the main MySQL one too, not just the gateway_status SQLite one either command actually touches) unless a cached runtime/schema.php already exists — gitignored, so CI never has it, forcing live schema introspection against a MySQL server that doesn't exist in a bare runner; fixed with a real mysql:8.4 service container, matching what a first-ever local install already does. (3) With both fixed, every step passed except the final git push, denied with a 403 — this repo's default GITHUB_TOKEN permission is read, confirmed via the GitHub API, not something specific to this workflow; fixed with an explicit permissions: contents: write. End-to-end verified live: the next run pinged all 5 configured gateways' real sandbox APIs (Stripe, Mollie, Adyen, GoCardless, Square — all pass), rebuilt gateway-status.sqlite, and pushed the result back to main as a github-actions[bot] commit, unattended (August 2026)
Gateway Status Page — Pagination Fix + Visibility Toggle — the public /gateway-status page's pagination summary was rendering the literal text Page {currentPage} of {totalPages} instead of real numbers, because GatewayStatusListWidget never passed a TranslatorInterface to GridView::widget() and fell back to a vendor-internal default translator that doesn't substitute those placeholders correctly here — fixed by computing the summary with sprintf() and this app's own translator via ->summaryTemplate(...), matching the pattern every other list widget in this app already uses (UsersListWidget likely has the same latent bug, not fixed — out of scope). Also added a no_front_gateway_status_page setting under Settings → Front Page, matching the existing no_front_{about,gallery,pricing,...}_page checkbox convention — but unlike those ten, which only hide a navbar link while the route stays reachable, this one also 404s /gateway-status itself when set, since the page can expose which payment providers are configured. Both the homepage link and a new navbar NavLink entry (main.php) are gated behind it. Verified: full-project Psalm clean, full Testo (772/772) and PHPUnit (3,824/3,824) passing, Functional SiteControllerCest (25/25, including a new permanent pagination-summary assertion), navbar link confirmed live via curl (August 2026)
Adyen Sandbox Check — First Multi-Secret Gateway Status Check — the weekly gateway-status GitHub Actions cron (feeding the public /gateway-status page linked from the homepage) now has a confirmed, genuinely read-only sandbox check for Adyen too, alongside the four already wired (Stripe, Mollie, GoCardless, Square) — all five now have real credentials from live-tested sandbox accounts this session. Adyen's paymentMethods() call ("Get a list of available payment methods" per the vendored SDK's own docblock, a real read despite the POST verb) needed both an API key and a merchant account, forcing a real schema decision CheckGatewaySandboxesCommand had deferred since YooKassa first hit the same limitation: GatewayStatusRow::$sandboxEnvVar (a single nullable string) became $sandboxEnvVars (a list<string>), backward-compatible with every existing single-secret gateway's JSON shape. Verified: full-project Psalm clean, Testo 772/772, PHPUnit 3824/3824, local dry run confirms every gateway (including Adyen) skips cleanly with no secrets configured (August 2026)
Gateway Title Links — Permalink + "Get Credentials" per Provider — each payment gateway's section title in Settings → Online Payment now links to two places: a same-page permalink (#gateway-settings-{driver}, via $urlFastRouteGenerator) for deep-linking straight to that gateway's section, and an external "Get credentials →" link straight to that provider's own developer/API dashboard — the actual page the Access Token/API Key/Secret being pasted into this app's fields comes from. Finding the right page on each provider's site was a recurring, real time cost this session (Square's own onboarding flow alone drew "what a bloody rigmarole!!!!!"). URLs are filled in one gateway at a time, only once hand-confirmed against a real account — a wrong link is worse than no link, so an unconfirmed gateway shows no external link yet rather than a guessed one. Five gateways confirmed so far, exactly the ones live-tested against real accounts this session: Adyen, GoCardless, Mollie, Square, Stripe (August 2026)
Attempted: Functional Suite in SonarCloud Coverage — CI Step Reverted — codeception/c3 (already in require-dev, gated behind a YII_C3 env var in public/index.php) was wired in but had three dormant bugs preventing it from ever actually working, found and fixed by isolating each with direct curl requests against the spawned test server: public/index.php required vendor/codeception/c3/c3.php directly instead of the Composer-plugin-generated root copy, whose __DIR__-relative lookups only resolve from the project root; that require also ran before this app's own Composer autoloader; and c3's own error handler crashed on an undefined constant whenever anything else went wrong, masking the real cause. Those fixes are kept — genuine, no downside. Wiring a codecept run Functional --coverage-xml=... step into the sonar CI job was reverted after the first real CI run: PCOV (CI's driver, not the Xdebug used to verify locally) failed nearly every test instantly, a compatibility issue that couldn't be reproduced or diagnosed locally, and even where it did work under Xdebug the per-test overhead was severe enough (15-16s/test) to make the whole approach impractical regardless. SonarCloud's PHP coverage is back to Unit + Testo only (August 2026)
Fixed codecept run Failing on final Classes — vendor/bin/codecept run (unlike the phpunit/testo commands used day to day) failed 97 tests with ClassIsFinalException on mocked final classes, because Codeception's suite configs never wired in Tests/bootstrap.php's DG\BypassFinals::enable() call at all. Moved the call to a global codeception.yml bootstrap (had to be global, not per-suite — Acceptance/Cli/Functional running first in the same process meant classes like Yiisoft\Router\CurrentRoute were already irreversibly loaded as final by the time a Unit-only bootstrap would have fired), and scoped BypassFinals::denyPaths() to exclude PHPUnit's own package — enabling it unscoped corrupted PHPUnit's internal TestStatus class hierarchy (readonly stripped inconsistently) and crashed the whole run. vendor/bin/codecept run now reports OK (3892 tests, 10395 assertions); phpunit/testo confirmed unaffected (August 2026)
PHPUnit 13's Stub-Without-Expectations Check — Eliminated, Not Tolerated — PHPUnit 13 flags createMock() objects configured with willReturn()/willReturnMap() but never verified with expects()/with(), distinguishing real behavioral mocks from plain stubs. 23 tests across 4 files (QuoteItemServiceTest, InvItemServiceCreditTest, InvAllowanceChargeServiceTest, PeppolUblXmlCreditNoteTest) were hitting this as silent N notice markers. Fixed with PHPUnit's own #[AllowMockObjectsWithoutExpectations] attribute at class level in each file, since 100% of each file's test methods were affected (13/13, 5/5, 3/3, 2/2) — existing expects() assertions elsewhere in the same tests are untouched and still enforced. vendor/bin/phpunit --testsuite=Unit now reports OK (3824 tests, 10243 assertions), zero notices. CLAUDE.md's test-quality standard updated to match (August 2026)
Removed the Parked Angular Scaffold — npm audit flagged 4 high-severity vulnerabilities, both tracing to @angular-devkit/build-angular's own dependency tree (nanoid, image-size via less). Rather than accept a breaking upgrade for code that never actually ran — the angular/ scaffold had real source and a real angular.json but was never built, never registered as an asset bundle, and its one real feature had already been reimplemented in plain TypeScript — deleted the scaffold and every @angular*/rxjs/zone.js/tslib dependency outright. npm install removed 709 packages; npm audit now reports 0 vulnerabilities. The real esbuild-based build pipeline, type-check, and vitest suite (143/143) all verified unaffected (August 2026)
Payment Gateway Guard Trait — SonarCloud Duplication Fix — SonarCloud's quality gate started failing on new_duplicated_lines_density (12.0% vs. 3% allowed) as a direct side effect of this session's own php:S1142 return-count fix, which had extracted an identical load-invoice/check-configured/check-balance guard chain into each of Square/PayPal/Razorpay/Robokassa/YooKassa's own controllers separately — same logic five times over, differing only in which service and gateway label each copy referenced. New PaymentGatewayGuardTrait (src/Invoice/PaymentInformation/Trait/) holds the one real implementation, parameterized by the shared PaymentGatewayInterface + a display label instead of a hardcoded service reference; all 6 controllers use it now, with Paystack's own extra client-email requirement composed on top rather than duplicated. Net: -248 lines across the 6 controllers, +1 new ~80-line trait. SquareMerchant.php's own flagged duplication (mirroring Merchant.php's shape) deliberately left as-is — inherent to being two genuinely separate entities, not a copy-paste artifact. Full-project Psalm clean; full Testo suite (772/772) and full PHPUnit suite (3,877/3,877) passing; live-curled all 6 refactored endpoints, unchanged clean 404s. The quality gate's other failing condition — new_coverage at 19.5% vs. 80% required, tracing to genuinely 0%-covered new lines across several controllers, OnlinePaymentRecorderService, and the TS7 typing-fix commit — is flagged but deliberately not attempted here, a substantially larger, separate body of work needing real new tests rather than a refactor (August 2026)
SquareMerchant — First Per-Provider Payment Entity — Square payments now write their own SquareMerchant audit record (a new square_merchant table) instead of the generic Merchant table every other gateway shares — the first of what's meant to become one entity per payment provider, not a one-off. Root problem: Merchant.provider_reference is a single string column, fine for eleven of twelve gateways, but Square genuinely needs two distinct provider-side identifiers persisted — order_id (what the webhook payload carries, needed to resolve back to this app's own invoice) and payment_id (what Square's refund API is actually keyed by) — previously only payment_id was stored, with order_id→invoice resolution done via a live re-fetch every webhook call, never persisted. New SquareMerchant entity/repository/service mirror Merchant's exact shape plus both references as independent columns; OnlinePaymentRecorderService and PaymentRefundController dispatch per-driver (Square → SquareMerchant, everything else → the shared Merchant table unchanged), extending the exact same per-driver dispatch idiom PaymentRefundController::dispatchRefund() already used — not new architectural surface. Replaces the generic row entirely for Square rather than supplementing it, a deliberate choice locked in before writing any code. Scoped to Square only this session — confirmed via Razorpay's and PayPal's own webhook handlers that neither has the same dual-reference problem, so the other ten gateways stay on the shared table until (if) they actually need their own. Full-project Psalm clean; full Testo suite (768/768, 12 new tests) and full PHPUnit suite (3,877/3,877) passing; square_merchant table verified via DESCRIBE; live-curled the new dependency chain (including OnlinePaymentRecorderService's new constructor dependency resolving cleanly from an unrelated gateway's own controller) — all clean, no DI errors (August 2026)
Why Two tsconfig Files? — Plain-English Explainer — tsconfig.json and tsconfig.sw.json aren't a style choice; they exist because src/typescript/sw.ts (the service worker) runs in a genuinely different global environment than every other page script — no document/window, but its own caches/clients — and TypeScript's lib setting is a single, program-wide choice that can't vary per file within one config. Both environments also define self differently (Window & typeof globalThis vs ServiceWorkerGlobalScope), which is a hard conflict, not just an inconvenience — including both libs in one config throws TS2451: Cannot redeclare block-scoped variable 'self'. A shop-floor-vs-back-office analogy for why one file needs its own separate config, why that's not the same trigger as "built differently" (a build-output-format change, like the WWW-Authenticate/top-level-await work below, doesn't need one), and why two flat sibling configs (chained in package.json's type-check script) is the right-sized fix here rather than TypeScript's heavier project-references machinery (August 2026)
TypeScript 7 — globalThis vs. Window Typing Fix — npm run type-check had been silently failing at the config-parsing stage since a Node update bumped typescript to ^7.0.2 (which removed moduleResolution: "node" and baseUrl outright), meaning it never actually reached real source-file checking for a while — "passing" only because nothing ran. Fixed by switching to moduleResolution: "bundler" (already the working pattern in tsconfig.sw.json) rather than downgrading TypeScript, a deliberate choice to keep the native/Go port's real speed win. That unblocked real checking for what looks like the first time in a while, surfacing the actual long-suspected root cause behind a recurring class of type friction: this app's own globalThis-not-window convention (typescript:S7764) was correct in every call site, but its ambient global declarations were written as declare global { interface Window { x: T } }, which augments Window's type without ever extending globalThis's own type the way it might seem like it should — TypeScript simply doesn't unify the two for this purpose. Fixed everywhere by switching to the form TypeScript does propagate onto globalThis — declare global { var x: T; } — in htmx.ts, flash-message-timer.ts, index.ts, product.ts, and family-commalist-picker.ts (var, not const/let, mirroring real JS: only a top-level var becomes a global-object property). Surfaced several genuine, independent bugs along the way that had simply never been checked before: bootstrap declared twice in types.ts with neither form actually reaching globalThis; TomSelect declared twice, conflictingly, between product.ts and types.ts; salesorder.ts's TomSelect-undefined guard not actually surviving narrowing across a .forEach() callback boundary; family.ts's FamilyGenerateResponse.success typed boolean where every sibling interface and the base type correctly use 0 | 1; and one genuinely dead file (family-commalist-integration.ts, zero references anywhere, superseded by family-commalist-picker.ts's plain-TypeScript reimplementation of the identical API) deleted outright. npm run type-check clean for the first time under TS7; Vitest 143/143; production build unchanged at 281.8kb, confirming every fix here was type-level or behavior-preserving (August 2026)
InvRecurring Cron Endpoint — Bearer Token Auth — a live, working example of the WWW-Authenticate explainer's "one endpoint already shaped like a program, not a person" case. invrecurring/cron (an external cron scheduler's HTTP trigger for generating due recurring invoices) used to check a cron_key URL query parameter by hand inside the controller action — the secret sat in the URL where it leaks into shell history/cron logs/access logs, a !== comparison rather than constant-time, and a wrong key returned 200 OK with {"success":false} rather than any real HTTP status. Now uses real HTTP Bearer auth: Yiisoft\Auth\Middleware\Authentication (backed by a new, narrow AuthenticatorInterface binding — new CronIdentity/CronTokenRepository, reusing the same cron_key setting value, just over Authorization: Bearer instead of the URL) rejects an unauthenticated request before the controller action ever runs, with a real 401 + WWW-Authenticate: Bearer realm="api" challenge. Full-project Psalm clean; full Testo suite (760/760, 4 new tests) and full PHPUnit suite (3,877/3,877) passing; live-curled all three cases (no token, wrong token, valid token) against the running local site (August 2026)
WWW-Authenticate vs. Session Auth — Plain-English Explainer — grew out of the Authentication DI crash fix below, and a look at yiisoft/auth's active interface-segregation work (issue #113, PRs #115/#125). Documents why the actual thrown exception is misleading (its own remediation sentence checks the wrong thing — the Authentication class autoloads fine, the real gap is one dependency deeper, an unbound AuthenticatorInterface), proposes a clearer replacement message naming the real cause with a copy-pasteable fix, then explains in plain terms — a festival wristband vs. a bouncer with no wristbands — why session-based login (this app, everywhere) and HTTP-challenge-style auth (WWW-Authenticate, Basic/Bearer) solve genuinely different problems despite sharing a name: one's for a human clicking through a browser, the other's for a program making a single standalone request with no human involved. Flags the one endpoint in this app already shaped like the latter — the recurring-invoice cron trigger, currently secured by a URL query-string secret instead of the request-header pattern challenge-style auth was built for — as the concrete example of when this would actually start to matter. Now includes click-to-expand Persian and Portuguese translations, chosen deliberately to balance yiisoft/auth's own maintainer base rather than as a token diversity pick: Persian for Yii's historically large Iranian developer community (alongside Russian, which the doc's intended upstream readers, samdark and vjik, already read natively), and Portuguese for Brazil, whose mandatory e-invoicing (NFe) regime is years ahead of the UK's own still-pending 2029 mandate (August 2026)
Authentication DI Crash Fix — a composer update that bumped yiisoft/auth surfaced an application-wide 500 Yiisoft\Di\NotFoundException for Yiisoft\Auth\Middleware\Authentication. Root cause: that middleware requires a Yiisoft\Auth\AuthenticatorInterface DI binding that has never existed anywhere in this app, in any version of yiisoft/auth — no implementation, no config binding — so it's been unconstructable since the day it was first referenced; the composer update most likely just exposed it by invalidating a stale compiled DI container cache that had been masking the failure. A first fix (commit 1066ac8e) removed a duplicate reference from config/common/routes/routes-backend.php's /backend/hmrc group, but the true, much higher-impact root cause turned out to be src/Middleware/RoutePermission.php's invoiceGroup(), which wraps almost every /invoice/* route in the app (~70+ route files) and applied the exact same broken middleware — confirmed live via curl that /invoice/client_invoices 500'd before the fix (commit 4fb3a633) and returned a clean 403 after. Also cleaned the identical dead pattern out of the Gii-style route-scaffold template (resources/views/invoice/generator/templates_protected/_route.php, which would otherwise have reintroduced this exact crash into every future generated module) and two inert docblock examples in ProductImageController.php/UploadController.php. RoutePermission::check() (session-based RBAC via AccessChecker) is, and always was, the real working permission gate — Authentication::class was redundant dead weight riding alongside it. Full-project Psalm clean; full Testo suite (756/756) and full PHPUnit suite (3,877/3,877, 23 pre-existing Cycle ORM mock notices only) passing; live-curled a broad route sample (invoice/{inv,client,quote,product,productimage,upload}, backend/hmrc), all returning clean 403s instead of 500s (August 2026)
Square Gateway — added Square, built against its Checkout API Payment Links (POST /v2/online-checkout/payment-links, an Order-based hosted checkout page), matching this app's existing redirect pattern rather than Square's Web Payments SDK. Square's official SDK (square/square-php-sdk, actively maintained, pushed 2026-07-14) is genuinely first-party — same reasoning as Razorpay/PayPal for not installing it: its HTTP layer is APIMatic-generated code on apimatic/unirest-php, not Guzzle, with no mockable test double. Every URL/field/formula is still ground-truthed from that SDK's real source: base URLs (connect.squareup.com live / connect.squareupsandbox.com sandbox — like PayPal, genuinely a different base URL, not just a different credential), Authorization: Bearer + a required Square-Version date header, POST /v2/online-checkout/payment-links/GET /v2/payments/{id}/GET /v2/orders/{id}/POST /v2/refunds. Square's docs site was reachable and confirmed the webhook signature formula directly from primary sources — notably base64(hmac_sha256(notification_url + raw_body, signature_key)), hashing the URL concatenated with the body (unlike every other HMAC-signed gateway here) and base64- rather than hex-encoded — plus the payment.created/payment.updated event names and the payload's data.object.payment.{id, status, order_id} shape. One genuine architectural wrinkle: order_id and this app's own invoice url_key are two separate values, bridged through a third field, Square's own reference_id — this app sends the invoice's url_key to Square as the Order's reference_id at Payment Link creation time (only available via the fuller Order-based request shape, not Quick Pay's ad hoc item), but the Payment webhook payload only ever hands back Square's own opaque order_id, never reference_id itself, so the webhook handler makes a second GET /v2/orders/{id} call specifically to read reference_id — the invoice's actual url_key — back out of that response. New SquareSignatureService (pure HMAC verification) + SquarePaymentService (implements PaymentGatewayInterface, plus getOrderReferenceId) + SquareWebhookHandler (verifies signature, resolves the order's reference_id, then always re-confirms via authenticated GET before marking paid) + dedicated SquarePaymentController, wired into the dispatch, refund controller, routes, and CsrfExemptMiddleware. Full-project Psalm clean; full Testo suite passing (21 new tests) and full PHPUnit suite (3,877 tests) passing after updating the legacy PaymentRefundControllerTest for the new constructor argument; live-curled the new routes, confirming clean 400/404 responses. Updated same day: the original "no registered company, sandbox_status stays untested permanently" note here was wrong even at the time it was written for every other untested gateway too — "permanently" was never accurate, since it depends entirely on the user's own company/account circumstances, which can and did change; see SquareMerchant and the gateway-status entry below — Square's weekly sandbox check is now wired up and waiting on a real credential, not permanently blocked (August 2026)
HomeCare Offline Invoice Viewer (PWA) — a field worker can now download their currently-allocated HomeCare invoices (client/item detail, no amounts — matches the worker RBAC role's existing Permissions::VIEW_PAYMENT restriction) while connected, then browse that data with zero connectivity. Built as an app-shell PWA, not a cached copy of inv/guest itself — that page is fully server-rendered with a live CSRF token and session state, which a service worker serving a stale copy would corrupt. Instead: a small, mostly-static shell page (inv/guest/offline) and its own tiny dedicated JS bundle are precached by a new service worker (public/sw.js, its own esbuild entry — a worker runs in a separate global scope and can't join the main IIFE); the actual invoice data is fetched once via a new JSON endpoint (GET /client_invoices/offline-data, reusing inv/guest's own resolveGuestAccess()/repoWorkerVisible() scoping) and stored in IndexedDB, then rendered from IndexedDB whenever the shell opens, online or off. The "Download for Offline" button also silently re-syncs in the background on every load of inv/guest while online — this is what keeps the copy fresh once broadband returns, without any actual write-back to the server (the feature is deliberately view-only, confirmed with the user upfront). This is the first PWA infrastructure (manifest + service worker) in this app; confirmed CSP already permits it (manifest-src 'self' / worker-src 'self', already present) with no migration needed. sw.ts needed its own separate tsconfig.sw.json (ServiceWorkerGlobalScope typing conflicts with the main config's DOM lib) — type-checks and builds cleanly on its own. Full-project Psalm clean; full Testo suite passing (5 new tests, a minimal harness mixing in the real Guest trait rather than constructing the much larger InvController) and full PHPUnit (3,877 tests) unaffected; live-curled the new static assets and routes against the running local site, confirming clean 200/404 responses (August 2026)
PayPal Gateway — added PayPal, this app's broadest-reach gateway (200+ markets across every populated continent), built against its Orders v2 REST API redirect flow (hosted "approve" page, not the JS Checkout SDK). PayPal's official SDK (paypal/paypal-server-sdk, actively maintained, pushed 2026-06-05) is genuinely first-party — unlike Robokassa/Paystack's thin community packages — but deliberately not installed, same reasoning as Razorpay: its HTTP layer is APIMatic-generated code on apimatic/unirest-php, not Guzzle, with no mockable test double. Every URL/field/formula is still ground-truthed from that SDK's real source: OAuth2 client-credentials token flow (POST /v1/oauth2/token, fetched fresh per operation rather than cached), POST /v2/checkout/orders/.../capture, POST /v2/payments/captures/{id}/refund. Unlike every other gateway added this session, PayPal's sandbox setting really is a different base URL (api-m.sandbox.paypal.com vs api-m.paypal.com), not just a different credential. PayPal's docs site was reachable and confirmed the verify-webhook-signature request shape, the PAYMENT.CAPTURE.COMPLETED event name, and the capture resource's invoice_id field (this app's own url_key carrier) directly from primary sources. One genuine architectural difference from every other gateway here: paypalComplete() is not purely read-only — PayPal requires a server-to-server capture call after the customer approves (no money moves automatically), so this action performs that required capture, but still leaves marking the invoice paid in this app's own database entirely to the webhook. PayPal's webhook signature verification is also unique: instead of a local HMAC, it calls PayPal's own verify-webhook-signature API (PayPal validates its RSA signature server-side against a hosted certificate), with the usual authenticated-GET re-confirmation still layered on top. New PaypalPaymentService (implements PaymentGatewayInterface, plus createPayment/captureOrder/verifyWebhookSignature) + PaypalWebhookHandler + dedicated PaypalPaymentController, wired into the dispatch, refund controller, routes, and CsrfExemptMiddleware. ⚠️ Untested against a real account: same barrier as YooKassa/Paystack/Razorpay — the user has no registered company; sandbox_status stays untested permanently. Full-project Psalm clean; full Testo suite passing (18 new tests) and full PHPUnit suite (3,877 tests) passing after updating the legacy PaymentRefundControllerTest for the new constructor argument; live-curled the new routes, confirming clean 400/404 responses (August 2026)
Razorpay Gateway — added Razorpay as this app's first India-region payment gateway, built against its Payment Links API (a hosted checkout page matching this app's existing redirect pattern) rather than its more commonly-integrated Orders + embedded-JS-Checkout-widget flow, avoiding any client-side JS integration. Razorpay's official PHP SDK (razorpay/razorpay, actively maintained, pushed 2026-07-23) is genuinely first-party — unlike Robokassa/Paystack's thin community packages — but was deliberately not installed: its HTTP layer is built on rmccue/requests rather than the Guzzle client every other gateway here is built and tested against, and it ships no mockable test double (its own tests are live integration tests requiring real API credentials). Every URL/field/formula is still ground-truthed directly from that SDK's real executable source: base URL https://api.razorpay.com, HTTP Basic auth (key_id:key_secret, not a Bearer token), POST /v1/payment_links/GET /v1/payment_links/{id}/POST /v1/refunds, and both signature formulas (Payment Link callback and webhook, each hash_hmac('sha256', ..., secret) but with genuinely different secrets — the API key secret vs. a separate webhook secret configured in the dashboard). Unlike Paystack, Razorpay's own docs site was reachable this session and confirmed the create-response shape (id, short_url, status) and the payment_link.paid webhook event's exact payload nesting directly from primary sources; only the precise X-Razorpay-Signature header name itself falls back to well-established general knowledge (its dedicated doc page 404'd). One genuine architectural wrinkle handled explicitly: Razorpay refunds are per-payment, not per-payment-link, so RazorpayWebhookHandler extracts both ids from the same trusted webhook payload and stores the payment id (not the link id) as the refund-capable provider reference. New RazorpaySignatureService + RazorpayPaymentService (implements PaymentGatewayInterface) + RazorpayWebhookHandler (verifies signature, then always re-confirms via authenticated GET before marking paid) + dedicated RazorpayPaymentController, wired into PaymentInformationController's dispatch, PaymentRefundController's refund dispatch, routes, and CsrfExemptMiddleware. ⚠️ Untested against a real account: same barrier as YooKassa/Paystack — the user has no registered company; sandbox_status stays untested permanently. Full-project Psalm clean; full Testo suite passing (20 new tests) and full PHPUnit suite (3,877 tests) passing after updating the legacy PaymentRefundControllerTest for the new constructor argument; live-curled the new routes, confirming clean 400/404 responses (August 2026)
Paystack Gateway — added Paystack as this app's first Africa-region payment gateway, following the exact direct-HTTP architectural pattern established for Robokassa/YooKassa this session (no third-party SDK dependency). Paystack's own primary docs site (paystack.com/docs) returned HTTP 403 to every fetch attempt, and Paystack's own GitHub org (PaystackHQ/paystack-php) turned out to be a stale 2017 mirror of the community yabacon/paystack-php package; that package (more recently touched, 2023, 116 stars) was read directly from its real executable source for research purposes only, never installed. Confirmed from it: base URL https://api.paystack.co, Authorization: Bearer {secretKey} auth, the {status, message, data} response envelope, /transaction/initialize//transaction/verify/{reference} endpoints, and the X-Paystack-Signature = hash_hmac('sha512', rawBody, secretKey) webhook formula (signed with the merchant's own secret key, not a separate webhook secret) — ground-truthed from Event::validFor()'s real code. The /refund endpoint specifically is not covered by that SDK at all (no refund route exists in it) and is built from Paystack's well-established public API shape instead, flagged explicitly as unconfirmed in PaystackPaymentService's own docblock. New PaystackSignatureService (pure HMAC verification) + PaystackPaymentService (direct Guzzle HTTP, implements PaymentGatewayInterface) + PaystackWebhookHandler (verifies the signature, then always re-confirms via an authenticated GET /transaction/verify/{reference} before marking an invoice paid — never trusts the webhook body alone) + dedicated PaystackPaymentController (PaymentInformationController is already at SonarQube's php:S1448 method-count ceiling), wired into PaymentInformationController's dispatch, PaymentRefundController's refund dispatch, routes, and CsrfExemptMiddleware. Paystack requires a customer email to initialize a transaction — paystackInForm() reads it from the invoice's client and shows a clear warning if none is on file, rather than silently sending an empty string. Settings: a single secretKey field, sandbox purely informational (test vs live is just which key prefix is configured, same base URL) — same convention as Mollie/YooKassa. ⚠️ Untested against a real account: the user has no registered company and cannot create even a test Paystack account, the same barrier already hit with YooKassa; sandbox_status stays untested permanently. Full-project Psalm clean; full Testo suite passing (21 new tests) and full PHPUnit suite (3,877 tests) passing, after updating the legacy PaymentRefundControllerTest for the new constructor argument; live-curled the new routes against the running local site, confirming clean 400/404 responses rather than a 500 from a DI-wiring mistake (August 2026)
Payment Gateway SDK Audit — continuing the sweep that found Mollie's missing webhook, audited Stripe/Adyen/Braintree/GoCardless/Amazon Pay for SDK version drift and the same "missing async confirmation" gap. Stripe, Adyen, and GoCardless were already clean (current SDKs, real signature verification, existing idempotency guards). Two real findings: Braintree was one minor version behind the actual latest release, whose changelog documents a path-traversal security fix in the Dispute/Address gateways (confirmed unused by this app, upgraded anyway since it was a free same-minor-version bump); Amazon Pay had two @psalm-suppress MixedReturnStatement annotations — a direct violation of this project's own no-suppression convention — caused by the SDK's generateButtonSignature() declaring no return type at all; fixed with an explicit (string) cast (a real type narrowing, not a guess) instead of suppressing the warning. Full-project Psalm clean; Testo 670/670 and PHPUnit 3,877/3,877 passing (August 2026)
Mollie Webhook — an audit of the current mollie/mollie-api-php SDK usage (prompted by a similar audit/fix pass on Robokassa, YooKassa, and Amazon Pay's tests) found Mollie was the only currently-integrated gateway in this app with no webhook at all: payment confirmation relied entirely on the customer's browser completing the redirect back to mollieComplete(), which did a fragile reverse lookup ($mollie->payments->page(), no filter — Mollie's API defaults to the 50 most recent payments store-wide) to find the right payment by metadata; if the customer never returned, or 50+ other payments happened meanwhile, the invoice was silently never marked paid. Ground-truthed Mollie's actual webhook model directly from the SDK's own docs: a separate, newer HMAC-signed "next-gen webhooks" system exists for account-wide subscriptions to other resource types (Payment Links, Payouts, Disputes, …), but this app's one-off Payment resources use the older "classic" per-payment webhook — a plain POST carrying only id, no signature at all, authenticated by calling back GET /payments/{id} with this app's own API key and trusting only that response, the same "re-confirm via an authenticated GET" shape already used for Robokassa/YooKassa. New MollieWebhookHandler + dedicated MolliePaymentController (PaymentInformationController is already at SonarQube's php:S1448 method-count ceiling) close the gap; mollieComplete() gained an idempotency guard since the webhook can now arrive before or after that redirect. MollieApiClient is constructor-injected specifically so Mollie's own official test-fake (Mollie\Api\Fake\MockMollieClient) can be substituted — the first Testo test in this app to exercise a real SDK's actual JSON hydration rather than a hand-mocked HTTP shape. Full-project Psalm clean; Testo 670/670 and PHPUnit 3,877/3,877 passing; live-curled the new route against the running local site, confirming a clean 400 "missing id" rather than a 500 from a DI-wiring mistake (August 2026)
Public Payment Gateway Status Page — a new /gateway-status page, linked from the homepage, lists every gateway's regions, SDK version, last-updated date, and sandbox/live test status, rendered with the same GridView grid mechanics (sortable columns, real pagination, a region filter, mobile-stacking via data-label) as the app's internal list pages — not a static HTML table — via a new GatewayStatusListWidget modeled on the smaller UsersListWidget. resources/gateway-status/gateways.json is the human-edited, PR-reviewable source of truth; php yii gateway-status/rebuild resolves SDK versions from composer.lock (bumping last_updated only when the version actually changed) and php yii gateway-status/check-sandboxes pings each gateway's sandbox API — Stripe, Mollie, and GoCardless have confirmed side-effect-free checks wired up so far, gated behind separate *_SANDBOX_* GitHub secrets a new weekly Actions workflow (gateway-status.yml, modeled on benchmark.yml's commit-back pattern) reads; any gateway/secret left unconfigured is skipped, not failed, so region coverage can roll out incrementally. live_tested_at is 100% human-curated forever — this project's live-testing precedent has always been manual, and automating real payment flows on a schedule would be reckless. The underlying gateway_status table is the first entity in this app to live in its own Cycle-ORM-managed SQLite database (#[Entity(database: 'gateway_status')]) rather than the shared MySQL one, synced via the existing BUILD_DATABASE=true convention — verified locally (full 3,877-test PHPUnit suite) that this doesn't disturb the main MySQL schema, since Cycle's schema compiler runs both databases through one shared pass. Asia was chosen as the first region to prioritize; Robokassa is the first concrete Asia-region gateway being added, built as a direct HTTP integration (no third-party SDK, given this session's own recent third-party-dependency CVE fixes) against Robokassa's modern JWT-based invoice API rather than its legacy MD5 query-string scheme. Every endpoint/formula (CreateInvoice, OpStateExt, the Result URL callback signature, and the Refund API) is ground-truthed against Robokassa's own official OpenAPI spec (docs.robokassa.ru/openapi/robokassa.yaml), which also confirms neither CreateInvoice nor OpStateExt supports a sandbox/test mode at all (IsTest only applies to Robokassa's legacy, unused redirect scheme) — matching what was independently found by checking Robokassa's own site directly. Refunds (RefundService/Refund/Create) needed a real fix: an earlier pass assumed Robokassa had no refund API at all, but the spec documents one — now wired up, requiring its own separate Password #3 credential (only issued once Robokassa support enables the Refund API for the merchant) and an OpKey looked up via OpStateExt first, since the Refund API isn't keyed by InvId. ⚠️ Untested against a real Robokassa account: Robokassa has no sandbox at all for any of these endpoints (x-robokassa-environment: testSupported: false) — the entire integration (payment initiation, status checks, the webhook, and refunds) is verified only against the official spec and mocked HTTP responses in Testo, never against a live merchant account, because doing so requires signing up for one locally. sandbox_status on the /gateway-status page stays untested permanently for this reason. Anyone enabling this gateway in production should treat it as unverified end-to-end until a real Robokassa merchant account confirms it. YooKassa (formerly Yandex.Checkout) followed as a second Russia/CIS-market gateway, built the same direct-HTTP way and ground-truthed the same way (reading YooKassa's own official yoomoney/yookassa-sdk-php source directly): base URL, HTTP Basic Auth (shopId:secretKey), the Idempotence-Key header, /payments//refunds paths, and status enums are all confirmed from that source — and, since no live account exists to test against, specifically re-checked against YooMoney's own actively-maintained Bitbucket source (git.yoomoney.ru, v3.14.0/June 2026) rather than relying solely on the stale GitHub mirror (~v2.3.0/2022) most public references point to; every security/correctness-critical piece (base URL, auth, endpoints, the IP-only webhook model with its exact IP ranges, and the error envelope shape) came back unchanged across four years of drift. YooKassa's API has a genuine sandbox — a free test shop hitting the same production base URL — but signing up in practice requires a TIN (Tax Identification Number, i.e. a registered legal entity), so an individual can't obtain test credentials at all; same practical barrier hit with Robokassa (different underlying cause: Robokassa has no sandbox API at all; YooKassa has one but gates account creation itself behind a TIN), so this integration remains untested against a real account for now too. Its webhook is architecturally unlike every other gateway here: YooKassa notifications carry no signature at all, only a documented IP allowlist (YookassaWebhookIpVerifier, exact CIDR ranges from the SDK's SecurityHelper), so YookassaWebhookHandler treats a passing IP check as a fast pre-filter only and always re-confirms via an authenticated GET /payments/{id} before trusting a notification enough to mark an invoice paid. Both gateways' customer-facing checkout step is now built too: like GoCardless, both host their own complete payment page, so RobokassaPaymentController/YookassaPaymentController each got a thin InForm() (redirect straight to the gateway-hosted page) / Complete() (deliberately read-only — re-reads current balance rather than trusting the customer's redirect back, since both confirm payment asynchronously via their webhook) pair mirroring GoCardlessPaymentController's existing shape, wired into PaymentInformationController's dispatch alongside Adyen/GoCardless. Full-project Psalm clean; Testo (661/664, 3 pre-existing unrelated failures) and PHPUnit (3,877/3,877) suites passing (August 2026)
Database Backup Feature + Recurring Invoice / Backup Console Commands — a new Settings → Backup tab lets an admin download a gzip-compressed SQL dump on demand (DatabaseBackupService, entirely through Cycle ORM's own DBAL — no mysqldump binary, portable to shared hosting); hit and fixed a real production bug where PDO::quote()'s actual parameter type is a strict string despite Cycle's DriverInterface::quote(mixed $value) interface signature being looser, throwing on every int column. Two new console commands follow for unattended/cron use: setting/backup-database (persistent timestamped path + --keep=N retention, live-verified against local MySQL) and invrecurring/process, which replaces the pre-existing curl + cron_key-in-URL HTTP trigger with a real entry point — extracting InvRecurringController::cron()'s logic into a shared InvRecurringCronService surfaced a genuine live bug in InvRecurringRepository::active()/CountActive(), which queried non-existent next_date/end_date columns (confirmed via DESCRIBE inv_recurring against the real database) instead of the actual next/end, with broken OR-only due-date logic. Full-project Psalm clean; Testo 603/606 (3 pre-existing unrelated failures), PHPUnit 3,877, both passing; the recurring-invoice command was deliberately not run live in this session since — unlike the backup command — it creates real invoice rows (August 2026)
HomeCare QR Auto-Invoice — Pitfalls Found and Fixed — a pitfalls review of the QR-scan auto-invoice facility, partly informed by how Stripe (idempotency keys), ride-hailing apps (discrete trip-completion events), and field-service scheduling tools (ServiceM8/Jobber/Squeegee) solve the same class of problem, surfaced a race condition (the eligibility check ran outside the transaction that created the invoice, so two near-simultaneous scans could both pass before either committed) and — the more consequential finding — that the old rule blocked on "any invoice dated after the last payment, regardless of status," meaning an admin's completely unrelated invoice, credit note, or bulk copy run would silently pause a client's automation with no indication the two things were connected; worse, cleaning up a race-condition duplicate by deleting it would immediately regenerate another one. Fixed with a new HomeCareVisit table — one row per (client, calendar day) with a unique DB index, so the constraint itself (not application locking) makes concurrent/repeat scans safe — and re-anchored eligibility on this facility's own last generated invoice rather than the client's whole invoice history, so unrelated admin actions can no longer interfere. Also added: a per-client homecare_auto_invoice_paused override (previously only a site-wide switch existed), and a staff-only Settings → HomeCare → 📋 Scan Log page recording every scan's outcome and failure reason, since neither existed before and both the "not eligible" and "something's broken" customer-facing messages were previously indistinguishable and untraceable. Requires a BUILD_DATABASE=true schema sync for the new table/column before use. Updated August 2026: the dormancy pitfall (going silent forever if the client's most recently paid invoice happened not to contain a Service item) is now fixed too — findInvoiceToCopyIfEligible() walks the client's paid invoices most-recent-first via a new repoClientPaidInvoicesquery() (ordered by the immutable id rather than the editable date_created, capped at 50) until it finds one with a Service item, instead of requiring the single latest paid invoice to have one. Two pitfalls remain deliberately unfixed: the permanent, non-expiring QR token (an accepted tradeoff — rotation/expiry would need its own UX decision, not requested) and backdated payment dates shifting the anchor (now largely moot, since nothing in the eligibility chain reads Payment.payment_date at all — the only hypothetical residual exposure is an admin manually flipping status_id on the specific invoice a HomeCareVisit row already points to). Full-project Psalm clean throughout; Testo 604/607 (3 pre-existing unrelated failures, up from 594/597), PHPUnit 3,877, all passing (August 2026)
HomeCare inv/guest Hidden Columns + CSP Inline-Handler Sweep, Third Wave — three issues reported together turned out to be two root causes. script-src 'self' (no unsafe-inline) silently blocks raw onclick/onchange attributes — the same CSP bug class as the first two sweeps (docs/CSP_INLINE_HANDLER_SWEEP_GAPS.md) — hit a third time: clicking a date field anywhere except the calendar icon did nothing, and a grep for onclick.*showPicker across the whole resources/views tree turned up 17 form files still using the broken inline pattern instead of the project's own established data-action="show-picker" delegation (already correct on FormFields::dateCreatedField() and the reporting pages) — only 2 of the 17 had actually been reported. Settings → Front Page's "select all" checkbox hit the identical CSP block via its own inline onchange; fixed with a new data-action="select-all" primitive added to data-actions.ts (3 new Vitest cases, 19/19 passing). Separately, new homecare_hidden_inv_guest_columns setting gives partial_settings_homecare.php a second checklist scoped to inv/guest.php's own smaller column set (Paid, Credit Note, Client, Date Created, Due Date, Total, Balance) — deliberately a new setting rather than reusing the staff-side one, since the two grids share almost no column keys. Psalm clean throughout; full Testo (593/596, pre-existing unrelated failures only), PHPUnit (3,875), and Vitest (143) suites all unaffected (August 2026)
GoCardless Direct Debit — Setup Guide (Plain English) — step-by-step walkthrough for a first-time GoCardless setup, written to spell out everything GoCardless's own dashboard leaves implicit: Sandbox (manage-sandbox.gocardless.com) and Live (manage.gocardless.com) are two entirely separate accounts with no in-account toggle; use a plain Access Token ("Direct integration"), never a "Partner app" (OAuth, only relevant to platforms managing many separate merchants' own accounts); the token needs Read-write scope or mandate/payment creation silently fails; both the access token and the webhook secret are shown by GoCardless once, immediately after creation, and can't be viewed again; and the exact webhook endpoint URL/settings-page fields to fill in. Also documents two real 422 errors hit during live sandbox testing and now handled by the app rather than left to reoccur: Custom payment references are not enabled for your scheme identifier (this app never sets a custom reference — payments are matched to invoices via metadata instead) and Your integration has already completed this redirect flow (a redirect flow can only be completed once; GoCardlessPaymentController::goCardlessComplete() now guards on Inv::direct_debit_date already being set so a page refresh/back-button retry re-shows the completion page instead of calling GoCardless — and, before that guard existed, could have scheduled a second Direct Debit collection against the customer) (August 2026)
Identity::getId() vs Identity::getUserId() — Auth/RBAC Lookups Using the Wrong Id — three call sites in the login/logout/OAuth-TFA path (AuthController::resolveLoginResponse(), AuthController::logout(), Callback::tfaCheckBeforeRedirects()) called $identity->getId() — which only ever returns the identity table's own auto-increment primary key — where they actually needed the signed-in user's id, to look up user_inv, check the admin RBAC role, and clear TFA state. Since identity.id and identity.user_id (the FK to the user table, itself on its own independent auto-increment sequence) only coincide when every identity/user row pair is created together in lockstep — which the app's several different signup/OAuth/console-creation paths don't guarantee — the two silently drift apart over the life of the database with no self-correcting mechanism; confirmed on this project's own dev DB at 2,570 of 4,579 identity rows (56%) with id != user_id. Fixed by switching all three call sites to $identity->getUserId() (narrowed via instanceof Identity, since the interface AuthService::getIdentity() returns doesn't declare it). Diagnosed via live login testing and direct MySQL queries at each step, not code reading alone — ruled out session staleness and stale FastCGI worker state along the way, and confirmed user/assignRole never touches the identity table. Psalm clean, PHPUnit --filter Auth (49 tests) and Testo Unit suite (569/572, the 3 failures pre-existing/unrelated RSA-key-generation environment errors) both pass (July 2026)
"Copy All to Date" Bulk Action on inv/index — a new toolbar button lets a manager copy every invoice currently matching inv/index's active filters to a single new date in one click, distinct from the existing checkbox-driven "copy to client(s)" flow: no row selection and no client picker, each copy just stays with its own original client. Reuses the same InvRepository::filterCombined() the grid itself calls to define "all" (so it always means precisely what's on screen) and the same copyInvToClient() machinery the existing bulk-copy feature already relies on. Since this is the only bulk action on the grid with no explicit selection step to double as an "I chose these" confirmation, a JS-side confirm() dialog is the sole safety gate before it runs. Updated July 2026: a full-project vendor/bin/psalm --no-cache run (not run at the time of the original change) caught that copyAllToDate() was actually calling indexApplyFilters() — a method that never existed anywhere in InvController — as UndefinedMethod plus a knock-on MixedAssignment; fixed to call the same filterCombined() the grid itself uses. A second, unrelated finding from the same run — CategorySecondaryRepository::optionsDataCategorySecondaries()'s bare @return array causing a MixedArgumentTypeCoercion on InvsFilterOptions's constructor — fixed by tightening the annotation to @return array<array-key, string>. Full-project Psalm clean, full Testo suite (242 tests) unaffected (July 2026)
Geolocation Blocked in Production — Three Independent Permissions-Policy Sources — the Settings > Location tab's live GPS tester worked locally but always failed on yii3i.online with "Location permission was denied", indistinguishable by error code from a real browser denial. Traced live via curl -sI to a Permissions-Policy: geolocation=() header set independently in three places that all needed fixing: public/.htaccess (fixed first, alone not enough), a stray untracked /var/www/invoice/.htaccess predating the public/-as-DocumentRoot layout (never touched by git pull since it isn't in the repo) plus a duplicate line in the live ssl.conf, and — the one that actually explained the header still being wrong after every Apache-side fix — config/web/params.php's own 'security-headers' middleware, which deliberately mirrors public/.htaccess's headers in PHP so they survive a change of web server, and had simply drifted out of sync. A red herring along the way: rc-service apache2 restart kept reporting success while the same master PID persisted across every attempt — fixed with a hard stop/pkill -9/start instead of trusting restart. Confirmed live via matching curl header output and the tester working in-browser (July 2026)
WSL to Alpine Deployment — step-by-step guide for pulling updates from GitHub to a live Alpine/Apache2 server via WSL; git stash/pop workflow; file ownership (chown apache:apache); session save-path configuration; Psalm on server; SCP file transfer; deploy script; OAuth2 and RBAC debugging commands. Updated July 2026 after a real incident: a distributed bot run flooding /login saturated the app's global rate-limit bucket and locked out legitimate logins — traced to the actual live log files (corrected a stale error_log path in the doc's own Rate Limiter Diagnosis section along the way), fixed with a raised app-side limit plus a new fail2ban section (install, filter, jail, verify steps, all confirmed working against this server's real log format) that bans flooding IPs at the iptables level, since this server turned out to have no Cloudflare in front of it (yii3i.online resolves straight to the origin Vultr IP) and Turnstile alone can't prevent bucket-saturation since it only runs after the rate limiter already counted the request. Also documents that mod_evasive isn't packaged for Alpine at all, and the decision not to compile it from source (unaudited C code in the Apache process, no apk upgrade security updates, manual recompiles forever) in favor of fail2ban as a third independent layer alongside the app rate-limiter and Turnstile
SonarQube Fixes: invoice.ts Cognitive Complexity + InvsColumnBuilder S138 — two thresholds tipped over by this month's inv/index work (Worker allocation column, "Copy All to Date"). handleClick()'s cognitive complexity (16, limit 15) fixed the same way the file already handled its PDF/HTML export checks — a new handleCopyClick() groups the three "copy invoice" branches (spreadsheet import, multi-copy, single copy) into one, mirroring the existing handleExportClick(). InvsColumnBuilder::buildColumns()'s line count (173, limit 150) was the harder one: the class was already sitting at exactly 20 methods (the S1448 ceiling), so a new named method to shrink it would have traded one violation for another — fixed with the same trick used for AuthController earlier this session, moving the Worker-column and quick-pay-column builders into a new InvsWorkerColumnTrait, since SonarQube doesn't count trait-provided methods toward the consuming class even though they're fully callable via $this->. buildColumns() is now 113 lines; InvsColumnBuilder's own method count reverified at exactly 20, not assumed. Full-project Psalm clean, Testo suite (242 tests) unaffected (July 2026)
Settings "Location" Tab — Live GPS Tester + Capture Placeholder — a new Settings tab rather than cramming a permission-prompt-driven widget into the inv/index breadcrumb (GPS is browser-only — navigator.geolocation, nothing PHP can read server-side). One card is a live "Test My Location" button (SettingsHandler.handleGeolocationTestClick()) that renders lat/long/accuracy straight from the browser with nothing submitted to the server, with specific messages for unsupported browsers, non-HTTPS contexts (this project's own invoice.myhost WAMP vhost doesn't qualify — only localhost does), and each of the three GeolocationPositionError codes rather than one generic failure. The other card is a new capture_gps_on_send toggle, off by default and honestly described as doing nothing yet — it's a placeholder for the still-unbuilt half of the worker/manager status workflow idea (capturing the manager's GPS + worker name at the moment an invoice is released to "sent"), whose worker-allocation half already shipped earlier this month. Full-project Psalm clean, Testo suite (242 tests) unaffected (July 2026)
What YII_ENV Actually Controls, and the Real Cause Behind "Clear the Cache" — grew out of a support question ("a new navbar link isn't showing on yii3i.online after a push") whose real cause was mundane (the edit had simply never been committed), but tracking it down surfaced the one genuine environment-driven cache mechanism in the app worth documenting precisely: YII_ENV=prod (via config/environments/prod/params.php) is the only place enableCache gets turned on for yiisoft/router-fastroute's UrlMatcher, which then caches the compiled FastRoute dispatch table with no TTL and no invalidation logic whatsoever — since runtime/ is gitignored, a git pull can never clear it on its own. Draws a hard line around what this cache does and doesn't affect: it's scoped purely to route matching — view/layout content (like a navbar link) is plain PHP re-executed on every request with zero caching anywhere in the stack, so "a menu item isn't showing up" is never this mechanism; it's almost always an uncommitted or unpushed file. Also documents the one other thing YII_ENV drives — SettingRepository::getEnv(), consumed in exactly two places (Auth/Trait/Callback.php, Auth/Trait/Oauth2.php) to gate the HMRC developer-sandbox OAuth2 test-user flow to dev only. Updated same month: after CacheInterface switched to APCu (below), php yii cache/clear's own APCu-clearing half turned out not to work for this case either — it runs on the CLI, which PHP gives its own memory pool entirely separate from the web server's, so it can never reach the cache the website is actually serving from. Restarting Apache/PHP-FPM is the real fix for a route change on prod now, not cache/clear (July 2026)
HomeCare Worker Allocation — inv/index Assignment and Scoped Guest Portal — lets a manager allocate a HomeCare invoice to a field worker from a new dropdown column on inv/index, via a new Worker entity with a genuinely-nullable Inv.worker_id BelongsTo. A new worker RBAC role — deliberately narrower than observer (no view.payment, no edit-type permissions) — gives the worker their own login, linked to a Worker record from the existing userinv/index admin screen after an ordinary signup. On inv/guest, a linked worker bypasses the usual client-assignment gate entirely and instead sees exactly (and only) whichever invoices are currently allocated to them, live, via a new InvRepository::repoWorkerVisible(); payment info (paid/total/balance columns, BACS quick-pay) is hidden for a worker-scoped request specifically, not just gated by the missing permission elsewhere. Full-project Psalm clean, new WorkerTest + full Testo suite (242 tests) passing, DB-level smoke-tested inside a rolled-back transaction (July 2026)
Invoice Checkbox-Copy — Full Bug Hunt, and Back-Button Fixes — hands-on live testing (creating invoices, copying them, inspecting the database directly) surfaced a chain of pre-existing bugs, each masking the next: invToInvInvAmount() refactored to mirror the already-correct SalesOrderToInvoiceConverter::soToInvoiceSoAmount() pattern (operating on each Inv's own attached InvAmount relation object rather than a re-fetched detached one), eliminating both the wrong-inv_id bug and the Cycle BelongsTo NullException at the root; saveInvAmountViaCalculations() deleted as dead code. invToInvInvTaxRates() used the array key 'amount' instead of the required inv_tax_rate_amount, so invoice-level tax was silently dropped on every copy — confirmed against soToInvoiceSoTaxRates()'s correct usage of the same key. copyInvToClient() never called invToInvInvAllowanceCharges() at all, unlike its sibling copy functions. InvItemService::saveInvItemAmount() applied the item's tax rate to the charge-inclusive subtotal instead of adding each charge/allowance's own vat_or_tax separately (matching the interactive add-charge UI's formula), which only coincidentally matched when rates lined up. Also fixed a silent, unrelated UI bug found along the way: the "back" buttons on inv/view and quote/view sat inside a data-bs-toggle="tab" nav, so Bootstrap's Tab plugin intercepted every click and called preventDefault() regardless of href, silently going nowhere; fixed by removing that attribute from just the back link and giving it a real href. salesorder/view had no back button at all — added one. Full-project Psalm clean throughout (July 2026)
Invoice Checkbox-Copy — Wrong InvAmount inv_id + Missing Cycle Relation Fix — copying an invoice via the checkbox on inv/index produced a new invoice whose amount didn't display correctly until it was opened once. invToInvInvAmount() (MultipleCopy.php, the sole code path that populates a copy's InvAmount row) built its save array with inv_id taken from the original invoice's InvAmount rather than the copy's own id — since Inv has a HasOne relation to InvAmount keyed on inv_id, this overwrote the copy's own foreign key to point at the original invoice, detaching it from the new one, so inv/index's by-inv_id lookup found nothing until opening the invoice ran NumberHelper::calculateInv() and re-saved it with the correct id; also fixed an adjacent copy-paste bug in the same block (packhandleship_total was reading getPackhandleshipTax() instead of getPackhandleshipTotal()). Fixing the inv_id value surfaced a second, deeper pre-existing bug live: InvAmountService::saveInvAmountViaCalculations() only ever set the plain inv_id scalar column, never Cycle ORM's separate, required (nullable: false) BelongsTo relation object (InvAmount.inv) — throwing Cycle\ORM\Exception\Relation\NullException on every checkbox-copy instead of the original silent-wrong-value symptom. Fixed by routing through the existing persist() helper (already used correctly by saveInvAmount()) to resolve and attach the Inv entity before saving. Psalm clean; existing InvAmountService test coverage (12 tests) unaffected (July 2026)
Adyen Guest Payment — Session countryCode Fix — selecting Adyen on the guest invoice page rendered the Drop-in fine, but choosing certain payment methods (observed with Pay by Bank) failed immediately with Adyen's generic red-cross error. Traced live via browser DevTools to a 422 Field 'countryCode' is not valid on POST /v1/sessions/{id}/payments: AdyenPaymentController::resolveCountryCode() correctly resolved the client's country, but only passed it to the front-end AdyenCheckout() config, never to session creation — so AdyenPaymentService::createSession() returned Adyen's full unfiltered payment-methods list (including country-restricted, US-only methods) for every session regardless of the guest's actual country. Fixed by passing countryCode into CreateCheckoutSessionRequest at session-creation time so Adyen filters methods to ones valid for that country. Psalm clean, existing Adyen test suite (22 tests) unaffected (July 2026)
Login Denial Message — Distinguish "Email Not Verified" from "Contact Administrator" — a HomeCare (or generic) signup customer who hadn't yet clicked their emailed confirmation link saw the same "contact the system administrator" message as an admin-deactivated account; AuthController::handleNonTfaPath() now checks for a still-live (unclicked) email-verification/homecare-email-verification token before falling back to the generic message, and shows "Access Denied: Click on the verification link sent to your email address." instead, via a new site/emailnotverified route/view mirroring the existing adminmustmakeactive pattern. Flagged, not-yet-fixed follow-up: an unrelated pre-existing disableToken() call on the same path invalidates a generic-signup user's real verification token on their first failed login attempt, so the new message only holds up reliably for HomeCare signups until that's addressed. Psalm errorLevel 1 clean (July 2026)
HomeCare Signup — Public Self-Service Flow — new unauthenticated /homecare-signup form/confirm flow (HomeCareSignupController/HomeCareSignupForm, deliberately separate from the generic SignupController) that always creates a Client and, only once the emailed confirmation link is clicked, resolves/creates the street (Family) and house-number Product (Service-type) and raises the first invoice — no durable business records exist for an unconfirmed/bot signup. Found and fixed a real data-integrity bug along the way: the initial street-name resolution used an unescaped LIKE match, so a %/_ in a customer-typed street name could silently merge two unrelated runs; replaced with an exact match on both family_name and category_secondary_id — the latter resolved from a form dropdown, with a not_set_yet_<timestamp> placeholder auto-created (never null) when the customer's area isn't listed yet, so two "new area" signups can never collide. Both actions switched from renderPartial() (explicitly skips the layout) to render() to inherit the site nav/footer, matching the generic signup flow; also surfaced and fixed an app-wide theming gap where the Bootstrap floating-label form theme — correct for single-input fields — broke RadioList groups (label rendered after and overlapping the options), fixed via a fieldConfigs override in config/common/params.php mirroring the existing Checkbox::class fix. Psalm errorLevel 1 clean throughout (July 2026)
Stripe Pay by Bank — Open Banking for UK & Finland — documents how to enable Stripe's Pay by Bank Open Banking payment method (UK and Finland are both generally-available customer locations per Stripe's docs, France/Germany/Ireland still private preview) so customers pay directly from their bank account/app instead of a card; this app already supports it with zero code changes since StripePaymentService::createPaymentIntent() creates every PaymentIntent with automatic_payment_methods.enabled = true rather than a hardcoded method list, so which methods appear is driven entirely by Settings → Payment methods in the Stripe Dashboard — turning on Pay by Bank and turning off Cards there is enough to go "Pay by Bank only"; covers the customer's bank-app redirect/approval flow, that it reuses the existing payment_intent.succeeded webhook handling unchanged, and its limitations (no recurring payments, no manual capture, no disputes, refunds supported up to 730 days) (July 2026)
Payment Gateway Refund — Live Testing & Adyen v6 Upgrade — the refund dropdown on payment/index (PaymentRefundController) had only ever been proven via a script calling each gateway's refund() directly; this pass drove it through the real UI end-to-end for all four PCI-compliant gateways and verified every result against the provider's own API/dashboard, not just this app's database — Stripe, Braintree (sandbox needs a manual Gateway::testing()->settle() force-settle before refund is possible — real Braintree\Test\Transaction::settle() is a trap, it hits an unconfigured global gateway), and Mollie (first genuine success-path refund test; previously only proven against a fake, rejected reference) all passed cleanly. Adyen surfaced a real production bug: the pinned Web SDK v5.40.0 crashed outright ("The following properties should not be passed to the client: askDonation") because Adyen's /sessions response now always includes a Giving/Donation field v5's Drop-in rejects — fixed by upgrading to v6.41.0, which required real code changes (confirmed against the actual CDN bundle, not just docs): the global renamed window.AdyenCheckout → window.AdyenWeb, Drop-in creation moved to a new AdyenWeb.Dropin(checkout) constructor, and countryCode became mandatory (resolved via the existing CountryHelper/league-iso3166 lookup). Since adyenComplete() is deliberately read-only and Adyen has no Stripe-CLI equivalent for local webhook forwarding, payment/refund confirmation was verified by replaying a genuinely HMAC-signed AUTHORISATION notification — built from a real sandbox transaction's actual pspReference and signed with the app's own configured HMAC key — against the local webhook route directly, exercising the real signature-verification and handler code end-to-end (July 2026)
Adyen Payment Gateway — Live Testing & Cross-Gateway CSP Fixes — Adyen added as a fifth PCI-compliant gateway and driven live end-to-end through the browser (session creation → Drop-in render → card/bank/Paysafecard), surfacing two external config gaps — a gateway_adyen_merchantAccount typo (ECON → ECOM, confirmed via Adyen's own API error in app.log) and the Adyen Client Key's Allowed-Origins CORS allowlist never including http://localhost (the actual "Adyen cannot test locally" blocker, fixed in the Adyen Customer Area, not code) — plus a batch of CSP domain gaps only visible by watching the console during a real payment flow: img-src missing *.adyen.com/*.cdn.adyen.com and separately *.media-amazon.com (Amazon's logo CDN, distinct from *.payments-amazon.com); connect-src missing Amazon's regional payments API domain (payments-eu.amazon.com etc. — first fix attempt used the CSP-invalid partial-label wildcard payments-*.amazon.com, silently ignored by browsers; corrected to *.amazon.com) and missing *.braintree-api.com entirely (Braintree Drop-in v3's tokenization API lives on a separate second-level domain from *.braintreegateway.com). Two unrelated bugs caught by the same live pass: the BACS quick-pay modal's inline <script> (ClipboardJS init) blocked by script-src — moved to src/typescript/bacs-quickpay.ts matching the payment-adyen.ts/payment-braintree.ts pattern — and a load-order bug where guest.php registers the Bootstrap-dependent bs5-lightbox asset before Bootstrap itself (reversed from invoice.php), throwing Cannot read properties of undefined (reading 'Modal') on every guest-facing page; fixed at the asset-dependency level ($depends on BootstrapJsOnlyAsset/BootstrapCdnJsOnlyAsset) rather than layout call order, which had already silently drifted out of sync once. Stripe's remaining console output confirmed informational only — HTTP-testing notices and Apple/Google Pay's inherent HTTPS requirement, not CSP or code issues. config/web/params.php and the mirrored public/.htaccess CSP header kept in sync throughout, per the established pattern (July 2026)
Payment Gateway Live Testing — Real Bugs Found Only Under End-to-End Testing — driving real invoices through Stripe/Braintree/Mollie/Amazon Pay end-to-end (admin-created invoice → Observer-role login → pay → server-side log/DB verification) surfaced 9 real defects invisible to static review and unit tests: Stripe's webhook secret was stored as plaintext (silently produces garbage on decrypt, no error — AES-256-CTR has no integrity check), the payment_method table was missing IDs 1–8 that every gateway hardcodes (FK violation on first live webhook), an (null !== $x) ?: 'unknown' boolean-cast-ternary bug recorded every payment reference as literal "1" instead of the real invoice number (present in original stripeComplete(), copied into the new webhook, and found identically in mollieComplete()), writing invoice status before the payment/merchant audit record left one invoice "paid" with no audit trail after a mid-request crash, Stripe's client-redirect succeeded status could race ahead of the async webhook and show a false "Payment failed", Braintree's card-nonce form had no CSRF token at all (the only one of the four gateways that POSTs a card nonce natively back to our own server), and Amazon Pay's CSP img-src was missing *.payments-amazon.com despite every other directive including it, silently breaking its button graphics. Amazon Pay's live payment itself stayed blocked on external Seller Central sandbox setup (storeId/clientId), confirmed as a config gap, not a code defect, after ruling out CSP via the live response header (July 2026)
Stripe Payment Gateway — Webhook Signature Verification & PaymentGatewayInterface — stripeComplete() previously marked invoices paid by trusting a client-supplied ?redirect_status=succeeded query parameter with no server-side confirmation — forging that URL could mark any invoice paid with no payment made. New POST /paymentinformation/stripeWebhook (outside RoutePermission::invoiceGroup(), matching the telegram/webhook precedent) verifies Stripe's signed events via StripePaymentService::verifyWebhookSignature() against a new webhookSecret setting and becomes the sole writer of payment status; stripeComplete() is now read-only, re-reading current state rather than trusting the redirect. New App\Middleware\CsrfExemptMiddleware decorates the globally-applied CsrfTokenMiddleware so this one webhook path skips CSRF validation (which would otherwise 422 every call from Stripe's servers before the signature check ever ran) — telegram/webhook/as4/receive look like they have the identical gap, flagged but not fixed. New PaymentGatewayInterface (getDriverKey()/isConfigured()/verifyPayment()) implemented fully for Stripe and retrofitted as thin, behavior-unchanged methods onto Braintree and Amazon Pay (both classes stay in active use regardless, so the conformance can't silently rot); a same-shape MolliePaymentGatewayAdapter was written too but had zero consumers anywhere — caught by a full-project Psalm run (UnusedClass; per-file Psalm explicitly can't detect this) and deleted rather than left as dead code, so Mollie has no interface conformance for now. Open Banking excluded on purpose — its SDK only exposes payment-creation calls, not lookup-by-reference, so a verifyPayment() there would be actively misleading. Stripe's JS/CSS now scoped to only its own payment page (previously loaded on every page in the app); dead stripeIncomplete route and a duplicate PaymentIntent-creation helper removed; new StripeWebhookSignatureTest covers the signature-verification primitive directly (pure HMAC, no network I/O — StripePaymentService itself can't be unit-tested due to a pre-existing dependency on the concrete final SettingRepository class). Post-merge SonarCloud CI (not local Psalm, which has no equivalent complexity checks) flagged the controller at 21 methods (php:S1448, limit 20) and stripeWebhook() at 5 returns (php:S1142, limit 3); fixed by extracting the shared recordOnlinePaymentsAndMerchant() (also used by Braintree/Mollie) into standalone Service\OnlinePaymentRecorderService, and the webhook's own signature/lookup/write logic into Service\StripeWebhookHandler with its guard-clause chain decomposed across resolveContext()/applyEvent() — controller action is now a one-line delegator, re-verified live end-to-end against a real invoice afterward. Doc includes a full local-testing setup guide for the Stripe CLI (winget install --id Stripe.StripeCli — note the exact casing, StripeCLI doesn't exist — stripe login, and critically stripe listen --forward-to <url>, since running bare stripe listen without --forward-to looks identical to working but silently never calls the app at all). Psalm errorLevel 1 clean (July 2026)
Turnstile Widget Silently Broken by CSP — Missing challenges.cloudflare.com — login broke immediately after configuring a real Turnstile secret key; root cause was CSP script-src/frame-src/child-src never allowing challenges.cloudflare.com, so the widget silently failed to render and cf-turnstile-response stayed permanently empty — invisible beforehand only because verifyTurnstile() bypasses checking entirely when no secret is configured; fixed by adding the domain to script-src/frame-src/child-src/connect-src in both config/web/params.php and the mirrored public/.htaccess, matching how Stripe/Braintree already appear across those same four directives (July 2026)
System Updates — PHP Version Check — new Settings tab checks php.net for a newer PHP patch release on the running major.minor branch, via a cached background console command (php yii system/check-php-version, matching the existing peppol-check/as4/monitor pattern) plus an on-demand "Check Now" button; four platform buttons (yii/alpine/linux/wamp) show copyable — never executed — upgrade commands, a hard requirement consistent with this session's CSP hardening work; PhpVersionCheckService shared between the console command and SettingController::checkPhpVersionNow(); extended SettingRepositoryInterface with withKey()/save() (confirmed SettingRepository is its only implementor first) since the previously read-only interface couldn't support persisting the cached result; frontend reuses data-actions.ts with two new generic primitives (toggle-panel, copy-to-clipboard) rather than a bespoke script; 9 new PHPUnit tests establish this codebase's first Guzzle MockHandler testing pattern; full suite clean (PHPUnit 3,702+69+9, Vitest 135, Psalm 0 errors); verified end-to-end against the real php.net API in dev, browser-rendering not verified due to no local MySQL in this sandbox (July 2026)
yii-dataview DropdownFilter CSP Bug — Reported and Fixed Upstream — filed yiisoft/yii-dataview#344 (root cause: DropdownFilter renders inline onChange="this.form.submit()", silently blocked by any strict script-src, final class gave consumers no workaround) and yiisoft/yii-dataview#345 (adds submitOnChange(bool $enabled): self, defaults true so existing output is byte-identical, non-breaking); verified against the real upstream toolchain before opening the PR — 514/514 tests, Psalm/php-cs-fixer/Rector all clean — after discovering the rossaddison/yii-dataview fork's master was a long-stale pre-1.0 branch and branching directly off upstream/master instead; this app's own data-actions.ts workaround stays regardless of upstream merge timing (July 2026)
CSP Inline-Handler Sweep Gaps — Second Wave — the original CSP hardening sweep searched for literal <script/onclick= text and missed PHP array-based ->addAttributes(['onclick' => '...']) attributes plus a vendor-rendered inline handler entirely outside app source; 17 instances across 12 files (inv/index dropdown filters via vendor/yiisoft/yii-dataview's DropdownFilter, group-row collapse, toolbar expand/collapse-all, 5× delete-confirm — one of which also fixed a latent unescaped-apostrophe JS-injection bug from string-concatenated confirm() calls — and showPicker/history.back) all reused the existing data-action/data-confirm delegation in src/typescript/data-actions.ts rather than inventing new mechanisms; added data-actions.test.ts (zero prior coverage) plus 3 new list-utils.test.ts cases, catching and fixing a listener-accumulation bug along the way (131/131 passing); documents that neither Vitest/jsdom nor the existing Codeception PhpBrowser Acceptance suite can catch this bug class since neither enforces CSP against real rendered JS — a Playwright or Codeception-WebDriver test is a flagged, not-yet-actioned follow-up (July 2026)
Angular Build Blocked by TypeScript 7 — Known Limitation — @angular-devkit/build-angular was never declared in package.json despite angular.json requiring its builders (:browser/:dev-server/:extract-i18n/:karma) — fixed, added at exact 22.0.7 matching this repo's Angular-pinning convention, reproduced as broken on Windows too (not Alpine-specific). That fix surfaced a deeper, still-unresolved conflict: @angular/compiler-cli@22.0.6 peer-requires typescript ">=6.0 <6.1" but this project deliberately pins typescript ^7.0.2 (plus @typescript/native-preview) for the ES2024/esbuild toolchain — TS 7's restructured internals break readConfiguration inside compiler-cli (Cannot read properties of undefined (reading 'Error')). Decision: documented as a known limitation rather than scoping an npm overrides entry or downgrading TypeScript project-wide, since Angular integration is already flagged fragile/WIP elsewhere in package.json; npm run build:css && npm run build:typescript works as a workaround, build:angular stays broken until Angular ships TS 7 support (July 2026)
esbuild Scripts Broke on Linux — node <path> vs Calling the Binary — build:typescript:dev/:auth/:prod hardcoded node node_modules/esbuild/bin/esbuild ...; esbuild's postinstall replaces that file with the real native binary on POSIX once the platform package (@esbuild/linux-x64) installs, so Node choked trying to parse raw ELF bytes as JavaScript (SyntaxError: Invalid or unexpected token) — worked on Windows only because that swap doesn't happen there; fix: call esbuild directly and let npm's node_modules/.bin shim resolve it per platform; verified identical bundle output on Windows post-fix (July 2026)
Updating PHP 8.4 on Alpine Linux — Companion to the initial setup guide: apk update + apk policy php84 to check what's available, apk upgrade $(apk info | grep '^php84') to update just the PHP packages (safer than a full-system upgrade on a live box), then a mandatory rc-service apache2 restart (mod_php) or rc-service php-fpm84 restart (php-fpm) since the CLI version updates immediately but the running web-server worker doesn't; covers Alpine's package version lagging behind the latest php.net upstream release and checking for *.apk-new config files left behind by the upgrade (July 2026)
Security Hardening Audit — All 9 Findings Fixed — Static/config-level security review, now fully remediated. Critical: file uploads (CompanyPrivateController, ProductAttachmentController) validate extension/MIME/size via Yiisoft\Validator\Rule\File before moveTo(); session cookie Secure flag now driven by SESSION_COOKIE_SECURE env var. High: /scan/{token} HomeCare QR endpoint rate-limited (global + per-IP); cookie-signing secret moved to COOKIE_SECRET_KEY env var. Medium: new SecurityHeadersMiddleware adds HSTS/Permissions-Policy at the PHP layer; CSP script-src hardened to 'self' — no unsafe-inline/unsafe-eval — with ~15 inline <script>/onclick/hx-on: blocks moved into src/typescript/*.ts (see Content Security Policy Updates); login now gated behind 2FA-or-admin-role plus a 5-attempts/15-minute per-account lockout. Low: 4 unescaped Html::encode() misses found and fixed via a full sweep; RBAC audit added the missing manage.hmrc permission and fixed 5 routes with broken or missing auth middleware. Follow-up regression caught after deploy: the CSP change silently broke Bootstrap Icons and other CSS site-wide — several AssetBundle classes loaded stylesheets via media="print" + inline onload="this.media='all'", and the new policy blocked that onload; fixed in 4 files by loading them as normal blocking <link>s instead. Psalm errorLevel 1 clean throughout (July 2026)
Home-Care QR Auto-Invoice Facility + Routes Config Refactor — Customer-facing recurring-invoice facility: a client's QR code (Client::client_qr_token) scans to a public, unauthenticated public/homecare-scan route (/scan/{token}) — a deliberate, scoped exception to the app's usual guest-access model — where HomeCareCleaningEligibilityService decides whether a new invoice should be generated (client has an invoice on file, last invoice paid with a payment date on record, nothing dated since) and generateHomeCareCleaningInvoice() reuses the existing invoice-copy machinery rather than a new converter, always forcing status_id = 2 (sent); print actions on both the guest (inv/guest/qr) and staff (client/printQrCode/{id}) sides share one ClientService::getOrCreateQrToken(). Alongside this, config/common/routes/routes.php (2,582 lines, 70+ controllers in one block) was split into 71 strictly-per-controller files via a depth-tracking script, verified byte-identical against php yii router/list at every pass; splitting surfaced 548 duplicated permission-check closures and 71 duplicated Group::create('/invoice') wrappers, eliminated via new App\Middleware\RoutePermission and App\Middleware\RateLimiter static helpers (PHP traits don't apply to plain route-config scripts); confirmed with standalone phpcpd/phpmd PHARs — "No clones found" (down from 1.41%). New php yii router/list --controller[=<name>] option (also wired into m.php) adds a Controller column and filters by controller-name prefix. Psalm errorLevel 1 clean · Testo Unit 139/139 (July 2026)
HMRC MTD Developer Sandbox — OAuth2 Backend Integration — HmrcApiCatalogue curates 8 MTD APIs (VAT, Self Assessment, Self-employed Business, etc.) with scope, identifier type (VRN/NINO/EORI), and route map; backend/hmrc dashboard shows a Full API Catalogue card (always visible, rows green-highlighted when within granted token scope), an Available APIs dropdown driving navigation to API-specific controller actions, and a "Log in with HMRC" button that initiates PKCE OAuth via auth/authclient; callbackDeveloperGovSandboxHmrc now checks getIdentity()->getId() !== null immediately after storing the 5 HMRC session tokens — if the admin is already authenticated it redirects straight to backend/hmrc without switching the session user; the login-page "Continue with Developer Gov Sandbox UK" button and its no_developer_sandbox_hmrc_continue_button setting were removed entirely — HMRC OAuth is now exclusively for API authorisation from backend/hmrc. Psalm errorLevel 1 clean (July 2026)
Mockery Bridge — Testo Integration — testo/bridge-mockery wired via MockeryPlugin in testo.php; Mockery::close() called automatically after every test so expectations are always verified with no per-test tearDown() boilerplate. Solves three pain points in this codebase: final Cycle ORM repository classes that PHPUnit's createMock() cannot subclass; fluent expects()/allows()/spy() API that separates strict expectations from stubs; and automatic teardown that prevents silently-skipped mock assertions. First use: As4RetryEngineTest — three detectMissingReceipts() scenarios (empty queue, null firstSentAt, EBMS:0301 timeout) all tested against interface mocks with no database. Psalm errorLevel 1 clean (July 2026)
Auth Controllers — Full Hardening Implementation — Seven-fix bot-hardening pattern extended to all five auth routes: /change (10/60 s global, 3/60 s per-IP), /forgotpassword (5/60 s global, 2/60 s per-IP — strictest, triggers email), /resetpassword/{token} (10/60 s global, 3/60 s per-IP — already token-gated). All routes now have LimitAlways + LimitCallback CF-Connecting-IP + TooManyRequestsMiddleware on both layers + Turnstile widget + pre-hydration verify + checkRateLimit() with distinct key prefixes. TurnstileVerification trait now shared by all four auth controllers. Repository interfaces introduced (InvRepositoryInterface, InvItemRepositoryInterface, SettingRepositoryInterface, etc.) to unblock mocking of final classes in PHPUnit. Dual @dataProvider + #[DataProvider] annotation pattern established for PHPUnit 13 + Codeception compatibility. PHPUnit 3,727 OK · Codeception 3,673 OK · Psalm no errors (July 2026)
Change Password Route — Bot Susceptibility Analysis — Assessment of /change against the seven fixes applied to /login (July 2026): route has no middleware (fixes #1–#3 all missing); FormHydrator runs before any IP check (fix #4 missing); AuthSecurityHelper not injected so checkRateLimit() is never called (fixes #6–#7 missing). Key mitigation: isGuest() guard means unauthenticated bots cannot reach POST processing, narrowing the threat to compromised authenticated sessions and scripted abuse. Priority gaps: no route-level rate limiting (High), checkRateLimit() absent (High), hydration before IP check (Medium), no Turnstile (Low–Medium). Recommended fix mirrors the login pattern: LimitAlways + LimitCallback closures in routes.php, AuthSecurityHelper injected, checkRateLimit() called before hydration, storage key prefix sha1('change_ctrl' . $ip) to avoid GCRA collision (July 2026)
Login Route — Hardening Implementation — Seven fixes applied to /login after the bot-susceptibility analysis (July 2026): LimitCallback reading CF-Connecting-IP for correct per-IP GCRA buckets; LimitAlways global counter (30/60 s) collapsing distributed botnet traffic; TooManyRequestsMiddleware on both rate-limit layers so FileCache CAS contention returns 429; Turnstile widget on the login form with token verification before FormHydrator runs; inner per-IP Counter(5,60) decoupled from the old test-inflated limit; HTTP_CF_CONNECTING_IP first in AuthSecurityHelper::getClientIpAddress() header chain; checkRateLimit() wired into login() with a distinct key prefix to activate the defence-in-depth layer. verifyTurnstile() extracted to a shared TurnstileVerification trait used by both AuthController and SignupController, eliminating the duplication SonarQube would have flagged. Psalm errorLevel 1 clean (July 2026)
Login Route — Bot Susceptibility Analysis — Seven deficiencies in the current /login protection (July 2026): LimitPerIp reads REMOTE_ADDR which is Cloudflare's edge IP behind the proxy — all users and bots share one bucket; no LimitAlways global path counter means a 900-IP botnet can deliver 18,000 attempts per 10 s before any per-IP bucket triggers; no failStoreUpdatedDataMiddleware means FileCache CAS contention silently forwards requests; no Turnstile on the login form allows unlimited automated credential-stuffing at zero CAPTCHA cost; the DI-bound limit of 20/10 s was raised for test-suite compatibility, not security, allowing 120 guesses per minute per IP; HTTP_CF_CONNECTING_IP is absent from AuthSecurityHelper::getClientIpAddress() header chain; checkRateLimit() is never called from login() leaving a defence-in-depth layer disconnected. Partial mitigations: 2FA (when enabled), userInv->getActive() gate, session ID regeneration on success — all operate after authentication, not before (July 2026)
Rate Limiter & Signup — Bot-Wave Hardening — Four fixes implemented after a 900-bot signup wave (July 2026): LimitCallback reading CF-Connecting-IP so the GCRA bucket fingerprints the real client IP rather than Cloudflare's edge IP (REMOTE_ADDR); a layered LimitAlways global path counter (50/10 s) that rejects the botnet's combined traffic before any per-IP bucket is consulted; TooManyRequestsMiddleware wired as the failStoreUpdatedDataMiddleware on both rate-limit layers so FileCache CAS contention returns 429 instead of silently forwarding the request; and verifyTurnstile() moved before FormHydrator::populateFromPostAndValidate() so malformed POST bodies that would fail validation early can no longer bypass Turnstile verification. Deployment assumption: direct origin access must be blocked at the firewall so CF-Connecting-IP cannot be spoofed. Psalm errorLevel 1 clean (July 2026)
Rate Limiter & Signup Bot-Protection — Known Limitations and Fixes — Six structural limitations in LimitRequestsMiddleware exposed by a 900-bot signup wave (July 2026): REMOTE_ADDR wrong behind Cloudflare's proxy (critical — real IP is in CF-Connecting-IP); per-IP GCRA bucket useless against botnets with distinct IPs; FileCache CAS failure silently allows requests through when no failStoreUpdatedDataMiddleware is set; Turnstile verification fires only after form validation passes, letting malformed POSTs bypass it for free; rate-limit headers (X-Rate-Limit-Reset) advertise the retry window to bots; GET and POST counted in separate buckets doubling the effective attempt allowance. Fix summary: LimitCallback reading CF-Connecting-IP, layered LimitAlways global counter, APCu storage or explicit fail-middleware, move Turnstile before hydration.
Golf Lessons & the Peppol Network — A Treasurer's Guide — A non-technical explainer for golf club treasurers who have never heard of Peppol: the full booking lifecycle from Purchase Order to Receipt Advice told in golf terms (England Golf register = SML, club mailroom = Access Point, official scorecard format = UBL 2.4, recorded-delivery envelope = AS4); the four-corner model illustrated with the PGA Pro ↔ Golf Club flow; what Yii3-i does at each corner (outbound: SoapEnvelopeBuilder → WsSecuritySigner → As4HttpClient; inbound: As4ReceiveController → As4DuplicateDetector → As4InvoiceImportService); the bilateral demo setup (localhost = club, yii3i.online = pro, ngrok for reverse direction); a full Peppol ↔ Golf glossary; and the one-component swap (StaticAs4SmpResolver → As4SmpResolver) that turns the demo into production Peppol (July 2026)
AS4 Bilateral Test Infrastructure — StaticAs4SmpResolver + as4/test-send — StaticAs4SmpResolver implements As4SmpResolverInterface and returns a fixed As4SmpEndpoint from env config, bypassing SMP/SML DNS for direct node-to-node testing between localhost and yii3i.online; php yii as4/test-send command sends a minimal bilateral ping XML (or a supplied UBL file) through the full outbound pipeline — WsSecuritySigner → SoapEnvelopeBuilder → As4HttpClient → peer /as4/receive — and reports HTTP status, receipt signal, and error detail; config/common/di/as4.php wired with 9 outbound bindings including PEM strings loaded from env-specified cert file paths; AS4_SIGNING_KEY_PATH, AS4_SIGNING_CERT_PATH, AS4_PEER_CERT_PATH, AS4_PEER_ENDPOINT, AS4_PEER_PARTY_ID, AS4_SENDER_PARTY_ID, AS4_PEER_TRANSPORT_PROFILE, AS4_RETRY_POLICY added to .env.example; swap StaticAs4SmpResolver → As4SmpResolver when moving to real Peppol 4-corner SMP lookup; Psalm errorLevel 1 clean (July 2026)
OpenPeppol Service Provider Certification — Preparation Guide — Six-section guide covering OpenPeppol membership application (APCA or SCA tier), test-pilot environment setup (test SMP + EFTIA interoperability lab), pre-certification interoperability checklist with cross-references to code (As4ReceiveController, As4RetryEngine, WsSecuritySigner, CycleOrmAs4MessageRepository), production certification steps (EFTIA test suite → conformance certificate → SML registration), post-certification obligations (annual recertification, cert renewal, Peppol Directory maintenance, changelog monitoring), and recommended production cron schedule for as4/retry, as4/monitor, and as4/status (July 2026)
Peppol PKI Certificate Request (CSR) — Template & Guide — OpenPeppol AP certificate CSR guide: exact subject field format (C=GB/O=.../OU=PEPPOL/CN=PNO<country>:<participantId>), three-step OpenSSL commands for key + CSR generation, Member Portal submission steps, install + verify commands (modulus fingerprint comparison, subject and validity checks), php yii as4/monitor --warn-days=30 cron for expiry alerting, file permission hardening (chmod 400 private key, chmod 700 cert directory), annual renewal checklist including requirement to generate a new key (not reuse the old one) and update Peppol Directory if the certificate fingerprint changes (July 2026)
AS4 Phase 2 — Message Log Dashboard & Console Tooling — As4MessageController with /as4/messages index (state-badged table: pending/sent/receiptReceived/delivered/failed/duplicate/received with Bootstrap colour coding) and /as4/messages/view/{id} detail page (6 cards: Identity, Routing, Retry State, Receipt, Error, Timestamps); PeppolMessageController with /peppol/messages index (inv link, status badge, retry count, sent/delivered/created) and detail page (Identity, Delivery, Error, UBL XML collapsible with copy button); four authenticated routes added before the public as4/receive endpoint; as4/retry command calls As4RetryEngine::processRetries() and detectMissingReceipts() — cron */5 * * * *; as4/status prints a per-state count table from findAllMessages(), warns if FAILED > 0; as4/resend --message-id=<id> confirms interactively, parses stored SOAP XML, calls sender, maps result to markReceiptReceived() or markSent(); as4/monitor --signing-cert --warn-days=30 checks cert expiry via openssl_x509_parse(), counts FAILED messages, exits code 1 if any issue (cron-alert friendly); all four commands registered in config/console/params.php; Psalm errorLevel 1 clean (July 2026)
AS4 Complete Implementation — Summary — Production-ready eDelivery AS4 2.0 implementation: class inventory, SOAP 1.2 envelope construction, ebMS3 UserMessage/SignalMessage, WS-Security Ed25519 signing + X25519 HKDF-AES-128-GCM encryption, outbound retry engine, inbound duplicate detection, receipt generation, and bilateral localhost ↔ yii3i.online test harness (July 2026)
AS4 Complete Implementation Guide — Directory-level reference for the AS4 stack: production checklist, environment variable reference (AS4_SIGNING_KEY_PATH, AS4_PEER_ENDPOINT, AS4_RETRY_POLICY, etc.), DI wiring in config/common/di/as4.php, quick-start commands, and swap path from bilateral test mode to real Peppol 4-corner SMP lookup (July 2026)
AS4 Integration Checklist — Phase-by-phase verification checklist — Project Setup → Core Classes → Outbound Pipeline → Inbound Pipeline → Console Commands → Production — with php yii verification commands at each milestone (July 2026)
AS4 Implementation Guide for eDelivery 2.0 — Complete XML and PHP reference for the eDelivery AS4 2.0 Common Profile: annotated SOAP 1.2 envelope, ebMS3 header structure, WS-Security BinarySecurityToken placement, Ed25519 signature computation, X25519 key agreement for payload encryption, and PHP class mapping to each XML layer (July 2026)
RBAC Bridge Table — user_rbac_link — UserRbacLink Cycle ORM entity bridges userinv.user_id (INT) to yii_rbac_assignment.user_id (VARCHAR); auto-increment id PK + UNIQUE indexes on user_id and rbac_user_id; FK ON DELETE RESTRICT prevents orphaned RBAC rows; syncIfEmpty() backfills from AssignmentsStorageInterface::getAll() on first /invoice load; new signups get a bridge row immediately via assignSignupRole() before admin activation; AppConstants::ROLE_ADMIN/OBSERVER/ACCOUNTANT constants eliminate S1192 duplication across 7 files; REDIRECT_USERINV_INDEX constant eliminates 14 occurrences in UserInvController; Psalm errorLevel 1 clean (July 2026)
Credit Note Workflow — read_only_toggle Interaction — Create Credit Invoice toolbar button appears for any Sent / Viewed / Paid invoice (reqStatusId() >= 2) or when is_read_only = true, provided the user has editInv permission and no credit note already exists (creditinvoice_parent_id = null); read_only_toggle setting (Sent = Peppol Requirement, Paid = Relaxed/General Use) is prospective only — changing it does not retroactively update existing invoice records; widened credit-button condition from === 4 to >= 2 so Sent/Viewed invoices with is_read_only = false no longer require a manual status-to-Paid workaround; createCreditConfirm() fixed to read group_id from request instead of hardcoded 4; GroupRepository::generateNumber() now throws \RuntimeException with descriptive message when group is missing; SQL alignment queries documented for DB migration after setting change (July 2026)
Batch Email — Send Selected Invoices to Clients — ☑️📧 Email Client toolbar button on inv/index; select invoices via checkboxes, confirmation modal shows per-invoice email preview, choose a From email from FromDropDown (➕ button adds a new verified sender and redirects back to the modal via ?openModal=batchEmail), choose an email template, confirm → one email per selected invoice with its PDF attached, each invoice marked Sent (status 2), InvSentLog entries created; {{{invoice_table}}} placeholder renders a responsive HTML table; FromDropDownRepository wired through InvIndexNavDeps → InvsListWidget → InvsToolbarParams → InvsToolbar; from_dropdown_id overrides the UserInv / EmailTemplate from-address fallback; MailerHelper::yiiMailerSend signature changed from ?string to array $pdfPaths; InvBatchEmailDeps value object avoids SonarQube S107; Psalm errorLevel 1 clean (June 2026)
Quick Pay — Per-Row Inline & Bulk Toolbar Payment — 💰 button added immediately right of the Status column on inv/index; clicking expands an HTMX inline form (date + bank ref) that calls inv/quickpay, saves a Payment via PaymentService, and recalculates inv_amount + status via InvRecalculator; fully-paid invoices show a ✅ YYYY-MM-DD badge instead; ☑️💰 Quick Pay toolbar button opens a Bootstrap 5 modal for all checked invoices (inv/bulkquickpay); date input uses showPicker() for native calendar on click; Payment BelongsTo annotation corrected from nullable: false → nullable: true (DB column was already nullable) fixing Cycle\ORM\Exception\Relation\NullException when invoice has no payment method set; three new GET routes; TypeScript handleBulkQuickPay(); quick.pay / bank.ref translation keys; Psalm errorLevel 1 clean (June 2026)
Invoice Copy — Spreadsheet Import — #modal-copy-inv-multiple on inv/index gains a CSV import section: download a four-column template (date_created, note, same_amount, payment_date), upload a filled CSV, preview parsed rows, then click Import Spreadsheet to bulk-copy selected invoices with per-row date, note, amount flag, and optional payment; CsvDateNormaliser round-trip validates six input formats (Y-m-d, d/m/y, d/m/Y, m/d/Y, d-m-Y, d.m.Y) to prevent PHP createFromFormat month-overflow silently producing wrong dates; PaymentService::savePayment guarded against false from createFromFormat; endpoint uses GET with rows_json param matching the multiplecopy pattern (bypasses CSRF and Apache redirect issues); parseCopyCsv() auto-detects , or ; delimiter; 10 Testo tests in CsvDateNormaliserTest; Psalm errorLevel 1 clean; TypeScript bundle rebuilt 146.4 kb (June 2026)
.env Overwritten on git pull — Untrack from Git Index — .env was committed to the repo despite its .gitignore entry; once tracked, .gitignore is ignored by git, so every pull overwrites production credentials; fix: git rm --cached .env removes it from the index without touching the file on disk; .gitignore then takes effect permanently (June 2026)
Invoice Index Filters Broken — RequestInputParametersResolver Missing — All inv/index filter dropdowns (invoice number, client, status, year-month, family name, etc.) silently returned the full unfiltered row set on every selection; root cause: InvIndexFilter implements RequestInputInterface with class-level #[FromQuery] but RequestInputParametersResolver was never added to CompositeParametersResolver in config/web/di/middleware-dispatcher.php — Yii3 DI therefore injected a blank DTO with all properties null, isset(null) is always false, so no filter branch ever fired; fix: one line adding Reference::to(RequestInputParametersResolver::class) to the composite (vendor DI definition already present); secondary fix: $nameParts[1] ?? '' guard in InvFilterTrait for single-word client names; Psalm errorLevel 1 clean (June 2026)
Testo Integration — PHP Testing Framework — Testo (php-testo/testo) runs alongside PHPUnit; #[Test] attribute style matches Cycle ORM entity mapping; testo/facade + yiisoft/injector injects real Yii3 services into test methods, eliminating mock boilerplate for integration tests; #[ExpectException] accepts class only — message assertions use try/catch; no mock library yet (roadmap issue #41 — Mockery bridge); migration strategy: DDD entity + helper tests migrate now, service/controller tests stay in PHPUnit until #41; dual runner in composer.json; SonarCloud merges both Clover coverage files; two working examples in Tests/Testo/ (FamilyTest, CacheDiTest) (June 2026)
Invoice Copy — Multi-Client Selection & Workflow Badge Fix — #modal-copy-inv-multiple on inv/index extended with a live-filter client checkbox list so selected invoices can be copied to any combination of clients in one step; ProductClient pivot synced after each copy; multiplecopy() falls back to the invoice's own client when no selection is made; workflow-type badge bug fixed — getSoId() !== null → > 0 so plain invoices copied with so_id = 0 no longer show the 🔀 Peppol badge; modal rewritten to Yiisoft\Html\Html as H conventions; TypeScript bundle rebuilt (144.1 kb); Psalm errorLevel 1 clean (June 2026)
PBES2 p2c Unbounded Iteration Count — CPU-Amplification DoS — web-token/jwt-framework transitive dep (via rossaddison/yii-auth-client); GHSA-3prj-6hqw-cm82; affected <= 4.1.6; installed 4.1.7 already contains the fix — DEFAULT_MAX_COUNT = 1_000_000 constant and p2c > max_count guard in PBES2AESKW::checkHeaderAdditionalParameters() enforced before any hash_pbkdf2() call; project also never registers PBES2 algorithms; no PR required; logged in snyk-resolved.db as resolved (June 2026)
JWT Framework — JWE Algorithm Confusion Fix — web-token/jwt-framework transitive dependency (via rossaddison/yii-auth-client); JWSVerifier already safe; JWEDecrypter passed merged protected+unprotected header to getKeyEncryptionAlgorithm() and getContentEncryptionAlgorithm() — last-wins array_merge() allowed attacker to override alg/enc via unprotected header (TOCTOU split, RFC 7516 §4.1.1/§4.1.2); fix reads both parameters exclusively from getSharedProtectedHeader(); $completeHeader preserved for decryptCEK() (ECDH epk/apu/apv legitimately in unprotected headers); is_string() guards added; contributed upstream as web-token/jwt-framework #658 (June 2026)
Dev Tools Console Improvements — Windows proc_open colour fix (CRT vs Win32 env blocks; FORCE_COLOR passed explicitly as 5th arg); ANSI background codes 40–107 + underline added so Psalm's green/red summary blocks render correctly; OSC 8 terminal hyperlinks converted to clickable HTML anchors (composer outdated package names open GitHub); Copy button on output panel (plain-text clipboard, green "Copied!" flash); SonarCloud Filter by Rule Key cascading dropdowns — language selector triggers live ?api=failing_rules fetch, rule-number dropdown populated with only the S#### codes currently failing for that language (June 2026)
Dev Tools Web UI — m.bat / m.php — m.bat replaced by a PHP built-in web server (php -S 127.0.0.1:8099 m.php) eliminating all batch-file stdin issues; 16 category submenus (Psalm, Composer, Node, TypeScript, Angular, Testing, Snyk, PHP-CS-Fixer, PHPCS, Rector, SonarCloud, Yii, GitHub, Peppol, Benchmarks, System); streaming output via proc_open() + ReadableStream; ANSI colour rendering; Bootstrap 5.3 dark theme; session-stored SonarCloud/GitHub tokens; Snyk Resolved Vulnerabilities Index (SQLite, seeded from .snyk, committed to repo, CWE advisory links); SQLite setup guide distinguishing CLI PHP from WAMP Apache PHP; 16 local SVG menu icons (Simple Icons for brands, Bootstrap Icons for generics, official Yii3 logo in brand colours) served statically via built-in server passthrough; Bootstrap 5 hover/focus popovers on each category card listing every submenu command for discoverability (June 2026)
AS4 Access Point — Bilateral & Peppol Roadmap — Living roadmap for the native AS4 Access Point built in PHP; outbound stack complete (As4RetryEngine, CycleOrmAs4MessageRepository, As4RetryPolicyInterface, As4SenderInterface, ebMS3 signal detection, atomic concurrency claim, 15 PHPUnit tests); Phase 1 plans the inbound pipeline (As4Receiver, As4SignatureVerifier, As4DuplicateDetector, As4ReceiptGenerator, As4ReceiveController) for bilateral testing between localhost and yii3i.online without Peppol PKI; Phase 2 maps the small delta to a full Peppol 4-corner Access Point (SMP lookup already built, Peppol-issued certificate + SML registration + EFTIA conformance remaining) (June 2026)
Peppol BIS Payload Validator — Schematron Caching — PeppolBisPayloadValidator caches the parsed SchematronDocument in a static property keyed by file path so only the first validate() call in a PHP-FPM worker pays the parse cost; every subsequent request in that worker reuses it at zero cost; cache survives across instances (a static property, not instance-bound, so it works regardless of DI singleton configuration) but is wiped on worker recycle/deploy; keyed by path so multiple .sch files coexist without collision; tests must call clearCache() in tearDown() to avoid one test's parsed document leaking into the next (June 2026)
Oxalis Access Point — Localhost Setup — Phase A: docker-compose up oxalis-mock runs a WireMock stub on port 8181 (no certificate needed, works today); Phase B: real Oxalis AS4 container on port 8080 once a test certificate is obtained from a Peppol AP provider; inbound callback wired to POST /peppol/inbound/delivery; all four env vars documented in .env.example (June 2026)
Invoice Index — Workflow Type Badges — Always-visible emoji badge column in InvsListWidget distinguishes standalone invoices (📄 grey), quote-derived invoices (💬→📄 teal), and full Peppol observer-workflow invoices (🔀 blue, so_id set); tooltips show full chain in UI language; peppol_workflow added to Group By dropdown with three named groups; Psalm errorLevel 1 clean (June 2026)
Peppol Code-List Currency Check — bin/check-peppol-codelists.php queries the GitHub Commits API for each of the five VEFA XML files in DownloadedXml/ and compares the last upstream commit date against the recorded download date; green = UP-TO-DATE, red = STALE; optional GITHUB_TOKEN raises rate limit from 60 to 5 000/hr; exit code 1 when stale (CI-friendly); exposed via m.bat [27], make peppol-check, and composer run peppol:check (June 2026)
Peppol XML Code-List Loaders — PeppolArrays refactor — Six S138 violations eliminated by replacing ~2 900 lines of hardcoded PHP arrays with a shared private static loadVefaCodeList(string $filename) that reads OpenPEPPOL VEFA-format XML at runtime via DOMXPath; Yiisoft\Aliases resolves @peppol to __DIR__; five XML files in DownloadedXml/ cover UNCL7143, ISO 6523 ICD, UNCL7161, UNCL5305, and EAS; six dead data files removed via git rm; psalm.xml UnusedVariable block updated; electronicAddressScheme() view key references migrated from code/description to Id/Name; upstream URL + quarterly-update note in resources/peppol/uncl2005.php establishes currency trail for UNCL2005 subset (June 2026)
SonarQube S107 — QuoteController + SalesOrderController — QuoteController 32p → 6p via QuoteControllerBaseDeps (7p), QuoteControllerInvDeps (6p), QuoteControllerQuoteDeps (6p), QuoteControllerSoDeps (5p), QuoteControllerInfraDeps (6p), QuoteControllerUIDeps (2p); SalesOrderController 17p → 3p via SoControllerBaseDeps (7p), SoControllerInvDeps (5p), SoControllerMiscDeps (3p); 2 dead params dropped from SalesOrderController (InvAmountService, IIACS); all properties re-declared at class level — zero trait/method files changed; Psalm errorLevel 1 clean (June 2026)
SonarQube S107 — InvController::__construct — 23-param constructor reduced to 4 params via InvControllerBaseDeps (7p: webService/userService/translator/webViewRenderer/session/sR/flash), InvControllerServiceDeps (6p: invAllowanceChargeService/invAmountService/invService/invCustomService/invItemService/invTaxRateService), InvControllerInfraDeps (6p: factory/htmlResponseFactory/logger/mailer/urlGenerator/delRepo), InvControllerUIDeps (4p: aciis/formFields/buttonsToolbarFull/customFieldProcessor); all 16 properties re-declared at class level and assigned from deps — zero trait files changed; Psalm errorLevel 1 clean (June 2026)
SonarQube S107 — QuoteEmailStage2Deps + QuoteEmailStage0Deps sub-split — 14-param QuoteEmailStage2Deps reduced to 3 params; 10-param QuoteEmailStage0Deps reduced to 2 params; shared QuoteEmailCustomDeps (6p: ccR/cfR/cvR/icR/pcR/qcR) used by both stages; QuoteEmailStage0EntityDeps (4p: etR/qR/socR/uiR); QuoteEmailStage2CoreDeps (5p: gR/iaR/iR/socR/uiR); QuoteEmailStage2RelationDeps (3p: qaR/qR/soR); Quote/Trait/Email.php updated throughout; Psalm errorLevel 1 clean (June 2026)
SonarQube S107 — InvEmailStage2Deps sub-split — 15-param InvEmailStage2Deps reduced to 3 params by introducing InvEmailCoreDeps (6p: iR/iaR/icR/islR/gR/uiR), InvEmailCustomDeps (6p: ccR/cfR/cvR/pcR/qcR/socR), InvEmailRelationDeps (3p: qaR/qR/soR); InvEmailService and Email.php trait updated throughout; Psalm errorLevel 1 clean (June 2026)
SonarQube S107 — SalesOrderViewService — 23-param SalesOrderViewDependencies replaced by four sub-groups + SalesOrderViewService (4p): SoViewCoreDeps (6p: soR/soaR/soiR/sotrR/socR/soiaR), SoViewItemDeps (5p: piR/pR/taskR/trR/uR), SoViewMetaDeps (5p: cfR/cvR/gR/invRepo/settingRepository), SoViewRelationDeps (6p: acsoiR/acsoR/dR/qR/ucR/uiR); dead cR param dropped; SalesOrderViewDependencies deleted; Psalm errorLevel 1 clean (June 2026)
SonarQube S107 — InvEmailService and InvViewService — Two S107 Application Services added: InvEmailService (7p) consolidates email send logic from the Email trait; InvEmailStage2Deps trimmed from 22 to 15 params by dropping 7 dead repos never referenced in Email.php; InvEmailStage1Data::$from: array replaced with typed $fromEmail/$fromName string fields; PSR-7 body extraction fixed using /** @var array $body['MailerInvForm'] */ (matches established Quote email trait pattern — avoids MixedAssignment/MixedArrayAccess without @psalm-suppress); InvViewService (5p) replaces the 29-param InvViewDeps by splitting into five sub-groups — InvViewCoreDeps (6p: iR/iaR/icR/irR/pymR/gR), InvViewItemDeps (6p), InvViewMetaDeps (6p), InvViewAllowanceDeps (5p), InvViewRelationDeps (6p); InvViewDeps deleted; View.php trait updated throughout; Psalm errorLevel 1 clean (June 2026)
SonarQube S1144 — False Positive: Private Methods Called Across Trait Boundaries — SonarQube cannot trace $this->method() calls that cross PHP trait file boundaries, so it incorrectly reports private class methods as unused when their only callers live in a composed trait; concrete example: displayEditDeleteButtons and flashNoEnabledGateways in InvController.php called from View.php, and redirectToAdminMustMakeActive in AuthController.php called from Callback.php; fix: change private to protected — S1144 only fires on private methods (June 2026)
SonarQube — S1142, S1448, S131, S3776 fixes — Five violations resolved: InvPdfService::generateHtml reduced from 4 returns to 2 by combining null guards; PaymentInformationController switch gained default: break; (S131); brainTreeInForm reduced from 4 returns to 3 by extracting initializeBraintree(): ?array; ProductController::add() cognitive complexity dropped from 36 to 2 by extracting handleAddPost and saveProductCustomFields; SalesOrdersListWidget dropped from 30 methods to 18 by extracting SalesOrdersColumnBuilder (8 column builders) and SalesOrdersGroupingRenderer (4 grouping helpers) into the same Widget\ namespace — Psalm errorLevel 1 clean throughout (June 2026)
SonarQube S107 — customValues() contract fix — Runtime fix applied to InvPdfService, QuotePdfService, and SalesOrderPdfService: customValues() must store entity objects ($values[] = $entity), not extracted strings — CustomValuesHelper::formValue() calls ->reqCustomFieldId() on each array element and never reads the array keys; array keys are irrelevant (June 2026)
SonarQube S107 — SalesOrderPdfService and PdfHelper.php deleted — PdfHelper::generateSalesorderPdf (17p) moved into SalesOrderPdfService; SalesOrderController::pdf reduced from inline PdfHelper construction + SalesOrderViewDependencies to 2 params; SalesOrderPdfCoreDeps (4p), SalesOrderPdfDocDeps (3p — no delivery location), SalesOrderPdfItemDeps (4p); PdfHelper.php deleted entirely — zero callers remained after all three PDF services extracted; dead $pdfhelper property removed from MailerHelper; bug fix: sales order PDF now uses client language correctly (old getPrintLanguage() always returned 'English' for sales orders) (June 2026)
SonarQube S107 — QuotePdfService and Quote Email Deps — Seven S107 violations eliminated (PdfHelper::generateQuotePdf 16p, PdfTrait::pdf 15p, two pdfDashboard* at 16p each, emailStage0 12p, emailStage1 28p, emailStage2 21p); QuotePdfCoreDeps, QuotePdfDocDeps, QuotePdfItemDeps all ≤6 params; QuotePdfService exposes clean generate(), findQuote(), and uiR() methods and is resolved automatically by Yii3 DI; QuoteEmailStage0Deps, QuoteEmailStage1Data, QuoteEmailStage2Deps consolidate email-stage wiring; PdfHelper::generateQuotePdf deleted; QuoteController no longer constructs PdfHelper; QuoteEmailStage1Data::$from: array replaced with typed $fromEmail/$fromName string fields (Psalm-enforced — no @psalm-suppress) (June 2026)
SonarQube S107 — DDD Application Service Pattern (InvPdfService) — Three S107 violations eliminated (InvPdfDeps 17p, generateInvPdf 19p, generateInvHtml 18p) by replacing them with a proper Application Service; InvPdfCoreDeps, InvPdfDocDeps, and InvPdfItemDeps group related repos into ≤6-param sub-deps classes; InvPdfService exposes a clean 3-param generate() method and is resolved automatically by Yii3 DI; PdfHelper reduced by ~242 lines; PdfTrait and Email trait now inject the service directly; documents the reusable pattern for the remaining 135 S107 violations (QuotePdfService, SalesOrderPdfService, InvEmailService, etc.) (June 2026)
MTD VAT — Purchase Entries & Bridging Software Strategy — PurchaseEntry lightweight entity for supplier invoice recording; CSV bridging import; VAT100 Box 4 and Box 7 auto-populated from PurchaseEntryRepository::repoVatTotalsForPeriod(); why inv_type on Inv was rejected; HMRC Developer Hub sandbox route map; PurchaseEntryVatAggregator extracted from repository so summation logic (Box 4 input VAT + Box 7 purchases ex-VAT, rounded to 2dp) is unit-testable without ORM infrastructure; 11 PHPUnit tests cover empty period, rounding, zero-rated supplies, large amounts, mixed VAT rates, and generator iterables (June 2026)
PHPUnit — Bypass Finals & 100 % PurchaseEntry Coverage — dg/bypass-finals added as a dev dependency so createMock() can double final repository classes; Tests/bootstrap.php calls DG\BypassFinals::enable() before autoload; phpunit.xml.dist bootstrap updated; 37-test PurchaseEntryServiceTest covering saveEntry field mapping, date parsing, created_at guard, deleteEntry delegation, and all four VAT quarter labels for UK / calendar-year / Australian tax years; all five PurchaseEntry classes now at 100 % line coverage (June 2026)
Purchase Entry — VAT Quarter Grouping, Locale Defaults & Index UI — GridView + HTMX partial swap on purchaseentry/index; group-by toggle (All / By Month / By Supplier / By Quarter); VAT quarter key derived from this_tax_year_from_date_* settings using modular arithmetic across the year boundary; disabled "By Quarter" button with flash warning when tax year not configured; breadcrumbs linking directly to setting/tabIndex?active=taxes#settings[field] with ⏳ tooltip when unset; locale-defaults page covering ~50 countries with one-click Apply (POST saves month + day, preserves existing year); all @psalm-suppress removed; S1131/S1192/S3358 SonarQube violations resolved (June 2026)
AllowanceCharge Amount Validation and View Toggle — Cross-field validation on AllowanceChargeForm via inline Callback closures in getRules(); enforces MFN × base ÷ 100 = amount in percentage mode and rejects non-positive fixed amounts; dynamic formula in error message; two translation keys added; AllowanceChargeToggleHandler TypeScript class reads data-ac-templates from the select element and switches quoteitemallowancecharge and quoteallowancecharge forms between fixed-amount and variable (base + live formula) mode without page reload (June 2026)
Peppol Schematron Validator — Route 1 — SchematronRuleRunner evaluates PEPPOL-EN16931-UBL.sch directly against the invoice DOM at runtime; XPath 2.0 subset implemented in PHP (normalize-space, substring, translate, castable as, sequence constructors, for…return, axis :: steps); ten u: checksum functions wired from existing PeppolValidator methods; hand-written rule methods gated off when .sch file present (June 2026)
Peppol Schematron Code Generation — PHP/TypeScript/Scala validator files generated from the official Peppol BIS Billing 3.0 Schematron .sch file; bin/generate-php-validators.php, bin/generate-ts-validators.php, bin/generate-scala-validators.php; VO layer; PHP upgrade path replacing PeppolValidator XPath methods with a hydrator + generated functions (June 2026)
TypeScript Vitest Coverage — Vitest + jsdom + v8 coverage wired into CI for inv-index.ts, list-utils.ts, and quote-index.ts; phpunit.xml.dist case fix for Linux CI; PHP and TS coverage fed to SonarCloud; coverage badge added (May 2026)
Pre-commit TypeScript IIFE Build Hook — .githooks/pre-commit rebuilds both IIFE bundles (≈ 20 ms via esbuild) and auto-stages the output before every commit so the compiled bundle is never stale relative to TypeScript source; prepare script in package.json runs git config core.hooksPath .githooks automatically after npm install on a fresh clone; esbuild invoked via node node_modules/esbuild/bin/esbuild to bypass missing .bin/ shim on Windows (June 2026)
SonarCloud First Gate — SonarCloud runs as a standalone job before the PHP matrix build; needs: [sonar] blocks all four runners until the quality gate passes; AI-assisted contributions must self-audit before commit (May 2026)
BACS Quick Pay — One-off bank-transfer modal on the invoice guest page: bank details card, per-invoice QR codes, copy-to-clipboard buttons, BacsPaymentService, 38 new PHPUnit tests; fixed gateway CDN script ordering, CSP https:// violations, and missing $bacsUnpaidInvs parameter bug (May 2026)
PCI Gateway Asset Loading — Stripe/Braintree/Amazon Pay CDN scripts moved to <head> (jsPosition = POSITION_HEAD) to guarantee they execute before the IIFE; protocol-relative // URLs replaced with explicit https:// to satisfy CSP on localhost (May 2026)
Sonarcloud CLI — Setup local SonarCloud integration in VS Code (May 2026)
Sonarcloud Setup — Setup local SonarCloud integration (May 2026)
Sonarqube IDE with m.bat/Makefile — Sonarqube IDE Setup (May 2026)
SCSS Architecture — two independent SCSS trees (light and dark); full import chain from _yii3i_variables.scss through Bootstrap 5 source to _core.scss and _custom_styles.scss; how Bootstrap !default variable overrides work; file roles; when and how to rebuild compiled CSS (May 2026)
FontAwesome to Bootstrap Icons — complete removal of FontAwesome from the asset pipeline: ~1.1 MB of font files deleted, dead SCSS rules removed from _core.scss and _welcome.scss, compiled FA rules removed from style.css and utilities.css, $fa-font-path removed from _yii3i_variables.scss; Bootstrap Icons (bi bi-*) confirmed as sole icon library; outstanding dark-theme SCSS import noted (May 2026)
CSS Variables Reorganization — plan to break monolithic style.css into six purpose-specific files (variables.css, base.css, layout.css, components.css, utilities.css, overrides.css); current live vs. planning-stage status; remaining migration items; original source line-range mapping (May 2026)
PDF Bootstrap 5 Shim — custom-pdf.css Bootstrap 5 utility shim for mPDF replacing kv-mpdf-bootstrap.min.css; full BS5 class inventory (typography, spacing, tables, colour, borders); templates.css fixed (clearfix::after removed, :nth-child → .odd/.even, th.text-end); all five PDF templates updated (text-end, m-0, item-table, visible <thead>, odd/even row shading); watermark src bug fixed in overdue.php; stray } removed from quote.php and salesorder.php (May 2026)
Lighthouse Performance Audit — How to run a Lighthouse audit from Chrome DevTools or the CLI against an authenticated page; performance score 68 → 95 via Apache compression modules, asset deduplication, CSS deferral, Amazon Pay JS conditionalisation, image resizing, and N+1 settings-query fix (May 2026)
Bootstrap 3 CSS Removal — incremental removal of InvoicePlane's legacy Bootstrap 3 styles from style.css (custom section reduced 32 %, 966 → 653 lines); 484 form-group → mb-3, 23 dropdown-button → dropdown-item, 12 input-sm → form-control-sm replacements across 120 view files; SonarCloud duplicate-selector warnings eliminated by excluding src/Invoice/Asset/**; .table { font-size: 0.25rem } bug fix (4 px invisible text); body *:focus { outline: none !important } removed (WCAG 2.1 accessibility) (May 2026)
Bootstrap 3 → Bootstrap 5 Migration Guide — PHP-community field guide documenting 16 categories of migration difficulty: class renames (grid, typography, buttons, forms, panels→cards, navbar, labels→badges, tables), data-attribute prefix change (data- → data-bs-), JS API ($(el).modal() → bootstrap.Modal.getOrCreateInstance(el)), mPDF CSS 2.1 limitations (no var(), no flexbox, no :nth-child), FontAwesome → Bootstrap Icons, Yii3 widget-layer BS3 class output, SCSS !default override order, input-group pitfall, SonarCloud false-positive suppression, and 10 things that surprised Claude most during the migration (May 2026)
Bootstrap 5 Table Mobile Stacking Fix — table, thead, tbody, th, td, tr { display: block } ported back into layout.css at @media (max-width: 767px); restores the vertical cell stacking that existed in the Bootstrap 3 era and was removed when BS3 CSS was stripped; td[data-label] scoping prevents 50 % padding gap on GridView cells that do not emit data-label attributes (May 2026)
Bootstrap 5 Tooltip Initialisation Fix — BootstrapJsOnlyAsset registered before InvoiceNodeModulesAsset so window.bootstrap is defined when the IIFE runs; dead DOMContentLoaded wrapper removed from initializeTooltips(); bare bootstrap identifier replaced with (window as any).bootstrap; new Tooltip() replaced with Tooltip.getOrCreateInstance() to prevent duplicate instances (May 2026)
Bootstrap 5 Settings Tabs & HTMX Page-Size Selector — BS5 tab accessibility pass on all settings partials (role="tablist/tab/tabpanel", aria-*); form-select applied to 16 partial files; 19 inline label style tags consolidated to overrides.css; page-size navbar buttons save via hx-get + hx-swap="none" then refresh #main-area via fetch+DOMParser+replaceWith without redirect or full reload (May 2026)
Global Page Size Navbar Selector — PageSizeLimiter widget removed from 27 views and 3 widget classes; replaced by a single <select> in the invoice layout navbar backed by a TypeScript PageSizeHandler; BootstrapJsOnlyAsset hash-collision fix; dark mode removed; CustomFieldRepository PSR-4 path fix (May 2026)
Onboarding — `Stacking Rule layout fix in src/Invoice/Asset/invoice/css/layout.css (May 2026)
Performance Benchmarks — custom hrtime() benchmark suite tracking Yii3's four core speed-critical components over the repo's lifespan: DI container (singleton cache, 5-level dependency chain), injector auto-wire (reflection-cache vs uncached), FastRoute URL matcher (50-route table, parametrised, worst-case, 404), and string helpers (StringHelper, Inflector, WildcardPattern, CombinedRegexp); results accumulate in benchmarks/results/history.json; interactive Chart.js dashboard with trend arrows, suite filters, run selector, and ops/sec bars; GitHub Actions records a run every Monday at 02:00 UTC with OPcache JIT enabled (May 2026)
FastRoute Dispatch Cache — UrlMatcher PSR-16 cache wiring: CacheInterface → FileCache → runtime/cache/routes-cache; cache disabled in dev via common/params.php, enabled in prod via environments/prod/params.php; YII_ENV environment variable drives which params file is loaded; new routes return 404 after git pull until rm -rf runtime/cache/* is run on the server — add to deploy script before PHP-FPM restart; benchmark context explaining why the Windows dev figures include compilation overhead that disappears in production (June 2026)
PHPUnit Entity Test Migration — 34 new PHPUnit entity tests across 6 batches; 36 Codeception unit tests migrated to PHPUnit\Framework\TestCase; 26 createMock() calls replaced with createStub(); 3 pre-existing DateTime/DateTimeImmutable entity bugs uncovered (May 2026)
Peppol SMP Lookup — participant discovery via SML DNS → SMP HTTP → XML parse; SmpResolver supports both PEPPOL SMP 1.0 and BDX SMP 1.0 namespaces; SmpEndpoint value object; PEPPOL_SML_ZONE and PEPPOL_SMP_BASE_URL env vars; 10-test PHPUnit suite; completes Phase 1 of the Peppol access point (May 2026)
HTMX Caching — Vary: HX-Request strategy for CDN/proxy caches; browser cache headers for XHR GET requests; why POST filter forms are exempt; Nginx cache-key configuration; current project status checklist (May 2026)
Peppol Send via Oxalis — end-to-end implementation of "Send via Peppol (Oxalis)" on the invoice view; PeppolMessage Cycle ORM entity; PeppolSendService PSR-18 HTTP wrapper; QUEUED→SENT→FAILED status lifecycle; WireMock Phase A dev setup; Yii3 DI config auto-loaded from OXALIS_BASE_URL env var (May 2026)
Peppol Oxalis Connect — Phase B real-Oxalis wiring: PeppolSendService switched from JSON to multipart/form-data; iso6523-actorid-upis:: and cenbii-procid-ubl:: scheme prefixes; PEPPOL_SENDER_ID env var; HTTP 4xx/5xx mapped to FAILED; PeppolInboundController delivery callback; DocumentTypeId busdox caveat; Phase B checklist (May 2026)
Oxalis Integration Plan — phased plan for self-hosted Peppol AS4 transport via Oxalis alongside the existing Storecove connector; cost comparison (managed AP vs. self-hosted); PeppolMessage state machine; PeppolSendService wrapping Oxalis REST API; inbound callback controller; SMP registration and OpenPeppol certification costs (May 2026)
Peppol Access Point PHP Guide — architectural overview for building a Peppol access point in PHP; AS4/WS-Security delegation strategy; recommended PHP libraries (xmlseclibs, sabre/xml); phased delivery from minimal outbound-only AP through full certification (May 2026)
HTMX Invoices List Widget — sort, filter, pagination, and group-by on the invoice list using HTMX 2.x; InvsListWidget wraps GridView with hx-boost; partial outerHTML swap of #InvsGridView; edit-column read-only/disable-read-only matrix; sent-log columns; group-by with paid/balance totals per group header; 44-test PHPUnit suite (May 2026)
HTMX Quotes List Widget — sort, filter, pagination, and group-by on the quote list using HTMX 2.x; QuotesListWidget wraps GridView with hx-boost; partial outerHTML swap of #QuotesGridView; group-by with collapsible headers; SonarQube S138/S3776/S107 refactoring (May 2026)
HTMX User Index — sort, pagination, and page-size selector on the user list using HTMX 2.x; UsersListWidget wraps GridView with hx-boost on sort and pagination links; partial outerHTML swap of #UsersGridView (May 2026)
HTMX Quote Item Entry — in-place product and task line item addition on the quote view using HTMX 2.x; dedicated QuoteItemHtmxController with quoteitemhtmx/addProduct and quoteitemhtmx/addTask POST-only routes; no full page reload; loading spinner with auto-reset on success; htmx 2.0.10 bundled into the TypeScript iife via npm (May 2026)
RBAC DB Storage — assignments migrated from resources/rbac/assignments.php to yii_rbac_assignment MySQL table via yiisoft/rbac-cycle-db; items remain PHP-file backed (May 2026)
Cycle ORM Transactions — InvService::withTransaction() wraps invoice create, credit, copy, and invoice-to-invoice confirm in atomic database transactions; orphaned rows on partial failure are no longer possible (May 2026)
Invoice Soft Delete & Trash — Trash page listing archived invoices with per-row restore; restore() method on Inv; explicit WHERE deleted_at IS NULL on all 48 InvRepository query methods; InvDeletionService removed; 17-test PHPUnit suite covering soft-delete and restore lifecycle (May 2026)
InvForm::show() Pattern — Bug fix: inv/view status dropdown always showed Draft because new InvForm() defaults status_id = 1; replaced with InvForm::show($inv) which copies all entity fields into the form (May 2026)
Cycle ORM Entity Behaviors — SoftDelete on Inv (audit-safe deletion) and Hook on Client (auto-sync client_full_name on create/update) (May 2026)
Cycle ORM Database Indexing — #[Index] attributes applied to Inv, Quote, SalesOrder, Product, Client, and Family entities; rules for choosing sort, filter, FK, and unique indexes (May 2026)
Family Drag-and-Drop Street Order — reorder streets for a cleaning run via native HTML5 drag-and-drop, persisted automatically on drop (May 2026)
Telegram Payment Providers — native Telegram invoicing via phptg/bot-api by Sergei Predvoditelev (vjik) (May 2026)
Alpine Linux CVE-2026-31431 Remediation — local privilege escalation via algif_aead kernel interface; immediate mitigation (/etc/modprobe.d/disable-algif.conf); kernel upgrade from 6.12.49 to 6.18.29 via apk; OpenRC Apache restart commands; post-reboot verification (May 2026)
Adapting forms for DDD (April 2026)
Entity to Infrastructure Migration Process (April 2026)
Architecture Domain Application Infrastructure (April 2026)
Cycle-Orm Psalm Lifecycle Safe Entities (April 2026)
Language Flag Dropdown (April 2026)
Settings Tabs Improvements (April 2026)
Soletrader Layout Improvements (April 2026)
Sidebar Improvements (April 2026)
Eslint Sonarqube Build Session (March 2026)
Avoiding RBAC Mutation (March 2026)
Php 8.4 Alpine Setup (March 2026)
Future Peppol Mena (March 2026)
Future Peppol Nigeria (March 2026)
Future Peppol Republic of South Africa (March 2026)
Future Peppol UK (March 2026)
Mobile-Desktop Toggle Toolbar (March 2026)
ssl.conf explained (March 2026)
Why Apache? (March 2026)
Apache2 vs. Nginx (March 2026)
Vultr Alpine Security (March 2026)
phpMyAdmin Vulnerabilities on Alpine (March 2026)
AuthController Production Environment Fix (March 2026)
Content Security Policy Updates — .htaccess CSP for Stripe/Braintree/Amazon Pay (March 2026); PSR-15 ContentSecurityPolicyMiddleware replacing it with script-src 'self' (no unsafe-inline/unsafe-eval), DI-injected policy string, payment-provider extensibility via params.php; response to htmx CodeQL alerts #194/#195 (June 2026)
Email Setup for yii3i.online (March 2026)
Automerge Renovate's dependency updates if tests pass (Feb 2026)
Fraud Prevention Headers Bugfix (Feb 2026)
UK e-invoicing B2B/B2G 2029 (Jan 2026)
PeppolValidator Integration. (Jan 2026)
CreditNote Integration. (Jan 2026)
VitePress Integration. (Dec 2025)
Prometheus Integration. (Dec 2025)
Prometheus Menu Integration. (Dec 2025)
Sonar Cloud Setup. (Nov 2025)
SonarQube for IDE Setup — VS Code Connected Mode setup; Windows startup timeout fix; JVM heap tuning; token generation explained in plain English; connectionId mismatch pitfall; Windows Defender exclusions (May 2026)
SonarCloud CLI — sonar-issues.php queries the SonarCloud API and prints all 4000+ issues in Psalm-style format with copyable file paths; filters by type, severity, PR, and hotspots; curl-based to bypass WAMP allow_url_fopen restriction; composer shortcuts included (May 2026)
Netbeans ↔️ Vs Code: Sync Guide. (Dec 2025)
Php Product Selection Workflow. (Dec 2025)
Security Commands. (Dec 2025)
Typescript Build Process. — IIFE bundle 134.6 KB (ES2024, esbuild); full function-by-function reference for all 21 source modules; Bootstrap Icons migration; icon-spin CSS animation replacing fa-spin (May 2026)
Typescript ES2023 Modernization. (Dec 2025)
Typescript ES2024 Modernization. (Dec 2025)
Typescript Go V7 Compatability Testing Guide. (Dec 2025)
Invoice Amount Magnifier using Angular. (Dec 2025)
Family Commalist Picker using Angular. (Dec 2025)
Cycle ORM HasOne and outerKey Issue. (Jan 2026)
Cycle ORM Join Optimization. (Jan 2026)
Cycle ORM Foreign Key Constraint Issue. (Jan 2026)
Netbeans IDE 25-28 Guide. (Dec 2025)
Tooltip Styles Configuration. (Jan 2026)
Stage 1: Suitability Assessment for VAT Test Suite
This section records the pre-conditions and steps required before a VAT quarterly submission can be tested end-to-end against the HMRC Making Tax Digital (MTD) API using the Developer Sandbox environment.
Critical: Fix Production URL Bug First
HmrcController::vatObligations() and HmrcController::vatReturnSubmit() currently
call https://api.service.hmrc.gov.uk (production). A sandbox OAuth2 token is
rejected at production endpoints. Before any VAT testing can succeed, those URLs must
be switched to https://test-api.service.hmrc.gov.uk when operating in sandbox mode.
DeveloperSandboxHmrc::setEnvironment() already exists for this purpose but is not
yet wired through to the controller HTTP calls.
Step 1 — Register on the HMRC Developer Hub
- Go to https://developer.service.hmrc.gov.uk and sign in (or create an account).
- Create a new Sandbox application.
- Set the redirect URI to match
DEVELOPER_GOV_SANDBOX_HMRC_API_CLIENT_RETURN_URLin.env(e.g.https://yii3i.online/callbackDeveloperGovSandboxHmrc). - Copy the new client ID and client secret into
.env:DEVELOPER_GOV_SANDBOX_HMRC_API_CLIENT_ID=... DEVELOPER_GOV_SANDBOX_HMRC_API_CLIENT_SECRET=... DEVELOPER_GOV_SANDBOX_HMRC_API_CLIENT_RETURN_URL=...
Step 2 — Subscribe to All Relevant APIs
Subscribe to every API below in the sandbox application. HMRC silently drops unsubscribed
scopes from the token response, so subscribing to all now costs nothing and means the
HmrcApiCatalogue can show the full grant without a second OAuth round-trip.
| API Name | Scope(s) | Identifier | Why Relevant |
|---|---|---|---|
| VAT (MTD) | read:vat, write:vat |
VRN | Core — obligations retrieval and VAT100 return submission (Boxes 1–9) |
| Self Assessment (Individual) | read:self-assessment, write:self-assessment |
NINO | Income and expenses for sole traders issuing invoices |
| Self-Employed Business | read:self-employment, write:self-employment |
NINO | Used in HmrcController::selfEmploymentBusinesses() |
| Business Details | read:self-assessment |
NINO | Business name and address on returns |
| Individual Calculations | read:self-assessment, write:self-assessment |
NINO | Tax calculation results |
| Income Received | read:self-assessment, write:self-assessment |
NINO | Invoice income classification |
| National Insurance Record | read:national-insurance-record |
NINO | NI contributions for self-employed |
| Customs Declarations | write:customs-declaration |
EORI | Import/export (future use) |
| Create Test User | (sandbox utility) | — | Required to generate sandbox VRN/NINO individuals via createTestUserIndividual() |
| Fraud Prevention Headers — Validate | (sandbox utility) | — | Used in HmrcController::fphValidate() |
| Fraud Prevention Headers — Feedback | (sandbox utility) | — | Used in HmrcController::fphFeedback() |
Step 3 — What Happens After Subscribing
Once the sandbox application is subscribed and the OAuth2 flow is completed via
/auth/authclient?authclient=developersandboxhmrc, the session stores hmrc_scope
containing all granted scopes. HmrcController::index() then displays the full
HmrcApiCatalogue with available APIs highlighted.
The VAT test sequence after that point is:
createTestUserIndividual— creates a sandbox individual with a VRN and NINO; the sandbox auto-generates open quarterly obligations for that VRN.- Store the VRN in Settings → VAT Registration Number.
vatObligations()— retrieves open quarterly obligations for the VRN.vatReturnPrepare()— auto-fills Box 1 (output VAT) and Box 6 (sales ex-VAT) fromInvAmountRepository::repoVatTotalsForPeriod(), and Box 4 (input VAT) and Box 7 (purchases ex-VAT) fromPurchaseEntryRepository::repoVatTotalsForPeriod(). Boxes 3 and 5 are computed client-side (JS). Boxes 2, 8, and 9 require manual entry.vatReturnSubmit()— POSTs the nine-boxreturnDatapayload to the sandbox endpoint and receives aprocessingDateconfirmation in the response.
Stage 2 Preview — PHPUnit Test Data Suite
Once the sandbox URL bug is fixed and credentials are configured, Stage 2 will add:
InvAmountRepository::repoVatTotalsForPeriod()PHPUnit tests — seed known invoices (status 2/3/4) within a fixed quarter and assert Box 1 and Box 6 totals.- Purchase entry fixtures — seed
PurchaseEntryrows with known VAT amounts and assert Box 4 and Box 7 totals. - Edge-case coverage — zero-rated lines, invoices outside the quarter, draft invoices (status 1, excluded), and mixed VAT rates.
Feature Specifics
- Cycle ORM Interface using Invoiceplane type database schema.
- Generate VAT invoices using mPDF.
- Code Generator - Controller to views.
- PCI-compliant payment gateway interfaces – Braintree Sandbox, Stripe Sandbox, and Amazon Pay integration tested.
- Generate OpenPeppol UBL 2.4 Invoice 3.0.15 XML invoices – validated with Ecosio.
- StoreCove API connector with JSON invoice.
- Invoice cycle – Quote to Sales Order (with client's purchase order details) to Invoice.
- Multiple language compliant – steps to generate new language files included.
- Separate Client Console and Company Console.
- Install with Composer.
- SonarQubeCloud / SonarCloud Code Analysis
- NetBeans 28 && Vs Code IDE Integration
- Eclipse IDE Integration
- SonarLint4NetBeans Plugin - Tools ... Options ... Miscellaneous ... php ... Rules
Installing with Composer in Windows
composer update
After a composer update, you'll need to manually:
- Set
BUILD_DATABASE=truein your.envfile - Start the application to trigger table creation
- Reset
BUILD_DATABASE=for better performance
Installing npm_modules
- Step 1: Download node.js at https://nodejs.org/en/download
- Step 2: Ensure C:\ProgramFiles\nodejs is in environment variable path. Search ... edit the system environment variables
- Step 3: Run
npm iinc:\wamp64\invoicefolder. This will install @popperjs, Bootstrap 5, and TypeScript into a new node_modules folder. - Step 4: Keep your npm up to date by running, for example,
npm install -g npm@10.8.1or justnpm install -g.
Rebuilding the TypeScript bundle (invoice-typescript-iife.js)
The compiled bundle at src/Invoice/Asset/rebuild/js/invoice-typescript-iife.js must be
rebuilt whenever TypeScript source files change (including src/typescript/htmx.ts which
bundles htmx 2.x). Run:
npm run build:typescript
Then copy the updated bundle to the Yii3-published assets directory so the browser receives the new file without a cache clear:
src/Invoice/Asset/rebuild/js/invoice-typescript-iife.js
→ public/assets/<hash>/rebuild/js/invoice-typescript-iife.js
The <hash> folder name is derived from the asset source path and stays stable between
builds — check public/assets/ for the existing folder name (e.g. 7246626a).
Recommended php.ini settings
- Step 1: Wampserver ... Php {version} ... Php Settings ... xdebug.mode = off
- Step 2: ... Maximum Execution Time = 360
Installing the database in mySql
- Create a database in mySql called yii3_i.
- The BUILD_DATABASE=true setting in the config/common/params.php file will ensure a firstrun setup of tables.
- After the setup of tables, ensure that this setting is changed back to false otherwise you will get performance issues.
The c:\wamp64\yii3-i\config\common\params.php file line approx. 193 will automatically build up the tables under database yii3-i.
'mode' => $_ENV['BUILD_DATABASE'] ? PhpFileSchemaProvider::MODE_WRITE_ONLY : PhpFileSchemaProvider::MODE_READ_AND_WRITE,
** If you adjust any Entity file you will have to always make two adjustments to** ** ensure the database is updated with the new changes and relevant fields: **
-
- Change the BUILD_DATABASE=false in the .env file at the root to BUILD_DATABASE=true
-
- Once the changes have been reflected and you have checked them via e.g. phpMyAdmin revert back to the original settings
Signup your first user using + Person icon. This user will automatically be assigned the admin role. If you do not have an internet connection you will receive an email failed message but you will still be able to login.
You or your customer, signup the second user as your Client/Customer. They will automatically be assigned the observer role. If you do not have an internet connection you will get a failed message but if your admin makes the 'Invoice User Account' status active the user will be able to log in.
If a user signs up by email, they will automatically be assigned as a client, and automatically be made active.
If your user has not signed up by email verification, to enable your signed-up Client to make payments:
- Step 1: Make sure you have created a client ie. Client ... View ... New
- Step 2: Create a Settings...Invoice User Account
- Step 3: Use the Assigned Client ... Burger Button ... and assign the New User Account to an existing Client.
- Step 4: Make sure they are active.
- Step 5: Make sure the relevant invoice has the status 'sent' either by manually editing the status of the invoice under Invoice ... View ... Options or by actually sending the invoice to the client by email under Invoice ... View ... Options.
To install at least a service and a product, and a foreign and a non-foreign client automatically, please follow these steps:
- Step 1: Settings ... View ... General ... Install Test Data ... Yes AND Use Test Date ... Yes
- Step 2: In the settings menu, you will now see 'Test data can now be installed'. Click on it.
The package by default will not use VAT and will use the traditional Invoiceplane type installation providing both line-item tax and invoice tax
If you require VAT based invoices, ensure VAT is setup by going to Settings ... Views ... Value Added Tax and use a separate database for this purpose. Only line-item tax will be available.
Steps to translate into another language:
GeneratorController includes a function google_translate_lang ...
This function takes the English app_lang.php array auto generated in
src/Invoice/Language/English
and translates it into the chosen locale (Settings...View...Google Translate)
outputting it to resources/views/generator/output_overwrite.
- Step 1: Download https://curl.haxx.se/ca/cacert.pem into active c:\wamp64\bin\php\php8.1.12 folder.
- Step 2: Select your project that you created under https://console.cloud.google.com/projectselector2/iam-admin/serviceaccounts?pportedpurview=project
- Step 3: Click on Actions icon and select Manage Keys.
- Step 4: Add Key.
- Step 5: Choose the JSON File option and download the file to src/Invoice/Google_translate_unique_folder.
- Step 6: You will have to enable the Cloud Translation API and provide your billing details. You will be charged 0 currency.
- Step 7: Move the file from views/generator/output_overwrite to eg. src/Invoice/Language/{your language}
Xml electronic invoices - Can be output if the following sequence is followed:
-
a: A logged in Client sets up their Peppol details on their side via Client...View...Options...Edit Peppol Details for e-invoicing.
-
b: A quote is created and sent by the Administrator to the Client.
-
c: A logged in Client creates a sales order from the quote with their purchase order number, purchase order line number, and their contact person in the modal.
-
d: A logged in Client, on each of the sales order line items, inputs their line item purchase order reference number, and their purchase order line number. (Mandatory or else exception will be raised).
-
e: A logged in Administrator, requests that terms and conditions be accepted.
-
f: A logged in Client accepts the terms and conditions.
-
g: A logged in Administrator, updates the status of the sales order from assembled, approved, confirmed, to generate.
-
h: A logged in Administrator can generate an invoice if the sales order status is on 'generate'
-
i: A logged in Administrator can now generate a Peppol XML Invoice using today's exchange rates set up in Settings...View...Peppol Electronic Invoicing...One of From Currency and one of To Currency.
-
j: Peppol exceptions will be raised.
Renovate Auto-Merge Configuration
This repository uses Renovate Bot with auto-merge functionality enabled. The platformAutomerge is set to true, which enables GitHub's native auto-merge feature for Renovate pull requests.
Auto-Merge Requirements
IMPORTANT: Before any auto-merge occurs, all required checks must pass, including:
✅ Required Tests
- Psalm Static Analysis - Must pass successfully
- All other CI/CD pipeline tests must pass
- Branch protection rules must be satisfied
How It Works
- Renovate creates a pull request for a dependency update
- GitHub's auto-merge is automatically enabled on the PR
- GitHub Actions/CI pipeline runs automatically
- Psalm static analysis tests are executed
- If Psalm and all other required checks pass ✅
- GitHub automatically merges the PR to
main
- GitHub automatically merges the PR to
- If Psalm or any check fails ❌
- The PR remains open
- No auto-merge occurs
- Manual review and fixes are required
Protection Mechanism
The auto-merge will NOT proceed if:
- ❌ Psalm detects any type errors or issues
- ❌ Any required status check fails
- ❌ Branch protection rules are not met
- ❌ Merge conflicts exist
This ensures that only dependency updates that pass all quality gates (including Psalm static analysis) are automatically merged to the main branch.
Configuration
The Renovate configuration in renovate.json includes:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended"
],
"platformAutomerge": true,
"major": {
"dependencyDashboardApproval": true
}
}
The platformAutomerge: true setting leverages GitHub's native auto-merge functionality, working in conjunction with your branch protection rules and required status checks to maintain code quality.
Benefits
- 🚀 Faster dependency updates
- 🛡️ Protected by Psalm static analysis
- ✅ Only merges when all tests pass
- 🔒 Main branch remains stable
- 🔄 Uses GitHub's native auto-merge feature
Additional Notes
Major version updates require manual approval via the Renovate Dependency Dashboard due to the "dependencyDashboardApproval": true setting for major updates.