Commit 14c4b6b18a8 for woocommerce
commit 14c4b6b18a83ff687b413f15579fdc5250fbcd1d
Author: daledupreez <dale@automattic.com>
Date: Thu Jun 18 16:24:00 2026 +0200
Add woocommerce-subscriptions-engine package (#65767)
* Initial shell for subscriptions-engine package
* Implement core entities and basic scripts
* Remove older repo reference
* Remove DROP TABLE commands from uninstall
* Update integration tests to reference renamed function
* Ensure unit tests and integration tests work; add wp-env as a dev dependency
* Remove ON_HOLD => EXPIRED transition
* Update PHP namespace and package to Automattic/WooCommerce/SubscriptionsEngine
* Rename package to woocommerce-subscriptions-engine
* Fix directory for test:integration script
* Add entry to CODEOWNERS
* Validate incoming status and schedule_source values
* Rename owner to extension_slug
* Add validation for starting_cycle
* Add validation for min_cycles and max_cycles
* Add validation/normalization for trial_duration in billing policy
* Add checks for delivery policy anchors
* Require positive order ID
* Add defensive code to Contract::create()
* Defensive code against invalid data in Pricing_Policy::from_array()
* Make bootstrap more robust against the 'init' action having run already
* Add guard against previously defined constant
* Add transition tests for EXPIRED status
* Remove lingering software reference in license.txt
* Remove lingering PHPCS exclusion that is not needed
* Update PHPUnit configuration to be more precise
* Add ABSPATH guard in subscriptions-engine.php
* Rename top-level file to match package name
* Linting fixes
* Fix package name in package.json
diff --git a/CODEOWNERS b/CODEOWNERS
index 5d194a8f09a..53a0087b18a 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -47,3 +47,6 @@
# Email Editor integrations
/plugins/woocommerce/src/Internal/EmailEditor/ @woocommerce/ballade
/plugins/woocommerce/client/admin/client/wp-admin-scripts/email-editor-integration/ @woocommerce/ballade
+
+# Subscriptions Engine packages
+/packages/php/woocommerce-subscriptions-engine/ @woocommerce/chronos
\ No newline at end of file
diff --git a/packages/php/woocommerce-subscriptions-engine/.wp-env.json b/packages/php/woocommerce-subscriptions-engine/.wp-env.json
new file mode 100644
index 00000000000..1ac6588b11f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/.wp-env.json
@@ -0,0 +1,28 @@
+{
+ "phpVersion": "8.3",
+ "plugins": [
+ "."
+ ],
+ "config": {
+ "JETPACK_AUTOLOAD_DEV": true,
+ "WP_DEBUG_LOG": true,
+ "WP_DEBUG_DISPLAY": true,
+ "ALTERNATE_WP_CRON": true
+ },
+ "mappings": {
+ },
+ "env": {
+ "development": {
+ "port": 8883
+ },
+ "tests": {
+ "port": 8083,
+ "plugins": [
+ "."
+ ],
+ "themes": [],
+ "config": {
+ }
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/README.md b/packages/php/woocommerce-subscriptions-engine/README.md
new file mode 100644
index 00000000000..b968921ef3a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/README.md
@@ -0,0 +1,3 @@
+# WooCommerce Subscriptions Engine
+
+This package implements a general-purpose subscriptions engine for WooCommerce.
diff --git a/packages/php/woocommerce-subscriptions-engine/changelog/.gitkeep b/packages/php/woocommerce-subscriptions-engine/changelog/.gitkeep
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/packages/php/woocommerce-subscriptions-engine/composer.json b/packages/php/woocommerce-subscriptions-engine/composer.json
new file mode 100644
index 00000000000..9ab2e1f46a8
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/composer.json
@@ -0,0 +1,69 @@
+{
+ "name": "automattic/woocommerce-subscriptions-engine",
+ "description": "Subscriptions engine for WooCommerce.",
+ "type": "wordpress-plugin",
+ "license": "GPL-2.0-or-later",
+ "prefer-stable": true,
+ "minimum-stability": "dev",
+ "version": "0.0.1",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "autoload-dev": {
+ "classmap": [
+ "tests/unit/",
+ "tests/integration/"
+ ]
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "automattic/jetpack-changelogger": "3.3.0",
+ "phpunit/phpunit": "^9.6",
+ "woocommerce/woocommerce-sniffs": "1.0.0",
+ "yoast/phpunit-polyfills": "^4.0"
+ },
+ "config": {
+ "platform": {
+ "php": "7.4"
+ },
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
+ "scripts": {
+ "post-install-cmd": [
+ "composer dump-autoload -o"
+ ],
+ "post-update-cmd": [
+ "composer dump-autoload -o"
+ ],
+ "env:start": "npx wp-env start",
+ "env:stop": "npx wp-env stop",
+ "env:destroy": "npx wp-env destroy",
+ "test:unit": "phpunit",
+ "test:integration": "npx wp-env run tests-cli --env-cwd=wp-content/plugins/woocommerce-subscriptions-engine ./vendor/bin/phpunit --configuration phpunit-integration.xml.dist",
+ "phpcs": "phpcs -s -p",
+ "phpcbf": "phpcbf -p"
+ },
+ "extra": {
+ "changelogger": {
+ "formatter": {
+ "filename": "../../../tools/changelogger/class-php-package-formatter.php"
+ },
+ "types": {
+ "fix": "Fixes an existing bug",
+ "add": "Adds functionality",
+ "update": "Update existing functionality",
+ "dev": "Development related task",
+ "tweak": "A minor adjustment to the codebase",
+ "performance": "Address performance issues",
+ "enhancement": "Improve existing functionality"
+ },
+ "changelog": "changelog.md"
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/composer.lock b/packages/php/woocommerce-subscriptions-engine/composer.lock
new file mode 100644
index 00000000000..be315b9902f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/composer.lock
@@ -0,0 +1,3612 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "c99e9fbb764865f370ade0b553c9729f",
+ "packages": [],
+ "packages-dev": [
+ {
+ "name": "automattic/jetpack-changelogger",
+ "version": "v3.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Automattic/jetpack-changelogger.git",
+ "reference": "8f63c829b8d1b0d7b1d5de93510d78523ed18959"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Automattic/jetpack-changelogger/zipball/8f63c829b8d1b0d7b1d5de93510d78523ed18959",
+ "reference": "8f63c829b8d1b0d7b1d5de93510d78523ed18959",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6",
+ "symfony/console": "^3.4 || ^5.2 || ^6.0",
+ "symfony/process": "^3.4 || ^5.2 || ^6.0",
+ "wikimedia/at-ease": "^1.2 || ^2.0"
+ },
+ "require-dev": {
+ "wikimedia/testing-access-wrapper": "^1.0 || ^2.0",
+ "yoast/phpunit-polyfills": "1.0.4"
+ },
+ "bin": [
+ "bin/changelogger"
+ ],
+ "type": "project",
+ "extra": {
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-changelogger",
+ "branch-alias": {
+ "dev-trunk": "3.3.x-dev"
+ },
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-changelogger/compare/${old}...${new}"
+ },
+ "version-constants": {
+ "::VERSION": "src/Application.php"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Automattic\\Jetpack\\Changelog\\": "lib",
+ "Automattic\\Jetpack\\Changelogger\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Jetpack Changelogger tool. Allows for managing changelogs by dropping change files into a changelog directory with each PR.",
+ "support": {
+ "source": "https://github.com/Automattic/jetpack-changelogger/tree/v3.3.0"
+ },
+ "time": "2022-12-26T13:49:01+00:00"
+ },
+ {
+ "name": "dealerdirect/phpcodesniffer-composer-installer",
+ "version": "v1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/composer-installer.git",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd",
+ "shasum": ""
+ },
+ "require": {
+ "composer-plugin-api": "^2.2",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.1.0 || ^4.0"
+ },
+ "require-dev": {
+ "composer/composer": "^2.2",
+ "ext-json": "*",
+ "ext-zip": "*",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev",
+ "yoast/phpunit-polyfills": "^1.0"
+ },
+ "type": "composer-plugin",
+ "extra": {
+ "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin"
+ },
+ "autoload": {
+ "psr-4": {
+ "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Franck Nijhof",
+ "email": "opensource@frenck.dev",
+ "homepage": "https://frenck.dev",
+ "role": "Open source developer"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer Standards Composer Installer Plugin",
+ "keywords": [
+ "PHPCodeSniffer",
+ "PHP_CodeSniffer",
+ "code quality",
+ "codesniffer",
+ "composer",
+ "installer",
+ "phpcbf",
+ "phpcs",
+ "plugin",
+ "qa",
+ "quality",
+ "standard",
+ "standards",
+ "style guide",
+ "stylecheck",
+ "tests"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/composer-installer/issues",
+ "security": "https://github.com/PHPCSStandards/composer-installer/security/policy",
+ "source": "https://github.com/PHPCSStandards/composer-installer"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2026-05-06T08:26:05+00:00"
+ },
+ {
+ "name": "doctrine/instantiator",
+ "version": "1.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^0.16 || ^1",
+ "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan-phpunit": "^1",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5",
+ "vimeo/psalm": "^4.30 || ^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/1.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-30T00:15:36+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.13.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpspec/prophecy": "^1.10",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-01T08:46:24+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v5.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "ext-json": "*",
+ "ext-tokenizer": "*",
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+ },
+ "time": "2025-12-06T11:56:16+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+ "reference": "54750ef60c58e43759730615a392c31c80e23176",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-03T12:33:53+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpcompatibility/php-compatibility",
+ "version": "9.3.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibility.git",
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243",
+ "reference": "9fb324479acf6f39452e0655d2429cc0d3914243",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3",
+ "squizlabs/php_codesniffer": "^2.3 || ^3.0.2"
+ },
+ "conflict": {
+ "squizlabs/php_codesniffer": "2.6.2"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "homepage": "https://github.com/wimg",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors"
+ }
+ ],
+ "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.",
+ "homepage": "http://techblog.wimgodden.be/tag/codesniffer/",
+ "keywords": [
+ "compatibility",
+ "phpcs",
+ "standards"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibility"
+ },
+ "time": "2019-12-27T09:44:58+00:00"
+ },
+ {
+ "name": "phpcompatibility/phpcompatibility-paragonie",
+ "version": "1.3.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf",
+ "shasum": ""
+ },
+ "require": {
+ "phpcompatibility/php-compatibility": "^9.0"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "paragonie/random_compat": "dev-master",
+ "paragonie/sodium_compat": "dev-master"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "lead"
+ }
+ ],
+ "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.",
+ "homepage": "http://phpcompatibility.com/",
+ "keywords": [
+ "compatibility",
+ "paragonie",
+ "phpcs",
+ "polyfill",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-09-19T17:43:28+00:00"
+ },
+ {
+ "name": "phpcompatibility/phpcompatibility-wp",
+ "version": "2.1.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa",
+ "shasum": ""
+ },
+ "require": {
+ "phpcompatibility/php-compatibility": "^9.0",
+ "phpcompatibility/phpcompatibility-paragonie": "^1.0",
+ "squizlabs/php_codesniffer": "^3.3"
+ },
+ "require-dev": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0"
+ },
+ "suggest": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.",
+ "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues."
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Wim Godden",
+ "role": "lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "lead"
+ }
+ ],
+ "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.",
+ "homepage": "http://phpcompatibility.com/",
+ "keywords": [
+ "compatibility",
+ "phpcs",
+ "standards",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues",
+ "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy",
+ "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCompatibility",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcompatibility",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-10-18T00:05:59+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsextra",
+ "version": "1.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
+ "reference": "b598aa890815b8df16363271b659d73280129101"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101",
+ "reference": "b598aa890815b8df16363271b659d73280129101",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.4",
+ "phpcsstandards/phpcsutils": "^1.2.0",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "phpcsstandards/phpcsdevtools": "^1.2.1",
+ "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors"
+ }
+ ],
+ "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSExtra"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-12T23:06:57+00:00"
+ },
+ {
+ "name": "phpcsstandards/phpcsutils",
+ "version": "1.2.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
+ "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
+ "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
+ "shasum": ""
+ },
+ "require": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
+ "php": ">=5.4",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
+ },
+ "require-dev": {
+ "ext-filter": "*",
+ "php-parallel-lint/php-console-highlighter": "^1.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
+ },
+ "type": "phpcodesniffer-standard",
+ "extra": {
+ "branch-alias": {
+ "dev-stable": "1.x-dev",
+ "dev-develop": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "PHPCSUtils/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Juliette Reinders Folmer",
+ "homepage": "https://github.com/jrfnl",
+ "role": "lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors"
+ }
+ ],
+ "description": "A suite of utility functions for use with PHP_CodeSniffer",
+ "homepage": "https://phpcsutils.com/",
+ "keywords": [
+ "PHP_CodeSniffer",
+ "phpcbf",
+ "phpcodesniffer-standard",
+ "phpcs",
+ "phpcs3",
+ "phpcs4",
+ "standards",
+ "static analysis",
+ "tokens",
+ "utility"
+ ],
+ "support": {
+ "docs": "https://phpcsutils.com/",
+ "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHPCSUtils"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-12-08T14:27:58+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "9.2.32",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.19.1 || ^5.1.0",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-text-template": "^2.0.4",
+ "sebastian/code-unit-reverse-lookup": "^2.0.3",
+ "sebastian/complexity": "^2.0.3",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/lines-of-code": "^1.0.4",
+ "sebastian/version": "^3.0.2",
+ "theseer/tokenizer": "^1.2.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.6"
+ },
+ "suggest": {
+ "ext-pcov": "PHP extension that provides line coverage",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.2.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-22T04:23:01+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "3.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-12-02T12:48:52+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:16:10+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "9.6.34",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a",
+ "reference": "b36f02317466907a230d3aa1d34467041271ef4a",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.5.0 || ^2",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.13.4",
+ "phar-io/manifest": "^2.0.4",
+ "phar-io/version": "^3.2.1",
+ "php": ">=7.3",
+ "phpunit/php-code-coverage": "^9.2.32",
+ "phpunit/php-file-iterator": "^3.0.6",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.4",
+ "phpunit/php-timer": "^5.0.3",
+ "sebastian/cli-parser": "^1.0.2",
+ "sebastian/code-unit": "^1.0.8",
+ "sebastian/comparator": "^4.0.10",
+ "sebastian/diff": "^4.0.6",
+ "sebastian/environment": "^5.1.5",
+ "sebastian/exporter": "^4.0.8",
+ "sebastian/global-state": "^5.0.8",
+ "sebastian/object-enumerator": "^4.0.4",
+ "sebastian/resource-operations": "^3.0.4",
+ "sebastian/type": "^3.2.1",
+ "sebastian/version": "^3.0.2"
+ },
+ "suggest": {
+ "ext-soap": "To be able to generate mocks based on WSDL files",
+ "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-27T05:45:00+00:00"
+ },
+ {
+ "name": "psr/container",
+ "version": "1.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/container.git",
+ "reference": "513e0666f7216c7459170d56df27dfcefe1689ea"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea",
+ "reference": "513e0666f7216c7459170d56df27dfcefe1689ea",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Psr\\Container\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common Container Interface (PHP FIG PSR-11)",
+ "homepage": "https://github.com/php-fig/container",
+ "keywords": [
+ "PSR-11",
+ "container",
+ "container-interface",
+ "container-interop",
+ "psr"
+ ],
+ "support": {
+ "issues": "https://github.com/php-fig/container/issues",
+ "source": "https://github.com/php-fig/container/tree/1.1.2"
+ },
+ "time": "2021-11-05T16:50:12+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:27:43+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:30:19+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "4.0.10",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-24T09:22:56+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:19:30+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "4.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-02T06:30:58+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "5.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:03:51+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "4.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-09-24T06:03:27+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "5.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T07:10:35+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.18 || ^5.0",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-12-22T06:20:34+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:12:34+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:14:26+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "4.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-08-10T06:57:39+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-14T16:00:52+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:13:03+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:39:44+00:00"
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "3.13.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
+ },
+ "bin": [
+ "bin/phpcbf",
+ "bin/phpcs"
+ ],
+ "type": "library",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-04T16:30:35+00:00"
+ },
+ {
+ "name": "symfony/console",
+ "version": "v5.4.47",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/console.git",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/deprecation-contracts": "^2.1|^3",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php73": "^1.9",
+ "symfony/polyfill-php80": "^1.16",
+ "symfony/service-contracts": "^1.1|^2|^3",
+ "symfony/string": "^5.1|^6.0"
+ },
+ "conflict": {
+ "psr/log": ">=3",
+ "symfony/dependency-injection": "<4.4",
+ "symfony/dotenv": "<5.1",
+ "symfony/event-dispatcher": "<4.4",
+ "symfony/lock": "<4.4",
+ "symfony/process": "<4.4"
+ },
+ "provide": {
+ "psr/log-implementation": "1.0|2.0"
+ },
+ "require-dev": {
+ "psr/log": "^1|^2",
+ "symfony/config": "^4.4|^5.0|^6.0",
+ "symfony/dependency-injection": "^4.4|^5.0|^6.0",
+ "symfony/event-dispatcher": "^4.4|^5.0|^6.0",
+ "symfony/lock": "^4.4|^5.0|^6.0",
+ "symfony/process": "^4.4|^5.0|^6.0",
+ "symfony/var-dumper": "^4.4|^5.0|^6.0"
+ },
+ "suggest": {
+ "psr/log": "For using the console logger",
+ "symfony/event-dispatcher": "",
+ "symfony/lock": "",
+ "symfony/process": ""
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Console\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Eases the creation of beautiful and testable command line interfaces",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "cli",
+ "command-line",
+ "console",
+ "terminal"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/console/tree/v5.4.47"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-06T11:30:55+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v2.5.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/605389f2a7e5625f273b53960dc46aeaf9c62918",
+ "reference": "605389f2a7e5625f273b53960dc46aeaf9c62918",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "2.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:11:13+00:00"
+ },
+ {
+ "name": "symfony/polyfill-ctype",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-ctype.git",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-ctype": "*"
+ },
+ "suggest": {
+ "ext-ctype": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Ctype\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Gert de Pagter",
+ "email": "BackEndTea@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for ctype functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "ctype",
+ "polyfill",
+ "portable"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-grapheme",
+ "version": "v1.38.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603",
+ "reference": "e9247d281d694a5120554d9afaf54e070e88a603",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's grapheme_* functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "grapheme",
+ "intl",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-26T05:58:03+00:00"
+ },
+ {
+ "name": "symfony/polyfill-intl-normalizer",
+ "version": "v1.38.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "suggest": {
+ "ext-intl": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for intl's Normalizer class and related functions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "intl",
+ "normalizer",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-25T13:48:31+00:00"
+ },
+ {
+ "name": "symfony/polyfill-mbstring",
+ "version": "v1.38.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-mbstring.git",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": ">=7.2"
+ },
+ "provide": {
+ "ext-mbstring": "*"
+ },
+ "suggest": {
+ "ext-mbstring": "For best performance"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Mbstring\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill for the Mbstring extension",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "mbstring",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-27T06:59:30+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php73",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php73.git",
+ "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+ "reference": "0f68c03565dcaaf25a890667542e8bd75fe7e5bb",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php73\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-09T11:45:10+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php80",
+ "version": "v1.37.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php80.git",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php80\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ion Bazan",
+ "email": "ion.bazan@gmail.com"
+ },
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-10T16:19:22+00:00"
+ },
+ {
+ "name": "symfony/process",
+ "version": "v5.4.51",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/process.git",
+ "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/process/zipball/467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f",
+ "reference": "467bfc56f18f5ef6d5ccb09324d7e988c1c0a98f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/polyfill-php80": "^1.16"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Process\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Executes commands in sub-processes",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/process/tree/v5.4.51"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-01-26T15:53:37+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v2.5.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "f37b419f7aea2e9abf10abd261832cace12e3300"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f37b419f7aea2e9abf10abd261832cace12e3300",
+ "reference": "f37b419f7aea2e9abf10abd261832cace12e3300",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "psr/container": "^1.1",
+ "symfony/deprecation-contracts": "^2.1|^3"
+ },
+ "conflict": {
+ "ext-psr": "<1.1|>=2"
+ },
+ "suggest": {
+ "symfony/service-implementation": ""
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
+ "branch-alias": {
+ "dev-main": "2.5-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Contracts\\Service\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Generic abstractions related to writing services",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "abstractions",
+ "contracts",
+ "decoupling",
+ "interfaces",
+ "interoperability",
+ "standards"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/service-contracts/tree/v2.5.4"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-09-25T14:11:13+00:00"
+ },
+ {
+ "name": "symfony/string",
+ "version": "v5.4.47",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/string.git",
+ "reference": "136ca7d72f72b599f2631aca474a4f8e26719799"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799",
+ "reference": "136ca7d72f72b599f2631aca474a4f8e26719799",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.5",
+ "symfony/polyfill-ctype": "~1.8",
+ "symfony/polyfill-intl-grapheme": "~1.0",
+ "symfony/polyfill-intl-normalizer": "~1.0",
+ "symfony/polyfill-mbstring": "~1.0",
+ "symfony/polyfill-php80": "~1.15"
+ },
+ "conflict": {
+ "symfony/translation-contracts": ">=3.0"
+ },
+ "require-dev": {
+ "symfony/error-handler": "^4.4|^5.0|^6.0",
+ "symfony/http-client": "^4.4|^5.0|^6.0",
+ "symfony/translation-contracts": "^1.1|^2",
+ "symfony/var-exporter": "^4.4|^5.0|^6.0"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "Resources/functions.php"
+ ],
+ "psr-4": {
+ "Symfony\\Component\\String\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "grapheme",
+ "i18n",
+ "string",
+ "unicode",
+ "utf-8",
+ "utf8"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/string/tree/v5.4.47"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-10T20:33:58+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.3.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2025-11-17T20:03:58+00:00"
+ },
+ {
+ "name": "wikimedia/at-ease",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wikimedia/at-ease.git",
+ "reference": "e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wikimedia/at-ease/zipball/e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33",
+ "reference": "e8ebaa7bb7c8a8395481a05f6dc4deaceab11c33",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2.9"
+ },
+ "require-dev": {
+ "mediawiki/mediawiki-codesniffer": "35.0.0",
+ "mediawiki/minus-x": "1.1.1",
+ "ockcyp/covers-validator": "1.3.3",
+ "php-parallel-lint/php-console-highlighter": "0.5.0",
+ "php-parallel-lint/php-parallel-lint": "1.2.0",
+ "phpunit/phpunit": "^8.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Wikimedia/Functions.php"
+ ],
+ "psr-4": {
+ "Wikimedia\\AtEase\\": "src/Wikimedia/AtEase/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "Tim Starling",
+ "email": "tstarling@wikimedia.org"
+ },
+ {
+ "name": "MediaWiki developers",
+ "email": "wikitech-l@lists.wikimedia.org"
+ }
+ ],
+ "description": "Safe replacement to @ for suppressing warnings.",
+ "homepage": "https://www.mediawiki.org/wiki/at-ease",
+ "support": {
+ "source": "https://github.com/wikimedia/at-ease/tree/v2.1.0"
+ },
+ "time": "2021-02-27T15:53:37+00:00"
+ },
+ {
+ "name": "woocommerce/woocommerce-sniffs",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/woocommerce/woocommerce-sniffs.git",
+ "reference": "3a65b917ff5ab5e65609e5dcb7bc62f9455bbef8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/woocommerce/woocommerce-sniffs/zipball/3a65b917ff5ab5e65609e5dcb7bc62f9455bbef8",
+ "reference": "3a65b917ff5ab5e65609e5dcb7bc62f9455bbef8",
+ "shasum": ""
+ },
+ "require": {
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0.0",
+ "php": ">=7.0",
+ "phpcompatibility/phpcompatibility-wp": "^2.1.0",
+ "wp-coding-standards/wpcs": "^3.0.0"
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "WooCommerce sniffs",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis",
+ "woocommerce",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/woocommerce/woocommerce-sniffs/issues",
+ "source": "https://github.com/woocommerce/woocommerce-sniffs/tree/1.0.0"
+ },
+ "time": "2023-09-29T13:52:33+00:00"
+ },
+ {
+ "name": "wp-coding-standards/wpcs",
+ "version": "3.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
+ "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6",
+ "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "ext-libxml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlreader": "*",
+ "php": ">=7.2",
+ "phpcsstandards/phpcsextra": "^1.5.0",
+ "phpcsstandards/phpcsutils": "^1.1.0",
+ "squizlabs/php_codesniffer": "^3.13.4"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
+ "phpcsstandards/phpcsdevtools": "^1.2.0",
+ "phpunit/phpunit": "^8.0 || ^9.0"
+ },
+ "suggest": {
+ "ext-iconv": "For improved results",
+ "ext-mbstring": "For improved results"
+ },
+ "type": "phpcodesniffer-standard",
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors"
+ }
+ ],
+ "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions",
+ "keywords": [
+ "phpcs",
+ "standards",
+ "static analysis",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues",
+ "source": "https://github.com/WordPress/WordPress-Coding-Standards",
+ "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki"
+ },
+ "funding": [
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "custom"
+ }
+ ],
+ "time": "2025-11-25T12:08:04+00:00"
+ },
+ {
+ "name": "yoast/phpunit-polyfills",
+ "version": "4.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
+ "reference": "134921bfca9b02d8f374c48381451da1d98402f9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/134921bfca9b02d8f374c48381451da1d98402f9",
+ "reference": "134921bfca9b02d8f374c48381451da1d98402f9",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1",
+ "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0 || ^11.0 || ^12.0"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "yoast/yoastcs": "^3.1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "4.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "phpunitpolyfills-autoload.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Team Yoast",
+ "email": "support@yoast.com",
+ "homepage": "https://yoast.com"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors"
+ }
+ ],
+ "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests",
+ "homepage": "https://github.com/Yoast/PHPUnit-Polyfills",
+ "keywords": [
+ "phpunit",
+ "polyfill",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues",
+ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
+ "source": "https://github.com/Yoast/PHPUnit-Polyfills"
+ },
+ "time": "2025-02-09T18:58:54+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "dev",
+ "stability-flags": {},
+ "prefer-stable": true,
+ "prefer-lowest": false,
+ "platform": {
+ "php": ">=7.4"
+ },
+ "platform-dev": {},
+ "platform-overrides": {
+ "php": "7.4"
+ },
+ "plugin-api-version": "2.9.0"
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/license.txt b/packages/php/woocommerce-subscriptions-engine/license.txt
new file mode 100644
index 00000000000..ffe165d04ec
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/license.txt
@@ -0,0 +1,356 @@
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program; if not, write to the Free Software
+Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+
+===================================
+
+
+GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+ Preamble
+
+The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+GNU GENERAL PUBLIC LICENSE
+TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+a) You must cause the modified files to carry prominent notices
+stating that you changed the files and the date of any change.
+
+b) You must cause any work that you distribute or publish, that in
+whole or in part contains or is derived from the Program or any
+part thereof, to be licensed as a whole at no charge to all third
+parties under the terms of this License.
+
+c) If the modified program normally reads commands interactively
+when run, you must cause it, when started running for such
+interactive use in the most ordinary way, to print or display an
+announcement including an appropriate copyright notice and a
+notice that there is no warranty (or else, saying that you provide
+a warranty) and that users may redistribute the program under
+these conditions, and telling the user how to view a copy of this
+License. (Exception: if the Program itself is interactive but
+does not normally print such an announcement, your work based on
+the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+a) Accompany it with the complete corresponding machine-readable
+source code, which must be distributed under the terms of Sections
+1 and 2 above on a medium customarily used for software interchange; or,
+
+b) Accompany it with a written offer, valid for at least three
+years, to give any third party, for a charge no more than your
+cost of physically performing source distribution, a complete
+machine-readable copy of the corresponding source code, to be
+distributed under the terms of Sections 1 and 2 above on a medium
+customarily used for software interchange; or,
+
+c) Accompany it with the information you received as to the offer
+to distribute corresponding source code. (This alternative is
+allowed only for noncommercial distribution and only if you
+received the program in object code or executable form with such
+an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+<one line to give the program's name and a brief idea of what it does.>
+Copyright (C) <year> <name of author>
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation; either version 2 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along
+with this program; if not, write to the Free Software Foundation, Inc.,
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+Gnomovision version 69, Copyright (C) year name of author
+Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+This is free software, and you are welcome to redistribute it
+under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+`Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+<signature of Ty Coon>, 1 April 1989
+Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/packages/php/woocommerce-subscriptions-engine/package.json b/packages/php/woocommerce-subscriptions-engine/package.json
new file mode 100644
index 00000000000..3b9fb08cfd4
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@automattic/woocommerce-subscriptions-engine",
+ "description": "Subscriptions engine for WooCommerce.",
+ "scripts": {
+ "update:php": "XDEBUG_MODE=off composer update --quiet",
+ "lint": "pnpm --if-present '/^lint:lang:.*$/'",
+ "lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
+ "lint:fix:lang:php": "composer run-script phpcbf",
+ "lint:lang:php": "composer run-script phpcs"
+ },
+ "license": "GPL-2.0-or-later",
+ "devDependencies": {
+ "@wordpress/env": "11.0.1-next.v.20260206T143.0"
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/phpcs.xml b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
new file mode 100644
index 00000000000..7ff32de0ecc
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/phpcs.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0"?>
+<ruleset name="Custom WordPress Standards">
+ <!-- Set the base standard to WordPress -->
+ <rule ref="WordPress"/>
+
+ <!-- Define files and folders to scan -->
+ <file>.</file>
+
+ <!-- Exclude test files from FileName rules -->
+ <rule ref="WordPress.Files.FileName">
+ <exclude-pattern>tests/*</exclude-pattern>
+ </rule>
+
+ <!-- Test method names are self-documenting; do not require per-method docblocks. -->
+ <rule ref="Squiz.Commenting.FunctionComment.Missing">
+ <exclude-pattern>tests/*</exclude-pattern>
+ </rule>
+ <rule ref="Generic.Commenting.DocComment.MissingShort">
+ <exclude-pattern>tests/*</exclude-pattern>
+ </rule>
+
+ <!--
+ The Core zone is WordPress-free by design (entities, value objects, the
+ state machine). WordPress-specific output-escaping rules do not apply
+ there: Core throws plain exceptions and never echoes, and pulling
+ esc_*() into Core would violate the zoning rule it exists to enforce.
+ Escaping is the integration layer's responsibility at the boundary.
+ -->
+ <rule ref="WordPress.Security.EscapeOutput">
+ <exclude-pattern>src/Core/*</exclude-pattern>
+ </rule>
+
+ <!--
+ The slow-meta-query heuristic targets wp_postmeta-style tables. The
+ engine's own contract-meta table merely shares the meta_key/meta_value
+ column names; it is always queried by indexed contract_id, so the
+ heuristic does not apply to the storage layer.
+ -->
+ <rule ref="WordPress.DB.SlowDBQuery">
+ <exclude-pattern>src/Integration/Storage/*</exclude-pattern>
+ </rule>
+
+ <!-- Exclude test files from the PSR2.Methods.MethodDeclaration.Underscore rule due to methods _after() and _before() -->
+ <rule ref="PSR2.Methods.MethodDeclaration.Underscore">
+ <exclude-pattern>tests/unit</exclude-pattern>
+ </rule>
+
+ <!-- Skip the vendor directory -->
+ <exclude-pattern>vendor/*</exclude-pattern>
+
+ <!-- Skip the node_modules directory -->
+ <exclude-pattern>node_modules/*</exclude-pattern>
+
+ <!-- Skip the PHPStan temp directory -->
+ <exclude-pattern>tasks/phpstan/temp/*</exclude-pattern>
+
+ <!-- Skip the build directory -->
+ <exclude-pattern>build/*</exclude-pattern>
+</ruleset>
diff --git a/packages/php/woocommerce-subscriptions-engine/phpunit-integration.xml.dist b/packages/php/woocommerce-subscriptions-engine/phpunit-integration.xml.dist
new file mode 100644
index 00000000000..8250f22b1b9
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/phpunit-integration.xml.dist
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ bootstrap="tests/integration/bootstrap.php"
+ backupGlobals="false"
+ colors="true"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ verbose="true"
+ xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
+ <testsuites>
+ <testsuite name="Integration">
+ <directory suffix="_Test.php">./tests/integration</directory>
+ </testsuite>
+ </testsuites>
+</phpunit>
diff --git a/packages/php/woocommerce-subscriptions-engine/phpunit.xml.dist b/packages/php/woocommerce-subscriptions-engine/phpunit.xml.dist
new file mode 100644
index 00000000000..2af6d1e1451
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/phpunit.xml.dist
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit backupGlobals="false"
+ backupStaticAttributes="false"
+ colors="true"
+ bootstrap="tests/unit/bootstrap.php"
+ convertErrorsToExceptions="true"
+ convertNoticesToExceptions="true"
+ convertWarningsToExceptions="true"
+ processIsolation="false"
+ stopOnFailure="false"
+ verbose="true">
+ <testsuites>
+ <testsuite name="WooCommerceSubscriptionsEngineUnitTestSuite">
+ <directory suffix="_Test.php">./tests/unit</directory>
+ </testsuite>
+ </testsuites>
+
+ <php>
+ <ini name="display_errors" value="true"/>
+ </php>
+</phpunit>
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract-status.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract-status.php
new file mode 100644
index 00000000000..02971bc6c5d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract-status.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * Contract_Status - the contract lifecycle state machine.
+ *
+ * Owns the set of valid statuses and the allowed transitions between them.
+ * Status transitions are validated here and applied by the {@see Contract} entity.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Contract_Status value/helper class.
+ */
+final class Contract_Status {
+
+ const ACTIVE = 'active';
+ const ON_HOLD = 'on-hold';
+ const PENDING_CANCELLATION = 'pending-cancellation';
+ const CANCELLED = 'cancelled';
+ const EXPIRED = 'expired';
+
+ /**
+ * All known statuses.
+ *
+ * @return array<int, string>
+ */
+ public static function all(): array {
+ return array(
+ self::ACTIVE,
+ self::ON_HOLD,
+ self::PENDING_CANCELLATION,
+ self::CANCELLED,
+ self::EXPIRED,
+ );
+ }
+
+ /**
+ * Whether `$status` is a known status.
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_valid( string $status ): bool {
+ return in_array( $status, self::all(), true );
+ }
+
+ /**
+ * Whether `$status` is terminal (no transitions out).
+ *
+ * @param string $status Status to check.
+ */
+ public static function is_terminal( string $status ): bool {
+ return self::is_valid( $status ) && array() === self::transitions()[ $status ];
+ }
+
+ /**
+ * Whether a contract may move from `$from` to `$to`.
+ *
+ * @param string $from Current status.
+ * @param string $to Target status.
+ */
+ public static function can_transition( string $from, string $to ): bool {
+ if ( ! self::is_valid( $from ) || ! self::is_valid( $to ) ) {
+ return false;
+ }
+
+ return in_array( $to, self::transitions()[ $from ], true );
+ }
+
+ /**
+ * Allowed transitions: current status => list of reachable statuses.
+ *
+ * @return array<string, array<int, string>>
+ */
+ private static function transitions(): array {
+ return array(
+ self::ACTIVE => array( self::ON_HOLD, self::PENDING_CANCELLATION, self::CANCELLED, self::EXPIRED ),
+ self::ON_HOLD => array( self::ACTIVE, self::PENDING_CANCELLATION, self::CANCELLED ),
+ self::PENDING_CANCELLATION => array( self::ACTIVE, self::CANCELLED ),
+ self::CANCELLED => array(),
+ self::EXPIRED => array(),
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract.php
new file mode 100644
index 00000000000..2218ed277b0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-contract.php
@@ -0,0 +1,555 @@
+<?php
+/**
+ * Contract - the stable, customer-facing identity of a subscription. Manages
+ * core data for the subscription and enforces lifecycle transitions through
+ * {@see Contract_Status}.
+ *
+ * Money totals are kept as decimal-safe strings; timestamps are GMT strings
+ * (`Y-m-d H:i:s`). The payment instrument is exposed as an {@see Instrument_Ref}
+ * rather than a live payment token.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use DomainException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Instrument_Ref;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Contract entity.
+ *
+ * Construct via {@see self::create()} for a new (unsaved) contract or
+ * {@see self::from_storage()} when hydrating a stored row.
+ */
+final class Contract {
+
+ const SCHEDULE_SOURCE_PRIMITIVE = 'primitive';
+ const SCHEDULE_SOURCE_GATEWAY = 'gateway';
+
+ const ADDRESS_BILLING = 'billing';
+ const ADDRESS_SHIPPING = 'shipping';
+
+ /**
+ * Contract id, or null before it is persisted.
+ *
+ * @var int|null
+ */
+ private $id;
+
+ /**
+ * Lifecycle status. See {@see Contract_Status}.
+ *
+ * @var string
+ */
+ private $status;
+
+ /**
+ * Owning customer id.
+ *
+ * @var int
+ */
+ private $customer_id;
+
+ /**
+ * ISO-4217 currency code, locked at creation.
+ *
+ * @var string
+ */
+ private $currency;
+
+ /**
+ * Foreign key to the selling plan.
+ *
+ * @var int
+ */
+ private $selling_plan_id;
+
+ /**
+ * Foreign key to the order that triggered this contract.
+ *
+ * @var int
+ */
+ private $origin_order_id;
+
+ /**
+ * Owning extension slug, or null until owner semantics are assigned.
+ *
+ * @var string|null
+ */
+ private $extension_slug;
+
+ /**
+ * Gateway code, or null.
+ *
+ * @var string|null
+ */
+ private $payment_method;
+
+ /**
+ * Human-readable gateway title, or null.
+ *
+ * @var string|null
+ */
+ private $payment_method_title;
+
+ /**
+ * Payment token id, or null.
+ *
+ * @var int|null
+ */
+ private $payment_token_id;
+
+ /**
+ * Recurring total per cycle (decimal-safe string).
+ *
+ * @var string
+ */
+ private $billing_total;
+
+ /**
+ * Recurring discount per cycle (decimal-safe string).
+ *
+ * @var string
+ */
+ private $discount_total;
+
+ /**
+ * Recurring shipping per cycle (decimal-safe string).
+ *
+ * @var string
+ */
+ private $shipping_total;
+
+ /**
+ * Recurring tax per cycle (decimal-safe string).
+ *
+ * @var string
+ */
+ private $tax_total;
+
+ /**
+ * When the contract goes (or went) active. GMT string.
+ *
+ * @var string
+ */
+ private $start_gmt;
+
+ /**
+ * Next renewal attempt, or null. GMT string.
+ *
+ * @var string|null
+ */
+ private $next_payment_gmt;
+
+ /**
+ * Last successful renewal payment, or null. GMT string.
+ *
+ * @var string|null
+ */
+ private $last_payment_gmt;
+
+ /**
+ * Last attempted renewal cycle regardless of outcome, or null. GMT string.
+ *
+ * @var string|null
+ */
+ private $last_attempt_gmt;
+
+ /**
+ * End of trial window, or null. GMT string.
+ *
+ * @var string|null
+ */
+ private $trial_end_gmt;
+
+ /**
+ * Hard end (cancelled / expired / max_cycles reached), or null. GMT string.
+ *
+ * @var string|null
+ */
+ private $end_gmt;
+
+ /**
+ * Count of successfully-paid renewal cycles.
+ *
+ * @var int
+ */
+ private $cycle_count;
+
+ /**
+ * Who runs renewals: 'primitive' (this engine) or 'gateway'.
+ *
+ * @var string
+ */
+ private $schedule_source;
+
+ /**
+ * Line items, each a plain associative array matching the items table shape.
+ *
+ * @var array<int, array<string, mixed>>
+ */
+ private $items;
+
+ /**
+ * Addresses keyed by type ('billing' | 'shipping').
+ *
+ * @var array{ billing: array<string, mixed>, shipping: array<string, mixed> }
+ */
+ private $addresses;
+
+ /**
+ * Contract meta as key => value.
+ *
+ * @var array<string, string>
+ */
+ private $meta;
+
+ /**
+ * Use {@see self::create()} or {@see self::from_storage()}.
+ *
+ * @param array<string, mixed> $fields Internal field map.
+ */
+ private function __construct( array $fields ) {
+ $this->id = $fields['id'];
+ $this->status = $fields['status'];
+ $this->customer_id = $fields['customer_id'];
+ $this->currency = $fields['currency'];
+ $this->selling_plan_id = $fields['selling_plan_id'];
+ $this->origin_order_id = $fields['origin_order_id'];
+ $this->extension_slug = $fields['extension_slug'];
+ $this->payment_method = $fields['payment_method'];
+ $this->payment_method_title = $fields['payment_method_title'];
+ $this->payment_token_id = $fields['payment_token_id'];
+ $this->billing_total = $fields['billing_total'];
+ $this->discount_total = $fields['discount_total'];
+ $this->shipping_total = $fields['shipping_total'];
+ $this->tax_total = $fields['tax_total'];
+ $this->start_gmt = $fields['start_gmt'];
+ $this->next_payment_gmt = $fields['next_payment_gmt'];
+ $this->last_payment_gmt = $fields['last_payment_gmt'];
+ $this->last_attempt_gmt = $fields['last_attempt_gmt'];
+ $this->trial_end_gmt = $fields['trial_end_gmt'];
+ $this->end_gmt = $fields['end_gmt'];
+ $this->cycle_count = $fields['cycle_count'];
+ $this->schedule_source = $fields['schedule_source'];
+ $this->items = $fields['items'];
+ $this->addresses = $fields['addresses'];
+ $this->meta = $fields['meta'];
+ }
+
+ /**
+ * Build a new, unsaved contract.
+ *
+ * @param array{
+ * customer_id: int,
+ * currency: string,
+ * selling_plan_id: int,
+ * origin_order_id: int,
+ * start_gmt: string,
+ * status?: string,
+ * extension_slug?: string,
+ * payment_method?: string,
+ * payment_method_title?: string,
+ * payment_token_id?: int,
+ * billing_total: string,
+ * discount_total?: string,
+ * shipping_total?: string,
+ * tax_total?: string,
+ * next_payment_gmt?: string,
+ * trial_end_gmt?: string,
+ * schedule_source: string,
+ * items: array<int, array<string, mixed>>,
+ * addresses: array{ billing: array<string, mixed>, shipping: array<string, mixed> },
+ * meta: array<string, string>,
+ * } $args Contract attributes.
+ * @throws DomainException If the contract attributes are not valid.
+ */
+ public static function create( array $args ): self {
+ $status = (string) ( $args['status'] ?? Contract_Status::ACTIVE );
+ if ( ! Contract_Status::is_valid( $status ) ) {
+ throw new DomainException(
+ sprintf( 'Contract: invalid status "%s".', $status )
+ );
+ }
+
+ $schedule_source = (string) ( $args['schedule_source'] ?? self::SCHEDULE_SOURCE_PRIMITIVE );
+ if ( ! in_array( $schedule_source, array( self::SCHEDULE_SOURCE_PRIMITIVE, self::SCHEDULE_SOURCE_GATEWAY ), true ) ) {
+ throw new DomainException(
+ sprintf( 'Contract: invalid schedule source "%s".', $schedule_source )
+ );
+ }
+
+ return new self(
+ array(
+ 'id' => null,
+ 'status' => $status,
+ 'customer_id' => (int) $args['customer_id'],
+ 'currency' => (string) $args['currency'],
+ 'selling_plan_id' => (int) $args['selling_plan_id'],
+ 'origin_order_id' => (int) $args['origin_order_id'],
+ 'extension_slug' => is_string( $args['extension_slug'] ?? null ) ? $args['extension_slug'] : null,
+ 'payment_method' => is_string( $args['payment_method'] ?? null ) ? $args['payment_method'] : null,
+ 'payment_method_title' => is_string( $args['payment_method_title'] ?? null ) ? $args['payment_method_title'] : null,
+ 'payment_token_id' => isset( $args['payment_token_id'] ) ? (int) $args['payment_token_id'] : null,
+ 'billing_total' => (string) ( $args['billing_total'] ?? '0' ),
+ 'discount_total' => (string) ( $args['discount_total'] ?? '0' ),
+ 'shipping_total' => (string) ( $args['shipping_total'] ?? '0' ),
+ 'tax_total' => (string) ( $args['tax_total'] ?? '0' ),
+ 'start_gmt' => (string) $args['start_gmt'],
+ 'next_payment_gmt' => $args['next_payment_gmt'] ?? null,
+ 'last_payment_gmt' => null,
+ 'last_attempt_gmt' => null,
+ 'trial_end_gmt' => is_string( $args['trial_end_gmt'] ?? null ) ? $args['trial_end_gmt'] : null,
+ 'end_gmt' => null,
+ 'cycle_count' => 0,
+ 'schedule_source' => $schedule_source,
+ 'items' => is_array( $args['items'] ?? null ) ? $args['items'] : array(),
+ 'addresses' => is_array( $args['addresses'] ?? null ) ? $args['addresses'] : array(),
+ 'meta' => is_array( $args['meta'] ?? null ) ? $args['meta'] : array(),
+ )
+ );
+ }
+
+ /**
+ * Hydrate from stored rows.
+ *
+ * @param array<string, mixed> $row Contract row.
+ * @param array<int, array<string, mixed>> $items Item rows.
+ * @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
+ * @param array<string, string> $meta Meta as key => value.
+ */
+ public static function from_storage( array $row, array $items = array(), array $addresses = array(), array $meta = array() ): self {
+ return new self(
+ array(
+ 'id' => isset( $row['id'] ) ? (int) $row['id'] : null,
+ 'status' => (string) $row['status'],
+ 'customer_id' => (int) $row['customer_id'],
+ 'currency' => (string) $row['currency'],
+ 'selling_plan_id' => (int) $row['selling_plan_id'],
+ 'origin_order_id' => (int) $row['origin_order_id'],
+ 'extension_slug' => isset( $row['extension_slug'] ) ? (string) $row['extension_slug'] : null,
+ 'payment_method' => isset( $row['payment_method'] ) ? (string) $row['payment_method'] : null,
+ 'payment_method_title' => isset( $row['payment_method_title'] ) ? (string) $row['payment_method_title'] : null,
+ 'payment_token_id' => isset( $row['payment_token_id'] ) ? (int) $row['payment_token_id'] : null,
+ 'billing_total' => (string) ( $row['billing_total'] ?? '0' ),
+ 'discount_total' => (string) ( $row['discount_total'] ?? '0' ),
+ 'shipping_total' => (string) ( $row['shipping_total'] ?? '0' ),
+ 'tax_total' => (string) ( $row['tax_total'] ?? '0' ),
+ 'start_gmt' => (string) $row['start_gmt'],
+ 'next_payment_gmt' => $row['next_payment_gmt'] ?? null,
+ 'last_payment_gmt' => $row['last_payment_gmt'] ?? null,
+ 'last_attempt_gmt' => $row['last_attempt_gmt'] ?? null,
+ 'trial_end_gmt' => $row['trial_end_gmt'] ?? null,
+ 'end_gmt' => $row['end_gmt'] ?? null,
+ 'cycle_count' => (int) ( $row['cycle_count'] ?? 0 ),
+ 'schedule_source' => (string) ( $row['schedule_source'] ?? self::SCHEDULE_SOURCE_PRIMITIVE ),
+ 'items' => $items,
+ 'addresses' => $addresses,
+ 'meta' => $meta,
+ )
+ );
+ }
+
+ /**
+ * Contract id, or null before save.
+ */
+ public function get_id(): ?int {
+ return $this->id;
+ }
+
+ /**
+ * Assign the id after a successful insert.
+ *
+ * @param int $id Contract id.
+ */
+ public function set_id( int $id ): void {
+ $this->id = $id;
+ }
+
+ /**
+ * Lifecycle status.
+ */
+ public function get_status(): string {
+ return $this->status;
+ }
+
+ /**
+ * Transition the contract to a new status.
+ *
+ * @param string $status Target status.
+ * @throws DomainException If the transition is not allowed by Contract_Status.
+ */
+ public function set_status( string $status ): void {
+ if ( $status === $this->status ) {
+ return;
+ }
+
+ if ( ! Contract_Status::can_transition( $this->status, $status ) ) {
+ throw new DomainException(
+ sprintf( 'Contract: illegal status transition from "%s" to "%s".', $this->status, $status )
+ );
+ }
+
+ $this->status = $status;
+ }
+
+ /**
+ * Owning customer id.
+ */
+ public function get_customer_id(): int {
+ return $this->customer_id;
+ }
+
+ /**
+ * ISO-4217 currency code.
+ */
+ public function get_currency(): string {
+ return $this->currency;
+ }
+
+ /**
+ * Foreign key to the selling plan.
+ */
+ public function get_selling_plan_id(): int {
+ return $this->selling_plan_id;
+ }
+
+ /**
+ * Foreign key to the origin order.
+ */
+ public function get_origin_order_id(): int {
+ return $this->origin_order_id;
+ }
+
+ /**
+ * Owning extension slug, or null.
+ */
+ public function get_extension_slug(): ?string {
+ return $this->extension_slug;
+ }
+
+ /**
+ * The payment instrument as an immutable reference.
+ */
+ public function get_payment_instrument(): Instrument_Ref {
+ return new Instrument_Ref( $this->payment_token_id, $this->payment_method, $this->payment_method_title );
+ }
+
+ /**
+ * Set the payment instrument from an immutable reference.
+ *
+ * @param Instrument_Ref $instrument Payment instrument reference.
+ */
+ public function set_payment_instrument( Instrument_Ref $instrument ): void {
+ $this->payment_token_id = $instrument->get_token_id();
+ $this->payment_method = $instrument->get_gateway();
+ $this->payment_method_title = $instrument->get_title();
+ }
+
+ /**
+ * Recurring total per cycle (decimal-safe string).
+ */
+ public function get_billing_total(): string {
+ return $this->billing_total;
+ }
+
+ /**
+ * Next renewal attempt, or null.
+ */
+ public function get_next_payment_gmt(): ?string {
+ return $this->next_payment_gmt;
+ }
+
+ /**
+ * Set the next renewal attempt timestamp.
+ *
+ * @param string|null $next_payment_gmt GMT string or null.
+ */
+ public function set_next_payment_gmt( ?string $next_payment_gmt ): void {
+ $this->next_payment_gmt = $next_payment_gmt;
+ }
+
+ /**
+ * Start timestamp (GMT string).
+ */
+ public function get_start_gmt(): string {
+ return $this->start_gmt;
+ }
+
+ /**
+ * Count of successfully-paid renewal cycles.
+ */
+ public function get_cycle_count(): int {
+ return $this->cycle_count;
+ }
+
+ /**
+ * Who runs renewals: 'primitive' or 'gateway'.
+ */
+ public function get_schedule_source(): string {
+ return $this->schedule_source;
+ }
+
+ /**
+ * Line items.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_items(): array {
+ return $this->items;
+ }
+
+ /**
+ * Addresses keyed by type.
+ *
+ * @return array<string, array<string, mixed>>
+ */
+ public function get_addresses(): array {
+ return $this->addresses;
+ }
+
+ /**
+ * Contract meta as key => value.
+ *
+ * @return array<string, string>
+ */
+ public function get_meta(): array {
+ return $this->meta;
+ }
+
+ /**
+ * Serialize the contract row (excluding generated id/timestamps).
+ *
+ * @return array<string, mixed>
+ */
+ public function to_storage(): array {
+ return array(
+ 'status' => $this->status,
+ 'customer_id' => $this->customer_id,
+ 'currency' => $this->currency,
+ 'selling_plan_id' => $this->selling_plan_id,
+ 'origin_order_id' => $this->origin_order_id,
+ 'extension_slug' => $this->extension_slug,
+ 'payment_method' => $this->payment_method,
+ 'payment_method_title' => $this->payment_method_title,
+ 'payment_token_id' => $this->payment_token_id,
+ 'billing_total' => $this->billing_total,
+ 'discount_total' => $this->discount_total,
+ 'shipping_total' => $this->shipping_total,
+ 'tax_total' => $this->tax_total,
+ 'start_gmt' => $this->start_gmt,
+ 'next_payment_gmt' => $this->next_payment_gmt,
+ 'last_payment_gmt' => $this->last_payment_gmt,
+ 'last_attempt_gmt' => $this->last_attempt_gmt,
+ 'trial_end_gmt' => $this->trial_end_gmt,
+ 'end_gmt' => $this->end_gmt,
+ 'cycle_count' => $this->cycle_count,
+ 'schedule_source' => $this->schedule_source,
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan-group.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan-group.php
new file mode 100644
index 00000000000..5ce6490daf5
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan-group.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * Plan_Group - a merchandising container for selling plans.
+ *
+ * `merchant_code` is an optional stable external identifier; when present it is
+ * unique at the storage layer and is the deduplication key consumers use to
+ * make group creation idempotent. `app_id` scopes a group to a solution family.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Plan_Group entity.
+ *
+ * Construct via {@see self::create()} for a new (unsaved) group or
+ * {@see self::from_storage()} when hydrating a stored row.
+ */
+final class Plan_Group {
+
+ /**
+ * Group id, or null before it is persisted.
+ *
+ * @var int|null
+ */
+ private $id;
+
+ /**
+ * Display name.
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * Optional stable external identifier; unique at the storage layer.
+ *
+ * @var string|null
+ */
+ private $merchant_code;
+
+ /**
+ * Display ordering metadata, e.g. [{ name, position }].
+ *
+ * @var array<int, mixed>
+ */
+ private $options_display;
+
+ /**
+ * Solution-family scope, e.g. a third-party app slug.
+ *
+ * @var string|null
+ */
+ private $app_id;
+
+ /**
+ * Use {@see self::create()} or {@see self::from_storage()}.
+ *
+ * @param int|null $id Group id, or null before save.
+ * @param string $name Display name.
+ * @param string|null $merchant_code Optional stable external identifier.
+ * @param array<int, mixed> $options_display Display ordering metadata.
+ * @param string|null $app_id Solution-family scope.
+ */
+ private function __construct( ?int $id, string $name, ?string $merchant_code, array $options_display, ?string $app_id ) {
+ $this->id = $id;
+ $this->name = $name;
+ $this->merchant_code = $merchant_code;
+ $this->options_display = $options_display;
+ $this->app_id = $app_id;
+ }
+
+ /**
+ * Build a new, unsaved group.
+ *
+ * @param array{
+ * name: string,
+ * merchant_code?: string|null,
+ * options_display?: array<int, mixed>,
+ * app_id?: string|null,
+ * } $args Group attributes.
+ */
+ public static function create( array $args ): self {
+ return new self(
+ null,
+ (string) $args['name'],
+ $args['merchant_code'] ?? null,
+ $args['options_display'] ?? array(),
+ $args['app_id'] ?? null
+ );
+ }
+
+ /**
+ * Hydrate from a stored row.
+ *
+ * @param array<string, mixed> $row Decoded plan-group row.
+ */
+ public static function from_storage( array $row ): self {
+ return new self(
+ isset( $row['id'] ) ? (int) $row['id'] : null,
+ (string) $row['name'],
+ isset( $row['merchant_code'] ) ? (string) $row['merchant_code'] : null,
+ is_array( $row['options_display'] ?? null ) ? $row['options_display'] : array(),
+ isset( $row['app_id'] ) ? (string) $row['app_id'] : null
+ );
+ }
+
+ /**
+ * Group id, or null before save.
+ */
+ public function get_id(): ?int {
+ return $this->id;
+ }
+
+ /**
+ * Assign the id after a successful insert.
+ *
+ * @param int $id Group id.
+ */
+ public function set_id( int $id ): void {
+ $this->id = $id;
+ }
+
+ /**
+ * Display name.
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ /**
+ * Set the display name.
+ *
+ * @param string $name Display name.
+ */
+ public function set_name( string $name ): void {
+ $this->name = $name;
+ }
+
+ /**
+ * Optional stable external identifier; unique at the storage layer.
+ */
+ public function get_merchant_code(): ?string {
+ return $this->merchant_code;
+ }
+
+ /**
+ * Display ordering metadata.
+ *
+ * @return array<int, mixed>
+ */
+ public function get_options_display(): array {
+ return $this->options_display;
+ }
+
+ /**
+ * Set the display ordering metadata.
+ *
+ * @param array<int, mixed> $options_display Display ordering metadata.
+ */
+ public function set_options_display( array $options_display ): void {
+ $this->options_display = $options_display;
+ }
+
+ /**
+ * Solution-family scope.
+ */
+ public function get_app_id(): ?string {
+ return $this->app_id;
+ }
+
+ /**
+ * Serialize to the storage column shape (excluding generated id/timestamps).
+ *
+ * @return array<string, mixed>
+ */
+ public function to_storage(): array {
+ return array(
+ 'name' => $this->name,
+ 'merchant_code' => $this->merchant_code,
+ 'options_display' => $this->options_display,
+ 'app_id' => $this->app_id,
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan.php b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan.php
new file mode 100644
index 00000000000..63060cadf33
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/Entity/class-plan.php
@@ -0,0 +1,480 @@
+<?php
+/**
+ * Plan - a subscription selling plan: cadence, pricing, and delivery policy for
+ * one or more products.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\Entity
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\Entity;
+
+use InvalidArgumentException;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Billing_Policy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Delivery_Policy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Pricing_Policy;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Plan entity.
+ *
+ * Construct via {@see self::create()} for a new (unsaved) plan or
+ * {@see self::from_storage()} when hydrating a stored row.
+ */
+final class Plan {
+
+ const DEFAULT_CATEGORY = 'SUBSCRIPTION';
+
+ const ALLOWED_POLICY_TYPES = array( 'percentage', 'fixed_amount', 'price' );
+
+ /**
+ * Plan id, or null before it is persisted.
+ *
+ * @var int|null
+ */
+ private $id;
+
+ /**
+ * Parent plan group id.
+ *
+ * @var int
+ */
+ private $group_id;
+
+ /**
+ * Display name.
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * Optional description.
+ *
+ * @var string|null
+ */
+ private $description;
+
+ /**
+ * Picker options, e.g. [{ name, value }].
+ *
+ * @var array<int, mixed>
+ */
+ private $options;
+
+ /**
+ * Billing cadence. Required - every plan has one.
+ *
+ * @var Billing_Policy
+ */
+ private $billing_policy;
+
+ /**
+ * Optional delivery policy.
+ *
+ * @var Delivery_Policy|null
+ */
+ private $delivery_policy;
+
+ /**
+ * Optional pricing policy.
+ *
+ * @var Pricing_Policy|null
+ */
+ private $pricing_policy;
+
+ /**
+ * Plan category.
+ *
+ * @var string
+ */
+ private $category;
+
+ /**
+ * Owning extension slug, or null until owner semantics are assigned.
+ *
+ * @var string|null
+ */
+ private $extension_slug;
+
+ /**
+ * Use {@see self::create()} or {@see self::from_storage()}.
+ *
+ * @param int|null $id Plan id, or null before save.
+ * @param int $group_id Parent plan group id.
+ * @param string $name Display name.
+ * @param string|null $description Optional description.
+ * @param array<int, mixed> $options Picker options.
+ * @param Billing_Policy $billing_policy Billing cadence.
+ * @param Delivery_Policy|null $delivery_policy Optional delivery policy.
+ * @param Pricing_Policy|null $pricing_policy Optional pricing policy.
+ * @param string $category Plan category.
+ * @param string|null $extension_slug Owning extension slug.
+ */
+ private function __construct(
+ ?int $id,
+ int $group_id,
+ string $name,
+ ?string $description,
+ array $options,
+ Billing_Policy $billing_policy,
+ ?Delivery_Policy $delivery_policy,
+ ?Pricing_Policy $pricing_policy,
+ string $category,
+ ?string $extension_slug
+ ) {
+ $this->id = $id;
+ $this->group_id = $group_id;
+ $this->name = $name;
+ $this->description = $description;
+ $this->options = $options;
+ $this->billing_policy = $billing_policy;
+ $this->delivery_policy = $delivery_policy;
+ $this->pricing_policy = $pricing_policy;
+ $this->category = $category;
+ $this->extension_slug = $extension_slug;
+ }
+
+ /**
+ * Build a new, unsaved plan.
+ *
+ * @param int $group_id Parent plan group id.
+ * @param array{
+ * name: string,
+ * billing_policy: Billing_Policy,
+ * description?: string,
+ * options?: array<int, mixed>,
+ * delivery_policy?: Delivery_Policy,
+ * pricing_policy?: Pricing_Policy,
+ * category: string,
+ * extension_slug?: string,
+ * } $args Plan attributes.
+ * @throws InvalidArgumentException If pricing_policy entries fail validation.
+ */
+ public static function create( int $group_id, array $args ): self {
+ $pricing_policy = $args['pricing_policy'] ?? null;
+ if ( null !== $pricing_policy ) {
+ self::validate_pricing_policy( $pricing_policy );
+ }
+
+ return new self(
+ null,
+ $group_id,
+ (string) $args['name'],
+ $args['description'] ?? null,
+ $args['options'] ?? array(),
+ $args['billing_policy'],
+ $args['delivery_policy'] ?? null,
+ $pricing_policy,
+ $args['category'] ?? self::DEFAULT_CATEGORY,
+ $args['extension_slug'] ?? null
+ );
+ }
+
+ /**
+ * Hydrate from a stored row. Policy columns arrive JSON-decoded.
+ *
+ * @param array<string, mixed> $row Decoded plan row.
+ */
+ public static function from_storage( array $row ): self {
+ return new self(
+ isset( $row['id'] ) ? (int) $row['id'] : null,
+ (int) $row['group_id'],
+ (string) $row['name'],
+ isset( $row['description'] ) ? (string) $row['description'] : null,
+ is_array( $row['options'] ?? null ) ? $row['options'] : array(),
+ Billing_Policy::from_array( $row['billing_policy'] ),
+ isset( $row['delivery_policy'] ) && is_array( $row['delivery_policy'] ) ? Delivery_Policy::from_array( $row['delivery_policy'] ) : null,
+ isset( $row['pricing_policy'] ) && is_array( $row['pricing_policy'] ) ? Pricing_Policy::from_array( $row['pricing_policy'] ) : null,
+ (string) ( $row['category'] ?? self::DEFAULT_CATEGORY ),
+ isset( $row['extension_slug'] ) ? (string) $row['extension_slug'] : null
+ );
+ }
+
+ /**
+ * Plan id, or null before save.
+ */
+ public function get_id(): ?int {
+ return $this->id;
+ }
+
+ /**
+ * Assign the id after a successful insert.
+ *
+ * @param int $id Plan id.
+ */
+ public function set_id( int $id ): void {
+ $this->id = $id;
+ }
+
+ /**
+ * Parent plan group id.
+ */
+ public function get_group_id(): int {
+ return $this->group_id;
+ }
+
+ /**
+ * Display name.
+ */
+ public function get_name(): string {
+ return $this->name;
+ }
+
+ /**
+ * Set the display name.
+ *
+ * @param string $name Display name.
+ */
+ public function set_name( string $name ): void {
+ $this->name = $name;
+ }
+
+ /**
+ * Optional description.
+ */
+ public function get_description(): ?string {
+ return $this->description;
+ }
+
+ /**
+ * Set the description.
+ *
+ * @param string|null $description Description.
+ */
+ public function set_description( ?string $description ): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Picker options.
+ *
+ * @return array<int, mixed>
+ */
+ public function get_options(): array {
+ return $this->options;
+ }
+
+ /**
+ * Set the picker options.
+ *
+ * @param array<int, mixed> $options Picker options.
+ */
+ public function set_options( array $options ): void {
+ $this->options = $options;
+ }
+
+ /**
+ * Billing cadence.
+ */
+ public function get_billing_policy(): Billing_Policy {
+ return $this->billing_policy;
+ }
+
+ /**
+ * Set the billing cadence.
+ *
+ * @param Billing_Policy $billing_policy Billing cadence.
+ */
+ public function set_billing_policy( Billing_Policy $billing_policy ): void {
+ $this->billing_policy = $billing_policy;
+ }
+
+ /**
+ * Optional delivery policy.
+ */
+ public function get_delivery_policy(): ?Delivery_Policy {
+ return $this->delivery_policy;
+ }
+
+ /**
+ * Set the delivery policy.
+ *
+ * @param Delivery_Policy|null $delivery_policy Delivery policy.
+ */
+ public function set_delivery_policy( ?Delivery_Policy $delivery_policy ): void {
+ $this->delivery_policy = $delivery_policy;
+ }
+
+ /**
+ * Optional pricing policy.
+ */
+ public function get_pricing_policy(): ?Pricing_Policy {
+ return $this->pricing_policy;
+ }
+
+ /**
+ * Set the pricing policy.
+ *
+ * @param Pricing_Policy|null $pricing_policy Pricing policy.
+ * @throws InvalidArgumentException If pricing_policy entries fail validation.
+ */
+ public function set_pricing_policy( ?Pricing_Policy $pricing_policy ): void {
+ if ( null !== $pricing_policy ) {
+ self::validate_pricing_policy( $pricing_policy );
+ }
+ $this->pricing_policy = $pricing_policy;
+ }
+
+ /**
+ * Plan category.
+ */
+ public function get_category(): string {
+ return $this->category;
+ }
+
+ /**
+ * Set the plan category.
+ *
+ * @param string $category Plan category.
+ */
+ public function set_category( string $category ): void {
+ $this->category = $category;
+ }
+
+ /**
+ * Owning extension slug, or null.
+ */
+ public function get_extension_slug(): ?string {
+ return $this->extension_slug;
+ }
+
+ /**
+ * Apply this plan's pricing policy (if any) to a base price for the cycle.
+ *
+ * When no pricing policy is set, returns `$base_price` unchanged.
+ *
+ * @param float $base_price The product's base price for this cycle.
+ * @param int $cycle 1-indexed cycle number (1 = first billing cycle).
+ */
+ public function calculate_price( float $base_price, int $cycle = 1 ): float {
+ if ( null === $this->pricing_policy ) {
+ return $base_price;
+ }
+
+ return $this->pricing_policy->calculate_price( $base_price, $cycle );
+ }
+
+ /**
+ * Serialize to the storage column shape (excluding generated id/timestamps).
+ *
+ * Policy value objects are returned as arrays; the repository JSON-encodes them.
+ *
+ * @return array<string, mixed>
+ */
+ public function to_storage(): array {
+ return array(
+ 'group_id' => $this->group_id,
+ 'name' => $this->name,
+ 'description' => $this->description,
+ 'options' => $this->options,
+ 'billing_policy' => $this->billing_policy->to_array(),
+ 'delivery_policy' => null !== $this->delivery_policy ? $this->delivery_policy->to_array() : null,
+ 'pricing_policy' => null !== $this->pricing_policy ? $this->pricing_policy->to_array() : null,
+ 'category' => $this->category,
+ 'extension_slug' => $this->extension_slug,
+ );
+ }
+
+ /**
+ * Validate every entry in a pricing policy's policies[] and one_time_fees[].
+ *
+ * Rules:
+ * - policies[].type is one of percentage, fixed_amount, price.
+ * - policies[].value is numeric and non-negative; percentage is capped at 100.
+ * - one_time_fees[].amount is numeric and non-negative.
+ * - one_time_fees[].taxable is a bool.
+ * - one_time_fees[].tax_class is string or null (preserves '' != null).
+ * - one_time_fees[].kind is intentionally not whitelisted - consumers extend
+ * with namespaced kinds.
+ *
+ * @param Pricing_Policy $pricing_policy Policy to validate.
+ * @throws InvalidArgumentException With a message naming the offending entry index.
+ */
+ private static function validate_pricing_policy( Pricing_Policy $pricing_policy ): void {
+ foreach ( $pricing_policy->get_policies() as $index => $entry ) {
+ if ( ! is_array( $entry ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: must be an array, got %s', (int) $index, gettype( $entry ) )
+ );
+ }
+
+ $type = $entry['type'] ?? null;
+ $value = $entry['value'] ?? null;
+ $starting_cycle = $entry['starting_cycle'] ?? null;
+
+ if ( ! is_string( $type ) || ! in_array( $type, self::ALLOWED_POLICY_TYPES, true ) ) {
+ $shown = is_scalar( $type ) ? (string) $type : gettype( $type );
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: invalid type %s', (int) $index, $shown )
+ );
+ }
+
+ if ( ! is_numeric( $value ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: value must be numeric, got %s', (int) $index, gettype( $value ) )
+ );
+ }
+
+ $value = (float) $value;
+
+ if ( $value < 0 ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: %s value must be non-negative, got %s', (int) $index, $type, $value )
+ );
+ }
+
+ if ( 'percentage' === $type && $value > 100 ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: percentage must not exceed 100, got %s', (int) $index, $value )
+ );
+ }
+
+ if ( null !== $starting_cycle ) {
+ if ( ! is_int( $starting_cycle ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: starting_cycle must be an integer, got %s', (int) $index, gettype( $starting_cycle ) )
+ );
+ }
+
+ if ( $starting_cycle < 1 ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.policies[%d]: starting_cycle must be at least 1, got %d', (int) $index, $starting_cycle )
+ );
+ }
+ }
+ }
+
+ foreach ( $pricing_policy->get_one_time_fees() as $index => $entry ) {
+ $amount = $entry['amount'] ?? null;
+ $taxable = $entry['taxable'] ?? null;
+ $tax_class = $entry['tax_class'] ?? null;
+
+ if ( ! is_numeric( $amount ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.one_time_fees[%d]: amount must be numeric, got %s', (int) $index, gettype( $amount ) )
+ );
+ }
+
+ if ( (float) $amount < 0 ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.one_time_fees[%d]: amount must be non-negative, got %s', (int) $index, $amount )
+ );
+ }
+
+ if ( ! is_bool( $taxable ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.one_time_fees[%d]: taxable must be a bool, got %s', (int) $index, gettype( $taxable ) )
+ );
+ }
+
+ if ( null !== $tax_class && ! is_string( $tax_class ) ) {
+ throw new InvalidArgumentException(
+ sprintf( 'pricing_policy.one_time_fees[%d]: tax_class must be string or null, got %s', (int) $index, gettype( $tax_class ) )
+ );
+ }
+ }
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-billing-policy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-billing-policy.php
new file mode 100644
index 00000000000..56c13f9e75a
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-billing-policy.php
@@ -0,0 +1,318 @@
+<?php
+/**
+ * Billing_Policy - typed value object for a plan's billing cadence and trial.
+ *
+ * Mirrors the `billing_policy` JSON column shape. Shape:
+ * {
+ * period: 'day' | 'week' | 'month' | 'year',
+ * interval: int,
+ * min_cycles: ?int,
+ * max_cycles: ?int,
+ * trial_duration: { length: int, unit: 'day'|'week'|'month'|'year' } | null
+ * }
+ *
+ * Trial is a native field: the first cycle's billing date is delayed by the
+ * trial at contract creation rather than modelled as a discount.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Billing_Policy value object.
+ *
+ * Immutable. Construct via {@see self::from_array()} when hydrating from a
+ * stored row, or via the constructor when building one in code.
+ */
+final class Billing_Policy {
+
+ /**
+ * Period unit: 'day' | 'week' | 'month' | 'year'.
+ *
+ * @var string
+ */
+ private $period;
+
+ /**
+ * Period count (e.g. 2 with 'week' = every 2 weeks).
+ *
+ * @var int
+ */
+ private $interval;
+
+ /**
+ * Minimum cycles before cancellation is allowed; null if unbounded below.
+ *
+ * @var int|null
+ */
+ private $min_cycles;
+
+ /**
+ * Total cycles before the contract ends; null if open-ended.
+ *
+ * @var int|null
+ */
+ private $max_cycles;
+
+ /**
+ * Native trial; null if no trial.
+ *
+ * @var array{length: int, unit: string}|null
+ */
+ private $trial_duration;
+
+ /**
+ * Build a billing policy.
+ *
+ * @param string $period One of 'day' | 'week' | 'month' | 'year'.
+ * @param int $interval Period count.
+ * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+ * @param int|null $max_cycles Total cycles before the contract ends.
+ * @param array{length: int, unit: string}|null $trial_duration Native trial; null if none.
+ */
+ public function __construct( string $period, int $interval, ?int $min_cycles, ?int $max_cycles, ?array $trial_duration ) {
+ $this->validate_min_max_cycles( $min_cycles, $max_cycles );
+
+ $this->period = $period;
+ $this->interval = $interval;
+ $this->min_cycles = $min_cycles;
+ $this->max_cycles = $max_cycles;
+ $this->trial_duration = self::normalize_trial_duration( $trial_duration );
+ }
+
+ /**
+ * Hydrate from the JSON-decoded `billing_policy` column shape.
+ *
+ * Missing nullable keys default to null. `period` and `interval` are required.
+ *
+ * @param array<string, mixed> $data Decoded billing_policy row.
+ * @throws DomainException If the data is not valid.
+ */
+ public static function from_array( array $data ): self {
+ if ( ! array_key_exists( 'period', $data ) ) {
+ throw new DomainException( 'Billing_Policy: period is required, but not supplied.' );
+ }
+ if ( ! array_key_exists( 'interval', $data ) ) {
+ throw new DomainException( 'Billing_Policy: interval is required, but not supplied.' );
+ }
+ if ( ! is_string( $data['period'] ) ) {
+ throw new DomainException( 'Billing_Policy: period must be a string, got ' . gettype( $data['period'] ) . '.' );
+ }
+ if ( ! is_int( $data['interval'] ) ) {
+ throw new DomainException( 'Billing_Policy: interval must be an integer, got ' . gettype( $data['interval'] ) . '.' );
+ }
+
+ $trial = $data['trial_duration'] ?? null;
+ if ( null !== $trial && ! is_array( $trial ) ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy: trial_duration must be null or an array, got %s.', wp_json_encode( $trial ) )
+ );
+ }
+
+ $trial = self::normalize_trial_duration( $trial );
+
+ return new self(
+ (string) $data['period'],
+ (int) $data['interval'],
+ isset( $data['min_cycles'] ) ? (int) $data['min_cycles'] : null,
+ isset( $data['max_cycles'] ) ? (int) $data['max_cycles'] : null,
+ $trial
+ );
+ }
+
+ /**
+ * Period unit: 'day' | 'week' | 'month' | 'year'.
+ */
+ public function get_period(): string {
+ return $this->period;
+ }
+
+ /**
+ * Period count. Together with `period` defines cadence.
+ */
+ public function get_interval(): int {
+ return $this->interval;
+ }
+
+ /**
+ * Minimum cycles before cancellation is allowed; null if unbounded below.
+ */
+ public function get_min_cycles(): ?int {
+ return $this->min_cycles;
+ }
+
+ /**
+ * Total cycles before the contract ends; null if open-ended.
+ */
+ public function get_max_cycles(): ?int {
+ return $this->max_cycles;
+ }
+
+ /**
+ * Native trial duration, or null if no trial.
+ *
+ * @return array{length: int, unit: string}|null
+ */
+ public function get_trial_duration(): ?array {
+ return $this->trial_duration;
+ }
+
+ /**
+ * Compute the next renewal moment by adding the policy's cadence to `$anchor`.
+ *
+ * Trial duration is not applied here. The result is normalized to UTC and
+ * period semantics are calendar-aware (matching DateTimeImmutable::modify()):
+ * adding 1 month to 2026-01-31 yields 2026-03-03, not 2026-02-31.
+ *
+ * @param DateTimeImmutable $anchor The moment the next cycle is computed from.
+ * @return DateTimeImmutable The next renewal moment in UTC.
+ * @throws DomainException If `period` is unknown or `interval` is not positive.
+ */
+ public function compute_next_renewal_from( DateTimeImmutable $anchor ): DateTimeImmutable {
+ if ( $this->interval <= 0 ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy::compute_next_renewal_from(): interval must be positive, got %d.', $this->interval )
+ );
+ }
+
+ $unit = $this->normalize_unit( $this->period, 'period' );
+ $utc = $anchor->setTimezone( new DateTimeZone( 'UTC' ) );
+
+ return $utc->modify( sprintf( '+%d %s', $this->interval, $unit ) );
+ }
+
+ /**
+ * Compute the first renewal moment for a freshly-created contract.
+ *
+ * Honours the policy's native trial: when set, the first cycle's billing
+ * date is the end of the trial. With no trial this delegates to
+ * {@see self::compute_next_renewal_from()} so there is one cadence-math path.
+ *
+ * @param DateTimeImmutable $contract_start Moment the contract was created.
+ * @return DateTimeImmutable The first renewal moment in UTC.
+ * @throws DomainException If trial length is not positive or trial unit is unknown.
+ */
+ public function compute_first_renewal_from( DateTimeImmutable $contract_start ): DateTimeImmutable {
+ if ( null === $this->trial_duration ) {
+ return $this->compute_next_renewal_from( $contract_start );
+ }
+
+ $length = (int) $this->trial_duration['length'];
+ $unit = (string) $this->trial_duration['unit'];
+
+ if ( $length <= 0 ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy::compute_first_renewal_from(): trial length must be positive, got %d.', $length )
+ );
+ }
+
+ $normalized_unit = $this->normalize_unit( $unit, 'trial unit' );
+
+ return $contract_start
+ ->setTimezone( new DateTimeZone( 'UTC' ) )
+ ->modify( sprintf( '+%d %s', $length, $normalized_unit ) );
+ }
+
+ /**
+ * Serialize back to the JSON column shape. Lossless round-trip with from_array().
+ *
+ * @return array<string, mixed>
+ */
+ public function to_array(): array {
+ return array(
+ 'period' => $this->period,
+ 'interval' => $this->interval,
+ 'min_cycles' => $this->min_cycles,
+ 'max_cycles' => $this->max_cycles,
+ 'trial_duration' => $this->trial_duration,
+ );
+ }
+
+ /**
+ * Validate and pass through a period/trial unit.
+ *
+ * @param string $unit The raw unit.
+ * @param string $label Where the unit came from, for the error message.
+ * @throws DomainException If the unit is not one of day/week/month/year.
+ */
+ private function normalize_unit( string $unit, string $label ): string {
+ if ( ! in_array( $unit, array( 'day', 'week', 'month', 'year' ), true ) ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy: invalid %s "%s".', $label, $unit )
+ );
+ }
+
+ return $unit;
+ }
+
+ /**
+ * Validate the min_cycles and max_cycles fields.
+ *
+ * @param int|null $min_cycles Minimum cycles before cancellation is allowed.
+ * @param int|null $max_cycles Total cycles before the contract ends.
+ * @throws DomainException If min_cycles or max_cycles are not valid.
+ */
+ private function validate_min_max_cycles( ?int $min_cycles, ?int $max_cycles ): void {
+ if ( null !== $min_cycles && $min_cycles < 0 ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy: min_cycles must be 0 or greater, got %d.', $min_cycles )
+ );
+ }
+
+ if ( null !== $max_cycles && $max_cycles < 0 ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy: max_cycles must be 0 or greater, got %d.', $max_cycles )
+ );
+ }
+
+ if ( null !== $min_cycles && null !== $max_cycles && $min_cycles > $max_cycles ) {
+ throw new DomainException(
+ sprintf( 'Billing_Policy: min_cycles cannot exceed max_cycles, got %d and %d.', $min_cycles, $max_cycles )
+ );
+ }
+ }
+
+ /**
+ * Normalize the trial duration.
+ *
+ * @param array{length: int, unit: string}|null $trial_duration The trial duration.
+ * @return array{length: int, unit: string}|null The normalized trial duration.
+ * @throws DomainException If the trial duration is not valid.
+ */
+ private static function normalize_trial_duration( ?array $trial_duration ): ?array {
+ if ( null === $trial_duration ) {
+ return null;
+ }
+
+ if ( ! array_key_exists( 'length', $trial_duration ) ) {
+ throw new DomainException( "Billing_Policy: trial_duration['length'] is required." );
+ }
+ if ( ! array_key_exists( 'unit', $trial_duration ) ) {
+ throw new DomainException( "Billing_Policy: trial_duration['unit'] is required." );
+ }
+ if ( ! is_int( $trial_duration['length'] ) ) {
+ throw new DomainException(
+ sprintf( "Billing_Policy: trial_duration['length'] must be an integer, got %s.", gettype( $trial_duration['length'] ) )
+ );
+ }
+ if ( ! is_string( $trial_duration['unit'] ) ) {
+ throw new DomainException(
+ sprintf( "Billing_Policy: trial_duration['unit'] must be a string, got %s.", gettype( $trial_duration['unit'] ) )
+ );
+ }
+
+ return array(
+ 'length' => (int) $trial_duration['length'],
+ 'unit' => (string) $trial_duration['unit'],
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-delivery-policy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-delivery-policy.php
new file mode 100644
index 00000000000..8e431f83a21
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-delivery-policy.php
@@ -0,0 +1,127 @@
+<?php
+/**
+ * Delivery_Policy - typed value object for a plan's delivery anchors, cutoff,
+ * and intent.
+ *
+ * Mirrors the `delivery_policy` JSON column shape, deliberately thin for now.
+ * Shape:
+ * {
+ * anchors: [{ type: 'MONTHDAY', day: int }, { type: 'YEARDAY', day: int, month: int }, ...],
+ * cutoff: ?mixed,
+ * intent: ?mixed
+ * }
+ *
+ * The shipping/delivery policy parameter set is a new concept still being
+ * designed, so `cutoff` and `intent` are passed through verbatim and anchor
+ * entries stay as plain associative arrays until a call site needs typed access.
+ *
+ * Lives in the WordPress-free Core zone.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Delivery_Policy value object.
+ *
+ * Immutable. Construct via {@see self::from_array()} when hydrating from a
+ * stored row, or via the constructor when building one in code.
+ */
+final class Delivery_Policy {
+
+ /**
+ * Anchor entries. Each: `{type, day, month?}`.
+ *
+ * @var array<int, array<string, mixed>>
+ */
+ private $anchors;
+
+ /**
+ * Cutoff window - shape to be designed; passed through verbatim.
+ *
+ * @var mixed
+ */
+ private $cutoff;
+
+ /**
+ * Delivery intent - shape to be designed; passed through verbatim.
+ *
+ * @var mixed
+ */
+ private $intent;
+
+ /**
+ * Build a delivery policy.
+ *
+ * @param array<int, array<string, mixed>> $anchors Anchor entries.
+ * @param mixed $cutoff Cutoff window.
+ * @param mixed $intent Delivery intent.
+ */
+ public function __construct( array $anchors, $cutoff, $intent ) {
+ $this->anchors = $anchors;
+ $this->cutoff = $cutoff;
+ $this->intent = $intent;
+ }
+
+ /**
+ * Hydrate from the JSON-decoded `delivery_policy` column shape.
+ *
+ * Missing keys default to safe values - empty array for `anchors`, null for
+ * `cutoff` and `intent`.
+ *
+ * @param array<string, mixed> $data Decoded delivery_policy row.
+ */
+ public static function from_array( array $data ): self {
+ $anchors = is_array( $data['anchors'] ?? null ) ? $data['anchors'] : array();
+ return new self(
+ $anchors,
+ $data['cutoff'] ?? null,
+ $data['intent'] ?? null
+ );
+ }
+
+ /**
+ * Anchor entries describing when in the cycle a charge fires.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ public function get_anchors(): array {
+ return $this->anchors;
+ }
+
+ /**
+ * Cutoff window. Shape to be designed; returned verbatim.
+ *
+ * @return mixed
+ */
+ public function get_cutoff() {
+ return $this->cutoff;
+ }
+
+ /**
+ * Delivery intent. Shape to be designed; returned verbatim.
+ *
+ * @return mixed
+ */
+ public function get_intent() {
+ return $this->intent;
+ }
+
+ /**
+ * Serialize back to the JSON column shape. Lossless round-trip with from_array().
+ *
+ * @return array<string, mixed>
+ */
+ public function to_array(): array {
+ return array(
+ 'anchors' => $this->anchors,
+ 'cutoff' => $this->cutoff,
+ 'intent' => $this->intent,
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-instrument-ref.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-instrument-ref.php
new file mode 100644
index 00000000000..99f3cf6d963
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-instrument-ref.php
@@ -0,0 +1,81 @@
+<?php
+/**
+ * Instrument_Ref - an immutable reference to a stored payment instrument.
+ *
+ * Carries the payment token id plus the gateway code and human-readable title
+ * frozen at the time the instrument was attached. The Core zone never loads a
+ * live payment token; the Payments host binding resolves the reference when a
+ * charge is attempted.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Instrument_Ref value object.
+ *
+ * Immutable. A null token id covers gateways that do not expose a stored token
+ * (for example some manual gateways).
+ */
+final class Instrument_Ref {
+
+ /**
+ * Payment token id, or null when the gateway exposes no token.
+ *
+ * @var int|null
+ */
+ private $token_id;
+
+ /**
+ * Gateway code (for example 'woocommerce_payments').
+ *
+ * @var string|null
+ */
+ private $gateway;
+
+ /**
+ * Human-readable gateway title, frozen at checkout time.
+ *
+ * @var string|null
+ */
+ private $title;
+
+ /**
+ * Build an instrument reference.
+ *
+ * @param int|null $token_id Payment token id, or null.
+ * @param string|null $gateway Gateway code.
+ * @param string|null $title Human-readable gateway title.
+ */
+ public function __construct( ?int $token_id, ?string $gateway = null, ?string $title = null ) {
+ $this->token_id = $token_id;
+ $this->gateway = $gateway;
+ $this->title = $title;
+ }
+
+ /**
+ * The referenced payment token id, or null.
+ */
+ public function get_token_id(): ?int {
+ return $this->token_id;
+ }
+
+ /**
+ * The gateway code, or null.
+ */
+ public function get_gateway(): ?string {
+ return $this->gateway;
+ }
+
+ /**
+ * The human-readable gateway title, or null.
+ */
+ public function get_title(): ?string {
+ return $this->title;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-order-ref.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-order-ref.php
new file mode 100644
index 00000000000..575b84c7179
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-order-ref.php
@@ -0,0 +1,59 @@
+<?php
+/**
+ * Order_Ref - an immutable reference to a WooCommerce order by id.
+ *
+ * The Core zone never loads a live order object; it holds a reference and
+ * commands effects through the Orders host binding in the integration layer.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Order_Ref value object.
+ *
+ * Immutable identity wrapper.
+ */
+final class Order_Ref {
+
+ /**
+ * Order id.
+ *
+ * @var int
+ */
+ private $id;
+
+ /**
+ * Build an order reference.
+ *
+ * @param int $id Order id.
+ * @throws \InvalidArgumentException If the order id is not greater than 0.
+ */
+ public function __construct( int $id ) {
+ if ( $id <= 0 ) {
+ throw new \InvalidArgumentException( 'Order id must be greater than 0.' );
+ }
+ $this->id = $id;
+ }
+
+ /**
+ * The referenced order id.
+ */
+ public function get_id(): int {
+ return $this->id;
+ }
+
+ /**
+ * Value equality by id.
+ *
+ * @param Order_Ref $other Reference to compare against.
+ */
+ public function equals( Order_Ref $other ): bool {
+ return $this->id === $other->id;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-pricing-policy.php b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-pricing-policy.php
new file mode 100644
index 00000000000..d5004faf678
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Core/ValueObject/class-pricing-policy.php
@@ -0,0 +1,185 @@
+<?php
+/**
+ * Pricing_Policy - typed value object for a plan's recurring price adjustments
+ * and one-time fees.
+ *
+ * Mirrors the `pricing_policy` JSON column shape. Shape:
+ * {
+ * policies: [
+ * { type: 'percentage'|'fixed_amount'|'price', value: float, starting_cycle?: int },
+ * ...
+ * ],
+ * one_time_fees: [
+ * { kind: string, amount: float, taxable: bool, tax_class: string|null },
+ * ...
+ * ]
+ * }
+ *
+ * `tax_class` empty-string semantics: `''` means the store's "Standard" class
+ * (the implicit default), not "no class." `null` is reserved for a fee that is
+ * genuinely untaxed. The two are not interchangeable - round-trip preserves
+ * whichever was supplied.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Pricing_Policy value object.
+ *
+ * Immutable. The plan column itself is nullable when neither policies nor fees
+ * apply; the value object never represents that absence - it always holds two
+ * arrays, possibly both empty.
+ */
+final class Pricing_Policy {
+
+ /**
+ * Recurring price adjustments, applied in array order.
+ *
+ * @var array<int, array{type: string, value: float, starting_cycle: ?int}>
+ */
+ private $policies;
+
+ /**
+ * One-time fees charged at contract creation.
+ *
+ * @var array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>
+ */
+ private $one_time_fees;
+
+ /**
+ * Build a pricing policy.
+ *
+ * @param array<int, array{type: string, value: float, starting_cycle?: int}> $policies Recurring price adjustments.
+ * @param array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}> $one_time_fees One-time fees.
+ */
+ public function __construct( array $policies, array $one_time_fees ) {
+ $this->policies = $policies;
+ $this->one_time_fees = $one_time_fees;
+ }
+
+ /**
+ * Hydrate from the JSON-decoded `pricing_policy` column shape.
+ *
+ * Missing top-level keys default to empty arrays. Numeric values are
+ * normalized to float so a whole-number round-trip does not silently drift
+ * from float to int and break type-strict comparisons downstream.
+ *
+ * @param array<string, mixed> $data Decoded pricing_policy row.
+ */
+ public static function from_array( array $data ): self {
+ $raw_policies = is_array( $data['policies'] ?? null ) ? $data['policies'] : array();
+ $policies = array_values(
+ array_filter(
+ array_map(
+ static function ( $entry ): ?array {
+ if ( ! is_array( $entry ) ) {
+ return null;
+ }
+ if ( isset( $entry['value'] ) && is_numeric( $entry['value'] ) ) {
+ $entry['value'] = (float) $entry['value'];
+ }
+ return $entry;
+ },
+ $raw_policies
+ ),
+ static function ( $entry ): bool {
+ return is_array( $entry );
+ }
+ )
+ );
+
+ $fees = array_map(
+ static function ( array $entry ): array {
+ if ( isset( $entry['amount'] ) && is_numeric( $entry['amount'] ) ) {
+ $entry['amount'] = (float) $entry['amount'];
+ }
+ return $entry;
+ },
+ $data['one_time_fees'] ?? array()
+ );
+
+ return new self( $policies, $fees );
+ }
+
+ /**
+ * Recurring price adjustments. Each entry: `{type, value, starting_cycle?}`.
+ *
+ * @return array<int, array{type: string, value: float, starting_cycle?: int}>
+ */
+ public function get_policies(): array {
+ return $this->policies;
+ }
+
+ /**
+ * One-time fees charged at contract creation.
+ *
+ * @return array<int, array{kind: string, amount: float, taxable: bool, tax_class: string|null}>
+ */
+ public function get_one_time_fees(): array {
+ return $this->one_time_fees;
+ }
+
+ /**
+ * Apply the recurring policy chain to a base price for the given cycle.
+ *
+ * Semantics:
+ * - Empty `policies` returns `$base_price` unchanged.
+ * - `type: 'percentage'` -> `base_price * (100 - value) / 100`.
+ * - `type: 'fixed_amount'` -> `max(0, base_price - value)` (clamped at zero).
+ * - `type: 'price'` -> `value` (replaces base price entirely).
+ * - `starting_cycle` gate: skip the entry when `$cycle < starting_cycle`.
+ * A missing `starting_cycle` means the entry applies to all cycles.
+ * - Entries are applied in array order; later entries operate on the result.
+ *
+ * One-time fees are intentionally not applied here.
+ *
+ * @param float $base_price The product's base price for this cycle.
+ * @param int $cycle 1-indexed cycle number (1 = first billing cycle).
+ */
+ public function calculate_price( float $base_price, int $cycle = 1 ): float {
+ $price = $base_price;
+
+ foreach ( $this->policies as $policy ) {
+ if ( isset( $policy['starting_cycle'] ) && $cycle < (int) $policy['starting_cycle'] ) {
+ continue;
+ }
+
+ $type = (string) ( $policy['type'] ?? '' );
+ $value = (float) ( $policy['value'] ?? 0 );
+
+ switch ( $type ) {
+ case 'percentage':
+ $price = $price * ( 100 - $value ) / 100;
+ break;
+ case 'fixed_amount':
+ $price = max( 0.0, $price - $value );
+ break;
+ case 'price':
+ $price = $value;
+ break;
+ default:
+ break;
+ }
+ }
+
+ return $price;
+ }
+
+ /**
+ * Serialize back to the JSON column shape. Lossless round-trip with from_array().
+ *
+ * @return array<string, mixed>
+ */
+ public function to_array(): array {
+ return array(
+ 'policies' => $this->policies,
+ 'one_time_fees' => $this->one_time_fees,
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-contract-repository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-contract-repository.php
new file mode 100644
index 00000000000..57236da893d
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-contract-repository.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * Contract_Repository - persistence for {@see Contract} entities.
+ *
+ * Lives in the integration layer: it owns the $wpdb access and spans the four
+ * contract tables (contract row, items, addresses, meta), hydrating the Core
+ * entity from clean arrays.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Contract repository.
+ */
+final class Contract_Repository {
+
+ /**
+ * Address columns persisted to the addresses table.
+ *
+ * @var array<int, string>
+ */
+ private const ADDRESS_COLUMNS = array(
+ 'first_name',
+ 'last_name',
+ 'company',
+ 'address_1',
+ 'address_2',
+ 'city',
+ 'state',
+ 'postcode',
+ 'country',
+ 'email',
+ 'phone',
+ );
+
+ /**
+ * Insert a new contract and its items, addresses, and meta.
+ *
+ * @param Contract $contract Contract to insert.
+ * @return int The new contract id.
+ * @throws \RuntimeException If the contract insert fails.
+ */
+ public function insert( Contract $contract ): int {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $data = $contract->to_storage();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACTS ),
+ array_merge(
+ $data,
+ array(
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( 'Failed to insert contract.' );
+ }
+
+ $id = (int) $wpdb->insert_id;
+ $contract->set_id( $id );
+
+ $this->insert_items( $id, $contract->get_items() );
+ $this->insert_addresses( $id, $contract->get_addresses() );
+ $this->insert_meta( $id, $contract->get_meta() );
+
+ return $id;
+ }
+
+ /**
+ * Fetch a contract by id, including its items, addresses, and meta.
+ *
+ * @param int $id Contract id.
+ * @return Contract|null Hydrated contract, or null if not found.
+ */
+ public function find( int $id ): ?Contract {
+ global $wpdb;
+
+ $contracts = Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$contracts} WHERE id = %d", $id ), ARRAY_A );
+
+ if ( null === $row ) {
+ return null;
+ }
+
+ return Contract::from_storage(
+ $row,
+ $this->find_items( $id ),
+ $this->find_addresses( $id ),
+ $this->find_meta( $id )
+ );
+ }
+
+ /**
+ * Delete a contract and its child rows.
+ *
+ * @param int $id Contract id.
+ * @return bool True when the contract row was removed.
+ */
+ public function delete( int $id ): bool {
+ global $wpdb;
+
+ foreach ( array(
+ Schema_Installer::TABLE_CONTRACT_ITEMS,
+ Schema_Installer::TABLE_CONTRACT_ADDRESSES,
+ Schema_Installer::TABLE_CONTRACT_META,
+ ) as $child ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->delete( Schema_Installer::get_table_name( $child ), array( 'contract_id' => $id ) );
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted = $wpdb->delete( Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACTS ), array( 'id' => $id ) );
+
+ return (bool) $deleted;
+ }
+
+ /**
+ * Insert line items for a contract.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<int, array<string, mixed>> $items Item rows.
+ */
+ private function insert_items( int $contract_id, array $items ): void {
+ global $wpdb;
+
+ foreach ( $items as $item ) {
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->insert(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_ITEMS ),
+ array(
+ 'contract_id' => $contract_id,
+ 'item_name' => (string) ( $item['item_name'] ?? '' ),
+ 'item_type' => (string) ( $item['item_type'] ?? 'line_item' ),
+ 'product_id' => isset( $item['product_id'] ) ? (int) $item['product_id'] : null,
+ 'variation_id' => isset( $item['variation_id'] ) ? (int) $item['variation_id'] : null,
+ 'quantity' => (string) ( $item['quantity'] ?? '1' ),
+ 'subtotal' => (string) ( $item['subtotal'] ?? '0' ),
+ 'total' => (string) ( $item['total'] ?? '0' ),
+ 'taxes' => isset( $item['taxes'] ) ? wp_json_encode( $item['taxes'] ) : null,
+ )
+ );
+ }
+ }
+
+ /**
+ * Insert addresses for a contract.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, array<string, mixed>> $addresses Address rows keyed by type.
+ */
+ private function insert_addresses( int $contract_id, array $addresses ): void {
+ global $wpdb;
+
+ foreach ( $addresses as $type => $address ) {
+ $record = array(
+ 'contract_id' => $contract_id,
+ 'address_type' => (string) $type,
+ );
+
+ foreach ( self::ADDRESS_COLUMNS as $column ) {
+ $record[ $column ] = isset( $address[ $column ] ) ? (string) $address[ $column ] : null;
+ }
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $wpdb->insert( Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_ADDRESSES ), $record );
+ }
+ }
+
+ /**
+ * Insert meta for a contract.
+ *
+ * @param int $contract_id Contract id.
+ * @param array<string, string> $meta Meta as key => value.
+ */
+ private function insert_meta( int $contract_id, array $meta ): void {
+ global $wpdb;
+
+ foreach ( $meta as $key => $value ) {
+ // These are the engine's own contract-meta columns, not post/order
+ // meta; the slow-meta-query heuristic does not apply.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $wpdb->insert(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_META ),
+ array(
+ 'contract_id' => $contract_id,
+ 'meta_key' => (string) $key,
+ 'meta_value' => (string) $value,
+ )
+ );
+ }
+ }
+
+ /**
+ * Load line items for a contract.
+ *
+ * @param int $contract_id Contract id.
+ * @return array<int, array<string, mixed>>
+ */
+ private function find_items( int $contract_id ): array {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_ITEMS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d ORDER BY id ASC", $contract_id ), ARRAY_A );
+
+ return is_array( $rows ) ? $rows : array();
+ }
+
+ /**
+ * Load addresses for a contract, keyed by address type.
+ *
+ * @param int $contract_id Contract id.
+ * @return array<string, array<string, mixed>>
+ */
+ private function find_addresses( int $contract_id ): array {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_ADDRESSES );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
+
+ $by_type = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ $by_type[ (string) $row['address_type'] ] = $row;
+ }
+
+ return $by_type;
+ }
+
+ /**
+ * Load meta for a contract as key => value.
+ *
+ * @param int $contract_id Contract id.
+ * @return array<string, string>
+ */
+ private function find_meta( int $contract_id ): array {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACT_META );
+
+ // These are the engine's own contract-meta columns, not post/order meta;
+ // the slow-meta-query heuristic does not apply.
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $rows = $wpdb->get_results( $wpdb->prepare( "SELECT meta_key, meta_value FROM {$table} WHERE contract_id = %d", $contract_id ), ARRAY_A );
+
+ $meta = array();
+ foreach ( is_array( $rows ) ? $rows : array() as $row ) {
+ $meta[ (string) $row['meta_key'] ] = (string) $row['meta_value'];
+ }
+
+ return $meta;
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-group-repository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-group-repository.php
new file mode 100644
index 00000000000..19ebaa156bb
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-group-repository.php
@@ -0,0 +1,115 @@
+<?php
+/**
+ * Plan_Group_Repository - persistence for {@see Plan_Group} entities.
+ *
+ * The engine's tables are private API; consumers reach plan groups through the public surface.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan_Group;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Plan_Group repository.
+ */
+final class Plan_Group_Repository {
+
+ /**
+ * Insert a new plan group and stamp its id back onto the entity.
+ *
+ * @param Plan_Group $group Group to insert.
+ * @return int The new group id.
+ * @throws \RuntimeException If the insert fails.
+ */
+ public function insert( Plan_Group $group ): int {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $data = $group->to_storage();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_PLAN_GROUPS ),
+ array(
+ 'name' => $data['name'],
+ 'merchant_code' => $data['merchant_code'],
+ 'options_display' => wp_json_encode( $data['options_display'] ),
+ 'app_id' => $data['app_id'],
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( 'Failed to insert plan group.' );
+ }
+
+ $id = (int) $wpdb->insert_id;
+ $group->set_id( $id );
+
+ return $id;
+ }
+
+ /**
+ * Fetch a plan group by id.
+ *
+ * @param int $id Group id.
+ * @return Plan_Group|null Hydrated group, or null if not found.
+ */
+ public function find( int $id ): ?Plan_Group {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_PLAN_GROUPS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+ if ( null === $row ) {
+ return null;
+ }
+
+ $row['options_display'] = self::decode_json( $row['options_display'] );
+
+ return Plan_Group::from_storage( $row );
+ }
+
+ /**
+ * Delete a plan group by id.
+ *
+ * @param int $id Group id.
+ * @return bool True when a row was removed.
+ */
+ public function delete( int $id ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted = $wpdb->delete(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_PLAN_GROUPS ),
+ array( 'id' => $id )
+ );
+
+ return (bool) $deleted;
+ }
+
+ /**
+ * Decode a JSON column into an array, tolerating null/empty values.
+ *
+ * @param mixed $value Raw column value.
+ * @return array<mixed>
+ */
+ private static function decode_json( $value ): array {
+ if ( ! is_string( $value ) || '' === $value ) {
+ return array();
+ }
+
+ $decoded = json_decode( $value, true );
+
+ return is_array( $decoded ) ? $decoded : array();
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-repository.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-repository.php
new file mode 100644
index 00000000000..e56c650cfac
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-plan-repository.php
@@ -0,0 +1,173 @@
+<?php
+/**
+ * Plan_Repository - persistence for {@see Plan} entities.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Plan repository.
+ */
+final class Plan_Repository {
+
+ /**
+ * Policy columns stored as JSON.
+ *
+ * @var array<int, string>
+ */
+ private const JSON_COLUMNS = array( 'options', 'billing_policy', 'delivery_policy', 'pricing_policy' );
+
+ /**
+ * Insert a new plan and stamp its id back onto the entity.
+ *
+ * @param Plan $plan Plan to insert.
+ * @return int The new plan id.
+ * @throws \RuntimeException If the insert fails.
+ */
+ public function insert( Plan $plan ): int {
+ global $wpdb;
+
+ $now = gmdate( 'Y-m-d H:i:s' );
+ $data = $plan->to_storage();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $inserted = $wpdb->insert(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_PLANS ),
+ array(
+ 'group_id' => $data['group_id'],
+ 'name' => $data['name'],
+ 'description' => $data['description'],
+ 'options' => wp_json_encode( $data['options'] ),
+ 'billing_policy' => wp_json_encode( $data['billing_policy'] ),
+ 'delivery_policy' => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
+ 'inventory_policy' => null,
+ 'pricing_policy' => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
+ 'category' => $data['category'],
+ 'extension_slug' => $data['extension_slug'],
+ 'date_created_gmt' => $now,
+ 'date_updated_gmt' => $now,
+ )
+ );
+
+ if ( false === $inserted ) {
+ throw new \RuntimeException( 'Failed to insert plan.' );
+ }
+
+ $id = (int) $wpdb->insert_id;
+ $plan->set_id( $id );
+
+ return $id;
+ }
+
+ /**
+ * Fetch a plan by id.
+ *
+ * @param int $id Plan id.
+ * @return Plan|null Hydrated plan, or null if not found.
+ */
+ public function find( int $id ): ?Plan {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_PLANS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), ARRAY_A );
+
+ if ( null === $row ) {
+ return null;
+ }
+
+ foreach ( self::JSON_COLUMNS as $column ) {
+ $row[ $column ] = self::decode_json( $row[ $column ] ?? null );
+ }
+
+ return Plan::from_storage( $row );
+ }
+
+ /**
+ * Persist changes to an existing plan.
+ *
+ * @param Plan $plan Plan to update. Must have an id.
+ * @return bool True on success.
+ * @throws \RuntimeException If the plan has no id.
+ */
+ public function update( Plan $plan ): bool {
+ global $wpdb;
+
+ $id = $plan->get_id();
+ if ( null === $id ) {
+ throw new \RuntimeException( 'Cannot update a plan that has no id.' );
+ }
+
+ $data = $plan->to_storage();
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $updated = $wpdb->update(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_PLANS ),
+ array(
+ 'name' => $data['name'],
+ 'description' => $data['description'],
+ 'options' => wp_json_encode( $data['options'] ),
+ 'billing_policy' => wp_json_encode( $data['billing_policy'] ),
+ 'delivery_policy' => null !== $data['delivery_policy'] ? wp_json_encode( $data['delivery_policy'] ) : null,
+ 'pricing_policy' => null !== $data['pricing_policy'] ? wp_json_encode( $data['pricing_policy'] ) : null,
+ 'category' => $data['category'],
+ 'extension_slug' => $data['extension_slug'],
+ 'date_updated_gmt' => gmdate( 'Y-m-d H:i:s' ),
+ ),
+ array( 'id' => $id )
+ );
+
+ return false !== $updated;
+ }
+
+ /**
+ * Delete a plan by id.
+ *
+ * @param int $id Plan id.
+ * @return bool True when a row was removed.
+ */
+ public function delete( int $id ): bool {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
+ $deleted = $wpdb->delete(
+ Schema_Installer::get_table_name( Schema_Installer::TABLE_PLANS ),
+ array( 'id' => $id )
+ );
+
+ return (bool) $deleted;
+ }
+
+ /**
+ * Decode a JSON column into an array.
+ *
+ * A SQL NULL column stays null so nullable policy columns
+ * (delivery_policy, pricing_policy) round-trip back to null rather than to
+ * an empty value object. A present-but-empty value decodes to an array.
+ *
+ * @param mixed $value Raw column value.
+ * @return array<mixed>|null
+ */
+ private static function decode_json( $value ): ?array {
+ if ( null === $value ) {
+ return null;
+ }
+
+ if ( ! is_string( $value ) || '' === $value ) {
+ return array();
+ }
+
+ $decoded = json_decode( $value, true );
+
+ return is_array( $decoded ) ? $decoded : array();
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-schema-installer.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-schema-installer.php
new file mode 100644
index 00000000000..3a553fc899f
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/Storage/class-schema-installer.php
@@ -0,0 +1,296 @@
+<?php
+/**
+ * Schema_Installer - owns the engine's baseline database tables.
+ *
+ * Creates and drops the plan tables (`wc_selling_plan_groups`,
+ * `wc_selling_plans`) and the contract tables (`wc_subscription_contracts`,
+ * `wc_subscription_contract_items`, `wc_subscription_contract_addresses`,
+ * `wc_subscription_contract_meta`). Mirrors the order/HPOS conventions:
+ * BIGINT UNSIGNED ids, `*_gmt` datetime columns, JSON columns for policy
+ * bundles, no foreign-key constraints.
+ *
+ * Schema is additive-only: columns shipped here are permanent.
+ *
+ * The engine is bundled rather than independently activated, so install runs
+ * through {@see self::maybe_install()} (a version-gated check on boot), not a
+ * plugin activation hook.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Schema installer and table-name resolver.
+ */
+final class Schema_Installer {
+
+ /**
+ * Schema version. Bump when the CREATE TABLE statements change so the
+ * version-gated install runs dbDelta again.
+ *
+ * 1.0.0 - baseline plan and contract tables, including the nullable `extension_slug`
+ * column on plans and contracts.
+ */
+ const VERSION = '1.0.0';
+
+ /**
+ * Option key tracking the installed schema version.
+ */
+ const VERSION_OPTION = 'wc_subscriptions_engine_db_version';
+
+ /**
+ * Logical table identifiers - keys map to unprefixed table names.
+ */
+ const TABLE_PLAN_GROUPS = 'plan_groups';
+ const TABLE_PLANS = 'plans';
+ const TABLE_CONTRACTS = 'contracts';
+ const TABLE_CONTRACT_ITEMS = 'contract_items';
+ const TABLE_CONTRACT_ADDRESSES = 'contract_addresses';
+ const TABLE_CONTRACT_META = 'contract_meta';
+
+ /**
+ * Resolve a logical identifier to its prefixed table name.
+ *
+ * @param string $logical One of the TABLE_* constants.
+ * @return string Prefixed table name.
+ * @throws \InvalidArgumentException If $logical is unknown.
+ */
+ public static function get_table_name( string $logical ): string {
+ global $wpdb;
+
+ $names = self::get_table_names( $wpdb->prefix );
+
+ if ( ! isset( $names[ $logical ] ) ) {
+ throw new \InvalidArgumentException(
+ sprintf( 'Unknown subscriptions-engine table identifier: %s', esc_html( $logical ) )
+ );
+ }
+
+ return $names[ $logical ];
+ }
+
+ /**
+ * Install or upgrade the tables when the stored version is behind the code.
+ *
+ * Cheap to call on every boot: it is a single option read in the common case.
+ */
+ public static function maybe_install(): void {
+ if ( self::is_current() ) {
+ return;
+ }
+
+ self::install();
+ }
+
+ /**
+ * Install (or upgrade) the tables. Idempotent - dbDelta handles the diff.
+ */
+ public static function install(): void {
+ global $wpdb;
+
+ require_once ABSPATH . 'wp-admin/includes/upgrade.php';
+
+ $collate = $wpdb->get_charset_collate();
+ $names = self::get_table_names( $wpdb->prefix );
+
+ foreach ( self::get_table_definitions( $names, $collate ) as $sql ) {
+ dbDelta( $sql );
+ }
+
+ update_option( self::VERSION_OPTION, self::VERSION );
+ }
+
+ /**
+ * Drop the tables and clear schema metadata.
+ *
+ * Intended for uninstall paths only, never deactivation.
+ */
+ public static function uninstall(): void {
+ global $wpdb;
+
+ // TODO: Determine what we should do with the tables when uninstalling - WOOSUBS-1718.
+
+ delete_option( self::VERSION_OPTION );
+ }
+
+ /**
+ * Whether the installed schema version matches Schema_Installer::VERSION.
+ */
+ public static function is_current(): bool {
+ return self::VERSION === get_option( self::VERSION_OPTION );
+ }
+
+ /**
+ * Map of logical => prefixed table names, keyed by TABLE_* constants.
+ *
+ * Contract tables use the `wc_subscription_*` prefix (what the data
+ * represents), while the namespace boundary is about code ownership.
+ *
+ * @param string $prefix Usually `$wpdb->prefix`.
+ * @return array<string, string>
+ */
+ private static function get_table_names( string $prefix ): array {
+ return array(
+ self::TABLE_PLAN_GROUPS => $prefix . 'wc_selling_plan_groups',
+ self::TABLE_PLANS => $prefix . 'wc_selling_plans',
+ self::TABLE_CONTRACTS => $prefix . 'wc_subscription_contracts',
+ self::TABLE_CONTRACT_ITEMS => $prefix . 'wc_subscription_contract_items',
+ self::TABLE_CONTRACT_ADDRESSES => $prefix . 'wc_subscription_contract_addresses',
+ self::TABLE_CONTRACT_META => $prefix . 'wc_subscription_contract_meta',
+ );
+ }
+
+ /**
+ * CREATE TABLE statements, formatted for dbDelta.
+ *
+ * The dbDelta function is fussy: each column on its own line, two spaces
+ * between name and type, `KEY` (not `INDEX`), no trailing comma before
+ * PRIMARY KEY. Do not reformat these without re-testing dbDelta diffing - it
+ * parses with regex.
+ *
+ * @param array<string, string> $names Map of logical => prefixed table names.
+ * @param string $collate Charset/collate clause from $wpdb.
+ * @return array<int, string>
+ */
+ private static function get_table_definitions( array $names, string $collate ): array {
+ $plan_groups = $names[ self::TABLE_PLAN_GROUPS ];
+ $plans = $names[ self::TABLE_PLANS ];
+ $contracts = $names[ self::TABLE_CONTRACTS ];
+ $contract_items = $names[ self::TABLE_CONTRACT_ITEMS ];
+ $contract_addresses = $names[ self::TABLE_CONTRACT_ADDRESSES ];
+ $contract_meta = $names[ self::TABLE_CONTRACT_META ];
+
+ // `merchant_code` is UNIQUE (not just KEY) for DB-enforced idempotency on
+ // consumer-supplied codes. NULL values are allowed and treated as distinct,
+ // so consumers that do not use merchant codes are unaffected.
+ $plan_groups_sql = "CREATE TABLE {$plan_groups} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ name VARCHAR(255) NOT NULL,
+ merchant_code VARCHAR(64) NULL,
+ options_display JSON NULL,
+ app_id VARCHAR(64) NULL,
+ date_created_gmt DATETIME NOT NULL,
+ date_updated_gmt DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ UNIQUE KEY merchant_code (merchant_code),
+ KEY app_id (app_id)
+) {$collate};";
+
+ // `extension_slug` records the registered slug of the extension that created the
+ // plan. Nullable while owner identifier/registration semantics are still
+ // open; tightened additively once decided.
+ $plans_sql = "CREATE TABLE {$plans} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ group_id BIGINT UNSIGNED NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ options JSON NOT NULL,
+ billing_policy JSON NOT NULL,
+ delivery_policy JSON NULL,
+ inventory_policy JSON NULL,
+ pricing_policy JSON NULL,
+ category VARCHAR(32) NOT NULL DEFAULT 'SUBSCRIPTION',
+ extension_slug VARCHAR(64) NULL,
+ date_created_gmt DATETIME NOT NULL,
+ date_updated_gmt DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY group_id (group_id),
+ KEY category (category),
+ KEY extension_slug (extension_slug)
+) {$collate};";
+
+ // `currency` is first-class (forward-compat for multi-currency recurring;
+ // today always the store base currency). `schedule_source` distinguishes
+ // contracts whose renewals this engine owns from gateway-owned schedules.
+ // `extension_slug` mirrors the plans column. Totals follow the order PHP-property
+ // naming rather than the HPOS storage-column names.
+ $contracts_sql = "CREATE TABLE {$contracts} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ status VARCHAR(20) NOT NULL,
+ customer_id BIGINT UNSIGNED NOT NULL,
+ currency CHAR(3) NOT NULL,
+ selling_plan_id BIGINT UNSIGNED NOT NULL,
+ origin_order_id BIGINT UNSIGNED NOT NULL,
+ extension_slug VARCHAR(64) NULL,
+ payment_method VARCHAR(100) NULL,
+ payment_method_title VARCHAR(200) NULL,
+ payment_token_id BIGINT UNSIGNED NULL,
+ billing_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ discount_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ shipping_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ tax_total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ start_gmt DATETIME NOT NULL,
+ next_payment_gmt DATETIME NULL,
+ last_payment_gmt DATETIME NULL,
+ last_attempt_gmt DATETIME NULL,
+ trial_end_gmt DATETIME NULL,
+ end_gmt DATETIME NULL,
+ cycle_count INT UNSIGNED NOT NULL DEFAULT 0,
+ schedule_source VARCHAR(20) NOT NULL DEFAULT 'primitive',
+ date_created_gmt DATETIME NOT NULL,
+ date_updated_gmt DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ KEY customer_status (customer_id, status),
+ KEY due (next_payment_gmt, status),
+ KEY origin_order (origin_order_id),
+ KEY extension_slug (extension_slug)
+) {$collate};";
+
+ $contract_items_sql = "CREATE TABLE {$contract_items} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ contract_id BIGINT UNSIGNED NOT NULL,
+ item_name VARCHAR(255) NOT NULL,
+ item_type VARCHAR(32) NOT NULL,
+ product_id BIGINT UNSIGNED NULL,
+ variation_id BIGINT UNSIGNED NULL,
+ quantity DECIMAL(12,4) NOT NULL DEFAULT 1,
+ subtotal DECIMAL(26,8) NOT NULL DEFAULT 0,
+ total DECIMAL(26,8) NOT NULL DEFAULT 0,
+ taxes JSON NULL,
+ PRIMARY KEY (id),
+ KEY contract (contract_id)
+) {$collate};";
+
+ // One billing + one shipping address per contract: composite PK on
+ // (contract_id, address_type). Mirrors the order-addresses column shape.
+ $contract_addresses_sql = "CREATE TABLE {$contract_addresses} (
+ contract_id BIGINT UNSIGNED NOT NULL,
+ address_type VARCHAR(20) NOT NULL,
+ first_name TEXT NULL,
+ last_name TEXT NULL,
+ company TEXT NULL,
+ address_1 TEXT NULL,
+ address_2 TEXT NULL,
+ city TEXT NULL,
+ state TEXT NULL,
+ postcode TEXT NULL,
+ country TEXT NULL,
+ email VARCHAR(320) NULL,
+ phone VARCHAR(100) NULL,
+ PRIMARY KEY (contract_id, address_type)
+) {$collate};";
+
+ $contract_meta_sql = "CREATE TABLE {$contract_meta} (
+ id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ contract_id BIGINT UNSIGNED NOT NULL,
+ meta_key VARCHAR(255) NOT NULL,
+ meta_value LONGTEXT NULL,
+ PRIMARY KEY (id),
+ KEY contract_key (contract_id, meta_key(100))
+) {$collate};";
+
+ return array(
+ $plan_groups_sql,
+ $plans_sql,
+ $contracts_sql,
+ $contract_items_sql,
+ $contract_addresses_sql,
+ $contract_meta_sql,
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/Integration/class-bootstrap.php b/packages/php/woocommerce-subscriptions-engine/src/Integration/class-bootstrap.php
new file mode 100644
index 00000000000..3ca7a6452b8
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/Integration/class-bootstrap.php
@@ -0,0 +1,57 @@
+<?php
+/**
+ * Bootstrap - wires the engine's integration layer into WordPress.
+ *
+ * The engine is bundled rather than independently activated, so it cannot rely
+ * on a plugin activation hook to install its schema. Instead it performs a
+ * version-gated install check on boot: cheap in the common case (a single
+ * option read) and self-healing if the tables are missing or behind.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine\Integration
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Integration;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Integration-layer bootstrap.
+ */
+final class Bootstrap {
+
+ /**
+ * Whether hooks have already been registered, to keep init idempotent when
+ * more than one consumer boots the engine in the same request.
+ *
+ * @var bool
+ */
+ private static $initialized = false;
+
+ /**
+ * Register the engine's WordPress hooks.
+ */
+ public static function init(): void {
+ if ( self::$initialized ) {
+ return;
+ }
+
+ self::$initialized = true;
+
+ if ( did_action( 'init' ) ) {
+ self::maybe_install_schema();
+ } else {
+ add_action( 'init', array( __CLASS__, 'maybe_install_schema' ) );
+ }
+ }
+
+ /**
+ * Install or upgrade the engine schema when it is missing or behind.
+ */
+ public static function maybe_install_schema(): void {
+ Schema_Installer::maybe_install();
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/src/class-package.php b/packages/php/woocommerce-subscriptions-engine/src/class-package.php
new file mode 100644
index 00000000000..236adeb6978
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/src/class-package.php
@@ -0,0 +1,51 @@
+<?php
+/**
+ * Main package class for the WooCommerce Subscriptions Engine.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine;
+
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Bootstrap;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Package entry point.
+ *
+ * The engine is a library bundled into WooCommerce core and consumed by the
+ * Lite and Premium packages; it is not a standalone, independently activated
+ * plugin. Consumers call {@see self::init()} during their own boot to wire the
+ * integration layer.
+ */
+final class Package {
+
+ /**
+ * Package version.
+ */
+ const VERSION = '0.0.1';
+
+ /**
+ * Boot the package's integration layer.
+ */
+ public static function init(): void {
+ Bootstrap::init();
+ }
+
+ /**
+ * Return the version of the package.
+ */
+ public static function get_version(): string {
+ return self::VERSION;
+ }
+
+ /**
+ * Return the absolute path to the package root.
+ */
+ public static function get_path(): string {
+ return dirname( __DIR__ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Contract_Repository_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Contract_Repository_Test.php
new file mode 100644
index 00000000000..baa7f384517
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Contract_Repository_Test.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Integration tests for Contract_Repository.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use Engine_Integration_Test_Case;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract_Status;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Contract_Repository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Contract_Repository
+ */
+class Contract_Repository_Test extends Engine_Integration_Test_Case {
+
+ private function make_contract(): Contract {
+ return Contract::create(
+ array(
+ 'customer_id' => 42,
+ 'currency' => 'USD',
+ 'selling_plan_id' => 7,
+ 'origin_order_id' => 1001,
+ 'extension_slug' => 'lite',
+ 'payment_method' => 'woocommerce_payments',
+ 'payment_method_title' => 'Credit card',
+ 'payment_token_id' => 55,
+ 'billing_total' => '19.99',
+ 'start_gmt' => '2026-06-15 00:00:00',
+ 'next_payment_gmt' => '2026-07-15 00:00:00',
+ 'items' => array(
+ array(
+ 'item_name' => 'Coffee bag',
+ 'item_type' => 'line_item',
+ 'product_id' => 200,
+ 'quantity' => '1',
+ 'subtotal' => '19.99',
+ 'total' => '19.99',
+ ),
+ ),
+ 'addresses' => array(
+ Contract::ADDRESS_BILLING => array(
+ 'first_name' => 'Ada',
+ 'last_name' => 'Lovelace',
+ 'country' => 'US',
+ 'email' => 'ada@example.test',
+ ),
+ Contract::ADDRESS_SHIPPING => array(
+ 'first_name' => 'Ada',
+ 'last_name' => 'Lovelace',
+ 'country' => 'US',
+ ),
+ ),
+ 'meta' => array(
+ 'source_channel' => 'pdp',
+ ),
+ )
+ );
+ }
+
+ public function test_contract_round_trips_with_children(): void {
+ $repo = new Contract_Repository();
+
+ $id = $repo->insert( $this->make_contract() );
+ $this->assertGreaterThan( 0, $id );
+
+ $fetched = $repo->find( $id );
+
+ $this->assertInstanceOf( Contract::class, $fetched );
+ $this->assertSame( $id, $fetched->get_id() );
+ $this->assertSame( 42, $fetched->get_customer_id() );
+ $this->assertSame( 'USD', $fetched->get_currency() );
+ $this->assertSame( 'lite', $fetched->get_extension_slug() );
+ $this->assertSame( Contract_Status::ACTIVE, $fetched->get_status() );
+ $this->assertSame( '2026-07-15 00:00:00', $fetched->get_next_payment_gmt() );
+
+ // Payment instrument reference.
+ $instrument = $fetched->get_payment_instrument();
+ $this->assertSame( 55, $instrument->get_token_id() );
+ $this->assertSame( 'woocommerce_payments', $instrument->get_gateway() );
+
+ // Items.
+ $items = $fetched->get_items();
+ $this->assertCount( 1, $items );
+ $this->assertSame( 'Coffee bag', $items[0]['item_name'] );
+
+ // Addresses.
+ $addresses = $fetched->get_addresses();
+ $this->assertArrayHasKey( Contract::ADDRESS_BILLING, $addresses );
+ $this->assertArrayHasKey( Contract::ADDRESS_SHIPPING, $addresses );
+ $this->assertSame( 'Ada', $addresses[ Contract::ADDRESS_BILLING ]['first_name'] );
+
+ // Meta.
+ $this->assertSame( 'pdp', $fetched->get_meta()['source_channel'] );
+ }
+
+ public function test_extension_slug_defaults_to_null_when_unset(): void {
+ $repo = new Contract_Repository();
+
+ $id = $repo->insert(
+ Contract::create(
+ array(
+ 'customer_id' => 1,
+ 'currency' => 'EUR',
+ 'selling_plan_id' => 2,
+ 'origin_order_id' => 3,
+ 'start_gmt' => '2026-06-15 00:00:00',
+ )
+ )
+ );
+
+ $this->assertNull( $repo->find( $id )->get_extension_slug() );
+ }
+
+ public function test_delete_removes_contract_and_children(): void {
+ global $wpdb;
+
+ $repo = new Contract_Repository();
+ $id = $repo->insert( $this->make_contract() );
+
+ $this->assertTrue( $repo->delete( $id ) );
+ $this->assertNull( $repo->find( $id ) );
+
+ $items_table = \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer::get_table_name(
+ \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer::TABLE_CONTRACT_ITEMS
+ );
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$items_table} WHERE contract_id = %d", $id ) );
+
+ $this->assertSame( '0', $remaining );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Plan_Repository_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Plan_Repository_Test.php
new file mode 100644
index 00000000000..134a7485f27
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Plan_Repository_Test.php
@@ -0,0 +1,187 @@
+<?php
+/**
+ * Integration tests for Plan_Repository (and Plan_Group_Repository).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use Engine_Integration_Test_Case;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan_Group;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Billing_Policy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Pricing_Policy;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Plan_Group_Repository;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Plan_Repository;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Plan_Repository
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Plan_Group_Repository
+ */
+class Plan_Repository_Test extends Engine_Integration_Test_Case {
+
+ private function make_group(): int {
+ $group = Plan_Group::create(
+ array(
+ 'name' => 'Coffee club',
+ 'merchant_code' => 'coffee-club',
+ )
+ );
+
+ return ( new Plan_Group_Repository() )->insert( $group );
+ }
+
+ public function test_plan_group_round_trips(): void {
+ $repo = new Plan_Group_Repository();
+
+ $id = $repo->insert(
+ Plan_Group::create(
+ array(
+ 'name' => 'Boxes',
+ 'merchant_code' => 'boxes',
+ 'options_display' => array( array( 'name' => 'Size' ) ),
+ 'app_id' => 'wc-subscriptions',
+ )
+ )
+ );
+
+ $fetched = $repo->find( $id );
+
+ $this->assertInstanceOf( Plan_Group::class, $fetched );
+ $this->assertSame( $id, $fetched->get_id() );
+ $this->assertSame( 'Boxes', $fetched->get_name() );
+ $this->assertSame( 'boxes', $fetched->get_merchant_code() );
+ $this->assertSame( 'wc-subscriptions', $fetched->get_app_id() );
+ $this->assertSame( array( array( 'name' => 'Size' ) ), $fetched->get_options_display() );
+ }
+
+ public function test_plan_round_trips_with_policies_and_extension_slug(): void {
+ $group_id = $this->make_group();
+ $repo = new Plan_Repository();
+
+ $plan = Plan::create(
+ $group_id,
+ array(
+ 'name' => 'Monthly',
+ 'description' => 'A monthly plan',
+ 'options' => array(
+ array(
+ 'name' => 'Monthly',
+ 'value' => 'monthly',
+ ),
+ ),
+ 'billing_policy' => Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'max_cycles' => 12,
+ )
+ ),
+ 'pricing_policy' => Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 10,
+ ),
+ ),
+ )
+ ),
+ 'extension_slug' => 'lite',
+ )
+ );
+
+ $id = $repo->insert( $plan );
+ $this->assertGreaterThan( 0, $id );
+ $this->assertSame( $id, $plan->get_id() );
+
+ $fetched = $repo->find( $id );
+
+ $this->assertInstanceOf( Plan::class, $fetched );
+ $this->assertSame( 'Monthly', $fetched->get_name() );
+ $this->assertSame( 'A monthly plan', $fetched->get_description() );
+ $this->assertSame( $group_id, $fetched->get_group_id() );
+ $this->assertSame( 'lite', $fetched->get_extension_slug() );
+ $this->assertSame( 'month', $fetched->get_billing_policy()->get_period() );
+ $this->assertSame( 12, $fetched->get_billing_policy()->get_max_cycles() );
+ $this->assertNotNull( $fetched->get_pricing_policy() );
+ $this->assertSame( 90.0, $fetched->calculate_price( 100.0 ) );
+ }
+
+ public function test_plan_without_optional_policies_round_trips(): void {
+ $group_id = $this->make_group();
+ $repo = new Plan_Repository();
+
+ $id = $repo->insert(
+ Plan::create(
+ $group_id,
+ array(
+ 'name' => 'Bare',
+ 'billing_policy' => Billing_Policy::from_array(
+ array(
+ 'period' => 'week',
+ 'interval' => 2,
+ )
+ ),
+ )
+ )
+ );
+
+ $fetched = $repo->find( $id );
+
+ $this->assertInstanceOf( Plan::class, $fetched );
+ $this->assertNull( $fetched->get_pricing_policy() );
+ $this->assertNull( $fetched->get_delivery_policy() );
+ $this->assertNull( $fetched->get_extension_slug() );
+ }
+
+ public function test_update_persists_changes(): void {
+ $group_id = $this->make_group();
+ $repo = new Plan_Repository();
+
+ $plan = Plan::create(
+ $group_id,
+ array(
+ 'name' => 'Before',
+ 'billing_policy' => Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ )
+ ),
+ )
+ );
+ $id = $repo->insert( $plan );
+
+ $plan->set_name( 'After' );
+ $this->assertTrue( $repo->update( $plan ) );
+
+ $this->assertSame( 'After', $repo->find( $id )->get_name() );
+ }
+
+ public function test_delete_removes_the_row(): void {
+ $group_id = $this->make_group();
+ $repo = new Plan_Repository();
+
+ $id = $repo->insert(
+ Plan::create(
+ $group_id,
+ array(
+ 'name' => 'Doomed',
+ 'billing_policy' => Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ )
+ ),
+ )
+ )
+ );
+
+ $this->assertTrue( $repo->delete( $id ) );
+ $this->assertNull( $repo->find( $id ) );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Schema_Installer_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Schema_Installer_Test.php
new file mode 100644
index 00000000000..ea184e5c921
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/Integration/Storage/Schema_Installer_Test.php
@@ -0,0 +1,90 @@
+<?php
+/**
+ * Integration tests for Schema_Installer.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Integration\Integration\Storage;
+
+use Engine_Integration_Test_Case;
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer
+ */
+class Schema_Installer_Test extends Engine_Integration_Test_Case {
+
+ /**
+ * The six baseline tables the installer owns.
+ *
+ * @return array<int, array<int, string>>
+ */
+ public function table_provider(): array {
+ return array(
+ array( Schema_Installer::TABLE_PLAN_GROUPS ),
+ array( Schema_Installer::TABLE_PLANS ),
+ array( Schema_Installer::TABLE_CONTRACTS ),
+ array( Schema_Installer::TABLE_CONTRACT_ITEMS ),
+ array( Schema_Installer::TABLE_CONTRACT_ADDRESSES ),
+ array( Schema_Installer::TABLE_CONTRACT_META ),
+ );
+ }
+
+ /**
+ * @dataProvider table_provider
+ *
+ * @param string $logical Logical table identifier.
+ */
+ public function test_each_baseline_table_exists( string $logical ): void {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( $logical );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
+
+ $this->assertSame( $table, $found, "Expected table {$table} to exist." );
+ }
+
+ public function test_version_option_is_set_after_install(): void {
+ $this->assertTrue( Schema_Installer::is_current() );
+ $this->assertSame( Schema_Installer::VERSION, get_option( Schema_Installer::VERSION_OPTION ) );
+ }
+
+ public function test_install_is_idempotent(): void {
+ // Running install again must not error or change the recorded version.
+ Schema_Installer::install();
+
+ $this->assertSame( Schema_Installer::VERSION, get_option( Schema_Installer::VERSION_OPTION ) );
+ }
+
+ public function test_unknown_table_identifier_throws(): void {
+ $this->expectException( \InvalidArgumentException::class );
+ Schema_Installer::get_table_name( 'not_a_table' );
+ }
+
+ public function test_plans_table_has_extension_slug_column(): void {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_PLANS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $column = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'extension_slug' ) );
+
+ $this->assertSame( 'extension_slug', $column );
+ }
+
+ public function test_contracts_table_has_extension_slug_column(): void {
+ global $wpdb;
+
+ $table = Schema_Installer::get_table_name( Schema_Installer::TABLE_CONTRACTS );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+ $column = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'extension_slug' ) );
+
+ $this->assertSame( 'extension_slug', $column );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php
new file mode 100644
index 00000000000..8c067ef9c8b
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/bootstrap.php
@@ -0,0 +1,98 @@
+<?php
+/**
+ * Integration-test bootstrap for the WooCommerce Subscriptions Engine.
+ *
+ * Loads the WordPress test framework, the engine plugin file, and installs the
+ * baseline schema once up front so per-test transaction rollback (provided by
+ * WP_UnitTestCase) keeps each test isolated without re-running DDL.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+use Automattic\WooCommerce\SubscriptionsEngine\Integration\Storage\Schema_Installer;
+
+// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- Bootstrap file mixes a class and procedural setup.
+
+/**
+ * Bootstrap runner for the integration suite.
+ */
+class Subscriptions_Engine_Tests_Bootstrap {
+
+ /**
+ * Singleton instance.
+ *
+ * @var Subscriptions_Engine_Tests_Bootstrap|null
+ */
+ protected static $instance = null;
+
+ /**
+ * Path to the WordPress tests directory.
+ *
+ * @var string
+ */
+ public $wp_tests_dir;
+
+ /**
+ * Path to this tests directory.
+ *
+ * @var string
+ */
+ public $tests_dir;
+
+ /**
+ * Path to the package root.
+ *
+ * @var string
+ */
+ public $plugin_dir;
+
+ /**
+ * Set up the integration testing environment.
+ */
+ public function __construct() {
+ $this->tests_dir = __DIR__;
+ $this->plugin_dir = dirname( dirname( $this->tests_dir ) );
+
+ $this->wp_tests_dir = getenv( 'WP_TESTS_DIR' ) ? getenv( 'WP_TESTS_DIR' ) : sys_get_temp_dir() . '/wordpress-tests-lib';
+
+ require_once $this->wp_tests_dir . '/includes/functions.php';
+
+ tests_add_filter( 'muplugins_loaded', array( $this, 'load_plugin' ) );
+
+ if ( ! defined( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH' ) ) {
+ define( 'WP_TESTS_PHPUNIT_POLYFILLS_PATH', __DIR__ . '/../../vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php' );
+ }
+
+ require_once $this->wp_tests_dir . '/includes/bootstrap.php';
+
+ // Install once, outside any test transaction, so the init-hook installer
+ // short-circuits during tests and DDL never breaks rollback isolation.
+ Schema_Installer::install();
+
+ require_once $this->plugin_dir . '/tests/integration/class-engine-integration-test-case.php';
+ }
+
+ /**
+ * Load the engine plugin file.
+ */
+ public function load_plugin(): void {
+ require_once $this->plugin_dir . '/subscriptions-engine.php';
+ }
+
+ /**
+ * Get the singleton instance.
+ *
+ * @return Subscriptions_Engine_Tests_Bootstrap
+ */
+ public static function instance(): self {
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+}
+
+Subscriptions_Engine_Tests_Bootstrap::instance();
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/integration/class-engine-integration-test-case.php b/packages/php/woocommerce-subscriptions-engine/tests/integration/class-engine-integration-test-case.php
new file mode 100644
index 00000000000..053cae2bcfb
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/integration/class-engine-integration-test-case.php
@@ -0,0 +1,17 @@
+<?php
+/**
+ * Base test case for engine integration tests.
+ *
+ * Schema is installed once in the bootstrap; WP_UnitTestCase wraps each test in
+ * a transaction and rolls it back, so test rows do not leak between tests.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+/**
+ * Engine integration test case.
+ */
+abstract class Engine_Integration_Test_Case extends WP_UnitTestCase {
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Contract_Status_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Contract_Status_Test.php
new file mode 100644
index 00000000000..3146d3bafb0
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Contract_Status_Test.php
@@ -0,0 +1,50 @@
+<?php
+/**
+ * Unit tests for the Contract_Status state machine.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract_Status;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Contract_Status
+ */
+class Contract_Status_Test extends TestCase {
+
+ public function test_known_statuses_are_valid(): void {
+ $this->assertTrue( Contract_Status::is_valid( Contract_Status::ACTIVE ) );
+ $this->assertFalse( Contract_Status::is_valid( 'nonsense' ) );
+ }
+
+ public function test_active_can_move_to_hold_and_back(): void {
+ $this->assertTrue( Contract_Status::can_transition( Contract_Status::ACTIVE, Contract_Status::ON_HOLD ) );
+ $this->assertTrue( Contract_Status::can_transition( Contract_Status::ON_HOLD, Contract_Status::ACTIVE ) );
+ }
+
+ public function test_cancelled_and_expired_are_terminal(): void {
+ $this->assertTrue( Contract_Status::is_terminal( Contract_Status::CANCELLED ) );
+ $this->assertTrue( Contract_Status::is_terminal( Contract_Status::EXPIRED ) );
+
+ foreach ( Contract_Status::all() as $target ) {
+ $this->assertFalse( Contract_Status::can_transition( Contract_Status::CANCELLED, $target ) );
+ $this->assertFalse( Contract_Status::can_transition( Contract_Status::EXPIRED, $target ) );
+ }
+ }
+
+ public function test_pending_cancellation_only_reaches_active_or_cancelled(): void {
+ $this->assertTrue( Contract_Status::can_transition( Contract_Status::PENDING_CANCELLATION, Contract_Status::ACTIVE ) );
+ $this->assertTrue( Contract_Status::can_transition( Contract_Status::PENDING_CANCELLATION, Contract_Status::CANCELLED ) );
+ $this->assertFalse( Contract_Status::can_transition( Contract_Status::PENDING_CANCELLATION, Contract_Status::ON_HOLD ) );
+ }
+
+ public function test_unknown_statuses_never_transition(): void {
+ $this->assertFalse( Contract_Status::can_transition( 'nonsense', Contract_Status::ACTIVE ) );
+ $this->assertFalse( Contract_Status::can_transition( Contract_Status::ACTIVE, 'nonsense' ) );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Plan_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Plan_Test.php
new file mode 100644
index 00000000000..af3d1ffbe21
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/Entity/Plan_Test.php
@@ -0,0 +1,141 @@
+<?php
+/**
+ * Unit tests for the Plan entity (pure-Core behavior: validation + pricing).
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\Entity;
+
+use InvalidArgumentException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Billing_Policy;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Pricing_Policy;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\Entity\Plan
+ */
+class Plan_Test extends TestCase {
+
+ private function billing(): Billing_Policy {
+ return Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ )
+ );
+ }
+
+ public function test_create_defaults_category_and_extension_slug(): void {
+ $plan = Plan::create(
+ 5,
+ array(
+ 'name' => 'Monthly box',
+ 'billing_policy' => $this->billing(),
+ )
+ );
+
+ $this->assertNull( $plan->get_id() );
+ $this->assertSame( 5, $plan->get_group_id() );
+ $this->assertSame( Plan::DEFAULT_CATEGORY, $plan->get_category() );
+ $this->assertNull( $plan->get_extension_slug() );
+ }
+
+ public function test_calculate_price_delegates_to_pricing_policy(): void {
+ $plan = Plan::create(
+ 1,
+ array(
+ 'name' => 'Discounted',
+ 'billing_policy' => $this->billing(),
+ 'pricing_policy' => Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 20,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+
+ $this->assertSame( 80.0, $plan->calculate_price( 100.0 ) );
+ }
+
+ public function test_calculate_price_without_pricing_policy_returns_base(): void {
+ $plan = Plan::create(
+ 1,
+ array(
+ 'name' => 'Plain',
+ 'billing_policy' => $this->billing(),
+ )
+ );
+
+ $this->assertSame( 42.0, $plan->calculate_price( 42.0 ) );
+ }
+
+ public function test_invalid_pricing_policy_type_is_rejected(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Plan::create(
+ 1,
+ array(
+ 'name' => 'Bad',
+ 'billing_policy' => $this->billing(),
+ 'pricing_policy' => Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'mystery',
+ 'value' => 1,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+ }
+
+ public function test_percentage_over_one_hundred_is_rejected(): void {
+ $this->expectException( InvalidArgumentException::class );
+
+ Plan::create(
+ 1,
+ array(
+ 'name' => 'Too much',
+ 'billing_policy' => $this->billing(),
+ 'pricing_policy' => Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 150,
+ ),
+ ),
+ )
+ ),
+ )
+ );
+ }
+
+ public function test_to_storage_exposes_extension_slug_and_decoded_policies(): void {
+ $plan = Plan::create(
+ 3,
+ array(
+ 'name' => 'Owned',
+ 'billing_policy' => $this->billing(),
+ 'extension_slug' => 'lite',
+ )
+ );
+
+ $storage = $plan->to_storage();
+
+ $this->assertSame( 'lite', $storage['extension_slug'] );
+ $this->assertSame( 3, $storage['group_id'] );
+ $this->assertIsArray( $storage['billing_policy'] );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Billing_Policy_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Billing_Policy_Test.php
new file mode 100644
index 00000000000..4fe498aca89
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Billing_Policy_Test.php
@@ -0,0 +1,243 @@
+<?php
+/**
+ * Unit tests for Billing_Policy.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use DateTimeImmutable;
+use DateTimeZone;
+use DomainException;
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Billing_Policy;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Billing_Policy
+ */
+class Billing_Policy_Test extends TestCase {
+
+ public function test_round_trips_through_array(): void {
+ $data = array(
+ 'period' => 'month',
+ 'interval' => 2,
+ 'min_cycles' => 1,
+ 'max_cycles' => 12,
+ 'trial_duration' => array(
+ 'length' => 14,
+ 'unit' => 'day',
+ ),
+ );
+
+ $policy = Billing_Policy::from_array( $data );
+
+ $this->assertSame( 'month', $policy->get_period() );
+ $this->assertSame( 2, $policy->get_interval() );
+ $this->assertSame( 1, $policy->get_min_cycles() );
+ $this->assertSame( 12, $policy->get_max_cycles() );
+ $this->assertSame( $data['trial_duration'], $policy->get_trial_duration() );
+ $this->assertSame( $data, $policy->to_array() );
+ }
+
+ public function test_missing_nullable_keys_default_to_null(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'week',
+ 'interval' => 1,
+ )
+ );
+
+ $this->assertNull( $policy->get_min_cycles() );
+ $this->assertNull( $policy->get_max_cycles() );
+ $this->assertNull( $policy->get_trial_duration() );
+ }
+
+ public function test_compute_next_renewal_adds_one_cadence_in_utc(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ )
+ );
+
+ $anchor = new DateTimeImmutable( '2026-01-15 10:00:00', new DateTimeZone( 'UTC' ) );
+ $next = $policy->compute_next_renewal_from( $anchor );
+
+ $this->assertSame( '2026-02-15 10:00:00', $next->format( 'Y-m-d H:i:s' ) );
+ $this->assertSame( 'UTC', $next->getTimezone()->getName() );
+ }
+
+ public function test_compute_first_renewal_honours_trial(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'trial_duration' => array(
+ 'length' => 7,
+ 'unit' => 'day',
+ ),
+ )
+ );
+
+ $start = new DateTimeImmutable( '2026-01-01 00:00:00', new DateTimeZone( 'UTC' ) );
+ $first = $policy->compute_first_renewal_from( $start );
+
+ $this->assertSame( '2026-01-08 00:00:00', $first->format( 'Y-m-d H:i:s' ) );
+ }
+
+ public function test_compute_first_renewal_without_trial_matches_next(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'year',
+ 'interval' => 1,
+ )
+ );
+
+ $start = new DateTimeImmutable( '2026-03-10 12:00:00', new DateTimeZone( 'UTC' ) );
+
+ $this->assertEquals(
+ $policy->compute_next_renewal_from( $start ),
+ $policy->compute_first_renewal_from( $start )
+ );
+ }
+
+ public function test_invalid_period_throws(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'fortnight',
+ 'interval' => 1,
+ )
+ );
+
+ $this->expectException( DomainException::class );
+ $policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
+ }
+
+ public function test_non_positive_interval_throws(): void {
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 0,
+ )
+ );
+
+ $this->expectException( DomainException::class );
+ $policy->compute_next_renewal_from( new DateTimeImmutable( '2026-01-01', new DateTimeZone( 'UTC' ) ) );
+ }
+
+ /**
+ * @dataProvider provide_min_and_max_cycles_validation_cases
+ * @param string|null $expected_exception_message The expected exception message, or null if no exception is expected.
+ * @param int|null $min_cycles The minimum number of cycles.
+ * @param int|null $max_cycles The maximum number of cycles.
+ */
+ public function test_min_and_max_cycles_validation( ?string $expected_exception_message, ?int $min_cycles, ?int $max_cycles ): void {
+ if ( null !== $expected_exception_message ) {
+ $this->expectException( DomainException::class );
+ $this->expectExceptionMessage( $expected_exception_message );
+ }
+
+ $policy = Billing_Policy::from_array(
+ array(
+ 'period' => 'month',
+ 'interval' => 1,
+ 'min_cycles' => $min_cycles,
+ 'max_cycles' => $max_cycles,
+ )
+ );
+
+ if ( null === $expected_exception_message ) {
+ $this->assertInstanceOf( Billing_Policy::class, $policy );
+ $this->assertSame( $min_cycles, $policy->get_min_cycles() );
+ $this->assertSame( $max_cycles, $policy->get_max_cycles() );
+ }
+ }
+
+ public function provide_min_and_max_cycles_validation_cases(): array {
+ return array(
+ 'min_cycles is 0, max_cycles is null' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 0,
+ 'max_cycles' => null,
+ ),
+ 'min_cycles is 0, max_cycles is positive' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 0,
+ 'max_cycles' => 10,
+ ),
+ 'min_cycles is 0, max_cycles is less than 0' => array(
+ 'expected_exception_message' => 'Billing_Policy: max_cycles must be 0 or greater, got -4.',
+ 'min_cycles' => 0,
+ 'max_cycles' => -4,
+ ),
+ 'max_cycles is 0, min_cycles is null' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => null,
+ 'max_cycles' => 0,
+ ),
+ 'max_cycles is 0, min_cycles is positive' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles cannot exceed max_cycles, got 5 and 0.',
+ 'min_cycles' => 5,
+ 'max_cycles' => 0,
+ ),
+ 'max_cycles is 0, min_cycles is greater than max_cycles' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles cannot exceed max_cycles, got 5 and 0.',
+ 'min_cycles' => 5,
+ 'max_cycles' => 0,
+ ),
+ 'max_cycles is positive, min_cycles is null' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => null,
+ 'max_cycles' => 10,
+ ),
+ 'max_cycles is positive, min_cycles is positive' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 1,
+ 'max_cycles' => 10,
+ ),
+ 'max_cycles is positive, min_cycles is the same as max_cycles' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 10,
+ 'max_cycles' => 10,
+ ),
+ 'min_cycles is positive, max_cycles is null' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 1,
+ 'max_cycles' => null,
+ ),
+ 'min_cycles is positive, max_cycles is positive' => array(
+ 'expected_exception_message' => null,
+ 'min_cycles' => 1,
+ 'max_cycles' => 10,
+ ),
+ 'min_cycles is positive, max_cycles is less than min_cycles' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles cannot exceed max_cycles, got 10 and 9.',
+ 'min_cycles' => 10,
+ 'max_cycles' => 9,
+ ),
+ 'min_cycles is negative, max_cycles is null' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles must be 0 or greater, got -1.',
+ 'min_cycles' => -1,
+ 'max_cycles' => null,
+ ),
+ 'min_cycles is negative, max_cycles is positive' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles must be 0 or greater, got -1.',
+ 'min_cycles' => -1,
+ 'max_cycles' => 10,
+ ),
+ 'min_cycles is negative, max_cycles is less than min_cycles' => array(
+ 'expected_exception_message' => 'Billing_Policy: min_cycles must be 0 or greater, got -1.',
+ 'min_cycles' => -1,
+ 'max_cycles' => -1,
+ ),
+ 'min_cycles is positive, max_cycles is negative' => array(
+ 'expected_exception_message' => 'Billing_Policy: max_cycles must be 0 or greater, got -1.',
+ 'min_cycles' => 1,
+ 'max_cycles' => -1,
+ ),
+ );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Pricing_Policy_Test.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Pricing_Policy_Test.php
new file mode 100644
index 00000000000..a65c7bdcda3
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/Core/ValueObject/Pricing_Policy_Test.php
@@ -0,0 +1,99 @@
+<?php
+/**
+ * Unit tests for Pricing_Policy.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\SubscriptionsEngine\Tests\Unit\Core\ValueObject;
+
+use PHPUnit\Framework\TestCase;
+use Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Pricing_Policy;
+
+/**
+ * @covers \Automattic\WooCommerce\SubscriptionsEngine\Core\ValueObject\Pricing_Policy
+ */
+class Pricing_Policy_Test extends TestCase {
+
+ public function test_empty_policy_returns_base_price(): void {
+ $policy = Pricing_Policy::from_array( array() );
+
+ $this->assertSame( 25.0, $policy->calculate_price( 25.0 ) );
+ $this->assertSame( array(), $policy->get_policies() );
+ $this->assertSame( array(), $policy->get_one_time_fees() );
+ }
+
+ public function test_percentage_discount_applies(): void {
+ $policy = Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 10,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( 90.0, $policy->calculate_price( 100.0 ) );
+ }
+
+ public function test_fixed_amount_is_clamped_at_zero(): void {
+ $policy = Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'fixed_amount',
+ 'value' => 30,
+ ),
+ ),
+ )
+ );
+
+ $this->assertSame( 0.0, $policy->calculate_price( 20.0 ) );
+ }
+
+ public function test_price_replaces_base_and_starting_cycle_gates(): void {
+ $policy = Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'price',
+ 'value' => 5,
+ 'starting_cycle' => 2,
+ ),
+ ),
+ )
+ );
+
+ // Cycle 1 is before the rule's starting cycle, so the base price stands.
+ $this->assertSame( 50.0, $policy->calculate_price( 50.0, 1 ) );
+ // Cycle 2 onward the rule fires and replaces the price.
+ $this->assertSame( 5.0, $policy->calculate_price( 50.0, 2 ) );
+ }
+
+ public function test_whole_number_values_normalize_to_float(): void {
+ $policy = Pricing_Policy::from_array(
+ array(
+ 'policies' => array(
+ array(
+ 'type' => 'percentage',
+ 'value' => 10,
+ ),
+ ),
+ 'one_time_fees' => array(
+ array(
+ 'kind' => 'enrollment',
+ 'amount' => 15,
+ 'taxable' => true,
+ ),
+ ),
+ )
+ );
+
+ $this->assertIsFloat( $policy->get_policies()[0]['value'] );
+ $this->assertIsFloat( $policy->get_one_time_fees()[0]['amount'] );
+ }
+}
diff --git a/packages/php/woocommerce-subscriptions-engine/tests/unit/bootstrap.php b/packages/php/woocommerce-subscriptions-engine/tests/unit/bootstrap.php
new file mode 100644
index 00000000000..e46142ef049
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/tests/unit/bootstrap.php
@@ -0,0 +1,21 @@
+<?php
+/**
+ * Unit-test bootstrap for the WooCommerce Subscriptions Engine.
+ *
+ * Deliberately autoloader-only: it defines ABSPATH so the `defined( 'ABSPATH' )
+ * || exit;` guards pass, but it stubs NO WordPress functions. The Core zone is
+ * WordPress-free, so its classes must load and run here with nothing but the
+ * Composer autoloader. If a Core class ever reaches for a WP/Woo symbol, these
+ * tests fatal - which is the executable form of the zoning rule.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+declare( strict_types=1 );
+
+// Satisfy the file-access guards without providing any WordPress behavior.
+if ( ! defined( 'ABSPATH' ) ) {
+ define( 'ABSPATH', __DIR__ . '/' );
+}
+
+require_once __DIR__ . '/../../vendor/autoload.php';
diff --git a/packages/php/woocommerce-subscriptions-engine/woocommerce-subscriptions-engine.php b/packages/php/woocommerce-subscriptions-engine/woocommerce-subscriptions-engine.php
new file mode 100644
index 00000000000..59ec28a7194
--- /dev/null
+++ b/packages/php/woocommerce-subscriptions-engine/woocommerce-subscriptions-engine.php
@@ -0,0 +1,29 @@
+<?php
+/**
+ * This file is part of the WooCommerce Subscriptions Engine package.
+ *
+ * @package Automattic\WooCommerce\SubscriptionsEngine
+ */
+
+/**
+ * Plugin Name: WooCommerce Subscriptions Engine
+ * Plugin URI: https://woocommerce.com/
+ * Description: An empty subscriptions-engine definition file to set up the wp-env test environment.
+ * Version: 0.0.1
+ * Author: WooCommerce
+ * Author URI: https://woocommerce.com
+ * Requires at least: 6.7
+ * Requires PHP: 7.4
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+$autoload_entry_point = __DIR__ . '/vendor/autoload.php';
+if ( file_exists( $autoload_entry_point ) ) {
+ require_once $autoload_entry_point;
+}
+// When the package is distributed as part of WooCommerce core, it will provide autoloading of necessary dependencies.
+
+if ( class_exists( \Automattic\WooCommerce\SubscriptionsEngine\Package::class ) ) {
+ \Automattic\WooCommerce\SubscriptionsEngine\Package::init();
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 34b9be0e708..28038a26b63 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2563,6 +2563,12 @@ importers:
specifier: 5.1.x
version: 5.1.4(webpack@5.97.1)
+ packages/php/woocommerce-subscriptions-engine:
+ devDependencies:
+ '@wordpress/env':
+ specifier: 11.0.1-next.v.20260206T143.0
+ version: 11.0.1-next.v.20260206T143.0(@types/node@24.12.2)
+
plugins/woocommerce:
dependencies:
'@woocommerce/admin-library':
@@ -35847,7 +35853,7 @@ snapshots:
jest-matcher-utils: 29.7.0
jest-mock: 29.7.0
- '@wordpress/jest-console@8.44.0(jest@29.7.0(@types/node@24.12.2)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))':
+ '@wordpress/jest-console@8.44.0(jest@29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))':
dependencies:
jest: 29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3))
jest-matcher-utils: 29.7.0
@@ -35865,7 +35871,7 @@ snapshots:
'@wordpress/jest-preset-default@12.22.0(@babel/core@7.25.7)(jest@29.7.0(@types/node@24.12.2)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))':
dependencies:
'@babel/core': 7.25.7
- '@wordpress/jest-console': 8.44.0(jest@29.7.0(@types/node@24.12.2)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))
+ '@wordpress/jest-console': 8.44.0(jest@29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))
babel-jest: 29.7.0(@babel/core@7.25.7)
jest: 29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3))
transitivePeerDependencies:
@@ -35883,7 +35889,7 @@ snapshots:
'@wordpress/jest-preset-default@12.44.0(@babel/core@7.25.7)(jest@29.7.0(@types/node@24.12.2)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))':
dependencies:
'@babel/core': 7.25.7
- '@wordpress/jest-console': 8.44.0(jest@29.7.0(@types/node@24.12.2)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))
+ '@wordpress/jest-console': 8.44.0(jest@29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3)))
babel-jest: 29.7.0(@babel/core@7.25.7)
jest: 29.7.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3))
transitivePeerDependencies:
@@ -37985,7 +37991,7 @@ snapshots:
ini: 4.1.2
minimisted: 2.0.1
octokit: 3.1.2
- pako: 1.0.10
+ pako: 1.0.11
pify: 4.0.1
readable-stream: 3.6.2
sha.js: 2.4.12