* Lock module manifests to spec v1
Backfill all module manifests to the locked v1 format:
manifest_version/type/id/name/version/description/author/license/
homepage_url/icon_url/requires. Versions align to the release
version, stamped at build time in the release-tree stage alongside
Version::VERSION and verified by the release workflow.
Brand mirrors FOSSBilling/modules byte-identically; adds a
vendored manifest validator (tools/manifest) run in CI on every
module manifest, and aligns content.sql seed versions with the
manifest versions.
Module manifest v1 (summary):
- manifest_version: 1, type: module
- requires as composer/semver ranges (fossbilling, php, modules)
- SPDX license expressions
- author as {name, url}
* Drop redundant recast flagged by Rector
* Format manifest validator to repo style
* Address manifest PR review: normalize v1 author/project_url, harden validator
* Enforce v1 manifest requires constraints at install time
Check requires.fossbilling (skipped on preview builds where the
running version is a placeholder), requires.php, and
requires.modules (presence plus installed version) in
installModule() before any install hook runs. Manifests without a
requires object pass through unchanged. Promotes composer/semver
to a production dependency and adds activate() unit coverage for
satisfied, unmet, missing-dependency, and invalid-constraint cases.
* Use spellcheck-clean fixture names and wording
* Review validator feedback: placeholder message style, guard non-array requires.modules
* Update job name to 'Validate Manifests'
* Resolve review: missing constraint placeholder, name-only author link
* Review follow-ups: nameless author fallback, core dependency version test
* Use spellcheck-clean fixture name
* Use neutral author fallback for nameless v1 authors
* Point requires enforcement at ExtensionType after enum merge
* Sync legacy seeded module versions on update
* Guard version sync on partial schemas, bump seeded patch level
* Allow version-sync SQL in strict patcher test doubles
* Address review: strict requires, preview parsing, manifest versions, drift-path sync
* Remove duplicated requires tests
* Use spellcheck-clean wording in docblock
* Address review: strict requires, preview parsing, manifest versions, drift sync
* Reject unversioned dependencies at install time
* Spellcheck-clean fixture name
* Block deactivation of modules with installed dependents
* Strict prerelease validation; seeded-sync integration test
* Address review: strict requires, preview parsing, manifest-gated sync, semver inputs
* Review followups: spellcheck-clean fixtures, integration test mock scope
* Deslop: clarify deactivate guard scope, merge duplicate version checks
* Address review: strict requires, manifest-gated sync, semver inputs, deactivate guard
* Fail closed on undeclared or non-string dependency versions
* Skip seeded-version updates that would rewrite the same value
* Real-method hasDeclaredVersion tests; honest stub comment
* Spellcheck-clean fixture name
* refactor(library): reorganize FOSSBilling core library
Group Update* prefix into FOSSBilling\Update\{Updater,Patcher,Finalization,ReadinessCheck}
Group Pagination into FOSSBilling\Pagination\{Service,Options,ViewPaginator}
Group System (Config,Environment,Version,Requirements) into FOSSBilling\System
Consolidate Security (Crypt,PasswordManager,Fingerprint,Session,CheckResult/Enum)
Group Exception into FOSSBilling\Exception\{BaseException,InformationException}
Fix I18n case (i18n.php -> I18n/I18n.php) and move Translate -> I18n/Translator
Group Logging (Logger, Monolog -> Factory)
Group Remote (CentralAlerts,ExtensionManager), move ErrorPage -> Http, InjectionAwareInterface -> Interfaces
Update all call sites (di.php, load.php, modules, tests) with no BC shims
Fix import aliasing and phpstan docblocks, remove unused imports
Tests: 2856 passed, phpstan OK
* refactor(validation): migrate Validate into Validation namespace
Split monolithic FOSSBilling\Validate (232 lines, 7 mixed responsibilities)
into focused validators:
- Validation\DomainValidator (isSldValid, isTldValid with DI cache/http)
- Validation\PasswordValidator (isPasswordStrong, passwordsMatch)
- Validation\EmailValidator (isEmailValid)
- Validation\BirthdayValidator (isBirthdayValid)
- Validation\RequiredValidator (checkRequiredParamsForArray)
- Validation\Validator facade retaining same API for $di['validator'] BC-free
Move FOSSBilling\Validate -> FOSSBilling\Validation\Validator,
add FOSSBilling\Validation\DomainValidator via $di['domainValidator'],
keep $di['validator'] as facade. Remove old Validate.php.
Update all call sites and tests (FOSSBilling\Validate -> FOSSBilling\Validation\Validator),
make Validator/DomainValidator non-final for Mockery.
Tests: 2856 passed, phpstan OK
* refactor(tools): split Tools into Utils/Security/Sanitizer/Validation (WIP)
Extract Tools god class (534 lines) into focused helpers:
- Utils\Str (slug, toCamelCase, fromCamelCase)
- Utils\Arr (safeCount, sortByOneKey, getPairsForTableByIds via arr service)
- Utils\Normalizer (normalizeBoolean, normalizePort)
- Utils\Network (isValidHttpInterface, getDefaultInterface, listHttpInterfaces, getExternalIP via network service)
- Utils\Format (humanReadableBytes)
- Security\Credential (generatePassword, session restore tokens)
- Sanitizer\Sanitizer (sanitizeContent, sanitizePlainText, sanitizeMarkdownContent)
- Validation\PhoneValidator + EmailValidator::validateAndSanitizeEmail
Update di.php: replace tools service with arr/network services,
migrate 23 src files to new helpers (FOSSBilling\Utils etc.),
keep Tools.php for remaining tests (to be removed next).
Tests: partial, 11 errors remain for slug mocks (to be fixed).
* refactor(api): introduce Role enum, tighten Api layer (deslopped)
- Add FOSSBilling\Api\Role enum (guest/client/admin) replacing stringly-typed role handling
- Api\Identity now stores Role, exposes getRole():Role + getType():string (BC), typeFromObject():Role
- Api\AbstractApi: make abstract, typed properties (string $ip, ?Module $module, Client|Admin|Guest|null $identity), typed setters/getters, fix BaseException usage
- Api\Proxy: compose Identity VO instead of duplicating type logic, keep lazy dispatcher from container, remove redundant helpers
- Api\Dispatcher: use Role enum and Identity VO, normalize identity, keep direct $di['update_finalization'] lookup, deslop finalization injection and apiClassSuffix helper
- Update tests/Pest IdentityTest to expect Role enum
- Minor: fix Cron/Staff Api call sites for typed identity
Deslop: removed hasModule/hasIdentity/hasService helpers, duplicate forwarding comment, unnecessary setFinalization/constructor injection and apiClassSuffix helper.
* refactor(cache): introduce Driver enum, use typed driver handling
- Add FOSSBilling\Cache\Driver enum (filesystem/redis/memcached)
- CacheFactory: parse config via Driver::tryFrom, match on enum, use ->value for messages, remove unused Tools import
- System\Api\Admin: validate driver via Driver::tryFrom instead of in_array(SUPPORTED_DRIVERS)
* refactor(doctrine+api): address review findings
- Doctrine: remove unused Tools import in DriverManagerFactory
- Doctrine: add Driver/Charset enums (pdo_mysql, utf8/utf8mb4/latin1) and use tryFrom for validation
- Api: make AbstractApi::setModule(object) lenient for test doubles (anonymous class in DispatcherTest)
- Tests: apiEndpoint() now ensures identity via getIdentity() catch and sets Admin/Client/Guest based on Api class
* Type AbstractApi module as Module
* Update Doctrine factory copyright years
* refactor(enums): relocate Client/Order status enums to domain modules
Move FOSSBilling\Enums\ClientStatusEnum -> Box\Mod\Client\Enum\Status
and FOSSBilling\Enums\ClientOrderStatusEnum -> Box\Mod\Order\Enum\Status,
colocating statuses with their owning entities (Client.php:29,
Order.php:24) and removing the generic FOSSBilling\Enums catch-all.
Drops Enum suffix, fixes ClientOrder vs Order naming mismatch, updates
sole consumer Massmailer\Service and deletes src/library/FOSSBilling/Enums/.
* refactor(exception): clean up BaseException and InformationException
- BaseException: type stackTrace(int $length,bool $protected), fix
camelCase ($Length->$length, $my_entry->$arg, $entry_file/file),
debug_backtrace typing, copyright 2025->2026
- InformationException: remove redundant passthrough __construct, now
minimal marker extends BaseException (retains 100+ user-facing
throw sites and Sentry ignore distinction)
* refactor(http): harden Http factories, fix escaping and copyrights
- CsvResponseFactory: validate table name preg_match /^[a-zA-Z0-9_]+$/ and throw
InformationException, add import, copyright 2025->2026
- ErrorPage: escape message via htmlspecialchars in renderPage, use
escapedMessage for fallback and original toggle, copyright 2025->2026
- ExceptionResponseFactory: split rawMessage, htmlspecialchars only for
API_MODE Json, pass raw to ErrorPage (single escape source)
- RequestFactory: copyright 2025->2026
* Clean up locale detection error handling
* refactor(interfaces): split generic Interfaces into domain colocated contracts
- Delete FOSSBilling\Interfaces catch-all (5 files)
- FOSSBilling\Interfaces\ApiArrayInterface -> FOSSBilling\Api\ArrayInterface
(ArrayInterface, removes Api stutter, 60+ entities)
- FOSSBilling\Interfaces\InjectionAwareInterface -> FOSSBilling\Container\InjectionAwareInterface
(Container, 100+ services)
- FOSSBilling\Interfaces\SecurityCheckInterface -> FOSSBilling\Security\CheckInterface
(Security, removes Security stutter, 4 checks)
- FOSSBilling\Interfaces\TimestampInterface -> FOSSBilling\Doctrine\TimestampInterface
(Doctrine, with TimestampTrait)
- FOSSBilling\Interfaces\WidgetProviderInterface -> Box\Mod\Widgets\ProviderInterface
(Widgets module, provider contract for Branding etc)
Update 172 usages, keep InjectionAware/Timestamp short names, shorten
ApiArray/SecurityCheck/WidgetProvider stutter.
* Type logger cache as array in factory
* Update pagination copyright headers
* Update CentralAlerts copyright year
* Clean up sanitizer class imports
* Refine security helpers and enum namespace
* Update system class copyright headers
* Move Twig AppArea enum to root namespace
* Update copyright year in update classes
* Drop legacy Arr/Str wrappers and DI binding
* refactor(utils): complete Tools split and delete god class
- Delete FOSSBilling\Tools (534 lines) god class, remove load.php require/use
- Migrate 16 call sites: di['tools']->validateAndSanitizeEmail -> EmailValidator::validateAndSanitizeEmail (8 Api), News slug -> Str::slug, generatePassword already via Credential, humanReadableBytes via Utils\Format, etc.
- Remove 20 unused use FOSSBilling\Tools imports (Order/Extension/Currency etc.)
- Harden Arr::getPairsForTableByIds with preg_match table validation and backticks (CsvResponseFactory style)
- Remove Str legacy wrappers (slugLegacy/slugInstance/to_camel_case)
- Update 11 test files to remove Tools mocks (Extension/Product/Profile/Custompages/Staff/Client/Email/Invoice/Support) and Container helper
- phpstan 443 OK (one less file), tests 60/6 pre-existing failures (AbstractApi identity/pagination) remain, Tools InvalidCount 54->0 for new code
* Update RequiredParams copyright year
* Update copyright headers to 2026
* Remove dead validation code
* fix(ci): address Rector, CS-Fixer and test failures after library reorg
- Run Rector fixes: RemoveUselessReturnTag, RemoveReadonlyOnReadonlyClass, ReadOnlyProperty, RecastingRemoval
- Run cs:fix (132 files) for import ordering and formatting
- Fix Pest helper to handle Mockery mocks for Admin/Client identities
- Fix DispatcherTest to use Module mocks for extension
- Update Client tests to remove Tools mocks and use new EmailValidator/Url
- Fix Custompages ServiceTest to use real Str::slug and long titles
- Fix Pagination mocks to use Service class
- Fix i18n, InformationException, Requirements, Config namespaces
- Add url to test container with SYSTEM_URL base
- Fix a few remaining service tests (Profile, Extension, Product, Support)
phpstan 451/451 OK, pest now 11 failures (down from 107)
* fix(tests): make suite green after reorg
- Fix Extension ServiceTest InformationException namespace and Str mock
- Fix Product Api Admin paginated products mock
- Fix Client/Service i18n, Profile i18n, and url container
- Fix Custompages long-title slug handling
- All 2998 tests pass (2 skipped), phpstan 451/451
* style: fix cs
* fix(docker): update Version.php path after move to System namespace
* fix(tests): remove remaining Tools references
- Delete obsolete ToolsTest (Tools class removed)
- Remove di['tools'] mocks from Profile ServiceTest
* feat(update): add patch115 to clean up library reorg orphans
Removes stale PSR-4 paths left on disk after refactor/library-reorganization
(Tools split, moved namespaces, low-case i18n) which persist on updated
installs because the updater extracts on top. Uses executeFileActions +
removeEmptyDirectories with exists guards, idempotent.
Bumps fresh-install last_patch to 115 to match latestPatchLevel().
* fix(cache): align TLS + instance_id merge with reorg namespaces
- Use Normalizer::normalizeBoolean instead of removed Tools
- Use BaseException + driver->value for instance_id/TLS errors
- Update tests to expect BaseException
* fix(tests): use US spelling to satisfy spellcheck
Normalise -> Normalize, normalised -> normalized
* refactor(box): move Box legacy globals to semantic namespaces
- Box_App/AppAdmin/AppClient -> FOSSBilling\Http\App/AppAdmin/AppClient
- Box_Authorization -> FOSSBilling\Security\Authorization
- Box_Event/EventDispatcher/EventManager -> FOSSBilling\Event\Event/Dispatcher/Manager
- Codemod 499 references across src/modules, src/di.php, src/index.php, tests
- Fix namespace imports (ArrayObject, Pimple\Container, ReflectionMethod, \RuntimeException, \InvalidArgumentException, \Box\Mod\*, \Override)
- Add Patcher patch116 to clean up orphaned src/library/Box/* + bump last_patch 115->116
* refactor(update): extract Patcher patches to Patch/* orchestrator
- Create FOSSBilling\Update\Patch\PatchInterface + PatchRegistry (explicit MAP, not Finder)
- Move 89 private patch25..patch116 methods to dedicated classes Patch25..Patch116
- Each Patch::apply(Patcher $patcher) delegates via public helpers ($patcher->executeSql etc.)
- Patcher now shrinks from 3624 to 1049 LOC: getPatches() uses PatchRegistry::MAP, applyCorePatches() loops [new PatchClass(), 'apply'] with call_user_func($patch, $this)
- Make helpers public (executeSql, fetchAll, tableHasColumn etc.) and properties public for patch access
- Update UpdatePatcherTest to invoke Patch*::apply and expect 'apply' method, keep ordering/skip checks
- No behavior change: MySQL guard, SchemaSynchronizer, last_patch 116 intact
* fix(spellcheck): add checkperm to dictionary after Box->Http move
* style: apply Rector StringClassNameToClassConstant + typed const
* style: apply Rector IfToNullCoalescing for changed files
* style: apply remaining Rector IfToNullCoalescing
* refactor(update): drop redundant Patch101
ADD INDEX client_order_unpaid_invoice_id_idx is already declared in
Order.php:19 and covered by SchemaSynchronizer's additive sync (safeAddedIndexes).
On PG/SQLite Patch101 never ran (isMysqlDriver guard), MySQL now gets it
via syncPortableSchema after the patch loop. Keeps last_patch at 116,
adds gap 101 alongside 92/95/98.
* refactor(update): make Patch116 superset of 115+116, keep 115 as no-op gap
* fix(spellcheck): add superset/orphans after Patch115/116 merge
* refactor(update): remove Patch115 file, keep 116 as superset
Patch115 orphan cleanup is now handled by Patch116 superset (39 paths).
115 remains a gap (like 92/95/98/101) — installs at 115 still get cleanup via
116's idempotent deletes.
* refactor(update): make Patch39 superset of 38+39, drop Patch38 file
38 (Ispconfig3/Virtualmin) + 39 (srp) are idempotent file deletions.
39 now deletes all 3 orphans; 38 remains gap like 101/115.
Installs at 37 -> 39 and 38 -> 39 both get cleanup via 39 superset.
* fix(spellcheck): add Ispconfig3 for Patch39 superset
* refactor(update): make Patch33 superset of 30-33, drop Patch30-32 files
30 (guzzlehttp) + 31 (htaccess/config.old) + 32 (phpmailer/Box/*) + 33 (FileCache)
are idempotent file deletions. 33 now deletes all 19 orphans; 30-32 remain gaps
like 38/101/115. Installs at 29 -> 33 and 32 -> 33 both get cleanup via 33 superset.
* fix(spellcheck): add idempotently/gaps/FileCache for Patch33 superset
* refactor(update): move gateway/branding helpers into Patch64
- migrateDefaultBrandingAssetsToPublicDirectory and
migrateGatewayAssetsToPublicDirectory were only used by Patch64.
Move them as private helpers inside Patch64 (param Patcher $patcher)
and remove public versions from Patcher.
- Patcher shrinks by ~66 LOC, stays public only for shared helpers.
* Drop getVersion from update patches
* refactor(update): move downloadable helpers into Patch65
- generateDownloadableStoredFilename, copyLegacyDownloadableFile,
migrateDownloadableProduct/Service/OrderStorageKeys were only used
by Patch65 (and internally). Move them as private helpers inside
Patch65 (private array $downloadableStorageMigrationMap + 5 methods
taking Patcher $patcher) and remove public versions + property from
Patcher. Keeps Patcher public API for shared helpers only.
* refactor(update): move email template helpers into Patch52
- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
used by Patch52 directly (and via Patcher::restoreLegacy... for 57/90).
Move them as private helpers inside Patch52 (taking Patcher $patcher)
so Patch52 is self-contained. Keep public versions in Patcher for
restoreLegacyDefaultEmailTemplates used by Patch57/90 (duplicate for now,
could also be moved if desired).
* refactor(update): move email template helpers into Patch52/57/90
- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
used by Patch52 direct and via Patcher::restoreLegacyDefaultEmailTemplates
for Patch57/90. Move them as private helpers into Patch52 (self-contained)
and duplicate into Patch57/90 alongside private
restoreLegacyDefaultEmailTemplates (which also moved from Patcher).
- Remove public versions from Patcher — keeps Patcher public API for shared
helpers only (executeSql, tableHasColumn etc.). Fixes unrelated
Patcher.php diff (\s vs \s) by removing the methods entirely.
* refactor(update): move custom page slug helpers into Patch102
- allocateUniqueCustomPageSlug and fitCustomPageSlug were only used
by Patch102 (via $patcher->). Move them as private helpers inside
Patch102 (taking Patcher $patcher, using $patcher->fetchOne and
$this->fitCustomPageSlug) and remove public versions from Patcher.
Keeps Patcher public API for shared helpers only.
* refactor(update): make Patcher helpers and patch bookkeeping driver-agnostic
- applyConfigPatches: normalize driver aliases (mysql/pgsql/sqlite -> pdo_*) and use per-driver port defaults (5432 for pgsql, none for sqlite) instead of hardcoded 3306; aligns with DriverManagerFactory::DEFAULT_PORTS.
- setPatchLevel: replace MySQL-only ON DUPLICATE KEY UPDATE with portable SELECT then UPDATE/INSERT so last_patch works on pgsql/sqlite.
- Rename isMysqlDriver -> isLegacyPatchDriver (keep isMysqlDriver as deprecated alias); availablePatches/applyCorePatches now use isLegacyPatchDriver; update SchemeSynchronizer and test docblocks.
- Make tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex portable via AbstractSchemaManager + driver-specific fallbacks (information_schema.tables WHERE table_schema='public' for pgsql, sqlite_master for sqlite); MySQL path keeps SHOW COLUMNS/INDEX for BC.
- updateTable: use driver-aware quoting (backticks for mysql, double quotes for pgsql/sqlite) instead of hardcoded backticks.
Verified: phpstan 536/536, cs:fix clean, pest 2990 passed.
* chore: add pg catalog words to spellcheck dictionary
* Remove deprecated compatibility shims
* refactor(doctrine): expand Driver enum and use it throughout
- Driver.php: add PdoPgsql/PdoSqlite cases, tryFromAlias(), defaultPort(), isMysql/isSqlite helpers; was single-case.
- DriverManagerFactory.php: use Driver::tryFromAlias() in normalizeDriver(), Driver::tryFrom()->defaultPort()/isMysql/isSqlite in getDatabaseConfig/buildConnectionParams/applySessionSettings instead of string compares.
- Patcher.php: use Driver enum in applyConfigPatches (alias + defaultPort), isLegacyPatchDriver (isMysql), tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex/updateTable (portable via AbstractSchemaManager + enum driver checks).
Ensures single source of truth for supported drivers; phpstan 536/536, pest 2990.
* Unify HTML sanitization across Twig and APIs
Replace the old sanitizer helpers with a shared HtmlSanitizerFactory and reuse context-specific sanitizers for content, markdown, adapter output, and theme settings. This also exposes sanitize_html and sanitize_markdown Twig filters, sanitizes markdown render output and email/signature HTML consistently, and simplifies support/email input cleanup by trimming/null-byte stripping instead of HTML-encoding raw input.
* fix: align PayGateway entities with Doctrine TimestampInterface after merge
* Type HTML sanitizer scheme constants
* style: fix import order and FQCN usage
* refactor(sanitizer): centralize HtmlSanitizerFactory sanitization and remove redundant config
* fix: add commonmark to cspell dictionary
* fix: apply Rector autofixes for library reorganization
Addresses Code Quality failures after merging main:
- remove unnecessary (string)/(bool) recasts (RecastingRemovalRector)
- add strict string casts for null-able args (NullToStrictStringFuncCallArgRector)
Files are limited to the PR's changed PHP set (BASE_SHA...HEAD) so
pre-existing drift elsewhere doesn't block this branch.
* fix: stabilize DomainValidator arrow function type for Rector
Add explicit string type to array_filter callback to prevent
oscillation between NullToStrictString (add cast) and
RecastingRemoval (remove cast) on the tld parameter.
* fix: apply Rector autofixes after merge from main
BinaryOpNullableToInstanceof / WhileNullableToInstanceof
* style: fix CS imports after Rector (Invoice / ServiceLicense FQCN -> imported)
* fix: use FOSSBilling\Event\Event in Antispam isSpam test (Box_Event -> Event reorg)
* Move src/library/FOSSBilling to src/core
First-party namespaced classes now live in src/core, leaving src/library
as the legacy PSR-0 adapter layer (Payment_, Registrar_, Server_).
- composer.json: PSR-4 FOSSBilling\ -> src/core/ (classmap unchanged)
- load.php/install.php: new PATH_CORE constant; pre-autoload requires repointed
- Update/ReadinessCheck: core/ added to the writable-folder checks
- Patch117: removes the obsolete library/FOSSBilling tree on upgraded installs
- Dockerfile: copies src/core in the vendor stages; release rewrites
(Version.php, SentryHelper.php, archive composer remap) repointed
- labeler/CODEOWNERS/cspell/AGENTS.md/test path references updated
- install/sql/content.sql: last_patch seed 116 -> 117
* Rename the FOSSBilling\ namespace to FOSSBilling\Core\
All first-party namespaced classes now live under FOSSBilling\Core\,
matching their src/core location. Clean cut, consistent with the Box\
removal on this branch: released extensions referencing the old names
must update their imports once in the same release.
- namespace/use/FQCN/docblock rewrite across src/ and tests/ (626 files)
- composer.json PSR-4: FOSSBilling\Core\ -> src/core/
- Dockerfile release composer remap updated to match
- SentryHelper: core.class tag for core paths, library.class retained
for the PSR-0 adapter layer
- Doctrine proxy namespaces (prod + test) follow the new prefix
- tests/Unit/FOSSBilling -> tests/Unit/FOSSBilling/Core; template
harness dirname depths adjusted
- ignore Strict/ test-harness findings artifacts
* Refresh the Composer autoloader after restoring the cached vendor tree
The php-build vendor cache is keyed on composer.lock, whose content-hash
does not change for autoload-only composer.json edits. On a cache hit the
install step is skipped entirely, leaving vendor/autoload.php generated
from the old PSR-4 mapping - so the library-to-core reorganization made
Rector fatal with "Trait FOSSBilling\Core\Doctrine\TimestampTrait not
found" while compiling analyzed files. Re-dump the autoloader from the
current composer.json after the cache restore.
* Trim comments on the two hand-written pieces to house style
Patch116 uses two dense lines with a short SHA rather than a commit
title; mirror that for Patch117. Shorten the autoloader-refresh comment
in php-quality-setup to the essentials.
* Fix two stale references from the library-to-core move
- GeoIP\Reader still built its bundled MMDB paths from PATH_LIBRARY
. FOSSBilling - point them at PATH_CORE so the default reader works
now that Patch117 removes the old tree; add existence tests so a
future move can't silently break this again.
- Dispatcher passed Request::getClientIp() (nullable on CLI, e.g. cron)
to the string-typed setIp(), TypeErroring before the first cron task;
coalesce to '' to match AbstractApi's own empty-string default.
* Dockerfile: namespace-wrapped cache sanity check belongs under Core
* Add dasharray to cspell words (SVG stroke-dasharray in branding assets)
Fixes Spellcheck failure on the push CI run.
* Deslop merge resolutions: trim comments, drop dead test stubs, import Patch classes
* Fix stale old-namespace references found in completeness audit
- CONTRIBUTING.md documented src/library/FOSSBilling paths/namespaces
- EntityMigrationCoverageTest read the deleted
library/FOSSBilling/UpdatePatcher.php; scan Patcher.php + Patch
classes instead (proven to detect ported migrations)
* Satisfy Rector 2.8: drop redundant cast, add nullable return type
* Address code review: restore Patch30-32, trim Patch33, drop retired hook call in Patch55
- Patch55 called mod_service('hook')->batchConnect(), removed on main
when the hook runtime was retired; the throw skipped the remaining
spamchecker cleanup. Match main (no hook lines).
- Patch33 had absorbed patches 30-32 cleanups, newly deleting the live
vendor/guzzlehttp (now required via sentry/sentry) for installs past
patch 30. Restore Patch30/31/32 as separate classes matching main and
trim Patch33 to the FileCache.php removal.
* Fix uninitialized Arr helper for group-restricted promos
enrichPromoApiArray built Arr without a container, so any promotion
with client-group restrictions fatally dereferenced null di in
getPairsForTableByIds. Initialize the helper with the service
container (with the file's usual missing-container guard) and add a
regression test.
* Keep client-group lookup portable: drop MySQL backticks
The Tools split introduced backtick quoting around the validated
table name; PostgreSQL rejects it. Main used the validated unquoted
name - restore that and assert the exact SQL in the regression test.
* Encrypt stored payment gateway and registrar secrets
Store only ciphertext for adapter fields marked secret in
pay_gateway.config and tld_registrar.config, decrypting when an
adapter is built or an admin reads the configuration. Adds a data
migration for existing rows; legacy plaintext rows keep working.
* Tighten SecretConfig docs to codebase style
* Address review: portable migration, concurrent-save guard, unknown adapters, strict ciphertext check
* Fix unrecognized word flagged by spellcheck
* Cover adapter read path with encrypted stored secrets
* Assert non-secret fields survive the registrar read path
* Update symfony packages to v7.4.22
* Tolerate rounded-up retryAfter in rate limiter test
Since symfony/rate-limiter 7.4.22 retryAfter is rounded up (up to one
second late) instead of up to one second early, so a 1-hour fixed
window can report 3601 instead of 3600. Relax the upper bound
accordingly.
---------
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: opencode <opencode@localhost>
* Memoize the PBKDF2-derived key in Crypt
getCurrentKey() ran 100,000 PBKDF2 rounds (about 200 ms) on every encrypt and decrypt, so each read of an encrypted setting was expensive. Keep the derived key for the life of the PHP process, keyed by a hash of the passphrase. Nothing is persisted.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* Harden Crypt key memoization: cap cache, de-flake tests
- Bound the in-memory derived-key memo to 32 entries with FIFO
eviction so long-lived workers fed many distinct passphrases
cannot grow memory without bound.
- Add Crypt::clearDerivedKeys() (@internal) so tests reset the memo
deterministically instead of relying on cross-test baselines.
- Fix potentially flaky wrong-key assertions: a wrong AES-CBC key can
occasionally decrypt to unrelated garbage that passes the plausibility
filter, so assert inequality rather than strict false.
- Remove the global cryptDerivedKeys() test helper in favor of a
closure-scoped reflection lookup.
* Address CodeRabbit review: cache lifetime doc and sentinel test
- Clarify the derived-key memo is request-scoped under PHP-FPM
(statics reset at request shutdown); CLI and long-lived workers
reuse it for their whole lifetime. No cross-request cache: out of
scope.
- Add a sentinel test proving cache hits reuse the stored key: with
the isset() guard removed the sentinel is overwritten and the test
fails, while the count-based assertions alone would still pass.
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
- Drop the str_starts_with('xn--') bypass so punycode suffixes go through
the same final-label check (rejects xn--p1ai.123, xn--).
- Allow digit-prefixed final labels that contain a letter (co.3abc) while
still rejecting all-numeric ones (co.123).
- Use \z instead of $ so trailing dots/newlines stay invalid.
- Document intentional choices: no length limits (normalizeTld enforces),
leading-dot stripping matches the online path (..co.uk behaves the same
online and offline).
When the public suffix list cannot be fetched, isTldValid() only accepted plain letters, so suffixes such as com.np or co.uk were rejected even though they validate when the list is available. Accept dot-separated labels of letters, digits and inner hyphens, still requiring a non-numeric last label and rejecting empty or hyphen-edged labels.
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* Index created_at and purge old activity in batches
The activity retention sweep ran four DELETE ... WHERE created_at <= ? statements on every cron run against columns with no index, so each sweep scanned whole tables, and a large backlog was removed in one statement. Index created_at on the four activity tables (entity metadata for fresh installs and the schema synchronizer, patch 135 for MySQL and MariaDB) and delete in bounded batches that pick up where they left off on the next run. The batches select ids first so the SQL stays portable across the supported databases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* 🎨 Clean up activity retention purge helper
Remove unused DBAL fetch in cleanupOldActivity, drop final from
the public batch-size constant for consistency, and allowlist
purge tables before interpolating into SQL.
* 🚀 Bound the full retention sweep to one deadline and batch size
Share a single 15s deadline across all four activity tables so a large
backlog cannot delay later cron tasks by up to ~60s, and shrink batches
to 500 ids so the expanded IN (?) delete stays within SQLite's
historic 999 host-parameter ceiling.
* 🚀 Order retention batches by the filter column
ORDER BY id could not use the created_at index for ordering, so the
database sorted the whole expired set or scanned by primary key on
every batch. ORDER BY created_at, id is served from the retention
index (covering index on SQLite, implicit PK tail on InnoDB).
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* Fix the toApiArray auditor crashing on try/catch blocks
The TryCatch branch read $node->try, which php-parser does not have (the
try body is $node->stmts), so the first toApiArray() containing a
try/catch passed null to collectAssignments() and the audit test died
with a TypeError. The catch and finally nodes were passed in whole, and
collectAssignments() has no branch for them, so their bodies were never
examined either.
Walk the try, catch and finally bodies instead. A new test audits a
temporary module whose toApiArray() sets a field conditionally in each
of the three; it fails with the TypeError before this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Name the audit test's temporary directory in plain words
cspell rejects "toapiarray".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* 🐛 Refund Stripe payments when an invoice is refunded
Admin refunds only created an internal credit note, so FOSSBilling
showed refunded while Stripe showed nothing. Issue the refund through
the Stripe API first (PaymentIntents, charges, and subscription
invoices), record an audit transaction, and add a gateway opt-out.
- Add Payment_Adapter_Stripe::refundPayment() and ::supportsRefunds()
with Stripe minor-unit conversion and idempotency keys
- Record dashboard refunds as credit notes via refund.created webhooks;
partial ones are left for the admin to record
- Reuse unrecorded Stripe refunds on retry instead of refunding twice
- Add gateway_refund API/option plus admin UI checkboxes
Fixes#4564
* 🐛 Ignore dashboard refund webhooks beaten by a concurrent refund
Both refund paths funnel through refundInvoice's locked status check,
so a duplicate credit note was already impossible. The remaining flaw
was UX: the losing webhook kept an ERROR transaction row for something
that resolved correctly. Re-check the invoice after a recording
failure and silently acknowledge when it is already settled.
* 🐛 Reverse deposit credit before issuing gateway refunds
A staff account with invoice.manage_invoices but without
client.manage_balance could refund a paid Add Funds invoice and return
real card money through Stripe while the wallet credit survived, an
exploitable double-spend. Require the balance permission for gateway
refunds of deposit invoices and debit the wallet credit atomically with
the credit note, failing closed on insufficient balance. Dashboard
refunds recording path reverses the credit the same way.
* Fix tests for gateway refund capability checks
Mockery magic methods are invisible to method_exists, so adapter
mocks must extend the real Payment_Adapter_Stripe class for the
refund-capability checks to behave as in production. Also update
older refund tests for the up-front paid-status guard.
* Fix tests for gateway refund capability checks
* Fix refund test mock counts for the orphan-reuse lookup
* Apply php-cs-fixer to refund tests
* Remove duplication from Stripe refund target resolution
* Make gateway_returned unavailable to API callers
* Fix idempotency key collisions and expand Stripe invoice payments
* Serialize invoice refunds under a per-invoice lock
* Apply Rector switch-to-match in createRefundDocument
* Reverse the deposit credit before calling the gateway
* Give each refund attempt its own idempotency scope
* Rename test variable tripping spellcheck
* Reject dashboard refunds that exceed the remaining amount
* Resolve Stripe refund targets through invoice payments
* Process invoice payments without a status
* Fail loudly when a gateway refund has no refundable payment
* Fail loudly when the gateway adapter cannot load
* Require exact line selection match for refund reuse
* Assert recorded amounts in partial reuse test
* Fix two stale comments left by the refund flow changes
* Refactor getLoginUrl and improve error handling
* Replace error_log with getLog()->error
* Fall back to the plain CWP panel URL when SSO is unavailable
The auto-login endpoint is not available on every API key, so a failed
user_session request must not break the login button. Mirror the WHM
manager: log the failure (username only, never tokens or URLs) and
return the standard panel URL.
Use timer 5, matching CWP's own WHMCS module, and cover the new
behaviour with unit tests.
* Reuse the CWP test manager helper for the session request test
* Handle transport failures and non-string session URLs in CWP login
Convert Symfony transport exceptions into Server_Exception so every
CWP request failure funnels through the plain panel URL fallback,
and only accept a non-empty string session URL.
* Bound the CWP session request duration
Limit user_session to 10s idle / 20s total so a hung panel fails fast
to the manual login URL instead of stalling the page load. Other
operations keep the existing client defaults.
---------
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* API always returns the 200 status code
Updated ApiResponseFactory::getStatusCode to map standard HTTP error status codes (400-599) to their corresponding HTTP status codes while preserving mappings for internal application codes and legacy OK status for unmapped internal error codes.
* Map colliding app codes (413,436,437,498) to 400, keep envelope code
Prevents HTTP 413 / non-standard 436,437,498 statuses for existing
application errors while still honouring explicit HTTP codes in 400-599.
Adds coverage and removes trailing blank line failing CS fixer.
---------
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* Fix WHM activation failing when package already exists (#4560)
- Request listpkgs with want=all and parse both legacy package and modern data.pkg shapes
- On addpkg already-exists, sync via editpkg and continue; only block activation on other errors
- Include WHM reason in Server_Exception and fix placeholder keys
* Add WHM API terms to spellcheck dictionary
* Address review: use typed exception for WHM already-exists, keep reasons out of API errors
- Add Server_PackageExistsException; request() now throws generic messages and translates already-exists at the WHM boundary
- checkPackageExists() catches the typed exception instead of matching getMessage()
* Fix Rector findings: remove unused catch variable and setAccessible call
* Handle modern WHM metadata.result failures in Whm::request
---------
Co-authored-by: opencode <opencode@localhost>
* Harden guest session-state API calls against cross-site requests
Extend the guest authentication guard to the session-bound cart mutations
(cart add/remove item, promo apply/remove, reset, currency change),
requiring the pre-login session nonce and a same-origin context like
client login, signup, and staff login already do. Cart changes accept GET
with the nonce in the query string, matching _checkCSRFToken()'s token
sources, so shipped fb_api_link usage keeps working.
Also pin an explicit SameSite=Lax on the session cookie when
security.mode is not strict: an unset attribute leaves cookie behavior to
each browser's default, and some browsers send cookies on cross-site
requests when it is missing. The installer session already uses Lax.
* Default queued cookies to SameSite Lax
CookieQueue now defaults to Lax so locale and timezone preference
cookies carry an explicit policy instead of leaving the behavior to
each browser's unset-attribute default. The CSRF cookie keeps its
explicit Strict, and callers can still pass null to omit the
attribute.
* Sign guest E2E requests with the pre-login nonce
The Live Tests failures on the cart guard came from the test helper
keeping its own copy of the guarded-endpoint list (login and signup
only), so guest cart calls went out unsigned and were rejected with
403. Attach the nonce to every guest request instead - the dispatcher
strips the parameter where it is not used - and let guest GET requests
carry it in the query string, which _checkCSRFToken() already accepts.
* Set the cURL URL after appending guest GET parameters
CURLOPT_URL copies the value at set time, so mutating $url after
curl_setopt_array() left the handle pointing at the query-less URL and
guest GET requests went out without the nonce or parameters. Re-set
CURLOPT_URL once the query string is appended.
* Address review comments on #4541: document UGX exclusion, hardcode Stripe decimal lists
- Rename STRIPE_ZERO_DECIMAL_CURRENCIES to STRIPE_ZERO_DECIMAL_CHARGE_CURRENCIES
and document why UGX is excluded (zero-decimal per Stripe's list, but its
special-case row requires charge amounts x100), plus ISK/HUF/TWD notes.
- Replace the CLDR-based three-decimal check with a hardcoded
STRIPE_THREE_DECIMAL_CURRENCIES list (BHD, JOD, KWD, OMR, TND); drop the
Symfony Intl dependency from this helper.
- Reword the StripeTest comment to state the UGX exception explicitly.
* Extend Stripe currency test coverage in #4541 follow-up
- getAmountInMinorUnits: add BHD (second three-decimal code), AFN/YER
(more CLDR-zero codes Stripe charges x100), TWD (payout-zero/charge-two
special case), unknown-code fallback (XXX) and lowercase inputs.
- Add getAmountFromMinorUnits round-trip test via invokePrivateMethod so the
read-back path is covered too.
* 🐛 Send Stripe amounts in Stripe's units, not CLDR's
The adapter scaled amounts by CLDR's fraction digits, which are 0 for
COP, HUF, IDR, ISK, PKR, UGX and several other currencies that Stripe
expects with two decimals, so it sent 1/100 of the invoice total and
read Stripe's amounts back with the same factor. Use Stripe's rules:
its zero-decimal currencies as-is, three-decimal ones with three
digits, everything else (ISK and UGX included) x100.
* ✏️ Avoid an unknown word in the Stripe currency test comment
* Harden Stripe renewal paid-skip for inconclusive billing reasons
Only skip the already-paid original invoice for the initial payment or
when billing_reason is missing (ambiguous: duplicate initial webhook vs
renewal — skipping avoids double-crediting). Adds regression tests that
a duplicate initial webhook is still skipped and documents the double
DB fetch as an identity-map hit.
* Rename test identifiers to satisfy spellcheck
🐛 Record Stripe renewal payments after the first invoice is paid
The subscription metadata always points at the first invoice of the
subscription, so the "already paid" skip in invoice.paid handling
returned early for every renewal: no funds were added and the renewal
invoice stayed unpaid. Apply the skip to the initial payment only.
Co-authored-by: Adam Daley <admdly@users.noreply.github.com>
* Fix cron-sent emails rendering dates in en_US instead of configured locale
In CLI/cron context there is no Accept-Language header, so
Locale::acceptFromHttp() fails and Locale::lookup() with an empty
detected locale returns the first enabled locale (en_US) instead of
null, overriding the configured i18n.locale default. Guard
getBrowserLocale() against empty/unparsable headers so it falls back
to the configured default, and stop queueing empty locale cookies.
Also plumb an explicit per-recipient locale through email rendering:
TwigFactory::createEmailEnvironment(), System render helpers, and
Email::sendTemplate() now honor the recipient's language
(client lang, per staff member on broadcasts), outranking browser
auto-detection, with Massmailer rendering per-client too.
Fixes#4524
* Fix tests: simulate header-less requests, update subject render expectation
Symfony's Request::create() seeds a default Accept-Language header,
so strip it to reproduce the CLI/cron condition in the new
getActiveLocale fallback tests. Update the existing
renderEmailSubjectString test for the new locale parameter.
🐛 Soften PayPal item_number echo check to a warning
PayPal freezes item_number at subscription signup while the invoice
number can change (renumbering, issuing), so the strict check added in
#4424 failed legitimate renewal payments for older subscriptions.
Downgrade the item_number mismatch to a warning log and keep
processing; the stable internal invoice id reference remains a hard
error. The signed callback binding, subscription linkage, and amount
checks remain the payment binding.
Fixes#4533
* Refresh default branding assets
Add updated light and dark logos plus an SVG favicon, and point installer and default company branding settings to the new assets. Update shared theme tests to use the renamed light logo.
* Complete default branding asset migration
* Preserve branding assets still referenced by settings
* Harden extension directory partial against sparse metadata
Remote directory entries may omit icon_url, author, author URL/id,
releases, or description. Under strict_variables any missing key
crashes the whole Extensions page (and the registrar/payment-gateway
tabs that reuse this partial). Default every optional field and fall
back to a generic icon/author label.
Adds sparse-metadata cases to ExtensionDirectoryTest covering
omitted/null icons, registrar author shapes without URL, missing
authors, and empty/missing releases.
Fixes#4486
* Use page-level type fallback for extension install links
Addresses review: entries without extension.type should stay in the
currently selected directory category instead of falling back to a
hard-coded mod.
* Serve stale extension directory data on refresh failures
The Extensions page blocks on a live directory fetch. Until now a
failed refresh meant every admin page load paid another HTTP timeout
(up to 5s each), and the cache key ignored the installed FOSSBilling
version, so an upgrade could briefly serve the previous version's
filtered list.
Keep a week-long last-known-good copy alongside the hourly entry:
failed refreshes fall back to it (with a warning logged), and
further attempts are suppressed for 5 minutes. The installed
version is now part of the cache key. Non-app exceptions from the
HTTP layer are normalized to FOSSBilling exceptions so callers can
rely on a single exception contract.
* Treat corrupted fresh directory cache entries as misses
* Validate directory results before writing either cache slot
* Validate stale directory results before serving them
* Harden extension directory partial against sparse metadata
Remote directory entries may omit icon_url, author, author URL/id,
releases, or description. Under strict_variables any missing key
crashes the whole Extensions page (and the registrar/payment-gateway
tabs that reuse this partial). Default every optional field and fall
back to a generic icon/author label.
Adds sparse-metadata cases to ExtensionDirectoryTest covering
omitted/null icons, registrar author shapes without URL, missing
authors, and empty/missing releases.
Fixes#4486
* Use page-level type fallback for extension install links
Addresses review: entries without extension.type should stay in the
currently selected directory category instead of falling back to a
hard-coded mod.
* Harden theme settings auth and CSRF checks
* Secure JSON webhook callback validation
Adds pre-persistence validation for JSON payment callbacks before any transaction lifecycle work or storage. This change bounds callback input in the IPN entrypoint, introduces a JsonWebhookAuthenticatorInterface, and validates Stripe webhook signatures through a reusable method before processing. It rejects unsupported/invalid gateways and oversized or malformed payloads, and adds unit coverage for missing, invalid, expired, malformed, oversized, and valid signed callbacks.
* Harden payment return flow
Route gateway return and cancel URLs through a dedicated `/invoice/payment-return` page that only redirects to fixed invoice destinations. This removes the legacy session restore token flow, avoids exposing session capability in payment handoffs, switches PayPal returns to browser redirects (`rm=1`), and adds unit coverage for the new payment return and gateway URL behavior.
* Harden admin transaction error rendering
Replaced inline `onclick` handling in the invoice transactions template with escaped `data-transaction-error*` attributes, and added a delegated click listener in `fossbilling.ts` to show messages safely. This removes executable inline attributes while preserving diagnostic text.
Added unit coverage for both Twig and admin JS to ensure hostile payloads stay as plain text, no script/image injection is introduced, and dynamically inserted error links still behave correctly.
* Harden email confirmation token lifecycle
Refactors client email confirmation to be transactional and replay-safe by binding tokens to the current email hash, enforcing expiry, and atomically consuming/revoking tokens during approval. Email changes from both admin and profile flows now revoke outstanding confirmation links and reset `email_approved` consistently, including concurrent update scenarios.
Adds a dedicated `EmailConfirmationTest` suite covering replay prevention, stale/legacy/expired token rejection, resend behavior, rollback safety, and profile-change edge cases, while removing outdated unit tests that no longer match the new confirmation flow.
* Harden invoice PDF logo fetching
Invoice PDF generation no longer allows Dompdf to reach remote network sources. Logos are now fetched through a no-private-network client, validated for MIME, size, and safe SVG content, and embedded as data URIs when allowed. Malformed, private, redirecting, or unsafe logo URLs are ignored without interrupting invoice rendering, and the new unit tests cover the security and compatibility cases.
* Harden guest login/signup CSRF checks
Adds a dedicated guard for guest `client/login` and `client/create` API calls to require POST, validate the pre-login session CSRF nonce, and block cross-site origins/fetch contexts before dispatch. Client-facing login/signup templates now include `CSRFToken` hidden inputs, and test helpers (PHP + Playwright) fetch `/login` first to reuse the anonymous session token. Unit tests were expanded to cover accepted/rejected authentication request paths and token propagation in shipped forms.
* Avoid persisting extension config on reads
Stop `Extension` config lookups from creating empty `ExtensionMeta` rows, and gate guest settings to core or active modules before reading config. Tests were updated to cover the non-writing read path and to ensure authorized config writes still preserve public settings.
* Harden extension archive extraction
Improve `downloadAndExtract()` security and cleanup behavior by staging extraction in a private temp directory, using cryptographically secure temp names, validating all ZIP entries before extraction (path, null-byte/colon checks, and allowed file types), and removing staged artifacts on failure. After successful validation/extraction, permissions are restored before moving to the final extension path.
Adds unit tests covering rejection of unsafe archive entries before any write occurs and confirming valid package files/directories still extract correctly.
* Harden extension directory data handling
Adds strict validation for extension IDs and catalog metadata in `ExtensionManager`, including cached responses, and rejects unsafe author URLs/schemes. Updates the admin extensions template to remove inline `onclick` usage and load README content via modal event data attributes, preventing injected names from becoming executable script. Includes new unit tests covering malformed directory payloads, cached validation behavior, safe rendering of quoted names, identifier pre-validation, and valid metadata edge cases.
* Harden client password reset revocation
Move client password changes and reset issuance into `ClientPasswordResetRepository` so password updates and reset-token revocation happen atomically. Admin, guest, and profile password changes now reuse the same transactional path, preventing stale recovery links from overwriting newer passwords and replacing outstanding reset tokens safely across supported drivers. Added unit coverage for revocation, rollback, stale-token rejection, and driver-specific locking behavior.
* Harden staff password reset transactions
Refactors admin password changes to go through `AdminPasswordResetRepository` so password updates and reset-token revocation happen atomically in one transaction. Profile and staff password-change flows, plus guest reset redemption/request flows, now reuse repository methods (`changePassword`/`replaceReset`) instead of manually persisting/removing entities.
Adds row-lock based eligibility rechecks (including SQLite-specific lock handling) to prevent stale or preloaded reset tokens from being reused across concurrent updates. Unit tests were updated and expanded with a new repository test suite covering revocation behavior, expiry/eligibility checks, rollback safety, side-effect failures, and concurrency races.
* Prevent public installer reinstall when debug is enabled
* Fix strict-variables signup test context for CSRFToken
* Fix PHP 8.5 SVG logo detection and Rector findings
Route image/svg+xml reported by getimagesizefromstring to the self-contained SVG validator instead of the raster path. Apply ReturnNeverTypeRector and ReadOnlyAnonymousClassRector suggestions in tests.
* Satisfy PHPStan for SVG mime check in PDF logo fetch
* Add nosniff and pjpeg to spellcheck dictionary
* Reuse CSRF cookie for pre-login API tests
Update the test API helper to read the pre-login CSRF token from the cookie jar before falling back to parsing the login page. This keeps signup and other back-to-back guest requests on the same session, even when `/login` redirects or the session id rotates.
* Send CSRF token in Playwright stale-login check
The password-change test asserts stale credentials are rejected with an invalid-credentials message. Since guest logins now require the pre-login session nonce, submit the double-submit cookie value like the real login form does; otherwise the request is rejected before credentials are checked. This also avoids the retry that pushed the suite over the client_signup rate limit.
* Address review findings: rate-limit ordering, staging cleanup, README race
- Run guest CSRF validation after the rate limiter so invalid-token floods still consume api_login/api_guest budgets.\n- Remove extension staging dir and archive when destination resolution, the already-installed check, or the final move fails.\n- Ignore stale extension README responses that arrive after another extension was selected.
* Check stale passwords from a fresh session in Playwright
Changing a password invalidates every session including the current one, so the old session can no longer pass CSRF validation. Run the stale-password login from a fresh anonymous context with its own pre-login nonce, like a real login attempt.
* Support full Unicode in custom pages and MySQL defaults
* Recognize charset migration terms in spellcheck
* Keep charset repair guidance in the pull request
* ✨ Route offline payments through explicit admin approval
Replace the fragile stored-IPN check for manual gateways with a
capability split: automated adapters verify callbacks, offline gateways
settle through a permission-checked approve action and state machine.
Centralize IPN decoding so typed adapter signatures never see null, give
adapters explicit error codes, and remove the dead validate_ipn flag.
* Satisfy Rector: drop redundant casts, readonly test double
* Make approval E2E immune to IPN-hash dedupe collisions
* Reword E2E variable flagged by spellcheck
* 🐛 Heal promo schema drift behind #4386/#4401 and surface sync failures
Add patch127 with the promo columns (requires_products, auto_apply,
priority, stackable) plus the auto_apply index that #4386 and #4401
shipped without a migration, crashing promo loads with "Unknown
column 't0.requires_products'" on installs the portable sync didn't
heal. Surface the drift state and last failed sync in the
finalization status (with a finalize-screen warning) so a repeatedly
failing sync is visible instead of living only in the update log.
Fixes#4433
* 📝 Allow LONGTEXT in spellcheck dictionary
Required by patch127's promo migration DDL.
* 🐛 Report schema drift independently of the sync retry cooldown
isSchemaOutOfSync() honors the cooldown, which hid the drift status
(and the finalize-screen warning) for an hour after each failed
sync. Status now uses a cooldown-ignoring hash check, and the warning
stays visible once finalized with drift remaining.
* Merge renewal invoices sharing a client, currency, due date, and period
Adds an opt-in renewal merge for the batch generator (#4118): orders
sharing a client, currency, due date, and billing period go onto one
invoice instead of one invoice per order. Precedence is per-order meta
override, then the client's tri-state preference, then the global
invoice_merge_renewals setting (off by default). Orders paid through
an active gateway subscription keep their one-invoice shape.
Grouping happens upstream of generation, so every order is invoiced
exactly once and stamped with the shared invoice id (no #1494/#1969
repeat). Each merged invoice is a fresh draft, so invoice immutability
is never violated.
* Deslop the renewal-merge diff
* Address review: boolean normalization, atomic merged invoices, settings off-switch, E2E flag restore
* Retry bucket orders singly when merged generation fails, issuance outside generation
* Reset the entity manager and stop the batch when merged generation closes it
* Allow clients to belong to multiple client groups (#4387)
* Use spellcheck-clean identifiers in multi-group E2E
* Renumber multi-group migration to patch125 after main merge
* Halve guest logins in multi-group E2E for the shared api_login budget
* Only migrate group assignments that reference existing groups
Reset the PHP opcode cache after extracting the update archive, so the next request's automatic finalization cannot run against pre-update classes and stamp the state finalized without applying patches.
Also heal a finalized state that still reports pending patches on the next request, keep offering finalization on the finalize screen until the patch count reaches zero, and report the database/code patch levels in the completion guard's error.