PHPackages                             rossaddison/invoice - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Database &amp; ORM](/categories/database)
4. /
5. rossaddison/invoice

ActiveProject[Database &amp; ORM](/categories/database)

rossaddison/invoice
===================

Builds the invoice folder for rossaddison/yii3-i

10[1 issues](https://github.com/rossaddison/invoice/issues)[1 PRs](https://github.com/rossaddison/invoice/pulls)PHPCI passing

Since Jul 11Pushed 1w ago1 watchersCompare

[ Source](https://github.com/rossaddison/invoice)[ Packagist](https://packagist.org/packages/rossaddison/invoice)[ RSS](/packages/rossaddison-invoice/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependenciesVersions (79)Used By (0)

[![Yii3](https://camo.githubusercontent.com/d6b0929173e28cc627430d2519ca1853466a70f37395877eaf4820cb3e1e1909/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f506f77657265645f62792d5969695f4672616d65776f726b2d677265656e2e7376673f7374796c653d666c6174)](https://www.yiiframework.com/)[![License](https://camo.githubusercontent.com/08cef40a9105b6526ca22088bc514fbfdbc9aac1ddbf8d4e6c750e3a88a44dca/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d626c75652e737667)](https://opensource.org/licenses/MIT)[![stable](https://camo.githubusercontent.com/1c1303e0a0fd04f6adf702a71ecabeeebe2c7c401091457bae102d9c12c4a7ea/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4e6f25323052656c65617365266d6573736167653d302e302e3026636f6c6f723d396366)](https://camo.githubusercontent.com/1c1303e0a0fd04f6adf702a71ecabeeebe2c7c401091457bae102d9c12c4a7ea/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4e6f25323052656c65617365266d6573736167653d302e302e3026636f6c6f723d396366)
[![Downloads](https://camo.githubusercontent.com/464a8d398cbacaaa8570fc3336414b5e8c52afc2612481a9e82e0eb30bb31cb1/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4176672f776b266d6573736167653d3132303026636f6c6f723d396366)](https://camo.githubusercontent.com/464a8d398cbacaaa8570fc3336414b5e8c52afc2612481a9e82e0eb30bb31cb1/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4176672f776b266d6573736167653d3132303026636f6c6f723d396366)
[![Build](https://camo.githubusercontent.com/339e2f6d56382dea70e32a9fb1a5d5e1e3c20578b15dfcf5e46e6d922019bf5b/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4275696c64266d6573736167653d50617373696e6726636f6c6f723d363666663030)](https://camo.githubusercontent.com/339e2f6d56382dea70e32a9fb1a5d5e1e3c20578b15dfcf5e46e6d922019bf5b/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d4275696c64266d6573736167653d50617373696e6726636f6c6f723d363666663030)[![Dependency Checker](https://camo.githubusercontent.com/a697ac2c8533107b6940c097cc03e2389003b58a60c1ea6003f78c2a0c0608a3/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d446570656e64656e6379253230436865636b6572266d6573736167653d50617373696e6726636f6c6f723d363666663030)](https://camo.githubusercontent.com/a697ac2c8533107b6940c097cc03e2389003b58a60c1ea6003f78c2a0c0608a3/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d446570656e64656e6379253230436865636b6572266d6573736167653d50617373696e6726636f6c6f723d363666663030)[![Static Analysis](https://camo.githubusercontent.com/ac6a4e827dfc010217cce0f8d5af07bb686a85edc04c52a027c1b9844b966081/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d537461746963253230416e616c79736973266d6573736167653d50617373696e6726636f6c6f723d363666663030)](https://camo.githubusercontent.com/ac6a4e827dfc010217cce0f8d5af07bb686a85edc04c52a027c1b9844b966081/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d537461746963253230416e616c79736973266d6573736167653d50617373696e6726636f6c6f723d363666663030)[![Psalm Level](https://camo.githubusercontent.com/49bf6e20e0f35ba8c49436ea401feb5090c2357bb8b4932ba61dd286bb5867e3/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d5073616c6d2532304c6576656c266d6573736167653d3126636f6c6f723d363666663030)](https://camo.githubusercontent.com/49bf6e20e0f35ba8c49436ea401feb5090c2357bb8b4932ba61dd286bb5867e3/68747470733a2f2f696d672e736869656c64732e696f2f7374617469632f76313f6c6162656c3d5073616c6d2532304c6576656c266d6573736167653d3126636f6c6f723d363666663030)[![type-coverage](https://camo.githubusercontent.com/983fab042fa6551406687d1e2d6e0f689cb79738a84b11c10010d6ffc7609a40/68747470733a2f2f73686570686572642e6465762f6769746875622f726f737361646469736f6e2f696e766f6963652f636f7665726167652e737667)](https://shepherd.dev/github/rossaddison/invoice)[![Coverage](https://camo.githubusercontent.com/f38af7dbbe1ebc4038b729b884387d1d9059945fc9d19dd53c208e57dfef5115/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f737361646469736f6e5f696e766f696365266d65747269633d636f766572616765)](https://sonarcloud.io/summary/new_code?id=rossaddison_invoice)[![Code Smells](https://camo.githubusercontent.com/e9b4b4ecf1c37760be6fdd943efe93f432b6913b5e9bbfd79f899781d75e3cf7/68747470733a2f2f736f6e6172636c6f75642e696f2f6170692f70726f6a6563745f6261646765732f6d6561737572653f70726f6a6563743d726f737361646469736f6e5f696e766f696365266d65747269633d636f64655f736d656c6c73)](https://sonarcloud.io/summary/new_code?id=rossaddison_invoice)[![PHP-CS-Fixer](https://camo.githubusercontent.com/a27b33fd4401e3ecdbd7e617b8b770ea2c6a4f3d50156dae4cfa936490d23232/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d2d63732d2d66697865722d656e61626c65642d626c75653f6c6f676f3d706870)](https://github.com/FriendsOfPHP/PHP-CS-Fixer)[![Stats](https://camo.githubusercontent.com/4e3358da6af90eb466fd5c98cdec184d81156c59f4c129f08f5031321bab0082/68747470733a2f2f6769746875622d726561646d652d73746174732e76657263656c2e6170702f6170693f757365726e616d653d726f737361646469736f6e)](https://camo.githubusercontent.com/4e3358da6af90eb466fd5c98cdec184d81156c59f4c129f08f5031321bab0082/68747470733a2f2f6769746875622d726561646d652d73746174732e76657263656c2e6170702f6170693f757365726e616d653d726f737361646469736f6e)[![Hosted by Vultr](https://camo.githubusercontent.com/b78313c399c694e1dd9337bfd10746e1f6777322119628bb4270d2d6c5bb0d8f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f686f7374696e672d76756c74722532302879696933692e6f6e6c696e65292d626c75653f6c6f676f3d76756c7472267374796c653d666c61742d737175617265)](https://camo.githubusercontent.com/b78313c399c694e1dd9337bfd10746e1f6777322119628bb4270d2d6c5bb0d8f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f686f7374696e672d76756c74722532302879696933692e6f6e6c696e65292d626c75653f6c6f676f3d76756c7472267374796c653d666c61742d737175617265)

(Place the contents of this download into the yii3-i invoice folder or run as a separate repository.)

Yii3-i (Rossaddison/Invoice)
============================

[](#yii3-i-rossaddisoninvoice)

A professional Open Source E-Invoicing System for PHP (Yii3) with UBL 2.4 and Peppol support.

Features
--------

[](#features)

### Vat Support

[](#vat-support)

### Multi-Currency Billing

[](#multi-currency-billing)

### Peppol UBL 2.4 E-Invoicing

[](#peppol-ubl-24-e-invoicing)

Automated generation and transmission of compliant UBL 2.4 documents via the Peppol network.

**Recent Implementations**

[Payment Record Channel Emoji — Charset Regression Fix](docs/PAYMENT_RECORD_CHANNEL_EMOJI_CHARSET_FIX_AUGUST_2026.md) — 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](docs/MYSQL_CONNECTION_CHARSET_BUG_AUGUST_2026.md) — 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](docs/PAYPAL_WEBHOOK_ID_DECODE_BUG_AUGUST_2026.md) — 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](docs/PAYPAL_CAPTURE_ERROR_LOGGING_AUGUST_2026.md) — 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](docs/MERCADO_PAGO_GATEWAY_AUGUST_2026.md) — 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](docs/GATEWAY_STATUS_PAGE_AUGUST_2026.md) — 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](docs/GATEWAY_STATUS_CI_ENV_FIX_AUGUST_2026.md) — 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](docs/GATEWAY_STATUS_PAGE_AUGUST_2026.md) — 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](docs/GATEWAY_STATUS_PAGE_AUGUST_2026.md) — 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`), 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](docs/GATEWAY_CREDENTIAL_URLS_AUGUST_2026.md) — 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](docs/CODECEPTION_COVERAGE_AUGUST_2026.md) — `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](docs/CODECEPTION_BYPASS_FINALS_AUGUST_2026.md) — `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](docs/PHPUNIT13_MOCK_EXPECTATIONS_AUGUST_2026.md) — 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](docs/ANGULAR_SCAFFOLD_REMOVED_AUGUST_2026.md) — `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](docs/PAYMENT_GATEWAY_GUARD_TRAIT_AUGUST_2026.md) — 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 `404`s. 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](docs/SQUARE_MERCHANT_PER_PROVIDER_ENTITY_AUGUST_2026.md) — 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](docs/TWO_TSCONFIG_FILES_EXPLAINED_AUGUST_2026.md) — `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](docs/TYPESCRIPT_7_GLOBALTHIS_TYPING_FIX_AUGUST_2026.md) — `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](docs/INVRECURRING_CRON_BEARER_AUTH_AUGUST_2026.md) — a live, working example of the [`WWW-Authenticate` explainer](docs/WWW_AUTHENTICATE_VS_SESSION_AUTH_AUGUST_2026.md)'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](docs/WWW_AUTHENTICATE_VS_SESSION_AUTH_AUGUST_2026.md) — grew out of the Authentication DI crash fix below, and a look at `yiisoft/auth`'s active interface-segregation work (issue [\#113](https://github.com/yiisoft/auth/issues/113), PRs [\#115](https://github.com/yiisoft/auth/pull/115)/[\#125](https://github.com/yiisoft/auth/pull/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](docs/AUTHENTICATION_DI_CRASH_FIX_AUGUST_2026.md) — 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 `403`s instead of `500`s (August 2026)

[Square Gateway](docs/SQUARE_GATEWAY_AUGUST_2026.md) — 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](docs/SQUARE_MERCHANT_PER_PROVIDER_ENTITY_AUGUST_2026.md) 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)](docs/HOMECARE_OFFLINE_PWA_AUGUST_2026.md) — 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](docs/PAYPAL_GATEWAY_AUGUST_2026.md) — 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](docs/RAZORPAY_GATEWAY_AUGUST_2026.md) — 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](docs/PAYSTACK_GATEWAY_AUGUST_2026.md) — 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](docs/PAYMENT_GATEWAY_SDK_AUDIT_AUGUST_2026.md) — 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](docs/MOLLIE_WEBHOOK_AUGUST_2026.md) — 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](docs/GATEWAY_STATUS_PAGE_AUGUST_2026.md) — 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](docs/DATABASE_BACKUP_AND_RECURRING_INVOICE_CONSOLE_COMMANDS_AUGUST_2026.md) — 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](docs/HOMECARE_AUTOINVOICE_PITFALLS_AUGUST_2026.md) — 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](docs/HOMECARE_GUEST_COLUMNS_AND_CSP_THIRD_WAVE_AUGUST_2026.md) — 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)](docs/GOCARDLESS_DIRECT_DEBIT_SETUP_GUIDE.md) — 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](docs/IDENTITY_VS_USER_ID_AUTH_FIX_JULY_2026.md) — 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](docs/INV_COPY_ALL_TO_DATE_JULY_2026.md) — 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`. Full-project Psalm clean, full Testo suite (242 tests) unaffected (July 2026)

[Geolocation Blocked in Production — Three Independent Permissions-Policy Sources](docs/GEOLOCATION_PERMISSIONS_POLICY_TRIPLE_SOURCE_JULY_2026.md) — the Settings &gt; 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](docs/WSL_TO_ALPINE_DEPLOYMENT.md) — 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](docs/SONARQUBE_COGNITIVE_COMPLEXITY_AND_S138_FIXES_JULY_2026.md) — 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](docs/SETTINGS_LOCATION_TAB_JULY_2026.md) — 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"](docs/YII_ENV_ROUTE_CACHE_AND_DEPLOY_JULY_2026.md) — 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](docs/HOMECARE_WORKER_ALLOCATION_JULY_2026.md) — 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](docs/INV_COPY_AND_BACK_BUTTON_FIXES_JULY_2026.md) — 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](docs/INV_COPY_AMOUNT_WRONG_INV_ID_FIX_JULY_2026.md) — 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](docs/ADYEN_SESSION_COUNTRYCODE_FIX_JULY_2026.md) — 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"](docs/HOMECARE_LOGIN_UNVERIFIED_EMAIL_MESSAGE.md) — 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](docs/HOMECARE_SIGNUP_PUBLIC_FLOW.md) — 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_` 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 &amp; Finland](docs/STRIPE_PAY_BY_BANK_UK_FINLAND.md) — 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](https://docs.stripe.com/payments/pay-by-bank), 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 &amp; Adyen v6 Upgrade](docs/PAYMENT_GATEWAY_REFUND_LIVE_TESTING_JULY_2026.md) — 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 &amp; Cross-Gateway CSP Fixes](docs/ADYEN_GATEWAY_LIVE_TESTING_AND_CSP_FIXES_JULY_2026.md) — 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 `` (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](docs/PAYMENT_GATEWAY_LIVE_TESTING_JULY_2026.md) — 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 &amp; `PaymentGatewayInterface`](docs/STRIPE_PAYMENT_GATEWAY_WEBHOOK.md) — `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 `, 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`](docs/TURNSTILE_CSP_FIX.md) — 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](docs/SYSTEM_UPDATES_PHP_VERSION_CHECK.md) — 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](docs/YII_DATAVIEW_DROPDOWNFILTER_UPSTREAM_FIX.md) — filed [yiisoft/yii-dataview#344](https://github.com/yiisoft/yii-dataview/issues/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](https://github.com/yiisoft/yii-dataview/pull/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](docs/CSP_INLINE_HANDLER_SWEEP_GAPS.md) — the original CSP hardening sweep searched for literal `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](docs/ANGULAR_TYPESCRIPT7_BUILD_CONFLICT.md) — `@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
