diff --git a/packages/pronamic/wp-datetime/CHANGELOG.md b/packages/pronamic/wp-datetime/CHANGELOG.md
new file mode 100644
index 0000000..015701f
--- /dev/null
+++ b/packages/pronamic/wp-datetime/CHANGELOG.md
@@ -0,0 +1,152 @@
+# Change Log
+
+All notable changes to this project will be documented in this file.
+
+This projects adheres to [Semantic Versioning](http://semver.org/) and [Keep a CHANGELOG](http://keepachangelog.com/).
+
+## [Unreleased][unreleased]
+-
+
+## [2.1.7] - 2023-10-30
+
+### Commits
+
+- Updated .gitattributes ([8fbdefa](https://github.com/pronamic/wp-datetime/commit/8fbdefa989366b3965afb4565574a8e9e88eab25))
+
+Full set of changes: [`2.1.6...2.1.7`][2.1.7]
+
+[2.1.7]: https://github.com/pronamic/wp-datetime/compare/v2.1.6...v2.1.7
+
+## [2.1.6] - 2023-10-30
+
+### Commits
+
+- Updated composer.json ([7ab2db5](https://github.com/pronamic/wp-datetime/commit/7ab2db5184322a31fd5b24f2d6e720fed24d3e6d))
+- Removed Grunt. ([1e3bf5c](https://github.com/pronamic/wp-datetime/commit/1e3bf5c213d14b4eae36e49b9bc785d7f5058086))
+- Added `if ( ! defined( 'ABSPATH' ) )`. ([81c818a](https://github.com/pronamic/wp-datetime/commit/81c818ae98a63222bf3f0f89f275b0317eb85caf))
+
+Full set of changes: [`2.1.5...2.1.6`][2.1.6]
+
+[2.1.6]: https://github.com/pronamic/wp-datetime/compare/v2.1.5...v2.1.6
+
+## [2.1.5] - 2023-10-13
+
+### Commits
+
+- It is recommended not to use reserved keyword "object". ([b5dcf67](https://github.com/pronamic/wp-datetime/commit/b5dcf67de0584953fbb855cb7feec8589b2faf8d))
+- Stand-alone post-increment statement found. ([a5a1a81](https://github.com/pronamic/wp-datetime/commit/a5a1a81bb8dfe5c33c49273ac4a81fb6af051968))
+
+Full set of changes: [`2.1.4...2.1.5`][2.1.5]
+
+[2.1.5]: https://github.com/pronamic/wp-datetime/compare/v2.1.4...v2.1.5
+
+## [2.1.4] - 2023-03-27
+
+### Commits
+
+- Set Composer type to `wordpress-plugin`. ([9563b8e](https://github.com/pronamic/wp-datetime/commit/9563b8e4f85c3fd935cff82cd7f94ee16e021870))
+
+Full set of changes: [`2.1.3...2.1.4`][2.1.4]
+
+[2.1.4]: https://github.com/pronamic/wp-datetime/compare/v2.1.3...v2.1.4
+
+## [2.1.3] - 2023-03-02
+### Added
+
+- Add .gitattributes.
+
+Full set of changes: [`2.1.2...2.1.3`][2.1.3]
+
+[2.1.3]: https://github.com/pronamic/wp-datetime/compare/v2.1.2...v2.1.3
+
+## [2.1.2] - 2023-01-31
+### Composer
+
+- Changed `php` from `>=8.0` to `>=7.4`.
+
+Full set of changes: [`2.1.1...2.1.2`][2.1.2]
+
+[2.1.2]: https://github.com/pronamic/wp-datetime/compare/v2.1.1...v2.1.2
+
+## [2.1.1] - 2022-12-28
+
+### Commits
+
+- Improve compatibility with PHP versions < 8.0. ([7842a7f](https://github.com/pronamic/wp-datetime/commit/7842a7f4978595b8341311d315c742ae66e569b8))
+
+Full set of changes: [`2.1.0...2.1.1`][2.1.1]
+
+[2.1.1]: https://github.com/pronamic/wp-datetime/compare/v2.1.0...v2.1.1
+
+## [2.1.0] - 2022-12-19
+- Increased minimum PHP version to version `8` or higher.
+- Improved support for PHP `8.1` and `8.2`.
+- Removed usage of deprecated constant `FILTER_SANITIZE_STRING`.
+
+Full set of changes: [`2.0.3...2.1.0`][2.1.0]
+
+[2.1.0]: https://github.com/pronamic/wp-datetime/compare/2.0.3...2.1.0
+
+## [2.0.3] - 2022-09-27
+- Update plugin version.
+
+## [2.0.2] - 2022-09-23
+- Coding standards.
+
+## [2.0.1] - 2022-04-11
+### Changed
+- Coding standards.
+
+## [2.0.0] - 2022-01-10
+### Added
+- Added `DateTimeTrait::create_from_interface`.
+
+### Removed
+- Removed `DateTime::create_from_mutable`.
+- Removed `DateTimeImmutable::create_from_mutable`.
+
+## [1.2.2] - 2021-08-26
+- Added the character `p` to the date format characters list which was added in PHP 8.
+
+## [1.2.1] - 2021-04-26
+- Happy 2021.
+
+## [1.2.0] - 2020-10-08
+- Added DateTimeImmutable class.
+- Added `DateTime::create_from_immutable( \DateTimeImmutable $object )` method.
+- Added `DateTimeImmutable::create_from_mutable( \DateTime $object )` method.
+- Override upstream `DateTime::createFromImmutable( $object )` method.
+- Override upstream `DateTimeImmutable::createFromMutable( $object )` method.
+- Override upstream `DateTimeInterface::createFromFormat( $format, $time, $timezone = null )` method.
+- Updated copyright.
+
+## [1.1.1] - 2019-12-17
+- Fix for WordPress core trac ticket 48319 (https://core.trac.wordpress.org/ticket/48319).
+- Updated PHP compatibility test version to PHP 5.6.
+- Updated tests.
+
+## [1.1.0] - 2019-08-26
+- Introduced a format translate function, will not switch to local timezone.
+
+## [1.0.2] - 2018-09-12
+- Fixed issue on PHP 5.6 or lower.
+
+## [1.0.1] - 2018-08-16
+- Override `createFromFormat` method to return WordPress DateTime object.
+- Use new `create_from_format` method instead of override, due to method signature Travis errors for different PHP versions.
+- Improved support for timezones.
+
+## 1.0.0
+- First release.
+
+[unreleased]: https://github.com/pronamic/wp-datetime/compare/2.0.0...HEAD
+[2.0.3]: https://github.com/pronamic/wp-datetime/compare/2.0.2...2.0.3
+[2.0.2]: https://github.com/pronamic/wp-datetime/compare/2.0.1...2.0.2
+[2.0.0]: https://github.com/pronamic/wp-datetime/compare/1.2.2...2.0.0
+[1.2.2]: https://github.com/pronamic/wp-datetime/compare/1.2.1...1.2.2
+[1.2.1]: https://github.com/pronamic/wp-datetime/compare/1.2.0...1.2.1
+[1.2.0]: https://github.com/pronamic/wp-datetime/compare/1.1.1...1.2.0
+[1.1.1]: https://github.com/pronamic/wp-datetime/compare/1.1.0...1.1.1
+[1.1.0]: https://github.com/pronamic/wp-datetime/compare/1.0.2...1.1.0
+[1.0.2]: https://github.com/pronamic/wp-datetime/compare/1.0.1...1.0.2
+[1.0.1]: https://github.com/pronamic/wp-datetime/compare/1.0.0...1.0.1
diff --git a/packages/pronamic/wp-datetime/README.md b/packages/pronamic/wp-datetime/README.md
new file mode 100644
index 0000000..2010083
--- /dev/null
+++ b/packages/pronamic/wp-datetime/README.md
@@ -0,0 +1,33 @@
+# WordPress DateTime
+
+## WordPress Filters
+
+### pronamic_datetime_default_format
+
+```php
+function prefix_pronamic_datetime_default_format( $format ) {
+ return _x( 'D j M Y \a\t H:i', 'default datetime format', 'pronamic-ideal' );
+}
+
+add_filter( 'pronamic_datetime_default_format', 'prefix_pronamic_datetime_default_format' );
+```
+
+## Note `date_i18n`
+
+It is important to note that `date_i18n()`:
+
+1. does not have full feature parity with `date()`, not all formats are supported (such as shorthands);
+2. does not accept Unix timestamp (despite documented to), the expected value is “WordPress timestamp” (offset by time zone);
+3. has issues with certain timezone settings, such as numerical ones;
+4. does _nothing_ with `$gmt` argument under normal circumstances;
+
+Any use of this function must be carefully audited for correctness, _especially_ in regards to output of time zones.
+
+Source: https://developer.wordpress.org/reference/functions/date_i18n/#comment-2403
+
+## Inspiration
+
+* https://github.com/woocommerce/woocommerce/blob/3.3.5/includes/class-wc-datetime.php
+* https://github.com/Rarst/wpdatetime
+
+[![Pronamic - Work with us](https://github.com/pronamic/brand-resources/blob/main/banners/pronamic-work-with-us-leaderboard-728x90%404x.png)](https://www.pronamic.eu/contact/)
diff --git a/packages/pronamic/wp-datetime/composer.json b/packages/pronamic/wp-datetime/composer.json
new file mode 100644
index 0000000..b4dfabd
--- /dev/null
+++ b/packages/pronamic/wp-datetime/composer.json
@@ -0,0 +1,77 @@
+{
+ "name": "pronamic/wp-datetime",
+ "description": "WordPress DateTime library.",
+ "license": "GPL-3.0-or-later",
+ "type": "wordpress-plugin",
+ "autoload": {
+ "psr-4": {
+ "Pronamic\\WordPress\\DateTime\\": "src"
+ }
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "koodimonni/composer-dropin-installer": true,
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "roots/wordpress-core-installer": true,
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "repositories": [
+ {
+ "type": "composer",
+ "url": "https://wp-languages.github.io"
+ }
+ ],
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "automattic/wordbless": "^0.4.1",
+ "bamarni/composer-bin-plugin": "^1.4",
+ "koodimonni-language/de_de": "*",
+ "koodimonni-language/en_gb": "*",
+ "koodimonni-language/fr_fr": "*",
+ "koodimonni-language/ja": "*",
+ "koodimonni-language/nl_nl": "*",
+ "overtrue/phplint": "^9.0",
+ "php-coveralls/php-coveralls": "^2.4",
+ "php-stubs/wordpress-globals": "^0.2.0",
+ "phpmd/phpmd": "^2.9",
+ "pronamic/pronamic-cli": "^1.1",
+ "pronamic/wp-coding-standards": "^2.0",
+ "roots/wordpress": "^6.0",
+ "yoast/phpunit-polyfills": "^2.0"
+ },
+ "scripts": {
+ "coverage": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover build/logs/clover.xml --coverage-text",
+ "coverage-clover": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-clover build/logs/clover.xml",
+ "coverage-html": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-html build/coverage-html",
+ "coverage-text": "XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text",
+ "coveralls": "vendor/bin/php-coveralls -v",
+ "phpcbf": "XDEBUG_MODE=off vendor/bin/phpcbf",
+ "phpcs": "XDEBUG_MODE=off vendor/bin/phpcs -s -v",
+ "phplint": "vendor/bin/phplint",
+ "phpmd": "vendor/bin/phpmd src,tests text phpmd.ruleset.xml --suffixes php",
+ "phpstan": "vendor/bin/phpstan analyse --memory-limit=-1",
+ "phpunit": "vendor/bin/phpunit",
+ "post-autoload-dump": [
+ "mkdir -p wordpress/wp-content/languages",
+ "mkdir -p wordpress/wp-content/languages/plugins",
+ "mkdir -p wordpress/wp-content/languages/themes",
+ "for file in languages/*.mo ; do cp $file wordpress/wp-content/languages/plugins/ ; done",
+ "for file in vendor/koodimonni-language/*/* ; do cp $file wordpress/wp-content/languages/ ; done",
+ "for file in vendor/koodimonni-language/*/* ; do cp $file wordpress/wp-content/languages/plugins/ ; done",
+ "for file in vendor/koodimonni-language/*/* ; do cp $file wordpress/wp-content/languages/themes/ ; done"
+ ],
+ "post-install-cmd": [
+ "echo 'Optionally run: composer bin all install'",
+ "mkdir -p wordpress/wp-content && cp vendor/automattic/wordbless/src/dbless-wpdb.php wordpress/wp-content/db.php"
+ ],
+ "post-update-cmd": [
+ "echo 'Optionally run: composer bin all update'",
+ "mkdir -p wordpress/wp-content && cp vendor/automattic/wordbless/src/dbless-wpdb.php wordpress/wp-content/db.php"
+ ],
+ "psalm": "vendor/bin/psalm"
+ }
+}
diff --git a/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.mo b/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.mo
new file mode 100644
index 0000000..f3c3638
Binary files /dev/null and b/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.mo differ
diff --git a/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.po b/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.po
new file mode 100644
index 0000000..a6212d1
--- /dev/null
+++ b/packages/pronamic/wp-datetime/languages/pronamic-datetime-nl_NL.po
@@ -0,0 +1,22 @@
+# Copyright (C) 2018
+# This file is distributed under the same license as the package.
+msgid ""
+msgstr ""
+"Project-Id-Version: Pronamic DateTime\n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-datetime\n"
+"POT-Creation-Date: 2019-12-17 22:34:03+00:00\n"
+"PO-Revision-Date: 2018-08-16 11:53+0100\n"
+"Last-Translator: Remco Tolsma \n"
+"Language-Team: Pronamic \n"
+"Language: nl_NL\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 1.7.4\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"X-Poedit-SourceCharset: UTF-8\n"
+
+#: src/DateTime.php:292
+msgctxt "default datetime format"
+msgid "D j M Y \\a\\t H:i"
+msgstr "D j M Y \\o\\m H:i"
diff --git a/packages/pronamic/wp-datetime/languages/pronamic-datetime.pot b/packages/pronamic/wp-datetime/languages/pronamic-datetime.pot
new file mode 100644
index 0000000..ef55ef3
--- /dev/null
+++ b/packages/pronamic/wp-datetime/languages/pronamic-datetime.pot
@@ -0,0 +1,19 @@
+# Copyright (C) 2019
+# This file is distributed under the same license as the package.
+msgid ""
+msgstr ""
+"Project-Id-Version: \n"
+"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/wp-datetime\n"
+"POT-Creation-Date: 2019-12-17 22:34:03+00:00\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=utf-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"PO-Revision-Date: 2019-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME \n"
+"Language-Team: LANGUAGE \n"
+"X-Generator: grunt-wp-i18n 1.0.3\n"
+
+#: src/DateTime.php:292
+msgctxt "default datetime format"
+msgid "D j M Y \\a\\t H:i"
+msgstr ""
\ No newline at end of file
diff --git a/packages/pronamic/wp-datetime/package.json b/packages/pronamic/wp-datetime/package.json
new file mode 100644
index 0000000..12aff5c
--- /dev/null
+++ b/packages/pronamic/wp-datetime/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "wp-datetime",
+ "version": "2.1.7",
+ "description": "WordPress DateTime library.",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/pronamic/wp-datetime.git"
+ },
+ "keywords": [
+ "pronamic",
+ "wordpress",
+ "datetime",
+ "date",
+ "time"
+ ],
+ "license": "GPL-3.0-or-later",
+ "bugs": {
+ "url": "https://github.com/pronamic/wp-datetime/issues"
+ },
+ "homepage": "https://github.com/pronamic/wp-datetime#readme"
+}
diff --git a/packages/pronamic/wp-datetime/pronamic-datetime.php b/packages/pronamic/wp-datetime/pronamic-datetime.php
new file mode 100644
index 0000000..a22c50a
--- /dev/null
+++ b/packages/pronamic/wp-datetime/pronamic-datetime.php
@@ -0,0 +1,38 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+/**
+ * Pronamic DateTime load plugin text domain.
+ */
+function pronamic_datetime_load_plugin_textdomain() {
+ load_plugin_textdomain( 'pronamic-datetime', false, basename( __DIR__ ) . '/languages' );
+}
+
+add_action( 'init', 'pronamic_datetime_load_plugin_textdomain' );
diff --git a/packages/pronamic/wp-datetime/src/DateTime.php b/packages/pronamic/wp-datetime/src/DateTime.php
new file mode 100644
index 0000000..edc203d
--- /dev/null
+++ b/packages/pronamic/wp-datetime/src/DateTime.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\DateTime
+ * @see https://github.com/woocommerce/woocommerce/blob/3.3.4/includes/class-wc-datetime.php
+ * @see https://github.com/Rarst/wpdatetime/
+ */
+
+namespace Pronamic\WordPress\DateTime;
+
+/**
+ * Date time
+ *
+ * @author Remco Tolsma
+ * @version 1.2.0
+ * @since 1.0.0
+ */
+class DateTime extends \DateTime implements \Pronamic\WordPress\DateTime\DateTimeInterface {
+ use DateTimeTrait;
+}
diff --git a/packages/pronamic/wp-datetime/src/DateTimeImmutable.php b/packages/pronamic/wp-datetime/src/DateTimeImmutable.php
new file mode 100644
index 0000000..e26ba66
--- /dev/null
+++ b/packages/pronamic/wp-datetime/src/DateTimeImmutable.php
@@ -0,0 +1,24 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\DateTime
+ * @see https://github.com/woocommerce/woocommerce/blob/3.3.4/includes/class-wc-datetime.php
+ * @see https://github.com/Rarst/wpdatetime/
+ */
+
+namespace Pronamic\WordPress\DateTime;
+
+/**
+ * Date time immutable
+ *
+ * @author Remco Tolsma
+ * @version 1.2.0
+ * @since 1.2.0
+ */
+class DateTimeImmutable extends \DateTimeImmutable implements \Pronamic\WordPress\DateTime\DateTimeInterface {
+ use DateTimeTrait;
+}
diff --git a/packages/pronamic/wp-datetime/src/DateTimeInterface.php b/packages/pronamic/wp-datetime/src/DateTimeInterface.php
new file mode 100644
index 0000000..58f92fe
--- /dev/null
+++ b/packages/pronamic/wp-datetime/src/DateTimeInterface.php
@@ -0,0 +1,114 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\DateTime
+ * @see https://github.com/woocommerce/woocommerce/blob/3.3.4/includes/class-wc-datetime.php
+ * @see https://github.com/Rarst/wpdatetime/
+ */
+
+namespace Pronamic\WordPress\DateTime;
+
+/**
+ * Date time interface
+ *
+ * @author Remco Tolsma
+ * @version 1.2.0
+ * @since 1.2.0
+ */
+interface DateTimeInterface extends \DateTimeInterface {
+ /**
+ * MySQL datetime format.
+ *
+ * @link https://dev.mysql.com/doc/en/datetime.html
+ * @link https://github.com/Rarst/wpdatetime/blob/0.3/src/WpDateTime.php#L10
+ *
+ * @var string
+ */
+ const MYSQL = 'Y-m-d H:i:s';
+
+ /**
+ * Date format characters in PHP.
+ *
+ * @link https://www.php.net/manual/en/function.date.php
+ * @link https://github.com/php/php-src/blob/php-7.3.10/ext/date/php_date.c#L1128-L1288
+ * @var string[]
+ */
+ const DATE_FORMAT_CHARACTERS = [
+ // Day.
+ 'd',
+ 'D',
+ 'j',
+ 'l',
+ 'S',
+ 'w',
+ 'N',
+ 'z',
+ // Week.
+ 'W',
+ 'o',
+ // Month.
+ 'F',
+ 'm',
+ 'M',
+ 'n',
+ 't',
+ // Year.
+ 'L',
+ 'y',
+ 'Y',
+ // Time.
+ 'a',
+ 'A',
+ 'B',
+ 'g',
+ 'G',
+ 'h',
+ 'H',
+ 'i',
+ 's',
+ 'u',
+ 'v',
+ // Timezone.
+ 'I',
+ 'P',
+ 'p',
+ 'O',
+ 'T',
+ 'e',
+ 'Z',
+ // Full date/time.
+ 'c',
+ 'r',
+ 'U',
+ ];
+
+ /**
+ * Format I18N.
+ *
+ * @link https://github.com/Rarst/wpdatetime/blob/0.3/src/WpDateTimeTrait.php#L79-L104
+ * @link https://github.com/WordPress/WordPress/blob/4.9.4/wp-includes/functions.php#L72-L151
+ * @link https://developer.wordpress.org/reference/functions/apply_filters/
+ *
+ * @param string|null $format Format.
+ *
+ * @return string
+ */
+ public function format_i18n( $format = null );
+
+ /**
+ * Create from format.
+ *
+ * @link https://www.php.net/manual/en/datetime.createfromformat.php
+ * @link https://www.php.net/manual/en/datetimeimmutable.createfromformat.php
+ *
+ * @param string $format Format accepted by date().
+ * @param string $time String representing the time.
+ * @param \DateTimeZone $timezone A DateTimeZone object representing the desired time zone.
+ * @return self|false
+ */
+ public static function create_from_format( $format, $time, \DateTimeZone $timezone = null );
+}
diff --git a/packages/pronamic/wp-datetime/src/DateTimeTrait.php b/packages/pronamic/wp-datetime/src/DateTimeTrait.php
new file mode 100644
index 0000000..a3bf1af
--- /dev/null
+++ b/packages/pronamic/wp-datetime/src/DateTimeTrait.php
@@ -0,0 +1,316 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\DateTime
+ * @see https://github.com/woocommerce/woocommerce/blob/3.3.4/includes/class-wc-datetime.php
+ * @see https://github.com/Rarst/wpdatetime/
+ */
+
+namespace Pronamic\WordPress\DateTime;
+
+/**
+ * Date time trait
+ *
+ * @author Remco Tolsma
+ * @version 1.2.0
+ * @since 1.2.0
+ */
+trait DateTimeTrait {
+ /**
+ * Slash date format characters.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/5.2/wp-includes/formatting.php#L2615-L2628
+ * @link https://www.php.net/manual/en/function.addcslashes.php
+ *
+ * @param string $value Value.
+ * @return string
+ */
+ private static function slash_date_format_characters( $value ) {
+ $charlist = implode( '', DateTimeInterface::DATE_FORMAT_CHARACTERS );
+
+ // Backslash the backslash.
+ $charlist .= '\\';
+
+ $value = \addcslashes( $value, $charlist );
+
+ return $value;
+ }
+
+ /**
+ * Translate.
+ *
+ * @since 1.0.1
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.6/wp-includes/functions.php#L103-L119
+ * @link https://github.com/WordPress/WordPress/blob/4.9.6/wp-includes/class-wp-locale.php#L116-L235
+ *
+ * @global \WP_Locale $wp_locale WordPress date and time locale object.
+ *
+ * @param string $format Format.
+ *
+ * @return string
+ */
+ private function format_i18n_translate( $format ) {
+ global $wp_locale;
+
+ if ( ! $wp_locale instanceof \WP_Locale ) {
+ return $format;
+ }
+
+ if ( empty( $wp_locale->month ) || empty( $wp_locale->weekday ) ) {
+ return $format;
+ }
+
+ $month = $wp_locale->get_month( $this->format( 'm' ) );
+ $weekday = $wp_locale->get_weekday( \intval( $this->format( 'w' ) ) );
+
+ $format_length = \strlen( $format );
+
+ $format_new = '';
+
+ for ( $i = 0; $i < $format_length; $i++ ) {
+ switch ( $format[ $i ] ) {
+ case 'D':
+ $format_new .= self::slash_date_format_characters( $wp_locale->get_weekday_abbrev( $weekday ) );
+
+ break;
+ case 'F':
+ $format_new .= self::slash_date_format_characters( $month );
+
+ break;
+ case 'l':
+ $format_new .= self::slash_date_format_characters( $weekday );
+
+ break;
+ case 'M':
+ $format_new .= self::slash_date_format_characters( $wp_locale->get_month_abbrev( $month ) );
+
+ break;
+ case 'a':
+ $format_new .= self::slash_date_format_characters( $wp_locale->get_meridiem( $this->format( 'a' ) ) );
+
+ break;
+ case 'A':
+ $format_new .= self::slash_date_format_characters( $wp_locale->get_meridiem( $this->format( 'A' ) ) );
+
+ break;
+ case '\\':
+ $format_new .= $format[ $i ];
+
+ if ( $i < $format_length ) {
+ ++$i;
+ }
+
+ // No break.
+ default:
+ $format_new .= $format[ $i ];
+
+ break;
+ }
+ }
+
+ return $format_new;
+ }
+
+ /**
+ * Format I18N timezone.
+ *
+ * @since 1.0.1
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.6/wp-includes/functions.php#L120-L136
+ * @link https://github.com/php/php-src/blob/php-7.2.7/ext/date/php_date.c#L1093-L1253
+ *
+ * @param string $format Format.
+ *
+ * @return string
+ */
+ private function format_i18n_timezone( $format ) {
+ $format_length = \strlen( $format );
+
+ $format_new = '';
+
+ for ( $i = 0; $i < $format_length; $i++ ) {
+ switch ( $format[ $i ] ) {
+ case 'P':
+ case 'I':
+ case 'O':
+ case 'T':
+ case 'Z':
+ case 'e':
+ $format_new .= self::slash_date_format_characters( $this->format( $format[ $i ] ) );
+
+ break;
+ case '\\':
+ $format_new .= $format[ $i ];
+
+ if ( $i < $format_length ) {
+ ++$i;
+ }
+
+ // No break.
+ default:
+ $format_new .= $format[ $i ];
+
+ break;
+ }
+ }
+
+ return $format_new;
+ }
+
+ /**
+ * Get WordPress timestamp.
+ *
+ * @since 1.0.1
+ *
+ * @return int
+ */
+ private function get_wp_timestamp() {
+ return $this->getTimestamp() + DateTimeZone::get_offset( $this );
+ }
+
+ /**
+ * Get local date for this date.
+ *
+ * @since 1.0.1
+ *
+ * @return self
+ */
+ public function get_local_date() {
+ $wp_timezone = DateTimeZone::get_default();
+
+ /**
+ * PHP BUG: DateTime::setTimezone(): Can only do this for zones with ID for now.
+ * PHP version < 5.4.26
+ * PHP version > 5.5 < 5.5.10
+ *
+ * @link https://bugs.php.net/bug.php?id=45543
+ * @link https://3v4l.org/mlZX7
+ */
+ if ( \version_compare( PHP_VERSION, '5.4.26', '<' ) || ( \version_compare( PHP_VERSION, '5.5', '>' ) && \version_compare( PHP_VERSION, '5.5.10', '<' ) ) ) {
+ return new self( \gmdate( DateTimeInterface::MYSQL, $this->get_wp_timestamp() ), $wp_timezone );
+ }
+
+ $date = clone $this;
+
+ $date = $date->setTimezone( $wp_timezone );
+
+ return $date;
+ }
+
+ /**
+ * Format translate.
+ *
+ * @link https://developer.wordpress.org/reference/functions/__/
+ *
+ * @since 1.1.0
+ * @param string $format Format.
+ * @return string
+ */
+ public function format_translate( $format ) {
+ $format = $this->format_i18n_translate( $format );
+ $format = $this->format_i18n_timezone( $format );
+
+ return $this->format( $format );
+ }
+
+ /**
+ * Format I18N.
+ *
+ * @link https://github.com/Rarst/wpdatetime/blob/0.3/src/WpDateTimeTrait.php#L79-L104
+ * @link https://github.com/WordPress/WordPress/blob/4.9.4/wp-includes/functions.php#L72-L151
+ * @link https://developer.wordpress.org/reference/functions/apply_filters/
+ *
+ * @param string|null $format Format.
+ *
+ * @return string
+ */
+ public function format_i18n( $format = null ) {
+ if ( \is_null( $format ) ) {
+ $format = \_x( 'D j M Y \a\t H:i', 'default datetime format', 'pronamic-datetime' );
+
+ $format = \apply_filters( 'pronamic_datetime_default_format', $format );
+ }
+
+ $date = $this->get_local_date();
+
+ $format = $date->format_i18n_translate( $format );
+ $format = $date->format_i18n_timezone( $format );
+
+ $result = \date_i18n( $format, $date->get_wp_timestamp() );
+
+ return $result;
+ }
+
+ /**
+ * Overrides upstream method to correct returned instance type to the inheriting one.
+ *
+ * {@inheritdoc}
+ *
+ * @param string $format Format.
+ * @param string $time String representing the time.
+ * @param DateTimeZone|null $timezone Timezone.
+ * @return self|false
+ */
+ #[\ReturnTypeWillChange]
+ public static function createFromFormat( // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
+ $format,
+ $time,
+ $timezone = null
+ ) {
+ return self::create_from_format( $format, $time, $timezone );
+ }
+
+ /**
+ * Parse a string into a new DateTime object according to the specified format.
+ *
+ * @link http://php.net/manual/en/datetime.createfromformat.php
+ * @link https://github.com/Rarst/wpdatetime/blob/0.3/src/WpDateTimeTrait.php#L56-L77
+ *
+ * @since 1.0.1
+ *
+ * @param string $format Format accepted by date().
+ * @param string $time String representing the time.
+ * @param \DateTimeZone $timezone A DateTimeZone object representing the desired time zone.
+ *
+ * @return self|false
+ */
+ #[\ReturnTypeWillChange]
+ public static function create_from_format( $format, $time, \DateTimeZone $timezone = null ) {
+ /*
+ * In PHP 5.6 or lower it's not possible to pass in an empty (null) timezone object.
+ * This will result in a `DateTime::createFromFormat() expects parameter 3 to be DateTimeZone, null given` error.
+ */
+ $created = empty( $timezone ) ?
+ parent::createFromFormat( $format, $time ) :
+ parent::createFromFormat( $format, $time, $timezone );
+
+ if ( false === $created ) {
+ return false;
+ }
+
+ $wp_date_time = new self( '@' . $created->getTimestamp() );
+
+ if ( null !== $timezone ) {
+ $wp_date_time = $wp_date_time->setTimezone( $timezone );
+ }
+
+ return $wp_date_time;
+ }
+
+ /**
+ * Create from interface.
+ *
+ * @link https://www.php.net/manual/en/datetime.createfrominterface.php
+ * @link https://php.watch/versions/8.0/datetime-immutable-createfrominterface
+ * @param \DateTimeInterface $value The mutable DateTime object that you want to convert to an immutable version.
+ * @return self
+ */
+ public static function create_from_interface( \DateTimeInterface $value ): self {
+ return new self( $value->format( 'Y-m-d H:i:s.u' ), $value->getTimezone() );
+ }
+}
diff --git a/packages/pronamic/wp-datetime/src/DateTimeZone.php b/packages/pronamic/wp-datetime/src/DateTimeZone.php
new file mode 100644
index 0000000..3a3e41c
--- /dev/null
+++ b/packages/pronamic/wp-datetime/src/DateTimeZone.php
@@ -0,0 +1,75 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\DateTime
+ * @see https://github.com/woocommerce/woocommerce/blob/3.3.4/includes/class-wc-datetime.php
+ * @see https://github.com/Rarst/wpdatetime/
+ */
+
+namespace Pronamic\WordPress\DateTime;
+
+/**
+ * Date time zone
+ *
+ * @author Remco Tolsma
+ * @version 1.2.0
+ * @since 1.0.0
+ * @psalm-immutable
+ */
+class DateTimeZone extends \DateTimeZone {
+ /**
+ * Get default timezone.
+ *
+ * @link https://github.com/Rarst/wpdatetime/blob/0.3/src/WpDateTimeZone.php
+ * @link https://github.com/WordPress/WordPress/blob/4.9.4/wp-includes/functions.php#L72-L151
+ *
+ * @return \DateTimeZone
+ */
+ public static function get_default() {
+ $timezone_string = \get_option( 'timezone_string' );
+
+ if ( ! empty( $timezone_string ) ) {
+ return new DateTimeZone( $timezone_string );
+ }
+
+ $gmt_offset = \get_option( 'gmt_offset' );
+ $hours = (int) $gmt_offset;
+ $minutes = \abs( ( $gmt_offset - (int) $gmt_offset ) * 60 );
+ $offset = \sprintf( '%+03d:%02d', $hours, $minutes );
+
+ /**
+ * Offset values as timezone parameter are supported since PHP 5.5.10.
+ *
+ * @link http://php.net/manual/en/datetimezone.construct.php
+ */
+ if ( \version_compare( PHP_VERSION, '5.5.10', '<' ) ) {
+ $date = new DateTime( $offset );
+
+ return $date->getTimezone();
+ }
+
+ return new \DateTimeZone( $offset );
+ }
+
+ /**
+ * Get offset.
+ *
+ * @param \DateTimeInterface $date DateTime object.
+ * @return int
+ */
+ public static function get_offset( $date ) {
+ $timezone_string = \get_option( 'timezone_string' );
+
+ if ( empty( $timezone_string ) ) {
+ return \intval( \floatval( \get_option( 'gmt_offset', 0 ) ) * HOUR_IN_SECONDS );
+ }
+
+ $timezone = new DateTimeZone( $timezone_string );
+
+ return $timezone->getOffset( $date );
+ }
+}
diff --git a/packages/pronamic/wp-html/CHANGELOG.md b/packages/pronamic/wp-html/CHANGELOG.md
new file mode 100644
index 0000000..9803cdd
--- /dev/null
+++ b/packages/pronamic/wp-html/CHANGELOG.md
@@ -0,0 +1,85 @@
+[2.0.2]: https://github.com/pronamic/wp-html/compare/2.0.1...2.0.2
+# Changelog
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [2.2.1] - 2023-10-30
+
+### Commits
+
+- Improve escaping for `
', $untested_plugin_versions )
+ );
+ }
+
+ if ( 0 !== count( $outdated_plugin_versions ) ) {
+ $status = 'critical';
+
+ $extensions_list_text .= sprintf(
+ '
%1$s
%2$s
',
+ __( 'Outdated unsupported plugin versions for which payments can not be processed:', 'pronamic_ideal' ),
+ \join( '', $outdated_plugin_versions )
+ );
+ }
+
+ // Supported extensions.
+ if ( 0 !== count( $supported_extensions ) ) {
+ $extensions_list_text .= sprintf(
+ '
%1$s
%2$s
',
+ __( 'Supported plugin versions:', 'pronamic_ideal' ),
+ \join( '', $supported_extensions )
+ );
+ }
+
+ // Result.
+ $label = sprintf(
+ /* translators: %s: plugin name */
+ __( '%s extensions are compatible', 'pronamic_ideal' ),
+ __( 'Pronamic Pay', 'pronamic_ideal' )
+ );
+
+ $description_text = __( 'Pronamic Pay uses extensions to integrate with form, booking and other e-commerce plugins. All extensions support the currently activated plugin versions.', 'pronamic_ideal' );
+
+ if ( 'good' !== $status ) {
+ $label = sprintf(
+ /* translators: %s: plugin name */
+ __( '%s extensions are incompatible', 'pronamic_ideal' ),
+ __( 'Pronamic Pay', 'pronamic_ideal' )
+ );
+
+ if ( 0 === count( $outdated_plugin_versions ) ) {
+ $label = sprintf(
+ /* translators: %s: plugin name */
+ __( '%s extensions might be incompatible', 'pronamic_ideal' ),
+ __( 'Pronamic Pay', 'pronamic_ideal' )
+ );
+ }
+
+ $description_text = __( 'Pronamic Pay uses extensions to integrate with form, booking and other e-commerce plugins. We have found that not all extensions are tested with or support the version of the currently activated plugins. Usually you can still accept payments, however if you experience payment issues it is advised to check the \'Plugins\' page for available updates.', 'pronamic_ideal' );
+ }
+
+ $result = [
+ 'test' => 'pronamic_pay_extensions_support',
+ 'label' => $label,
+ 'description' => sprintf( '
%s
%s
', \esc_html( $description_text ), $extensions_list_text ),
+ 'badge' => [
+ 'label' => __( 'Payments', 'pronamic_ideal' ),
+ 'color' => 'blue',
+ ],
+ 'status' => $status,
+ 'actions' => '',
+ ];
+
+ return $result;
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminModule.php b/packages/wp-pay/core/src/Admin/AdminModule.php
new file mode 100644
index 0000000..e5f7f0f
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminModule.php
@@ -0,0 +1,944 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Number\Number;
+use Pronamic\WordPress\Money\Currency;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Address;
+use Pronamic\WordPress\Pay\AddressHelper;
+use Pronamic\WordPress\Pay\ContactName;
+use Pronamic\WordPress\Pay\ContactNameHelper;
+use Pronamic\WordPress\Pay\Core\Util;
+use Pronamic\WordPress\Pay\CreditCard;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\CustomerHelper;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentLines;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Subscriptions\Subscription;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionInterval;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPhase;
+
+/**
+ * WordPress Pay admin
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 1.0.0
+ */
+class AdminModule {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Admin settings page.
+ *
+ * @var AdminSettings
+ */
+ public $settings;
+
+ /**
+ * Admin about page.
+ *
+ * @var AdminAboutPage|null
+ */
+ public $about_page;
+
+ /**
+ * Admin dashboard page.
+ *
+ * @var AdminDashboard
+ */
+ public $dashboard;
+
+ /**
+ * Admin site health.
+ *
+ * @var AdminHealth
+ */
+ public $health;
+
+ /**
+ * Admin reports page.
+ *
+ * @var AdminReports
+ */
+ public $reports;
+
+ /**
+ * Admin tour page.
+ *
+ * @var AdminTour
+ */
+ public $tour;
+
+ /**
+ * Construct and initialize an admin object.
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'admin_init', [ $this, 'admin_init' ] );
+ add_action( 'admin_menu', [ $this, 'admin_menu' ] );
+
+ add_action( 'load-post.php', [ $this, 'maybe_test_payment' ] );
+
+ add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
+
+ add_filter( 'parent_file', [ $this, 'admin_menu_parent_file' ] );
+
+ // Modules.
+ $this->settings = new AdminSettings( $plugin );
+ $this->dashboard = new AdminDashboard();
+ $this->health = new AdminHealth( $plugin );
+ $this->reports = new AdminReports( $plugin );
+ $this->tour = new AdminTour( $plugin );
+
+ // About page.
+ $about_page_file = $this->plugin->get_option( 'about_page_file' );
+
+ if ( null !== $about_page_file ) {
+ $this->about_page = new AdminAboutPage( $plugin, $about_page_file );
+ }
+ }
+
+ /**
+ * Admin initialize.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ // Maybe.
+ $this->maybe_redirect();
+
+ // Post types.
+ new AdminGatewayPostType( $this->plugin );
+
+ $admin_payment_post_type = new AdminPaymentPostType( $this->plugin );
+
+ $admin_payment_post_type->admin_init();
+
+ $admin_subscription_post_type = new AdminSubscriptionPostType( $this->plugin );
+
+ $admin_subscription_post_type->admin_init();
+ }
+
+ /**
+ * Maybe redirect.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.4.4/includes/admin/class-wc-admin.php#L29
+ * @link https://github.com/woothemes/woocommerce/blob/2.4.4/includes/admin/class-wc-admin.php#L96-L122
+ *
+ * @return void
+ */
+ public function maybe_redirect() {
+ $redirect = get_transient( 'pronamic_pay_admin_redirect' );
+
+ // Check.
+ if (
+ empty( $redirect )
+ ||
+ \wp_doing_ajax()
+ ||
+ \wp_doing_cron()
+ ||
+ is_network_admin()
+ ||
+ filter_has_var( INPUT_GET, 'activate-multi' )
+ ||
+ ! current_user_can( 'manage_options' )
+ ) {
+ return;
+ }
+
+ /**
+ * Delete the `pronamic_pay_admin_redirect` transient.
+ *
+ * If we don't get the `true` confirmation we will bail out
+ * so users will not get stuck in a redirect loop.
+ *
+ * We have had issues with this with caching plugins like
+ * W3 Total Cache and on Savvii hosting environments.
+ *
+ * @link https://developer.wordpress.org/reference/functions/delete_transient/
+ */
+ $result = delete_transient( 'pronamic_pay_admin_redirect' );
+
+ if ( true !== $result ) {
+ return;
+ }
+
+ /**
+ * Redirect.
+ */
+ wp_safe_redirect( $redirect );
+
+ exit;
+ }
+
+ /**
+ * Sanitize the specified value to a boolean.
+ *
+ * @param mixed $value Value.
+ * @return boolean
+ */
+ public static function sanitize_boolean( $value ) {
+ return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
+ }
+
+ /**
+ * Configurations dropdown.
+ *
+ * @param array $args Arguments.
+ * @return string|null
+ */
+ public static function dropdown_configs( $args ) {
+ $defaults = [
+ 'name' => 'pronamic_pay_config_id',
+ 'echo' => true,
+ 'selected' => false,
+ 'payment_method' => null,
+ ];
+
+ $args = wp_parse_args( $args, $defaults );
+
+ // Output.
+ $output = '';
+
+ // Dropdown.
+ $id = $args['name'];
+ $name = $args['name'];
+ $selected = $args['selected'];
+
+ if ( false === $selected ) {
+ $selected = get_option( $id );
+ }
+
+ $output .= sprintf(
+ '',
+ esc_attr( $id ),
+ esc_attr( $name )
+ );
+
+ $options = Plugin::get_config_select_options( $args['payment_method'] );
+
+ foreach ( $options as $value => $name ) {
+ $output .= sprintf(
+ '%s ',
+ esc_attr( $value ),
+ selected( $value, $selected, false ),
+ esc_html( $name )
+ );
+ }
+
+ $output .= sprintf( ' ' );
+
+ // Output.
+ if ( $args['echo'] ) {
+ echo wp_kses(
+ $output,
+ [
+ 'select' => [
+ 'id' => [],
+ 'name' => [],
+ ],
+ 'option' => [
+ 'value' => [],
+ 'selected' => [],
+ ],
+ ]
+ );
+
+ return null;
+ }
+
+ return $output;
+ }
+
+ /**
+ * Check if scripts should be enqueued based on the hook and current screen.
+ *
+ * @link https://developer.wordpress.org/reference/functions/get_current_screen/
+ * @link https://developer.wordpress.org/reference/classes/wp_screen/
+ *
+ * @param string $hook Hook.
+ * @return bool True if scripts should be enqueued, false otherwise.
+ */
+ private function should_enqueue_scripts( $hook ) {
+ // Check if the hook contains the value 'pronamic_pay'.
+ if ( false !== strpos( $hook, 'pronamic_pay' ) ) {
+ return true;
+ }
+
+ // Check if the hook contains the value 'pronamic_ideal'.
+ if ( false !== strpos( $hook, 'pronamic_ideal' ) ) {
+ return true;
+ }
+
+ // Check current screen for some values related to Pronamic Pay.
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return false;
+ }
+
+ // Current screen is dashboard.
+ if ( 'dashboard' === $screen->id ) {
+ return true;
+ }
+
+ // Gravity Forms.
+ if ( 'toplevel_page_gf_edit_forms' === $screen->id ) {
+ return true;
+ }
+
+ // CHeck if current screen post type is related to Pronamic Pay.
+ if ( in_array(
+ $screen->post_type,
+ [
+ 'pronamic_gateway',
+ 'pronamic_payment',
+ 'pronamic_pay_form',
+ 'pronamic_pay_gf',
+ 'pronamic_pay_subscr',
+ ],
+ true
+ ) ) {
+ return true;
+ }
+
+ // Other.
+ return false;
+ }
+
+ /**
+ * Enqueue admin scripts.
+ *
+ * @param string $hook Hook.
+ * @return void
+ */
+ public function enqueue_scripts( $hook ) {
+ if ( ! $this->should_enqueue_scripts( $hook ) ) {
+ return;
+ }
+
+ $min = SCRIPT_DEBUG ? '' : '.min';
+
+ // Tippy.js - https://atomiks.github.io/tippyjs/.
+ wp_register_script(
+ 'tippy.js',
+ plugins_url( '../../assets/tippy.js/tippy.all' . $min . '.js', __FILE__ ),
+ [],
+ '3.4.1',
+ true
+ );
+
+ // Pronamic.
+ wp_register_style(
+ 'pronamic-pay-icons',
+ plugins_url( '../../fonts/dist/pronamic-pay-icons.css', __FILE__ ),
+ [],
+ $this->plugin->get_version()
+ );
+
+ wp_register_style(
+ 'pronamic-pay-admin',
+ plugins_url( '../../css/admin' . $min . '.css', __FILE__ ),
+ [ 'pronamic-pay-icons' ],
+ $this->plugin->get_version()
+ );
+
+ wp_register_script(
+ 'pronamic-pay-admin',
+ plugins_url( '../../js/dist/admin' . $min . '.js', __FILE__ ),
+ [ 'jquery', 'tippy.js' ],
+ $this->plugin->get_version(),
+ true
+ );
+
+ // Enqueue.
+ wp_enqueue_style( 'pronamic-pay-admin' );
+ wp_enqueue_script( 'pronamic-pay-admin' );
+ }
+
+ /**
+ * Maybe test payment.
+ *
+ * @return void
+ */
+ public function maybe_test_payment() {
+ if ( ! \filter_has_var( \INPUT_POST, 'test_pay_gateway' ) ) {
+ return;
+ }
+
+ if ( ! \check_admin_referer( 'test_pay_gateway', 'pronamic_pay_test_nonce' ) ) {
+ return;
+ }
+
+ // Amount.
+ $currency_code = 'EUR';
+
+ if ( \array_key_exists( 'test_currency_code', $_POST ) ) {
+ $currency_code = \sanitize_text_field( \wp_unslash( $_POST['test_currency_code'] ) );
+ }
+
+ $value = array_key_exists( 'test_amount', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['test_amount'] ) ) : '';
+
+ try {
+ $amount = Number::from_string( $value );
+ } catch ( \Exception $e ) {
+ \wp_die( \esc_html( $e->getMessage() ) );
+ }
+
+ $price = new TaxedMoney( $amount, $currency_code, 0, 0 );
+
+ /*
+ * Payment.
+ */
+ $payment = new Payment();
+
+ $order_id = (string) \time();
+
+ $payment->order_id = $order_id;
+
+ $payment->set_config_id( \filter_input( \INPUT_POST, 'post_ID', \FILTER_SANITIZE_NUMBER_INT ) );
+
+ if ( \array_key_exists( 'pronamic_pay_test_payment_method', $_POST ) ) {
+ $payment_method = \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_test_payment_method'] ) );
+
+ $payment->set_payment_method( $payment_method );
+ }
+
+ // Description.
+ $description = \sprintf(
+ /* translators: %s: order ID */
+ __( 'Test %s', 'pronamic_ideal' ),
+ $order_id
+ );
+
+ $payment->set_description( $description );
+
+ // Source.
+ $payment->set_source( 'test' );
+ $payment->set_source_id( $order_id );
+
+ /*
+ * Credit Card.
+ * Test card to simulate a 3-D Secure registered card.
+ *
+ * @link http://www.paypalobjects.com/en_US/vhelp/paypalmanager_help/credit_card_numbers.htm
+ */
+ $credit_card = new CreditCard();
+
+ $expiration_date = new \DateTime( '+5 years' );
+
+ $credit_card->set_expiration_month( (int) $expiration_date->format( 'n' ) );
+ $credit_card->set_expiration_year( (int) $expiration_date->format( 'Y' ) );
+ $credit_card->set_name( 'Pronamic' );
+ $credit_card->set_number( '5300000000000006' );
+ $credit_card->set_security_code( '123' );
+
+ $payment->set_credit_card( $credit_card );
+
+ // Data.
+ $user = \wp_get_current_user();
+
+ // Name.
+ $name = ContactNameHelper::from_array(
+ [
+ 'first_name' => $user->first_name,
+ 'last_name' => $user->last_name,
+ ]
+ );
+
+ // Customer.
+ $customer = CustomerHelper::from_array(
+ [
+ 'name' => $name,
+ 'email' => $user->user_email,
+ 'phone' => \array_key_exists( 'test_phone', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['test_phone'] ) ) : '',
+ 'user_id' => $user->ID,
+ ]
+ );
+
+ $payment->set_customer( $customer );
+
+ // Billing address.
+ $address = AddressHelper::from_array(
+ [
+ 'name' => $name,
+ 'email' => $user->user_email,
+ 'phone' => null === $customer ? null : $customer->get_phone(),
+ 'line_1' => 'Billing Line 1',
+ 'postal_code' => '1234 AB',
+ 'city' => 'Billing City',
+ 'country_code' => 'NL',
+ ]
+ );
+
+ $payment->set_billing_address( $address );
+
+ $address = AddressHelper::from_array(
+ [
+ 'name' => $name,
+ 'email' => $user->user_email,
+ 'phone' => null === $customer ? null : $customer->get_phone(),
+ 'line_1' => 'Shipping Line 1',
+ 'postal_code' => '5678 XY',
+ 'city' => 'Shipping City',
+ 'country_code' => 'NL',
+ ]
+ );
+
+ $payment->set_shipping_address( $address );
+
+ // Lines.
+ $payment->lines = new PaymentLines();
+
+ $line = $payment->lines->new_line();
+
+ $line->set_name( __( 'Test', 'pronamic_ideal' ) );
+ $line->set_unit_price( $price );
+ $line->set_quantity( 1 );
+ $line->set_total_amount( $price );
+
+ $payment->set_total_amount( $payment->lines->get_amount() );
+
+ // Subscription.
+ $test_subscription = \filter_input( \INPUT_POST, 'pronamic_pay_test_subscription', \FILTER_VALIDATE_BOOLEAN );
+ $interval = \filter_input( \INPUT_POST, 'pronamic_pay_test_repeat_interval', \FILTER_VALIDATE_INT );
+ $interval_period = \array_key_exists( 'pronamic_pay_test_repeat_frequency', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_test_repeat_frequency'] ) ) : '';
+
+ if ( ! empty( $test_subscription ) && ! empty( $interval ) && ! empty( $interval_period ) ) {
+ $subscription = new Subscription();
+
+ $subscription->set_description( $description );
+ $subscription->set_lines( $payment->get_lines() );
+
+ // Ends on.
+ $total_periods = null;
+
+ if ( \array_key_exists( 'pronamic_pay_ends_on', $_POST ) ) {
+ switch ( $_POST['pronamic_pay_ends_on'] ) {
+ case 'count':
+ $count = \filter_input( \INPUT_POST, 'pronamic_pay_ends_on_count', \FILTER_VALIDATE_INT );
+
+ if ( ! empty( $count ) ) {
+ $total_periods = $count;
+ }
+
+ break;
+ case 'date':
+ $end_date = \array_key_exists( 'pronamic_pay_ends_on_date', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_ends_on_date'] ) ) : '';
+
+ if ( ! empty( $end_date ) ) {
+ $interval_spec = 'P' . $interval . Util::to_period( $interval_period );
+
+ $period = new \DatePeriod(
+ new \DateTime(),
+ new \DateInterval( $interval_spec ),
+ new \DateTime( $end_date )
+ );
+
+ $total_periods = iterator_count( $period );
+ }
+
+ break;
+ }
+ }
+
+ // Phase.
+ $phase = new SubscriptionPhase(
+ $subscription,
+ new DateTimeImmutable(),
+ new SubscriptionInterval( 'P' . $interval . Util::to_period( $interval_period ) ),
+ $price
+ );
+
+ $phase->set_total_periods( $total_periods );
+
+ $subscription->add_phase( $phase );
+
+ $period = $subscription->new_period();
+
+ if ( null !== $period ) {
+ $payment->add_period( $period );
+ }
+ }
+
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ return;
+ }
+
+ // Start.
+ try {
+ $payment = Plugin::start_payment( $payment );
+
+ $gateway->redirect( $payment );
+ } catch ( \Exception $e ) {
+ Plugin::render_exception( $e );
+
+ exit;
+ }
+ }
+
+ /**
+ * Admin menu parent file.
+ *
+ * @param string $parent_file Parent file for admin menu.
+ * @return string
+ */
+ public function admin_menu_parent_file( $parent_file ) {
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return $parent_file;
+ }
+
+ switch ( $screen->id ) {
+ case AdminGatewayPostType::POST_TYPE:
+ case AdminPaymentPostType::POST_TYPE:
+ case AdminSubscriptionPostType::POST_TYPE:
+ return 'pronamic_ideal';
+ }
+
+ return $parent_file;
+ }
+
+ /**
+ * Get menu icon URL.
+ *
+ * @link https://developer.wordpress.org/reference/functions/add_menu_page/
+ * @return string
+ * @throws \Exception Throws exception when retrieving menu icon fails.
+ */
+ private function get_menu_icon_url() {
+ /**
+ * Icon URL.
+ *
+ * Pass a base64-encoded SVG using a data URI, which will be colored to match the color scheme.
+ * This should begin with 'data:image/svg+xml;base64,'.
+ *
+ * We use a SVG image with default fill color #A0A5AA from the default admin color scheme:
+ * https://github.com/WordPress/WordPress/blob/5.2/wp-includes/general-template.php#L4135-L4145
+ *
+ * The advantage of this is that users with the default admin color scheme do not see the repaint:
+ * https://github.com/WordPress/WordPress/blob/5.2/wp-admin/js/svg-painter.js
+ *
+ * @link https://developer.wordpress.org/reference/functions/add_menu_page/
+ */
+ $file = __DIR__ . '/../../images/dist/wp-pay-wp-admin-fresh-base.svgo-min.svg';
+
+ if ( ! \is_readable( $file ) ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not read WordPress admin menu icon from file: %s.',
+ \esc_html( $file )
+ )
+ );
+ }
+
+ $svg = \file_get_contents( $file, true );
+
+ if ( false === $svg ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not read WordPress admin menu icon from file: %s.',
+ \esc_html( $file )
+ )
+ );
+ }
+
+ $icon_url = \sprintf(
+ 'data:image/svg+xml;base64,%s',
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+ \base64_encode( $svg )
+ );
+
+ return $icon_url;
+ }
+
+ /**
+ * Create the admin menu.
+ *
+ * @return void
+ */
+ public function admin_menu() {
+ /**
+ * Badges.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.3.13/includes/admin/class-wc-admin-menus.php#L145
+ */
+ $counts = wp_count_posts( 'pronamic_payment' );
+
+ $payments_pending_count = \property_exists( $counts, 'payment_pending' ) ? $counts->payment_pending : 0;
+
+ $counts = wp_count_posts( 'pronamic_pay_subscr' );
+
+ $subscriptions_on_hold_count = \property_exists( $counts, 'subscr_on_hold' ) ? $counts->subscr_on_hold : 0;
+
+ $badges = [
+ 'pay' => [
+ 'title' => [],
+ 'count' => 0,
+ 'html' => '',
+ ],
+ 'payments' => [
+ 'title' => \sprintf(
+ /* translators: %d: payments pending count */
+ \_n( '%d payment pending', '%d payments pending', $payments_pending_count, 'pronamic_ideal' ),
+ $payments_pending_count
+ ),
+ 'count' => $payments_pending_count,
+ 'html' => '',
+ ],
+ 'subscriptions' => [
+ 'title' => \sprintf(
+ /* translators: %d: subscriptions on hold count */
+ \_n( '%d subscription on hold', '%d subscriptions on hold', $subscriptions_on_hold_count, 'pronamic_ideal' ),
+ $subscriptions_on_hold_count
+ ),
+ 'count' => $subscriptions_on_hold_count,
+ 'html' => '',
+ ],
+ ];
+
+ foreach ( $badges as &$badge ) {
+ $count = $badge['count'];
+
+ if ( 0 === $count ) {
+ continue;
+ }
+
+ $title = \array_key_exists( 'title', $badge ) && \is_string( $badge['title'] ) ? $badge['title'] : '';
+
+ $badge['html'] = \sprintf(
+ ' %1$d ',
+ $count,
+ $title
+ );
+
+ // Pay badge.
+ $badges['pay']['count'] += $count;
+
+ if ( ! empty( $title ) ) {
+ $badges['pay']['title'][] = $title;
+ }
+ }
+
+ $modules = \apply_filters( 'pronamic_pay_modules', [] );
+
+ /**
+ * Submenu pages.
+ */
+ $submenu_pages = [];
+
+ $submenu_pages[] = [
+ 'page_title' => __( 'Payments', 'pronamic_ideal' ),
+ 'menu_title' => __( 'Payments', 'pronamic_ideal' ) . $badges['payments']['html'],
+ 'capability' => 'edit_payments',
+ 'menu_slug' => 'edit.php?post_type=pronamic_payment',
+ ];
+
+ if ( \in_array( 'subscriptions', $modules, true ) ) {
+ $submenu_pages[] = [
+ 'page_title' => __( 'Subscriptions', 'pronamic_ideal' ),
+ 'menu_title' => __( 'Subscriptions', 'pronamic_ideal' ) . $badges['subscriptions']['html'],
+ 'capability' => 'edit_payments',
+ 'menu_slug' => 'edit.php?post_type=pronamic_pay_subscr',
+ ];
+ }
+
+ if ( \in_array( 'reports', $modules, true ) ) {
+ $submenu_pages[] = [
+ 'page_title' => __( 'Reports', 'pronamic_ideal' ),
+ 'menu_title' => __( 'Reports', 'pronamic_ideal' ),
+ 'capability' => 'edit_payments',
+ 'menu_slug' => 'pronamic_pay_reports',
+ 'function' => function () {
+ $this->reports->page_reports();
+ },
+ ];
+ }
+
+ $submenu_pages[] = [
+ 'page_title' => __( 'Configurations', 'pronamic_ideal' ),
+ 'menu_title' => __( 'Configurations', 'pronamic_ideal' ),
+ 'capability' => 'manage_options',
+ 'menu_slug' => 'edit.php?post_type=pronamic_gateway',
+ ];
+
+ $submenu_pages[] = [
+ 'page_title' => __( 'Settings', 'pronamic_ideal' ),
+ 'menu_title' => __( 'Settings', 'pronamic_ideal' ),
+ 'capability' => 'manage_options',
+ 'menu_slug' => 'pronamic_pay_settings',
+ 'function' => function () {
+ $this->render_page( 'settings' );
+ },
+ ];
+
+ $minimum_capability = $this->get_minimum_capability( $submenu_pages );
+
+ try {
+ $menu_icon_url = $this->get_menu_icon_url();
+ } catch ( \Exception $e ) {
+ // @todo Log.
+
+ /**
+ * If retrieving the menu icon URL fails we will
+ * fallback to the WordPress money dashicon.
+ *
+ * @link https://developer.wordpress.org/resource/dashicons/#money
+ */
+ $menu_icon_url = 'dashicons-money';
+ }
+
+ $pay_badge = '';
+
+ if ( 0 !== $badges['pay']['count'] ) {
+ $pay_badge = \sprintf(
+ ' %1$d ',
+ $badges['pay']['count'],
+ \implode( ', ', $badges['pay']['title'] )
+ );
+ }
+
+ add_menu_page(
+ __( 'Pronamic Pay', 'pronamic_ideal' ),
+ __( 'Pay', 'pronamic_ideal' ) . $pay_badge,
+ $minimum_capability,
+ 'pronamic_ideal',
+ function () {
+ $this->render_page( 'dashboard' );
+ },
+ $menu_icon_url
+ );
+
+ // Add submenu pages.
+ foreach ( $submenu_pages as $page ) {
+ /**
+ * To keep PHPStan happy we use an if/else statement for
+ * the 6th $function parameter which should be a callable
+ * function. Unfortunately this is not documented
+ * correctly in WordPress.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/5.2/wp-admin/includes/plugin.php#L1296-L1377
+ */
+ if ( array_key_exists( 'function', $page ) ) {
+ add_submenu_page(
+ 'pronamic_ideal',
+ $page['page_title'],
+ $page['menu_title'],
+ $page['capability'],
+ $page['menu_slug'],
+ $page['function']
+ );
+ } else {
+ add_submenu_page(
+ 'pronamic_ideal',
+ $page['page_title'],
+ $page['menu_title'],
+ $page['capability'],
+ $page['menu_slug']
+ );
+ }
+ }
+
+ // Change title of plugin submenu page to 'Dashboard'.
+ global $submenu;
+
+ if ( isset( $submenu['pronamic_ideal'] ) ) {
+ /* phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited */
+ $submenu['pronamic_ideal'][0][0] = __( 'Dashboard', 'pronamic_ideal' );
+ }
+ }
+
+ /**
+ * Get minimum capability from submenu pages.
+ *
+ * @param array $pages Submenu pages.
+ *
+ * @return string
+ */
+ public function get_minimum_capability( array $pages ) {
+ foreach ( $pages as $page ) {
+ if ( \current_user_can( $page['capability'] ) ) {
+ return $page['capability'];
+ }
+ }
+
+ return 'edit_payments';
+ }
+
+ /**
+ * Render the specified page.
+ *
+ * @param string $name Page identifier.
+ * @return void
+ */
+ public function render_page( $name ) {
+ include __DIR__ . '/../../views/page-' . $name . '.php';
+ }
+
+ /**
+ * Get a CSS class for the specified post status.
+ *
+ * @param string $post_status Post status.
+ * @return string
+ */
+ public static function get_post_status_icon_class( $post_status ) {
+ switch ( $post_status ) {
+ case 'payment_pending':
+ case 'subscr_pending':
+ return 'pronamic-pay-icon-pending';
+
+ case 'payment_cancelled':
+ case 'subscr_cancelled':
+ return 'pronamic-pay-icon-cancelled';
+
+ case 'payment_completed':
+ case 'subscr_completed':
+ return 'pronamic-pay-icon-completed';
+
+ case 'payment_refunded':
+ return 'pronamic-pay-icon-refunded';
+
+ case 'payment_failed':
+ case 'subscr_failed':
+ return 'pronamic-pay-icon-failed';
+
+ case 'payment_on_hold':
+ case 'payment_expired':
+ case 'subscr_expired':
+ case 'subscr_on_hold':
+ return 'pronamic-pay-icon-on-hold';
+
+ case 'payment_authorized':
+ case 'payment_reserved':
+ case 'subscr_active':
+ default:
+ return 'pronamic-pay-icon-processing';
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminNotification.php b/packages/wp-pay/core/src/Admin/AdminNotification.php
new file mode 100644
index 0000000..858fd1e
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminNotification.php
@@ -0,0 +1,117 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * WordPress admin notification.
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 3.7.0
+ */
+class AdminNotification {
+ /**
+ * ID.
+ *
+ * @var string
+ */
+ private $id;
+
+ /**
+ * Name.
+ *
+ * @var string
+ */
+ private $name;
+
+ /**
+ * Condition.
+ *
+ * @var bool
+ */
+ private $condition;
+
+ /**
+ * Version.
+ *
+ * @var string
+ */
+ private $version;
+
+ /**
+ * Constructs and initializes an notices object.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.4.3/includes/admin/class-wc-admin-notices.php
+ * @param string $id ID.
+ * @param string $name Name.
+ * @param bool $condition Condition.
+ * @param string $version Version.
+ */
+ public function __construct( $id, $name, $condition, $version ) {
+ $this->id = $id;
+ $this->name = $name;
+ $this->condition = $condition;
+ $this->version = $version;
+ }
+
+ /**
+ * Get ID.
+ *
+ * @return string
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Is met.
+ *
+ * @return bool
+ */
+ public function is_met() {
+ return $this->condition;
+ }
+
+ /**
+ * Get version.
+ *
+ * @return string
+ */
+ public function get_version() {
+ return $this->version;
+ }
+
+ /**
+ * Get message.
+ *
+ * @return string
+ */
+ public function get_message() {
+ $message = \sprintf(
+ 'We notice that the "%1$s" plugin is active, support for the "%1$s" plugin has been removed from the Pronamic Pay plugin since version %2$s.',
+ $this->get_name(),
+ $this->get_version()
+ );
+
+ return $message;
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminPaymentBulkActions.php b/packages/wp-pay/core/src/Admin/AdminPaymentBulkActions.php
new file mode 100644
index 0000000..270195f
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminPaymentBulkActions.php
@@ -0,0 +1,233 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\Plugin;
+use WP_Query;
+
+/**
+ * WordPress admin payment bulk actions
+ *
+ * @link https://www.skyverge.com/blog/add-custom-bulk-action/
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 4.1.0
+ */
+class AdminPaymentBulkActions {
+ /**
+ * Constructs and initializes an admin payment bulk actions object.
+ */
+ public function __construct() {
+ add_action( 'load-edit.php', [ $this, 'load' ] );
+ }
+
+ /**
+ * Load.
+ *
+ * @return void
+ */
+ public function load() {
+ // Current user.
+ if ( ! current_user_can( 'edit_payments' ) ) {
+ return;
+ }
+
+ // Screen.
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return;
+ }
+
+ if ( 'edit-pronamic_payment' !== $screen->id ) {
+ return;
+ }
+
+ // Bulk actions.
+ add_filter( 'bulk_actions-' . $screen->id, [ $this, 'bulk_actions' ] );
+
+ add_filter( 'handle_bulk_actions-' . $screen->id, [ $this, 'handle_bulk_action' ], 10, 3 );
+
+ // Admin notices.
+ add_action( 'admin_notices', [ $this, 'admin_notices' ] );
+ }
+
+ /**
+ * Custom bulk actions.
+ *
+ * @link https://make.wordpress.org/core/2016/10/04/custom-bulk-actions/
+ * @link https://github.com/WordPress/WordPress/blob/4.7/wp-admin/includes/class-wp-list-table.php#L440-L452
+ * @param array $bulk_actions Bulk actions.
+ * @return array
+ */
+ public function bulk_actions( $bulk_actions ) {
+ // Don't allow edit in bulk.
+ unset( $bulk_actions['edit'] );
+
+ // Bulk check payment status.
+ $bulk_actions['pronamic_payment_check_status'] = __( 'Check Payment Status', 'pronamic_ideal' );
+
+ return $bulk_actions;
+ }
+
+ /**
+ * Handle bulk action.
+ *
+ * @see hhttps://make.wordpress.org/core/2016/10/04/custom-bulk-actions/
+ * @link https://github.com/WordPress/WordPress/blob/4.7/wp-admin/edit.php#L166-L167
+ * @param string $sendback Sendback URL.
+ * @param string $doaction Action indicator.
+ * @param array $post_ids Post ID's to bulk edit.
+ * @return string
+ */
+ public function handle_bulk_action( $sendback, $doaction, $post_ids ) {
+ if ( 'pronamic_payment_check_status' !== $doaction ) {
+ return $sendback;
+ }
+
+ $status_updated = 0;
+ $skipped_check = 0;
+ $unsupported_gateways = [];
+
+ foreach ( $post_ids as $post_id ) {
+ $payment = get_pronamic_payment( $post_id );
+
+ if ( null === $payment ) {
+ continue;
+ }
+
+ // Only check status for pending payments.
+ if ( \Pronamic\WordPress\Pay\Payments\PaymentStatus::OPEN !== $payment->status && '' !== $payment->status ) {
+ ++$skipped_check;
+
+ continue;
+ }
+
+ // Make sure gateway supports `payment_status_request` feature.
+ $config_id = $payment->get_config_id();
+
+ if ( null === $config_id ) {
+ continue;
+ }
+
+ if ( ! \in_array( $config_id, $unsupported_gateways, true ) ) {
+ $gateway = $payment->get_gateway();
+
+ if ( null !== $gateway && ! $gateway->supports( 'payment_status_request' ) ) {
+ $unsupported_gateways[] = $config_id;
+ }
+ }
+
+ if ( \in_array( $config_id, $unsupported_gateways, true ) ) {
+ continue;
+ }
+
+ Plugin::update_payment( $payment, false );
+
+ ++$status_updated;
+ }
+
+ $sendback = add_query_arg(
+ [
+ 'status_updated' => $status_updated,
+ 'skipped_check' => $skipped_check,
+ 'unsupported_gateways' => implode( ',', $unsupported_gateways ),
+ '_wpnonce' => \wp_create_nonce( 'pronamic_pay_bulk_check_status' ),
+ ],
+ $sendback
+ );
+
+ return $sendback;
+ }
+
+ /**
+ * Admin notices.
+ *
+ * @return void
+ */
+ public function admin_notices() {
+ if (
+ ! \array_key_exists( 'status_updated', $_GET )
+ ||
+ ! \array_key_exists( 'skipped_check', $_GET )
+ ||
+ ! \array_key_exists( 'unsupported_gateways', $_GET )
+ ) {
+ return;
+ }
+
+ if ( ! \check_admin_referer( 'pronamic_pay_bulk_check_status' ) ) {
+ return;
+ }
+
+ // Status updated.
+ $updated = filter_input( INPUT_GET, 'status_updated', FILTER_VALIDATE_INT );
+
+ if ( $updated > 0 ) {
+ /* translators: %s: number updated payments */
+ $message = sprintf( _n( '%s payment updated.', '%s payments updated.', $updated, 'pronamic_ideal' ), number_format_i18n( $updated ) );
+
+ printf(
+ '',
+ esc_html( $message )
+ );
+ }
+
+ // Skipped.
+ $skipped = filter_input( INPUT_GET, 'skipped_check', FILTER_VALIDATE_INT );
+
+ if ( $skipped > 0 ) {
+ $message = sprintf(
+ /* translators: %s: number skipped payments */
+ _n( '%s payment is not updated because it already has a final payment status.', '%s payments are not updated because they already have a final payment status.', $skipped, 'pronamic_ideal' ),
+ number_format_i18n( $skipped )
+ );
+
+ printf(
+ '',
+ esc_html( $message )
+ );
+ }
+
+ // Unsupported gateways.
+ $gateways = \wp_parse_id_list( \sanitize_text_field( \wp_unslash( $_GET['unsupported_gateways'] ) ) );
+
+ $gateways = array_filter( $gateways );
+ $gateways = array_unique( $gateways );
+
+ if ( ! empty( $gateways ) ) {
+ $query = new WP_Query(
+ [
+ 'post_type' => 'pronamic_gateway',
+ 'post__in' => $gateways,
+ 'nopaging' => true,
+ 'ignore_sticky_posts' => true,
+ 'no_found_rows' => true,
+ 'update_post_meta_cache' => false,
+ 'update_post_term_cache' => false,
+ ]
+ );
+
+ $titles = wp_list_pluck( $query->posts, 'post_title' );
+
+ $message = sprintf(
+ /* translators: %s: gateways lists */
+ __( 'Requesting the current payment status is unsupported by %s.', 'pronamic_ideal' ),
+ implode( ', ', $titles )
+ );
+
+ printf(
+ '',
+ esc_html( $message )
+ );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminPaymentPostType.php b/packages/wp-pay/core/src/Admin/AdminPaymentPostType.php
new file mode 100644
index 0000000..92beb34
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminPaymentPostType.php
@@ -0,0 +1,819 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentPostType;
+use Pronamic\WordPress\Pay\Plugin;
+use WP_Post;
+
+/**
+ * WordPress admin payment post type
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 1.0.0
+ */
+class AdminPaymentPostType {
+ /**
+ * Post type.
+ *
+ * @var string
+ */
+ const POST_TYPE = 'pronamic_payment';
+
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Admin notices.
+ *
+ * @var array
+ */
+ private $admin_notices = [];
+
+ /**
+ * Constructs and initializes an admin payment post type object.
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( $plugin ) {
+ $this->plugin = $plugin;
+
+ add_filter( 'request', [ $this, 'request' ] );
+
+ add_filter( 'manage_edit-' . self::POST_TYPE . '_columns', [ $this, 'columns' ] );
+ add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', [ $this, 'sortable_columns' ] );
+ add_filter( 'list_table_primary_column', [ $this, 'primary_column' ], 10, 2 );
+
+ add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', [ $this, 'custom_columns' ], 10, 2 );
+
+ add_action( 'load-post.php', [ $this, 'maybe_process_payment_action' ] );
+ add_action( 'load-post.php', [ $this, 'maybe_display_anonymized_notice' ] );
+
+ add_action( 'admin_notices', [ $this, 'admin_notices' ] );
+
+ add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
+
+ add_filter( 'post_row_actions', [ $this, 'post_row_actions' ], 10, 2 );
+
+ add_filter( 'default_hidden_columns', [ $this, 'default_hidden_columns' ] );
+
+ add_filter( 'post_updated_messages', [ $this, 'post_updated_messages' ] );
+
+ // Bulk Actions.
+ new AdminPaymentBulkActions();
+ }
+
+ /**
+ * Filters and sorting handler.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.3.13/includes/admin/class-wc-admin-post-types.php#L1585-L1596
+ *
+ * @param array $vars Request variables.
+ * @return array
+ */
+ public function request( $vars ) {
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return $vars;
+ }
+
+ // Check payment post type.
+ if ( self::POST_TYPE !== $screen->post_type ) {
+ return $vars;
+ }
+
+ // Check post status var.
+ if ( isset( $vars['post_status'] ) && ! empty( $vars['post_status'] ) ) {
+ return $vars;
+ }
+
+ // Set request post status from payment states.
+ $vars['post_status'] = array_keys( PaymentPostType::get_payment_states() );
+ $vars['post_status'][] = 'publish';
+
+ return $vars;
+ }
+
+ /**
+ * Maybe process payment action.
+ *
+ * @return void
+ */
+ public function maybe_process_payment_action() {
+ // Current user.
+ if ( ! current_user_can( 'edit_payments' ) ) {
+ return;
+ }
+
+ // Screen.
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return;
+ }
+
+ if ( ! ( 'post' === $screen->base && 'pronamic_payment' === $screen->post_type ) ) {
+ return;
+ }
+
+ $post_id = filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT );
+
+ $payment = get_pronamic_payment( $post_id );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ // Status check action.
+ if ( filter_has_var( INPUT_GET, 'pronamic_pay_check_status' ) && check_admin_referer( 'pronamic_payment_check_status_' . $post_id ) ) {
+ try {
+ Plugin::update_payment( $payment, false );
+ } catch ( \Exception $e ) {
+ Plugin::render_exception( $e );
+
+ exit;
+ }
+
+ $this->admin_notices[] = [
+ 'type' => 'info',
+ 'message' => __( 'Payment status updated.', 'pronamic_ideal' ),
+ ];
+ }
+ }
+
+ /**
+ * Maybe display anonymized notice.
+ *
+ * @link https://developer.wordpress.org/reference/functions/get_current_screen/
+ * @return void
+ */
+ public function maybe_display_anonymized_notice() {
+ // Current user.
+ if ( ! current_user_can( 'edit_payments' ) ) {
+ return;
+ }
+
+ // Screen.
+ $screen = get_current_screen();
+
+ if ( null === $screen || 'post' !== $screen->base || 'pronamic_payment' !== $screen->post_type ) {
+ return;
+ }
+
+ $post_id = filter_input( INPUT_GET, 'post', FILTER_SANITIZE_NUMBER_INT );
+
+ $payment = new Payment( $post_id );
+
+ if ( ! $payment->is_anonymized() ) {
+ return;
+ }
+
+ $this->admin_notices[] = [
+ 'type' => 'info',
+ 'message' => __( 'This payment has been anonymized. Personal details are not available anymore.', 'pronamic_ideal' ),
+ ];
+ }
+
+ /**
+ * Admin notices.
+ *
+ * @return void
+ */
+ public function admin_notices() {
+ foreach ( $this->admin_notices as $notice ) {
+ printf(
+ '',
+ esc_attr( $notice['type'] ),
+ esc_html( $notice['message'] )
+ );
+ }
+ }
+
+ /**
+ * Columns.
+ *
+ * @param array $columns Columns.
+ * @return array
+ */
+ public function columns( $columns ) {
+ $columns = [
+ 'cb' => ' ',
+ 'pronamic_payment_status' => sprintf(
+ '%s ',
+ esc_html__( 'Status', 'pronamic_ideal' ),
+ esc_html__( 'Status', 'pronamic_ideal' )
+ ),
+ 'pronamic_payment_subscription' => sprintf(
+ '%s ',
+ esc_html__( 'Subscription', 'pronamic_ideal' ),
+ esc_html__( 'Subscription', 'pronamic_ideal' )
+ ),
+ 'pronamic_payment_method' => '',
+ 'pronamic_payment_title' => __( 'Payment', 'pronamic_ideal' ),
+ 'pronamic_payment_transaction' => __( 'Transaction', 'pronamic_ideal' ),
+ 'pronamic_payment_gateway' => __( 'Gateway', 'pronamic_ideal' ),
+ 'pronamic_payment_description' => __( 'Description', 'pronamic_ideal' ),
+ 'pronamic_payment_customer' => __( 'Customer', 'pronamic_ideal' ),
+ 'pronamic_payment_amount' => __( 'Amount', 'pronamic_ideal' ),
+ 'pronamic_payment_date' => __( 'Date', 'pronamic_ideal' ),
+ ];
+
+ return $columns;
+ }
+
+ /**
+ * Default hidden columns.
+ *
+ * @param array $hidden Default hidden columns.
+ * @return array
+ */
+ public function default_hidden_columns( $hidden ) {
+ $hidden[] = 'pronamic_payment_gateway';
+ $hidden[] = 'pronamic_payment_description';
+
+ return $hidden;
+ }
+
+ /**
+ * Sortable columns.
+ *
+ * @param array $sortable_columns Sortable columns.
+ * @return array
+ */
+ public function sortable_columns( $sortable_columns ) {
+ $sortable_columns['pronamic_payment_title'] = 'ID';
+ $sortable_columns['pronamic_payment_date'] = 'date';
+
+ return $sortable_columns;
+ }
+
+ /**
+ * Primary column name.
+ *
+ * @param string $column_name Primary column name.
+ * @param string $screen_id Screen ID.
+ *
+ * @return string
+ */
+ public function primary_column( $column_name, $screen_id ) {
+ if ( 'edit-pronamic_payment' !== $screen_id ) {
+ return $column_name;
+ }
+
+ return 'pronamic_payment_title';
+ }
+
+ /**
+ * Custom columns.
+ *
+ * @link https://codex.wordpress.org/Plugin_API/Action_Reference/manage_$post_type_posts_custom_column
+ * @link https://developer.wordpress.org/reference/functions/get_post_status/
+ * @link https://developer.wordpress.org/reference/functions/get_post_status_object/
+ *
+ * @param string $column Column.
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public function custom_columns( $column, $post_id ) {
+ $payment = get_pronamic_payment( $post_id );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ switch ( $column ) {
+ case 'pronamic_payment_status':
+ $post_status = get_post_status( $post_id );
+
+ if ( false === $post_status ) {
+ break;
+ }
+
+ $label = __( 'Unknown', 'pronamic_ideal' );
+
+ if ( 'trash' === $post_status ) {
+ $post_status = get_post_meta( $post_id, '_wp_trash_meta_status', true );
+ }
+
+ $status_object = get_post_status_object( $post_status );
+
+ if ( isset( $status_object, $status_object->label ) ) {
+ $label = $status_object->label;
+ }
+
+ printf(
+ '%s ',
+ esc_attr( AdminModule::get_post_status_icon_class( $post_status ) ),
+ esc_attr( $label ),
+ esc_html( $label )
+ );
+
+ break;
+ case 'pronamic_payment_subscription':
+ $subscriptions = $payment->get_subscriptions();
+
+ foreach ( $subscriptions as $subscription ) {
+ $label = __( 'Recurring payment', 'pronamic_ideal' );
+ $class = 'pronamic-pay-icon-recurring';
+
+ if ( $subscription->is_first_payment( $payment ) ) {
+ $label = __( 'First of recurring payment', 'pronamic_ideal' );
+ $class = ' pronamic-pay-icon-recurring-first';
+ }
+
+ edit_post_link(
+ sprintf(
+ '%s ',
+ esc_attr( $class ),
+ esc_attr( $label ),
+ esc_attr( $label )
+ ),
+ '',
+ '',
+ (int) $subscription->get_id()
+ );
+ }
+
+ break;
+ case 'pronamic_payment_method':
+ $payment_method = $payment->get_payment_method();
+
+ $icon_url = PaymentMethods::get_icon_url( $payment_method );
+
+ if ( null !== $icon_url ) {
+ \printf(
+ ' ',
+ \esc_url( $icon_url ),
+ \esc_attr( (string) PaymentMethods::get_name( $payment_method ) )
+ );
+ }
+
+ break;
+ case 'pronamic_payment_title':
+ $source_id = $payment->get_source_id();
+ $source_description = $payment->get_source_description();
+
+ // Post ID text.
+ $text = sprintf(
+ '#%s ',
+ esc_html( strval( $post_id ) )
+ );
+
+ $link = get_edit_post_link( $post_id );
+
+ if ( null !== $link ) {
+ $text = sprintf(
+ '%s ',
+ esc_url( $link ),
+ $text
+ );
+ }
+
+ // Source text.
+ $source_id_text = '';
+
+ if ( null !== $source_id ) {
+ $source_id_text = '#' . strval( $source_id );
+ }
+
+ $source_link = $payment->get_source_link();
+
+ if ( null !== $source_link ) {
+ $source_id_text = sprintf(
+ '%s ',
+ esc_url( $source_link ),
+ $source_id_text
+ );
+ }
+
+ // Output.
+ echo wp_kses(
+ sprintf(
+ /* translators: 1: edit post link with post ID, 2: source description, 3: source ID text */
+ __( '%1$s for %2$s %3$s', 'pronamic_ideal' ),
+ $text,
+ $source_description,
+ $source_id_text
+ ),
+ [
+ 'a' => [
+ 'href' => true,
+ 'class' => true,
+ ],
+ 'strong' => [],
+ ]
+ );
+
+ break;
+ case 'pronamic_payment_gateway':
+ $config_id = (int) $payment->get_config_id();
+
+ $gateway_id = get_post_meta( $config_id, '_pronamic_gateway_id', true );
+
+ $integration = $this->plugin->gateway_integrations->get_integration( $gateway_id );
+
+ if ( null !== $integration ) {
+ printf(
+ '%2$s ',
+ \esc_url( (string) \get_edit_post_link( $config_id ) ),
+ \esc_html( \get_the_title( $config_id ) )
+ );
+ } elseif ( ! empty( $config_id ) ) {
+ echo esc_html( get_the_title( $config_id ) );
+ } else {
+ echo '—';
+ }
+
+ break;
+ case 'pronamic_payment_transaction':
+ $transaction_id = get_post_meta( $post_id, '_pronamic_payment_transaction_id', true );
+ $transaction_id = strval( $transaction_id );
+
+ $url = $payment->get_provider_link();
+
+ if ( empty( $url ) ) {
+ echo esc_html( $transaction_id );
+ } else {
+ printf(
+ '%s ',
+ esc_url( $url ),
+ esc_html( $transaction_id )
+ );
+ }
+
+ break;
+ case 'pronamic_payment_description':
+ echo esc_html( (string) $payment->get_description() );
+
+ break;
+ case 'pronamic_payment_amount':
+ $total_amount = $payment->get_total_amount();
+
+ $remaining_amount = $total_amount;
+
+ $tip = [];
+
+ $refunded_amount = $payment->get_refunded_amount();
+
+ if ( ! $refunded_amount->is_zero() ) {
+ $remaining_amount = $remaining_amount->subtract( $refunded_amount );
+
+ $tip[] = \sprintf(
+ /* translators: %s: formatted amount */
+ __( '%s refunded', 'pronamic_ideal' ),
+ $refunded_amount->format_i18n()
+ );
+ }
+
+ $charged_back_amount = $payment->get_charged_back_amount();
+
+ if ( null !== $charged_back_amount ) {
+ $remaining_amount = $remaining_amount->subtract( $charged_back_amount );
+
+ $tip[] = \sprintf(
+ /* translators: %s: formatted amount */
+ __( '%s charged back', 'pronamic_ideal' ),
+ $charged_back_amount->format_i18n()
+ );
+ }
+
+ // Check refunded amount.
+ if ( $total_amount->get_value() === $remaining_amount->get_value() ) {
+ echo esc_html( $total_amount->format_i18n() );
+
+ break;
+ }
+
+ // Show original amount and remaining amount.
+ \printf(
+ '%1$s %2$s',
+ esc_html( $total_amount->format_i18n() ),
+ \esc_html( $remaining_amount->format_i18n() ),
+ \esc_html( implode( ', ', $tip ) )
+ );
+
+ break;
+ case 'pronamic_payment_date':
+ echo esc_html( $payment->date->format_i18n() );
+
+ break;
+ case 'pronamic_payment_customer':
+ $customer = $payment->get_customer();
+
+ if ( null !== $customer ) {
+ $text = \strval( $customer->get_name() );
+
+ if ( empty( $text ) ) {
+ $text = \strval( $customer->get_email() );
+ }
+
+ echo \esc_html( $text );
+ }
+
+ break;
+ }
+ }
+
+ /**
+ * Add meta boxes.
+ *
+ * @param string $post_type Post Type.
+ * @return void
+ */
+ public function add_meta_boxes( $post_type ) {
+ if ( self::POST_TYPE !== $post_type ) {
+ return;
+ }
+
+ add_meta_box(
+ 'pronamic_payment',
+ __( 'Payment', 'pronamic_ideal' ),
+ [ $this, 'meta_box_info' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_payment_lines',
+ __( 'Payment Lines', 'pronamic_ideal' ),
+ [ $this, 'meta_box_lines' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ $modules = \apply_filters( 'pronamic_pay_modules', [] );
+
+ if ( \in_array( 'subscriptions', $modules, true ) ) {
+ \add_meta_box(
+ 'pronamic_payment_subscription',
+ \__( 'Subscription', 'pronamic_ideal' ),
+ [ $this, 'meta_box_subscription' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+ }
+
+ \add_meta_box(
+ 'pronamic_payment_refunds',
+ \__( 'Refunds', 'pronamic_ideal' ),
+ [ $this, 'meta_box_refunds' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_payment_notes',
+ __( 'Notes', 'pronamic_ideal' ),
+ [ $this, 'meta_box_notes' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_payment_update',
+ __( 'Update', 'pronamic_ideal' ),
+ [ $this, 'meta_box_update' ],
+ $post_type,
+ 'side',
+ 'high'
+ );
+
+ // @link http://kovshenin.com/2012/how-to-remove-the-publish-box-from-a-post-type/.
+ remove_meta_box( 'submitdiv', $post_type, 'side' );
+ }
+
+ /**
+ * Pronamic Pay gateway config meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_info( $post ) {
+ $plugin = $this->plugin;
+ $payment = get_pronamic_payment( $post->ID );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ include __DIR__ . '/../../views/meta-box-payment-info.php';
+ }
+
+ /**
+ * Pronamic Pay payment lines meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_lines( $post ) {
+ $payment = get_pronamic_payment( $post->ID );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ $lines = $payment->get_lines();
+
+ include __DIR__ . '/../../views/meta-box-payment-lines.php';
+ }
+
+ /**
+ * Pronamic Pay payment refunds meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_refunds( $post ) {
+ $payment = get_pronamic_payment( $post->ID );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ include __DIR__ . '/../../views/meta-box-payment-refunds.php';
+ }
+
+ /**
+ * Pronamic Pay gateway config meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_notes( $post ) {
+ $notes = get_comments(
+ [
+ 'post_id' => $post->ID,
+ 'type' => 'payment_note',
+ 'orderby' => [ 'comment_date_gmt', 'comment_ID' ],
+ ]
+ );
+
+ include __DIR__ . '/../../views/meta-box-notes.php';
+ }
+
+ /**
+ * Pronamic Pay payment subscription meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_subscription( $post ) {
+ $payment = get_pronamic_payment( $post->ID );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ include __DIR__ . '/../../views/meta-box-payment-subscription.php';
+ }
+
+ /**
+ * Post row actions.
+ *
+ * @param array $actions Actions array.
+ * @param WP_Post $post WordPress post.
+ * @return array
+ */
+ public function post_row_actions( $actions, $post ) {
+ if ( self::POST_TYPE === $post->post_type ) {
+ return [ '' ];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Post updated messages.
+ *
+ * @link https://codex.wordpress.org/Function_Reference/register_post_type
+ * @link https://github.com/WordPress/WordPress/blob/4.4.2/wp-admin/edit-form-advanced.php#L134-L173
+ * @link https://github.com/woothemes/woocommerce/blob/2.5.5/includes/admin/class-wc-admin-post-types.php#L111-L168
+ * @param array $messages Message.
+ * @return array
+ */
+ public function post_updated_messages( $messages ) {
+ global $post;
+
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352797&filters[translation_id]=37948900
+ $scheduled_date = date_i18n( __( 'M j, Y @ H:i', 'pronamic_ideal' ), strtotime( $post->post_date ) );
+
+ $messages[ self::POST_TYPE ] = [
+ 0 => '', // Unused. Messages start at index 1.
+ 1 => __( 'Payment updated.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352799&filters[translation_id]=37947229.
+ 2 => $messages['post'][2],
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352800&filters[translation_id]=37947870.
+ 3 => $messages['post'][3],
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352798&filters[translation_id]=37947230.
+ 4 => __( 'Payment updated.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352801&filters[translation_id]=37947231.
+ /* phpcs:disable WordPress.Security.NonceVerification.Recommended */
+ /* translators: %s: date and time of the revision */
+ 5 => isset( $_GET['revision'] ) ? sprintf( __( 'Payment restored to revision from %s.', 'pronamic_ideal' ), strval( wp_post_revision_title( (int) $_GET['revision'], false ) ) ) : false,
+ /* phpcs:enable WordPress.Security.NonceVerification.Recommended */
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352802&filters[translation_id]=37949178.
+ 6 => __( 'Payment published.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352803&filters[translation_id]=37947232.
+ 7 => __( 'Payment saved.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352804&filters[translation_id]=37949303.
+ 8 => __( 'Payment submitted.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352805&filters[translation_id]=37949302.
+ /* translators: %s: scheduled date */
+ 9 => sprintf( __( 'Payment scheduled for: %s.', 'pronamic_ideal' ), '' . $scheduled_date . ' ' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352806&filters[translation_id]=37949301.
+ 10 => __( 'Payment draft updated.', 'pronamic_ideal' ),
+ ];
+
+ return $messages;
+ }
+
+ /**
+ * Pronamic Pay payment update meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_update( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is used in include.
+ $post
+ ) {
+ wp_nonce_field( 'pronamic_payment_update', 'pronamic_payment_update_nonce' );
+
+ include __DIR__ . '/../../views/meta-box-payment-update.php';
+ }
+
+ /**
+ * Admin init.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ $this->maybe_update_payment();
+ }
+
+ /**
+ * Maybe update payment status.
+ *
+ * @return void
+ */
+ private function maybe_update_payment() {
+ if ( ! \array_key_exists( 'pronamic_payment_update', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_payment_id', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_payment_status', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_payment_update_nonce', $_POST ) ) {
+ return;
+ }
+
+ $nonce = \sanitize_text_field( \wp_unslash( $_POST['pronamic_payment_update_nonce'] ) );
+
+ if ( ! \wp_verify_nonce( $nonce, 'pronamic_payment_update' ) ) {
+ \wp_die( \esc_html__( 'Action failed. Please refresh the page and retry.', 'pronamic_ideal' ) );
+ }
+
+ $payment_id = \sanitize_text_field( \wp_unslash( $_POST['pronamic_payment_id'] ) );
+
+ $payment = \get_pronamic_payment( $payment_id );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ $status = \sanitize_text_field( \wp_unslash( $_POST['pronamic_payment_status'] ) );
+
+ if ( '' === $status ) {
+ return;
+ }
+
+ $payment->set_status( $status );
+ $payment->save();
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminReports.php b/packages/wp-pay/core/src/Admin/AdminReports.php
new file mode 100644
index 0000000..bfea9d0
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminReports.php
@@ -0,0 +1,371 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * WordPress admin reports
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.0.0
+ */
+class AdminReports {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * AdminReports constructor.
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'admin_print_styles', [ $this, 'admin_css' ] );
+ }
+
+ /**
+ * Page reports.
+ *
+ * @return void
+ */
+ public function page_reports() {
+ $admin_reports = $this;
+
+ include __DIR__ . '/../../views/page-reports.php';
+ }
+
+ /**
+ * Enqueue admin scripts.
+ *
+ * @return void
+ */
+ public function admin_css() {
+ // Check if this is the reports page.
+ /* phpcs:ignore WordPress.Security.NonceVerification.Recommended */
+ if ( ! \array_key_exists( 'page', $_GET ) || 'pronamic_pay_reports' !== $_GET['page'] ) {
+ return;
+ }
+
+ $min = \SCRIPT_DEBUG ? '' : '.min';
+
+ // Flot - http://www.flotcharts.org/.
+ $flot_version = '0.8.0-alpha';
+
+ wp_register_script(
+ 'flot',
+ plugins_url( '../../assets/flot/jquery.flot' . $min . '.js', __FILE__ ),
+ [ 'jquery' ],
+ $flot_version,
+ true
+ );
+
+ wp_register_script(
+ 'flot-time',
+ plugins_url( '../../assets/flot/jquery.flot.time' . $min . '.js', __FILE__ ),
+ [ 'flot' ],
+ $flot_version,
+ true
+ );
+
+ wp_register_script(
+ 'flot-resize',
+ plugins_url( '../../assets/flot/jquery.flot.resize' . $min . '.js', __FILE__ ),
+ [ 'flot' ],
+ $flot_version,
+ true
+ );
+
+ // Accounting.js - http://openexchangerates.github.io/accounting.js.
+ wp_register_script(
+ 'accounting',
+ plugins_url( '../../assets/accounting/accounting' . $min . '.js', __FILE__ ),
+ [ 'jquery' ],
+ '0.4.1',
+ true
+ );
+
+ // Reports.
+ wp_register_script(
+ 'pronamic-pay-admin-reports',
+ plugins_url( '../../js/dist/admin-reports' . $min . '.js', __FILE__ ),
+ [
+ 'jquery',
+ 'flot',
+ 'flot-time',
+ 'flot-resize',
+ 'accounting',
+ ],
+ $this->plugin->get_version(),
+ true
+ );
+
+ global $wp_locale;
+
+ wp_localize_script(
+ 'pronamic-pay-admin-reports',
+ 'pronamicPayAdminReports',
+ [
+ 'data' => $this->get_reports(),
+ 'monthNames' => array_values( $wp_locale->month_abbrev ),
+ ]
+ );
+
+ // Enqueue.
+ wp_enqueue_script( 'pronamic-pay-admin-reports' );
+ }
+
+ /**
+ * Get reports.
+ *
+ * @return array
+ */
+ public function get_reports() {
+ $start = new \DateTime( 'First day of January' );
+ $end = new \DateTime( 'Last day of December' );
+
+ $data = [
+ (object) [
+ 'label' => __( 'Number successful payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_completed', 'COUNT', $start, $end ),
+ 'color' => '#dbe1e3',
+ 'bars' => (object) [
+ 'fillColor' => '#dbe1e3',
+ 'fill' => true,
+ 'show' => true,
+ 'lineWidth' => 0,
+ 'barWidth' => 2419200000 * 0.5,
+ 'align' => 'center',
+ ],
+ 'shadowSize' => 0,
+ 'hoverable' => false,
+ 'class' => 'completed-count',
+ ],
+ (object) [
+ 'label' => __( 'Open payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_pending', 'SUM', $start, $end ),
+ 'yaxis' => 2,
+ 'color' => '#b1d4ea',
+ 'points' => (object) [
+ 'show' => true,
+ 'radius' => 5,
+ 'lineWidth' => 2,
+ 'fillColor' => '#FFF',
+ 'fill' => true,
+ ],
+ 'lines' => (object) [
+ 'show' => true,
+ 'lineWidth' => 2,
+ 'fill' => false,
+ ],
+ 'shadowSize' => 0,
+ 'tooltipFormatter' => 'money',
+ 'class' => 'pending-sum',
+ ],
+ (object) [
+ 'label' => __( 'Successful payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_completed', 'SUM', $start, $end ),
+ 'yaxis' => 2,
+ 'color' => '#3498db',
+ 'points' => (object) [
+ 'show' => true,
+ 'radius' => 6,
+ 'lineWidth' => 4,
+ 'fillColor' => '#FFF',
+ 'fill' => true,
+ ],
+ 'lines' => (object) [
+ 'show' => true,
+ 'lineWidth' => 5,
+ 'fill' => false,
+ ],
+ 'shadowSize' => 0,
+ 'prepend_tooltip' => '€ ',
+ 'tooltipFormatter' => 'money',
+ 'class' => 'completed-sum',
+ ],
+ (object) [
+ 'label' => __( 'Cancelled payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_cancelled', 'SUM', $start, $end ),
+ 'yaxis' => 2,
+ 'color' => '#F1C40F',
+ 'points' => (object) [
+ 'show' => true,
+ 'radius' => 5,
+ 'lineWidth' => 2,
+ 'fillColor' => '#FFF',
+ 'fill' => true,
+ ],
+ 'lines' => (object) [
+ 'show' => true,
+ 'lineWidth' => 2,
+ 'fill' => false,
+ ],
+ 'shadowSize' => 0,
+ 'prepend_tooltip' => '€ ',
+ 'tooltipFormatter' => 'money',
+ 'class' => 'cancelled-sum',
+ ],
+ (object) [
+ 'label' => __( 'Expired payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_expired', 'SUM', $start, $end ),
+ 'yaxis' => 2,
+ 'color' => '#DBE1E3',
+ 'points' => (object) [
+ 'show' => true,
+ 'radius' => 5,
+ 'lineWidth' => 2,
+ 'fillColor' => '#FFF',
+ 'fill' => true,
+ ],
+ 'lines' => (object) [
+ 'show' => true,
+ 'lineWidth' => 2,
+ 'fill' => false,
+ ],
+ 'shadowSize' => 0,
+ 'prepend_tooltip' => '€ ',
+ 'tooltipFormatter' => 'money',
+ 'class' => 'expired-sum',
+ ],
+ (object) [
+ 'label' => __( 'Failed payments', 'pronamic_ideal' ),
+ 'data' => $this->get_report( 'payment_failed', 'SUM', $start, $end ),
+ 'yaxis' => 2,
+ 'color' => '#E74C3C',
+ 'points' => (object) [
+ 'show' => true,
+ 'radius' => 5,
+ 'lineWidth' => 2,
+ 'fillColor' => '#FFF',
+ 'fill' => true,
+ ],
+ 'lines' => (object) [
+ 'show' => true,
+ 'lineWidth' => 2,
+ 'fill' => false,
+ ],
+ 'shadowSize' => 0,
+ 'prepend_tooltip' => '€ ',
+ 'tooltipFormatter' => 'money',
+ 'class' => 'failed-sum',
+ ],
+ ];
+
+ foreach ( $data as $serie ) {
+ // @codingStandardsIgnoreStart
+ $serie->legendValue = array_sum( wp_list_pluck( $serie->data, 1 ) );
+ // @codingStandardsIgnoreEnd
+ }
+
+ return $data;
+ }
+
+ /**
+ * Get report.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.3.11/assets/js/admin/reports.js
+ * @link https://github.com/woothemes/woocommerce/blob/master/includes/admin/reports/class-wc-report-sales-by-date.php
+ *
+ * @param string $status Status.
+ * @param string $aggregate Aggregate function.
+ * @param \DateTime $start Start date.
+ * @param \DateTime $end End date.
+ *
+ * @return array
+ *
+ * @throws \Exception Throws exception on date interval error.
+ */
+ private function get_report( $status, $aggregate, $start, $end ) {
+ global $wpdb;
+
+ $interval = new \DateInterval( 'P1M' );
+ $period = new \DatePeriod( $start, $interval, $end );
+
+ $date_format = '%Y-%m';
+
+ /* phpcs:ignore WordPress.DB.DirectDatabaseQuery */
+ $results = $wpdb->get_results(
+ $wpdb->prepare(
+ "
+ SELECT
+ DATE_FORMAT( post.post_date, %s ) AS month,
+ post.ID
+ FROM
+ $wpdb->posts AS post
+ WHERE
+ post.post_type = 'pronamic_payment'
+ AND
+ post.post_date BETWEEN %s AND %s
+ AND
+ post.post_status = %s
+ ORDER BY
+ post_date
+ ;
+ ",
+ $date_format,
+ $start->format( 'Y-m-d' ),
+ $end->format( 'Y-m-d' ),
+ $status
+ )
+ );
+
+ $months = wp_list_pluck( $results, 'month' );
+
+ switch ( $aggregate ) {
+ case 'COUNT':
+ $data = array_count_values( $months );
+
+ break;
+ case 'SUM':
+ $data = array_fill_keys(
+ $months,
+ 0
+ );
+
+ foreach ( $results as $post ) {
+ $payment = new Payment( $post->ID );
+
+ $data[ $post->month ] += $payment->get_total_amount()->get_value();
+ }
+
+ break;
+ }
+
+ $report = [];
+
+ foreach ( $period as $date ) {
+ $key = $date->format( 'Y-m' );
+
+ $value = 0;
+
+ if ( isset( $data[ $key ] ) ) {
+ $value = (float) $data[ $key ];
+ }
+
+ $report[] = [
+ // Flot requires milliseconds so multiply with 1000.
+ $date->getTimestamp() * 1000,
+ $value,
+ ];
+ }
+
+ return $report;
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminSettings.php b/packages/wp-pay/core/src/Admin/AdminSettings.php
new file mode 100644
index 0000000..9eb42ab
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminSettings.php
@@ -0,0 +1,305 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Html\Element;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Util;
+
+/**
+ * WordPress iDEAL admin
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.0.0
+ */
+class AdminSettings {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Constructs and initialize an admin object.
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'admin_init', [ $this, 'admin_init' ] );
+ }
+
+ /**
+ * Admin initialize.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ // Settings - General.
+ add_settings_section(
+ 'pronamic_pay_general',
+ __( 'General', 'pronamic_ideal' ),
+ function () {
+ },
+ 'pronamic_pay'
+ );
+
+ // Default Config.
+ add_settings_field(
+ 'pronamic_pay_config_id',
+ __( 'Default Gateway', 'pronamic_ideal' ),
+ [ $this, 'input_page' ],
+ 'pronamic_pay',
+ 'pronamic_pay_general',
+ [
+ 'post_type' => 'pronamic_gateway',
+ 'show_option_none' => __( '— Select a gateway —', 'pronamic_ideal' ),
+ 'label_for' => 'pronamic_pay_config_id',
+ ]
+ );
+
+ // Remove data on uninstall.
+ add_settings_field(
+ 'pronamic_pay_uninstall_clear_data',
+ __( 'Remove Data', 'pronamic_ideal' ),
+ [ $this, 'input_checkbox' ],
+ 'pronamic_pay',
+ 'pronamic_pay_general',
+ [
+ 'legend' => __( 'Remove Data', 'pronamic_ideal' ),
+ 'description' => __( 'Remove all plugin data on uninstall', 'pronamic_ideal' ),
+ 'label_for' => 'pronamic_pay_uninstall_clear_data',
+ 'classes' => 'regular-text',
+ 'type' => 'checkbox',
+ ]
+ );
+
+ // Debug mode.
+ $debug_mode_args = [
+ 'legend' => \__( 'Debug Mode', 'pronamic_ideal' ),
+ 'description' => \__( 'Enable debug mode', 'pronamic_ideal' ),
+ 'label_for' => 'pronamic_pay_debug_mode',
+ 'type' => 'checkbox',
+ ];
+
+ if ( defined( 'PRONAMIC_PAY_DEBUG' ) && PRONAMIC_PAY_DEBUG ) {
+ $debug_mode_args['value'] = true;
+ $debug_mode_args['disabled'] = \disabled( PRONAMIC_PAY_DEBUG, true, false );
+ }
+
+ \add_settings_field(
+ 'pronamic_pay_debug_mode',
+ \__( 'Debug Mode', 'pronamic_ideal' ),
+ [ $this, 'input_checkbox' ],
+ 'pronamic_pay',
+ 'pronamic_pay_general',
+ $debug_mode_args
+ );
+
+ if ( $this->plugin->is_debug_mode() || $this->plugin->subscriptions_module->is_processing_disabled() ) {
+ \add_settings_field(
+ 'pronamic_pay_subscriptions_processing_disabled',
+ \__( 'Disable Recurring Payments', 'pronamic_ideal' ),
+ [ $this, 'input_checkbox' ],
+ 'pronamic_pay',
+ 'pronamic_pay_general',
+ [
+ 'legend' => \__( 'Disable starting recurring payments at gateway', 'pronamic_ideal' ),
+ 'description' => \__( 'Disable starting recurring payments at gateway', 'pronamic_ideal' ),
+ 'label_for' => 'pronamic_pay_subscriptions_processing_disabled',
+ 'type' => 'checkbox',
+ ]
+ );
+ }
+
+ if ( version_compare( $this->plugin->get_version(), '10', '>=' ) ) {
+ // Settings - Payment Methods.
+ \add_settings_section(
+ 'pronamic_pay_payment_methods',
+ \__( 'Payment Methods', 'pronamic_ideal' ),
+ function () {
+ },
+ 'pronamic_pay'
+ );
+
+ foreach ( $this->plugin->get_payment_methods() as $payment_method ) {
+ $id = 'pronamic_pay_payment_method_' . $payment_method->get_id() . '_status';
+
+ add_settings_field(
+ $id,
+ $payment_method->get_name(),
+ [ $this, 'select_payment_method_status' ],
+ 'pronamic_pay',
+ 'pronamic_pay_payment_methods',
+ [
+ 'label_for' => $id,
+ ]
+ );
+ }
+ }
+ }
+
+ /**
+ * Input text.
+ *
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function input_element( $args ) {
+ $defaults = [
+ 'type' => 'text',
+ 'classes' => 'regular-text',
+ 'description' => '',
+ ];
+
+ $args = wp_parse_args( $args, $defaults );
+
+ $name = $args['label_for'];
+ $value = get_option( $name );
+
+ $element = new Element(
+ 'input',
+ [
+ 'name' => $name,
+ 'id' => $name,
+ 'type' => $args['type'],
+ 'class' => $args['classes'],
+ 'value' => $value,
+ ]
+ );
+
+ $element->output();
+
+ if ( ! empty( $args['description'] ) ) {
+ printf(
+ '%s
',
+ esc_html( $args['description'] )
+ );
+ }
+ }
+
+ /**
+ * Input checkbox.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.1/wp-admin/options-writing.php#L60-L68
+ * @link https://github.com/WordPress/WordPress/blob/4.9.1/wp-admin/options-reading.php#L110-L141
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function input_checkbox( $args ) {
+ $id = $args['label_for'];
+ $name = $args['label_for'];
+ $value = \array_key_exists( 'value', $args ) ? $args['value'] : get_option( $name );
+ $legend = $args['legend'];
+
+ echo '';
+
+ printf(
+ '%s ',
+ esc_html( $legend )
+ );
+
+ printf(
+ '',
+ esc_attr( $id )
+ );
+
+ $attributes = [
+ 'name' => $name,
+ 'id' => $id,
+ 'type' => 'checkbox',
+ 'value' => '1',
+ ];
+
+ if ( $value ) {
+ $attributes['checked'] = 'checked';
+ }
+
+ if ( \array_key_exists( 'disabled', $args ) && $args['disabled'] ) {
+ $attributes['disabled'] = 'disabled';
+ }
+
+ $element = new Element( 'input', $attributes );
+
+ $element->output();
+
+ echo esc_html( $args['description'] );
+
+ echo ' ';
+
+ echo ' ';
+ }
+
+ /**
+ * Input page.
+ *
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function input_page( $args ) {
+ $name = $args['label_for'];
+
+ $selected = get_option( $name, '' );
+
+ if ( false === $selected ) {
+ $selected = '';
+ }
+
+ wp_dropdown_pages(
+ [
+ 'name' => esc_attr( $name ),
+ 'post_type' => esc_attr( isset( $args['post_type'] ) ? $args['post_type'] : 'page' ),
+ 'selected' => esc_attr( $selected ),
+ 'show_option_none' => esc_attr( isset( $args['show_option_none'] ) ? $args['show_option_none'] : __( '— Select a page —', 'pronamic_ideal' ) ),
+ 'class' => 'regular-text',
+ ]
+ );
+ }
+
+ /**
+ * Select payment method status.
+ *
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function select_payment_method_status( $args ) {
+ $name = $args['label_for'];
+
+ $selected = get_option( $name, '' );
+
+ $statuses = [
+ '' => '',
+ 'active' => \__( 'Active', 'pronamic_ideal' ),
+ 'inactive' => \__( 'Inactive', 'pronamic_ideal' ),
+ ];
+
+ \printf(
+ '',
+ \esc_attr( $name ),
+ \esc_attr( $name )
+ );
+
+ foreach ( $statuses as $status => $label ) {
+ \printf(
+ '%s ',
+ \esc_attr( $status ),
+ \selected( $status, $selected, false ),
+ \esc_html( $label )
+ );
+ }
+
+ echo ' ';
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminSubscriptionPostType.php b/packages/wp-pay/core/src/Admin/AdminSubscriptionPostType.php
new file mode 100644
index 0000000..fbc1253
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminSubscriptionPostType.php
@@ -0,0 +1,852 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPeriod;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPostType;
+use Pronamic\WordPress\Pay\Util;
+use WP_Post;
+use WP_Query;
+
+/**
+ * WordPress admin subscription post type
+ *
+ * @author Reüel van der Steege
+ * @version 2.5.0
+ * @since 1.0.0
+ */
+class AdminSubscriptionPostType {
+ /**
+ * Post type.
+ *
+ * @var string
+ */
+ const POST_TYPE = 'pronamic_pay_subscr';
+
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Constructs and initializes an admin payment post type object.
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( $plugin ) {
+ $this->plugin = $plugin;
+
+ add_filter( 'request', [ $this, 'request' ] );
+
+ add_filter( 'manage_edit-' . self::POST_TYPE . '_columns', [ $this, 'columns' ] );
+ add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', [ $this, 'sortable_columns' ] );
+ add_filter( 'bulk_actions-edit-' . self::POST_TYPE, [ $this, 'bulk_actions' ] );
+ add_filter( 'list_table_primary_column', [ $this, 'primary_column' ], 10, 2 );
+
+ add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', [ $this, 'custom_columns' ], 10, 2 );
+
+ add_action( 'load-post.php', [ $this, 'maybe_process_subscription_action' ] );
+
+ add_action( 'admin_notices', [ $this, 'admin_notices' ] );
+
+ add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
+
+ add_filter( 'post_row_actions', [ $this, 'post_row_actions' ], 10, 2 );
+
+ add_action( 'pre_get_posts', [ $this, 'pre_get_posts' ] );
+
+ add_filter( 'removable_query_args', [ $this, 'removable_query_args' ] );
+
+ add_filter( 'post_updated_messages', [ $this, 'post_updated_messages' ] );
+ }
+
+ /**
+ * Filters and sorting handler.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.3.13/includes/admin/class-wc-admin-post-types.php#L1585-L1596
+ *
+ * @param array $vars Request variables.
+ * @return array
+ */
+ public function request( $vars ) {
+ $screen = get_current_screen();
+
+ if ( null === $screen ) {
+ return $vars;
+ }
+
+ // Check payment post type.
+ if ( self::POST_TYPE !== $screen->post_type ) {
+ return $vars;
+ }
+
+ // Check post status var.
+ if ( isset( $vars['post_status'] ) && ! empty( $vars['post_status'] ) ) {
+ return $vars;
+ }
+
+ // Set request post status from payment states.
+ $vars['post_status'] = array_keys( SubscriptionPostType::get_states() );
+ $vars['post_status'][] = 'publish';
+
+ return $vars;
+ }
+
+ /**
+ * Removable query arguments.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/5.3/wp-admin/includes/misc.php#L1204-L1230
+ * @link https://developer.wordpress.org/reference/functions/wp_removable_query_args/
+ * @param array $args Arguments.
+ * @return array
+ */
+ public function removable_query_args( $args ) {
+ $args[] = 'pronamic_payment_created';
+
+ return $args;
+ }
+
+ /**
+ * Custom bulk actions.
+ *
+ * @link https://make.wordpress.org/core/2016/10/04/custom-bulk-actions/
+ * @link https://github.com/WordPress/WordPress/blob/4.7/wp-admin/includes/class-wp-list-table.php#L440-L452
+ * @param array $bulk_actions Bulk actions.
+ * @return array
+ */
+ public function bulk_actions( $bulk_actions ) {
+ // Don't allow edit in bulk.
+ unset( $bulk_actions['edit'] );
+
+ return $bulk_actions;
+ }
+
+ /**
+ * Maybe process subscription action.
+ *
+ * @return void
+ */
+ public function maybe_process_subscription_action() {
+ // Current user.
+ if ( ! \current_user_can( 'edit_payments' ) ) {
+ return;
+ }
+
+ // Screen.
+ $screen = \get_current_screen();
+
+ if ( null === $screen ) {
+ return;
+ }
+
+ if ( ! ( 'post' === $screen->base && 'pronamic_pay_subscr' === $screen->post_type ) ) {
+ return;
+ }
+
+ $post_id = \filter_input( \INPUT_GET, 'post', \FILTER_SANITIZE_NUMBER_INT );
+
+ $subscription = \get_pronamic_subscription( $post_id );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ // Start payment for next period action.
+ if ( \filter_input( \INPUT_GET, 'period_payment', \FILTER_VALIDATE_BOOLEAN ) && \check_admin_referer( 'pronamic_period_payment_' . $post_id ) ) {
+ try {
+ $sequence_number = \filter_input( INPUT_GET, 'sequence_number', \FILTER_VALIDATE_INT );
+
+ $phase = $subscription->get_phase_by_sequence_number( $sequence_number );
+
+ if ( null === $phase ) {
+ return;
+ }
+
+ if ( ! isset( $_GET['start_date'] ) || ! isset( $_GET['end_date'] ) ) {
+ return;
+ }
+
+ $start_date = new DateTimeImmutable( \sanitize_text_field( \wp_unslash( $_GET['start_date'] ) ) );
+ $end_date = new DateTimeImmutable( \sanitize_text_field( \wp_unslash( $_GET['end_date'] ) ) );
+
+ $period = new SubscriptionPeriod( $phase, $start_date, $end_date, $phase->get_amount() );
+
+ $payment = $period->new_payment();
+
+ $payment->set_meta( 'mollie_sequence_type', 'recurring' );
+
+ $payment->set_lines( $subscription->get_lines() );
+
+ $payment = Plugin::start_payment( $payment );
+
+ // Redirect for notice.
+ $url = \add_query_arg(
+ 'pronamic_payment_created',
+ $payment->get_id(),
+ \get_edit_post_link( $post_id, 'raw' )
+ );
+
+ \wp_safe_redirect( $url );
+
+ exit;
+ } catch ( \Exception $e ) {
+ Plugin::render_exception( $e );
+
+ exit;
+ }
+ }
+ }
+
+ /**
+ * Admin notices.
+ *
+ * @return void
+ */
+ public function admin_notices() {
+ /* phpcs:ignore WordPress.Security.NonceVerification.Recommended */
+ $payment_ids = \array_key_exists( 'pronamic_payment_created', $_GET ) ? \sanitize_text_field( \wp_unslash( $_GET['pronamic_payment_created'] ) ) : null;
+
+ if ( null === $payment_ids ) {
+ return;
+ }
+
+ // Payment created for period.
+ $payment_ids = \wp_parse_id_list( $payment_ids );
+
+ foreach ( $payment_ids as $payment_id ) {
+ $edit_post_link = \sprintf(
+ /* translators: %d: payment ID */
+ __( 'Payment #%d', 'pronamic_ideal' ),
+ $payment_id
+ );
+
+ // Add post edit link.
+ $edit_post_url = \get_edit_post_link( $payment_id );
+
+ if ( null !== $edit_post_url ) {
+ $edit_post_link = \sprintf(
+ '%2$s ',
+ \esc_url( $edit_post_url ),
+ $edit_post_link
+ );
+ }
+
+ // Display notice.
+ \printf(
+ '',
+ \wp_kses_post(
+ \sprintf(
+ /* translators: %s: payment post edit link */
+ __( '%s has been created.', 'pronamic_ideal' ),
+ \wp_kses_post( $edit_post_link )
+ )
+ )
+ );
+ }
+ }
+
+ /**
+ * Pre get posts.
+ *
+ * @param WP_Query $query WordPress query.
+ * @return void
+ */
+ public function pre_get_posts( $query ) {
+ /**
+ * The `WP_Query::get` function can return different variable type.
+ * For now this function can only handle one specific string orderby.
+ *
+ * @link https://developer.wordpress.org/reference/classes/wp_query/get/
+ * @link https://developer.wordpress.org/reference/classes/wp_query/#order-orderby-parameters
+ * @link https://github.com/WordPress/WordPress/blob/5.2/wp-includes/class-wp-query.php#L1697-L1713
+ */
+ $orderby = $query->get( 'orderby' );
+
+ if ( ! is_string( $orderby ) ) {
+ return;
+ }
+
+ $map = [
+ 'pronamic_subscription_next_payment' => '_pronamic_subscription_next_payment',
+ ];
+
+ if ( ! isset( $map[ $orderby ] ) ) {
+ return;
+ }
+
+ $meta_key = $map[ $orderby ];
+
+ $query->set( 'meta_key', $meta_key );
+ $query->set( 'orderby', 'meta_value' );
+ }
+
+ /**
+ * Columns.
+ *
+ * @param array $columns Columns.
+ * @return array
+ */
+ public function columns( $columns ) {
+ $columns = [
+ 'cb' => ' ',
+ 'pronamic_subscription_status' => sprintf(
+ '%s ',
+ esc_html__( 'Status', 'pronamic_ideal' ),
+ esc_html__( 'Status', 'pronamic_ideal' )
+ ),
+ 'pronamic_subscription_method' => '',
+ 'pronamic_subscription_title' => __( 'Subscription', 'pronamic_ideal' ),
+ 'pronamic_subscription_customer' => __( 'Customer', 'pronamic_ideal' ),
+ 'pronamic_subscription_amount' => __( 'Amount', 'pronamic_ideal' ),
+ 'pronamic_subscription_recurring' => __( 'Recurrence', 'pronamic_ideal' ),
+ 'pronamic_subscription_next_payment' => __( 'Next payment', 'pronamic_ideal' ),
+ 'pronamic_subscription_date' => __( 'Date', 'pronamic_ideal' ),
+ ];
+
+ return $columns;
+ }
+
+ /**
+ * Sortable columns.
+ *
+ * @param array $sortable_columns Sortable columns.
+ * @return array
+ */
+ public function sortable_columns( $sortable_columns ) {
+ $sortable_columns['pronamic_subscription_title'] = 'ID';
+ $sortable_columns['pronamic_subscription_next_payment'] = 'pronamic_subscription_next_payment';
+ $sortable_columns['pronamic_subscription_date'] = 'date';
+
+ return $sortable_columns;
+ }
+
+ /**
+ * Primary column name.
+ *
+ * @param string $column_name Primary column name.
+ * @param string $screen_id Screen ID.
+ *
+ * @return string
+ */
+ public function primary_column( $column_name, $screen_id ) {
+ if ( 'edit-pronamic_pay_subscr' !== $screen_id ) {
+ return $column_name;
+ }
+
+ return 'pronamic_subscription_title';
+ }
+
+ /**
+ * Custom columns.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/5.1/wp-admin/includes/class-wp-posts-list-table.php#L1183-L1193
+ *
+ * @param string $column Column.
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public function custom_columns( $column, $post_id ) {
+ $subscription = get_pronamic_subscription( $post_id );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ $phase = $subscription->get_display_phase();
+
+ switch ( $column ) {
+ case 'pronamic_subscription_status':
+ $post_status = get_post_status( $post_id );
+
+ $label = __( 'Unknown', 'pronamic_ideal' );
+
+ if ( 'trash' === $post_status ) {
+ $post_status = get_post_meta( $post_id, '_wp_trash_meta_status', true );
+ }
+
+ $status_object = get_post_status_object( $post_status );
+
+ if ( isset( $status_object, $status_object->label ) ) {
+ $label = $status_object->label;
+ }
+
+ printf(
+ '%s ',
+ esc_attr( AdminModule::get_post_status_icon_class( $post_status ) ),
+ esc_attr( $label ),
+ esc_html( $label )
+ );
+
+ break;
+ case 'pronamic_subscription_method':
+ $payment_method = $subscription->get_payment_method();
+
+ $icon_url = PaymentMethods::get_icon_url( $payment_method );
+
+ if ( null !== $icon_url ) {
+ \printf(
+ ' ',
+ \esc_url( $icon_url ),
+ \esc_attr( (string) PaymentMethods::get_name( $payment_method ) )
+ );
+ }
+
+ break;
+ case 'pronamic_subscription_title':
+ $source_id = $subscription->get_source_id();
+ $source_description = $subscription->get_source_description();
+
+ $text = sprintf(
+ '#%s ',
+ esc_html( strval( $post_id ) )
+ );
+
+ $link = get_edit_post_link( $post_id );
+
+ if ( null !== $link ) {
+ $text = sprintf(
+ '%s ',
+ esc_url( $link ),
+ $text
+ );
+ }
+
+ $source_id_text = '#' . strval( $source_id );
+
+ $source_link = $subscription->get_source_link();
+
+ if ( null !== $source_link ) {
+ $source_id_text = sprintf(
+ '%s ',
+ esc_url( $source_link ),
+ $source_id_text
+ );
+ }
+
+ echo wp_kses(
+ sprintf(
+ /* translators: 1: edit post link with post ID, 2: source description, 3: source ID text */
+ __( '%1$s for %2$s %3$s', 'pronamic_ideal' ),
+ $text,
+ $source_description,
+ $source_id_text
+ ),
+ [
+ 'a' => [
+ 'href' => true,
+ 'class' => true,
+ ],
+ 'strong' => [],
+ ]
+ );
+
+ break;
+ case 'pronamic_subscription_gateway':
+ $payment = get_pronamic_payment_by_meta( '_pronamic_payment_subscription_id', $post_id );
+
+ $config_id = null;
+
+ if ( $payment ) {
+ $payment_id = $payment->get_id();
+
+ if ( null !== $payment_id ) {
+ $config_id = get_post_meta( $payment_id, '_pronamic_payment_config_id', true );
+ }
+ }
+
+ echo empty( $config_id ) ? '—' : esc_html( get_the_title( $config_id ) );
+
+ break;
+ case 'pronamic_subscription_description':
+ echo esc_html( get_post_meta( $post_id, '_pronamic_subscription_description', true ) );
+
+ break;
+ case 'pronamic_subscription_amount':
+ echo esc_html( null === $phase ? '—' : $phase->get_amount()->format_i18n() );
+
+ break;
+ case 'pronamic_subscription_recurring':
+ $total_periods = ( null === $phase ? null : $phase->get_total_periods() );
+
+ if ( null === $phase || 1 === $total_periods ) :
+ // No recurrence.
+ echo '—';
+
+ elseif ( null === $total_periods ) :
+ // Infinite.
+ echo esc_html( strval( Util::format_recurrences( $phase->get_interval() ) ) );
+
+ else :
+ // Fixed number of recurrences.
+ printf(
+ '%s %s',
+ esc_html( strval( Util::format_recurrences( $phase->get_interval() ) ) ),
+ esc_html( strval( Util::format_frequency( $total_periods ) ) )
+ );
+
+ endif;
+
+ break;
+ case 'pronamic_subscription_next_payment':
+ $next_payment_date = $subscription->get_next_payment_date();
+
+ echo empty( $next_payment_date ) ? '—' : esc_html( $next_payment_date->format_i18n( \__( 'D j M Y', 'pronamic_ideal' ) ) );
+
+ break;
+ case 'pronamic_subscription_date':
+ if ( null !== $subscription->date ) {
+ echo esc_html( $subscription->date->format_i18n() );
+ }
+
+ break;
+ case 'pronamic_subscription_customer':
+ $text = get_post_meta( $post_id, '_pronamic_subscription_customer_name', true );
+
+ $customer = $subscription->get_customer();
+
+ if ( null !== $customer ) {
+ $contact_name = $customer->get_name();
+
+ if ( null !== $contact_name ) {
+ $text = strval( $contact_name );
+ }
+
+ if ( empty( $text ) ) {
+ $text = $customer->get_email();
+ }
+ }
+
+ echo esc_html( $text );
+
+ break;
+ }
+ }
+
+ /**
+ * Add meta boxes.
+ *
+ * @param string $post_type Post Type.
+ * @return void
+ */
+ public function add_meta_boxes( $post_type ) {
+ if ( self::POST_TYPE !== $post_type ) {
+ return;
+ }
+
+ add_meta_box(
+ 'pronamic_subscription',
+ __( 'Subscription', 'pronamic_ideal' ),
+ [ $this, 'meta_box_info' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_payment_lines',
+ __( 'Payment Lines', 'pronamic_ideal' ),
+ [ $this, 'meta_box_lines' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_subscription_phases',
+ __( 'Phases', 'pronamic_ideal' ),
+ [ $this, 'meta_box_phases' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_subscription_payments',
+ __( 'Payments', 'pronamic_ideal' ),
+ [ $this, 'meta_box_payments' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_subscription_notes',
+ __( 'Notes', 'pronamic_ideal' ),
+ [ $this, 'meta_box_notes' ],
+ $post_type,
+ 'normal',
+ 'high'
+ );
+
+ add_meta_box(
+ 'pronamic_subscription_update',
+ __( 'Update', 'pronamic_ideal' ),
+ [ $this, 'meta_box_update' ],
+ $post_type,
+ 'side',
+ 'high'
+ );
+
+ // @link http://kovshenin.com/2012/how-to-remove-the-publish-box-from-a-post-type/.
+ remove_meta_box( 'submitdiv', $post_type, 'side' );
+ }
+
+ /**
+ * Pronamic Pay subscription info meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_info( $post ) {
+ $plugin = $this->plugin;
+ $subscription = get_pronamic_subscription( $post->ID );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ include __DIR__ . '/../../views/meta-box-subscription-info.php';
+ }
+
+ /**
+ * Pronamic Pay payment lines meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_lines( $post ) {
+ $subscription = get_pronamic_subscription( $post->ID );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ $lines = $subscription->get_lines();
+
+ include __DIR__ . '/../../views/meta-box-payment-lines.php';
+ }
+
+ /**
+ * Pronamic Pay subscription notes meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_notes( $post ) {
+ $notes = get_comments(
+ [
+ 'post_id' => $post->ID,
+ 'type' => 'subscription_note',
+ 'orderby' => [ 'comment_date_gmt', 'comment_ID' ],
+ ]
+ );
+
+ include __DIR__ . '/../../views/meta-box-notes.php';
+ }
+
+ /**
+ * Pronamic Pay subscription phases meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_phases( $post ) {
+ $subscription = get_pronamic_subscription( $post->ID );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ $phases = $subscription->get_phases();
+
+ include __DIR__ . '/../../views/meta-box-subscription-phases.php';
+ }
+
+ /**
+ * Pronamic Pay subscription payments meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_payments( $post ) {
+ $subscription = get_pronamic_subscription( $post->ID );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ $plugin = $this->plugin;
+
+ include __DIR__ . '/../../views/meta-box-subscription-payments.php';
+ }
+
+ /**
+ * Post row actions.
+ *
+ * @param array $actions Actions array.
+ * @param WP_Post $post WordPress post.
+ * @return array
+ */
+ public function post_row_actions( $actions, $post ) {
+ if ( self::POST_TYPE === $post->post_type ) {
+ $actions = [ '' ];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * Post updated messages.
+ *
+ * @link https://codex.wordpress.org/Function_Reference/register_post_type
+ * @link https://github.com/WordPress/WordPress/blob/4.4.2/wp-admin/edit-form-advanced.php#L134-L173
+ * @link https://github.com/woothemes/woocommerce/blob/2.5.5/includes/admin/class-wc-admin-post-types.php#L111-L168
+ * @param array $messages Message.
+ * @return array
+ */
+ public function post_updated_messages( $messages ) {
+ global $post;
+
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352797&filters[translation_id]=37948900
+ $scheduled_date = date_i18n( __( 'M j, Y @ H:i', 'pronamic_ideal' ), strtotime( $post->post_date ) );
+
+ $messages[ self::POST_TYPE ] = [
+ 0 => '', // Unused. Messages start at index 1.
+ 1 => __( 'Subscription updated.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352799&filters[translation_id]=37947229.
+ 2 => $messages['post'][2],
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352800&filters[translation_id]=37947870.
+ 3 => $messages['post'][3],
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352798&filters[translation_id]=37947230.
+ 4 => __( 'Subscription updated.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352801&filters[translation_id]=37947231.
+ /* phpcs:disable WordPress.Security.NonceVerification.Recommended */
+ /* translators: %s: date and time of the revision */
+ 5 => isset( $_GET['revision'] ) ? sprintf( __( 'Subscription restored to revision from %s.', 'pronamic_ideal' ), strval( wp_post_revision_title( (int) $_GET['revision'], false ) ) ) : false,
+ /* phpcs:enable WordPress.Security.NonceVerification.Recommended */
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352802&filters[translation_id]=37949178.
+ 6 => __( 'Subscription published.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352803&filters[translation_id]=37947232.
+ 7 => __( 'Subscription saved.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352804&filters[translation_id]=37949303.
+ 8 => __( 'Subscription submitted.', 'pronamic_ideal' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352805&filters[translation_id]=37949302.
+ /* translators: %s: scheduled date */
+ 9 => sprintf( __( 'Subscription scheduled for: %s.', 'pronamic_ideal' ), '' . $scheduled_date . ' ' ),
+ // @link https://translate.wordpress.org/projects/wp/4.4.x/admin/nl/default?filters[status]=either&filters[original_id]=2352806&filters[translation_id]=37949301.
+ 10 => __( 'Subscription draft updated.', 'pronamic_ideal' ),
+ ];
+
+ return $messages;
+ }
+
+ /**
+ * Pronamic Pay subscription update meta box.
+ *
+ * @param WP_Post $post The object for the current post/page.
+ * @return void
+ */
+ public function meta_box_update( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is used in include.
+ $post
+ ) {
+ wp_nonce_field( 'pronamic_subscription_update', 'pronamic_subscription_update_nonce' );
+
+ include __DIR__ . '/../../views/meta-box-subscription-update.php';
+ }
+
+ /**
+ * Admin init.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ $this->maybe_update_subscription();
+ }
+
+ /**
+ * Maybe update subscription.
+ *
+ * @return void
+ */
+ private function maybe_update_subscription() {
+ if ( ! \array_key_exists( 'pronamic_subscription_update', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_subscription_id', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_subscription_status', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_subscription_update_nonce', $_POST ) ) {
+ return;
+ }
+
+ $nonce = \sanitize_text_field( \wp_unslash( $_POST['pronamic_subscription_update_nonce'] ) );
+
+ if ( ! \wp_verify_nonce( $nonce, 'pronamic_subscription_update' ) ) {
+ \wp_die( \esc_html__( 'Action failed. Please refresh the page and retry.', 'pronamic_ideal' ) );
+ }
+
+ $subscription_id = (int) \sanitize_text_field( \wp_unslash( $_POST['pronamic_subscription_id'] ) );
+
+ $subscription = \get_pronamic_subscription( $subscription_id );
+
+ if ( null === $subscription ) {
+ return;
+ }
+
+ $status = \sanitize_text_field( \wp_unslash( $_POST['pronamic_subscription_status'] ) );
+
+ if ( '' !== $status ) {
+ $subscription->set_status( $status );
+ }
+
+ if ( \array_key_exists( 'hidden_pronamic_pay_next_payment_date', $_POST ) && \array_key_exists( 'pronamic_subscription_next_payment_date', $_POST ) ) {
+ $old_value = \sanitize_text_field( \wp_unslash( $_POST['hidden_pronamic_pay_next_payment_date'] ) );
+
+ $new_value = \sanitize_text_field( \wp_unslash( $_POST['pronamic_subscription_next_payment_date'] ) );
+
+ if ( ! empty( $new_value ) && $old_value !== $new_value ) {
+ $new_date = new DateTimeImmutable( $new_value );
+
+ $next_payment_date = $subscription->get_next_payment_date();
+
+ $updated_date = null === $next_payment_date ? clone $new_date : clone $next_payment_date;
+
+ $updated_date = $updated_date->setDate( (int) $new_date->format( 'Y' ), (int) $new_date->format( 'm' ), (int) $new_date->format( 'd' ) );
+
+ if ( false !== $updated_date ) {
+ $subscription->set_next_payment_date( $updated_date );
+
+ $note = \sprintf(
+ /* translators: %1: old formatted date, %2: new formatted date */
+ \__( 'Next payment date updated from %1$s to %2$s.', 'pronamic_ideal' ),
+ null === $next_payment_date ? '' : $next_payment_date->format_i18n( \__( 'D j M Y', 'pronamic_ideal' ) ),
+ $updated_date->format_i18n( \__( 'D j M Y', 'pronamic_ideal' ) )
+ );
+
+ $subscription->add_note( $note );
+ }
+ }
+ }
+
+ $subscription->save();
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/AdminTour.php b/packages/wp-pay/core/src/Admin/AdminTour.php
new file mode 100644
index 0000000..d9a2756
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/AdminTour.php
@@ -0,0 +1,429 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * WordPress admin tour
+ *
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 1.0.0
+ */
+class AdminTour {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Constructs and initializes an pointers object.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.2.4/wp-includes/js/wp-pointer.js
+ * @link https://github.com/WordPress/WordPress/blob/4.2.4/wp-admin/includes/template.php#L1955-L2016
+ * @link https://github.com/Yoast/wordpress-seo/blob/2.3.4/admin/class-pointers.php
+ *
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'admin_init', [ $this, 'admin_init' ] );
+ }
+
+ /**
+ * Admin initialize.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ $this->handle_ignore_tour_request();
+
+ if ( ! get_user_meta( get_current_user_id(), 'pronamic_pay_ignore_tour', true ) ) {
+ add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
+ }
+ }
+
+ /**
+ * Handle ignore tour request.
+ *
+ * @return void
+ */
+ private function handle_ignore_tour_request() {
+ if ( ! \array_key_exists( 'pronamic_pay_ignore_tour', $_GET ) ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_pay_nonce', $_GET ) ) {
+ return;
+ }
+
+ $nonce = \sanitize_text_field( \wp_unslash( $_GET['pronamic_pay_nonce'] ) );
+
+ if ( ! \wp_verify_nonce( $nonce, 'pronamic_pay_ignore_tour' ) ) {
+ return;
+ }
+
+ $value = \sanitize_text_field( \wp_unslash( $_GET['pronamic_pay_ignore_tour'] ) );
+
+ \update_user_meta( \get_current_user_id(), 'pronamic_pay_ignore_tour', ( 'true' === $value ) );
+ }
+
+ /**
+ * Admin enqueue scripts.
+ *
+ * @return void
+ */
+ public function admin_enqueue_scripts() {
+ $min = \SCRIPT_DEBUG ? '' : '.min';
+
+ // Pointers.
+ wp_register_style(
+ 'pronamic-pay-admin-tour',
+ plugins_url( '../../css/admin-tour' . $min . '.css', __FILE__ ),
+ [
+ 'wp-pointer',
+ ],
+ $this->plugin->get_version()
+ );
+
+ wp_register_script(
+ 'pronamic-pay-admin-tour',
+ plugins_url( '../../js/dist/admin-tour' . $min . '.js', __FILE__ ),
+ [
+ 'jquery',
+ 'wp-pointer',
+ ],
+ $this->plugin->get_version(),
+ true
+ );
+
+ wp_localize_script(
+ 'pronamic-pay-admin-tour',
+ 'pronamicPayAdminTour',
+ [
+ 'pointers' => $this->get_pointers(),
+ ]
+ );
+
+ // Enqueue.
+ wp_enqueue_style( 'pronamic-pay-admin-tour' );
+ wp_enqueue_script( 'pronamic-pay-admin-tour' );
+ }
+
+ /**
+ * Get pointer content.
+ *
+ * @param string $pointer Pointer key.
+ * @return string
+ * @throws \Exception When output buffering is not active.
+ */
+ private function get_content( $pointer ) {
+ $content = '';
+
+ $path = __DIR__ . '/../../views/pointer-' . $pointer . '.php';
+
+ if ( is_readable( $path ) ) {
+ ob_start();
+
+ $admin_tour = $this;
+
+ include $path;
+
+ $content = '';
+
+ $output = ob_get_clean();
+
+ if ( false !== $output ) {
+ $content .= $output;
+ }
+
+ $content .= $this->get_navigation( $pointer );
+ }
+
+ return $content;
+ }
+
+ /**
+ * Get pointers.
+ *
+ * @return array
+ */
+ private function get_pointers() {
+ $pointers = [];
+
+ $screen = get_current_screen();
+
+ if ( null !== $screen ) {
+ switch ( $screen->id ) {
+ case 'toplevel_page_pronamic_ideal':
+ $pointers = [
+ [
+ // @link https://github.com/WordPress/WordPress/blob/4.7/wp-admin/edit.php#L321
+ 'selector' => '.wrap h1',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'dashboard' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ case 'edit-pronamic_payment':
+ $pointers = [
+ [
+ 'selector' => '.wrap .wp-header-end',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'payments' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ case 'edit-pronamic_gateway':
+ $pointers = [
+ [
+ 'selector' => '.wrap .wp-header-end',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'gateways' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ case 'edit-pronamic_pay_form':
+ $pointers = [
+ [
+ 'selector' => '.wrap .wp-header-end',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'forms' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ }
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $page = \array_key_exists( 'page', $_GET ) ? \sanitize_text_field( \wp_unslash( $_GET['page'] ) ) : '';
+
+ switch ( $page ) {
+ case 'pronamic_pay_settings':
+ $pointers = [
+ [
+ 'selector' => '.wrap .wp-header-end',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'settings' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ case 'pronamic_pay_reports':
+ $pointers = [
+ [
+ 'selector' => '.wrap .wp-header-end',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'reports' ),
+ 'position' => (object) [
+ 'edge' => 'top',
+ 'align' => ( is_rtl() ) ? 'left' : 'right',
+ ],
+ 'pointerWidth' => 450,
+ ],
+ ],
+ ];
+
+ break;
+ }
+
+ if ( empty( $pointers ) ) {
+ $pointers = [
+ [
+ 'selector' => 'li.toplevel_page_pronamic_ideal',
+ 'options' => (object) [
+ 'content' => $this->get_content( 'start' ),
+ 'position' => (object) [
+ 'edge' => 'left',
+ 'align' => 'center',
+ ],
+ ],
+ ],
+ ];
+ }
+
+ return $pointers;
+ }
+
+ /**
+ * Get tour close URL.
+ *
+ * @return string
+ */
+ public function get_close_url() {
+ return wp_nonce_url(
+ add_query_arg(
+ [
+ 'pronamic_pay_ignore_tour' => 'true',
+ ]
+ ),
+ 'pronamic_pay_ignore_tour',
+ 'pronamic_pay_nonce'
+ );
+ }
+
+ /**
+ * Get pages.
+ *
+ * @return string[]
+ */
+ private function get_pages() {
+ $modules = \apply_filters( 'pronamic_pay_modules', [] );
+
+ $pages = [
+ 'dashboard' => \add_query_arg( 'page', 'pronamic_ideal', \admin_url( 'edit.php' ) ),
+ 'payments' => \add_query_arg( 'post_type', 'pronamic_payment', \admin_url( 'edit.php' ) ),
+ 'gateways' => \add_query_arg( 'post_type', 'pronamic_gateway', \admin_url( 'edit.php' ) ),
+ 'settings' => \add_query_arg( 'page', 'pronamic_pay_settings', \admin_url( 'admin.php' ) ),
+ ];
+
+ if ( \in_array( 'forms', $modules, true ) ) {
+ $pages['forms'] = \add_query_arg( 'post_type', 'pronamic_pay_form', \admin_url( 'edit.php' ) );
+ }
+
+ if ( \in_array( 'reports', $modules, true ) ) {
+ $pages['reports'] = \add_query_arg( 'page', 'pronamic_pay_reports', \admin_url( 'admin.php' ) );
+ }
+
+ return $pages;
+ }
+
+ /**
+ * Get navigation.
+ *
+ * @param string $current Current page.
+ * @return string
+ */
+ private function get_navigation( $current ) {
+ $content = '';
+
+ return $content;
+ }
+
+ /**
+ * Get next page URL.
+ *
+ * @param string $current Current page key.
+ * @return string|false
+ */
+ private function get_next_page( $current ) {
+ $pages = $this->get_pages();
+
+ do {
+ if ( \key( $pages ) === $current ) {
+ return \next( $pages );
+ }
+ } while ( \next( $pages ) );
+
+ return false;
+ }
+
+ /**
+ * Get previous page URL.
+ *
+ * @param string $current Current page key.
+ * @return string|false
+ */
+ private function get_previous_page( $current ) {
+ $pages = $this->get_pages();
+
+ do {
+ if ( \key( $pages ) === $current ) {
+ return \prev( $pages );
+ }
+ } while ( \next( $pages ) );
+
+ return false;
+ }
+}
diff --git a/packages/wp-pay/core/src/Admin/Install.php b/packages/wp-pay/core/src/Admin/Install.php
new file mode 100644
index 0000000..11184ad
--- /dev/null
+++ b/packages/wp-pay/core/src/Admin/Install.php
@@ -0,0 +1,173 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Admin
+ */
+
+namespace Pronamic\WordPress\Pay\Admin;
+
+use Pronamic\WordPress\Pay\AbstractIntegration;
+use Pronamic\WordPress\Pay\Payments\PaymentPostType;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Upgrades\Upgrade620;
+
+/**
+ * WordPress admin install
+ *
+ * @author Remco Tolsma
+ * @version 2.3.2
+ * @since 1.0.0
+ */
+class Install {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Constructs and initializes an install object.
+ *
+ * @link https://github.com/woothemes/woocommerce/blob/2.4.3/includes/class-wc-install.php
+ * @param Plugin $plugin Plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'init', [ $this, 'init' ], 5 );
+ }
+
+ /**
+ * Initialize.
+ *
+ * @return void
+ */
+ public function init() {
+ if ( \get_option( 'pronamic_pay_version', null ) !== $this->plugin->get_version() ) {
+ $this->install();
+ }
+
+ // Integrations.
+ $integrations = $this->get_upgradeable_integrations();
+
+ foreach ( $integrations as $integration ) {
+ $version_option_name = $integration->get_version_option_name();
+
+ if ( null === $version_option_name ) {
+ continue;
+ }
+
+ $version_option = \strval( $integration->get_version_option() );
+
+ $upgrades = $integration->get_upgrades();
+
+ foreach ( $upgrades as $upgrade ) {
+ $version = $upgrade->get_version();
+
+ if ( ! \version_compare( $version_option, $version, '<' ) ) {
+ continue;
+ }
+
+ $upgrade->execute();
+
+ \update_option( $version_option_name, $version );
+ }
+ }
+ }
+
+ /**
+ * Install.
+ *
+ * @return void
+ */
+ private function install() {
+ // Roles.
+ $this->create_roles();
+
+ // Rewrite Rules.
+ \flush_rewrite_rules();
+
+ // Version.
+ $version = $this->plugin->get_version();
+
+ // Action.
+ \do_action( 'pronamic_pay_install' );
+
+ // Update version.
+ \update_option( 'pronamic_pay_version', $version );
+ }
+
+ /**
+ * Create roles.
+ *
+ * @link https://codex.wordpress.org/Function_Reference/register_post_type
+ * @link https://github.com/woothemes/woocommerce/blob/v2.2.3/includes/class-wc-install.php#L519-L562
+ * @link https://github.com/woothemes/woocommerce/blob/v2.2.3/includes/class-wc-post-types.php#L245
+ * @return void
+ */
+ private function create_roles() {
+ // Payer role.
+ \add_role(
+ 'payer',
+ __( 'Payer', 'pronamic_ideal' ),
+ [
+ 'read' => true,
+ ]
+ );
+
+ // @link https://developer.wordpress.org/reference/functions/wp_roles/.
+ $roles = \wp_roles();
+
+ // Payments.
+ $payment_capabilities = PaymentPostType::get_capabilities();
+
+ unset( $payment_capabilities['publish_posts'] );
+ unset( $payment_capabilities['create_posts'] );
+
+ foreach ( $payment_capabilities as $capability ) {
+ $roles->add_cap( 'administrator', $capability );
+ }
+ }
+
+ /**
+ * Get upgradeable integrations.
+ *
+ * @return array
+ */
+ private function get_upgradeable_integrations() {
+ $integrations = $this->plugin->integrations;
+
+ $integrations = \array_filter(
+ $integrations,
+ /**
+ * Filter integration with version option name.
+ *
+ * @param AbstractIntegration $integration Integration object.
+ * @return bool True if integration has version option name, false otherwise.
+ */
+ function ( $integration ) {
+ if ( ! $integration->is_active() ) {
+ return false;
+ }
+
+ if ( null === $integration->get_version_option_name() ) {
+ return false;
+ }
+
+ if ( ! $integration->get_upgrades()->are_executable() ) {
+ return false;
+ }
+
+ return true;
+ }
+ );
+
+ return $integrations;
+ }
+}
diff --git a/packages/wp-pay/core/src/Banks/BankAccountDetails.php b/packages/wp-pay/core/src/Banks/BankAccountDetails.php
new file mode 100644
index 0000000..6bc9226
--- /dev/null
+++ b/packages/wp-pay/core/src/Banks/BankAccountDetails.php
@@ -0,0 +1,299 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Banks;
+
+/**
+ * Bank details
+ *
+ * @author Reüel van der Steege
+ * @since 2.2.6
+ * @version 2.2.6
+ */
+class BankAccountDetails {
+ /**
+ * Bank name.
+ *
+ * @var string|null
+ */
+ private $bank_name;
+
+ /**
+ * Name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * IBAN.
+ *
+ * @var string|null
+ */
+ private $iban;
+ /**
+ * BIC.
+ *
+ * @var string|null
+ */
+ private $bic;
+
+ /**
+ * Account number.
+ *
+ * @var string|null
+ */
+ private $account_number;
+
+ /**
+ * Account holder city.
+ *
+ * @var string|null
+ */
+ private $city;
+
+ /**
+ * Account holder country.
+ *
+ * @var string|null
+ */
+ private $country;
+
+ /**
+ * Get bank name.
+ *
+ * @return string|null
+ */
+ public function get_bank_name() {
+ return $this->bank_name;
+ }
+
+ /**
+ * Set bank name.
+ *
+ * @param string|null $bank_name Bank name.
+ * @return void
+ */
+ public function set_bank_name( $bank_name ) {
+ $this->bank_name = $bank_name;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set name.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get IBAN.
+ *
+ * @return string|null
+ */
+ public function get_iban() {
+ return $this->iban;
+ }
+
+ /**
+ * Set IBAN.
+ *
+ * @param string|null $iban IBAN.
+ * @return void
+ */
+ public function set_iban( $iban ) {
+ $this->iban = $iban;
+ }
+
+ /**
+ * Get BIC.
+ *
+ * @return string|null
+ */
+ public function get_bic() {
+ return $this->bic;
+ }
+
+ /**
+ * Set BIC.
+ *
+ * @param string|null $bic Bic.
+ * @return void
+ */
+ public function set_bic( $bic ) {
+ $this->bic = $bic;
+ }
+
+ /**
+ * Get account number.
+ *
+ * @return string|null
+ */
+ public function get_account_number() {
+ return $this->account_number;
+ }
+
+ /**
+ * Set account number.
+ *
+ * @param string|null $account_number Account number.
+ * @return void
+ */
+ public function set_account_number( $account_number ) {
+ $this->account_number = $account_number;
+ }
+
+ /**
+ * Get city.
+ *
+ * @return string|null
+ */
+ public function get_city() {
+ return $this->city;
+ }
+
+ /**
+ * Set city.
+ *
+ * @param string|null $city City.
+ * @return void
+ */
+ public function set_city( $city ) {
+ $this->city = $city;
+ }
+
+ /**
+ * Get country.
+ *
+ * @return string|null
+ */
+ public function get_country() {
+ return $this->country;
+ }
+
+ /**
+ * Set country.
+ *
+ * @param string|null $country Country.
+ * @return void
+ */
+ public function set_country( $country ) {
+ $this->country = $country;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'name' => $this->get_name(),
+ 'account_number' => $this->get_account_number(),
+ 'iban' => $this->get_iban(),
+ 'bic' => $this->get_bic(),
+ 'bank_name' => $this->get_bank_name(),
+ 'city' => $this->get_city(),
+ 'country' => $this->get_country(),
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create bank account details from object.
+ *
+ * @param mixed $json JSON.
+ * @param BankAccountDetails|null $bank_account_details Bank account details.
+ *
+ * @return BankAccountDetails
+ *
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json, $bank_account_details = null ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( null === $bank_account_details ) {
+ $bank_account_details = new self();
+ }
+
+ if ( isset( $json->name ) ) {
+ $bank_account_details->set_name( $json->name );
+ }
+
+ if ( isset( $json->iban ) ) {
+ $bank_account_details->set_iban( $json->iban );
+ }
+
+ if ( isset( $json->bic ) ) {
+ $bank_account_details->set_bic( $json->bic );
+ }
+
+ if ( isset( $json->account_number ) ) {
+ $bank_account_details->set_account_number( $json->account_number );
+ }
+
+ if ( isset( $json->bank_name ) ) {
+ $bank_account_details->set_bank_name( $json->bank_name );
+ }
+
+ if ( isset( $json->city ) ) {
+ $bank_account_details->set_city( $json->city );
+ }
+
+ if ( isset( $json->country ) ) {
+ $bank_account_details->set_country( $json->country );
+ }
+
+ return $bank_account_details;
+ }
+
+ /**
+ * Create an string representation of this object
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = [
+ \trim( (string) $this->get_name() ),
+ \trim( (string) $this->get_bank_name() ),
+ \trim( (string) $this->get_iban() ),
+ \trim( (string) $this->get_bic() ),
+ \trim( (string) $this->get_account_number() ),
+ \trim( (string) $this->get_city() ),
+ \trim( (string) $this->get_country() ),
+ ];
+
+ $pieces = \array_filter( $pieces );
+
+ $string = \implode( PHP_EOL, $pieces );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Banks/BankTransferDetails.php b/packages/wp-pay/core/src/Banks/BankTransferDetails.php
new file mode 100644
index 0000000..d763897
--- /dev/null
+++ b/packages/wp-pay/core/src/Banks/BankTransferDetails.php
@@ -0,0 +1,151 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Banks;
+
+/**
+ * Bank transfer details
+ *
+ * @author Reüel van der Steege
+ * @since 2.2.6
+ * @version 2.2.6
+ */
+class BankTransferDetails {
+ /**
+ * Bank account details.
+ *
+ * @var BankAccountDetails|null
+ */
+ private $bank_account;
+
+ /**
+ * Reference.
+ *
+ * @var string|null
+ */
+ private $reference;
+
+ /**
+ * Get bank account.
+ *
+ * @return BankAccountDetails|null
+ */
+ public function get_bank_account() {
+ return $this->bank_account;
+ }
+
+ /**
+ * Set bank account.
+ *
+ * @param BankAccountDetails|null $bank_account Bank account.
+ * @return void
+ */
+ public function set_bank_account( $bank_account ) {
+ $this->bank_account = $bank_account;
+ }
+
+ /**
+ * Get reference.
+ *
+ * @return string|null
+ */
+ public function get_reference() {
+ return $this->reference;
+ }
+
+ /**
+ * Set reference.
+ *
+ * @param string|null $reference Reference.
+ * @return void
+ */
+ public function set_reference( $reference ) {
+ $this->reference = $reference;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [];
+
+ // Bank account.
+ $bank_account = $this->get_bank_account();
+
+ if ( null !== $bank_account ) {
+ $data['bank_account'] = $bank_account->get_json();
+ }
+
+ // Reference.
+ $data['reference'] = $this->get_reference();
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create bank account details from object.
+ *
+ * @param mixed $json JSON.
+ * @param BankTransferDetails|null $bank_transfer_details Bank account details.
+ *
+ * @return BankTransferDetails
+ *
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json, $bank_transfer_details = null ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( null === $bank_transfer_details ) {
+ $bank_transfer_details = new self();
+ }
+
+ if ( isset( $json->bank_account ) ) {
+ $bank_transfer_details->set_bank_account( BankAccountDetails::from_json( $json->bank_account ) );
+ }
+
+ if ( isset( $json->reference ) ) {
+ $bank_transfer_details->set_reference( $json->reference );
+ }
+
+ return $bank_transfer_details;
+ }
+
+ /**
+ * Create an string representation of this object
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = [
+ $this->get_bank_account(),
+ $this->get_reference(),
+ ];
+
+ $pieces = array_map( 'strval', $pieces );
+
+ $pieces = array_map( 'trim', $pieces );
+
+ $pieces = array_filter( $pieces );
+
+ $string = implode( PHP_EOL, $pieces );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Blocks/BlocksModule.php b/packages/wp-pay/core/src/Blocks/BlocksModule.php
new file mode 100644
index 0000000..83b1465
--- /dev/null
+++ b/packages/wp-pay/core/src/Blocks/BlocksModule.php
@@ -0,0 +1,59 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Blocks;
+
+use Pronamic\WordPress\Number\Number;
+use Pronamic\WordPress\Number\Parser as NumberParser;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Forms\FormsSource;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Plugin;
+use WP_Error;
+
+/**
+ * Blocks
+ *
+ * @author Reüel van der Steege
+ * @since 2.5.0
+ * @version 2.1.7
+ */
+class BlocksModule {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ global $wp_version;
+
+ add_filter( 'block_categories_all', [ $this, 'block_categories' ], 10 );
+
+ if ( \version_compare( $wp_version, '5.8', '<' ) ) {
+ add_filter( 'block_categories', [ $this, 'block_categories' ], 10 );
+ }
+ }
+
+ /**
+ * Block categories.
+ *
+ * @param array $categories Block categories.
+ * @return array
+ */
+ public function block_categories( $categories ) {
+ $categories[] = [
+ 'slug' => 'pronamic-pay',
+ 'title' => \__( 'Pronamic Pay', 'pronamic_ideal' ),
+ 'icon' => null,
+ ];
+
+ return $categories;
+ }
+}
diff --git a/packages/wp-pay/core/src/Cards.php b/packages/wp-pay/core/src/Cards.php
new file mode 100644
index 0000000..3b21531
--- /dev/null
+++ b/packages/wp-pay/core/src/Cards.php
@@ -0,0 +1,209 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Cards
+ *
+ * @author Reüel van der Steege
+ * @version 2.7.1
+ * @since 2.4.0
+ */
+class Cards {
+ /**
+ * Cards.
+ *
+ * @var array
+ */
+ private $cards;
+
+ /**
+ * Cards constructor.
+ */
+ public function __construct() {
+ $this->register_cards();
+ }
+
+ /**
+ * Register cards.
+ *
+ * @return void
+ */
+ private function register_cards() {
+ $this->cards = [
+ // Cards.
+ [
+ 'bic' => null,
+ 'brand' => 'american-express',
+ 'title' => __( 'American Express', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'carta-si',
+ 'title' => __( 'Carta Si', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'carte-bleue',
+ 'title' => __( 'Carte Bleue', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'dankort',
+ 'title' => __( 'Dankort', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'diners-club',
+ 'title' => __( 'Diners Club', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'discover',
+ 'title' => __( 'Discover', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'jcb',
+ 'title' => __( 'JCB', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'maestro',
+ 'title' => __( 'Maestro', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'mastercard',
+ 'title' => __( 'Mastercard', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'unionpay',
+ 'title' => __( 'UnionPay', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => null,
+ 'brand' => 'visa',
+ 'title' => __( 'Visa', 'pronamic_ideal' ),
+ ],
+
+ // Banks.
+ [
+ 'bic' => 'abna',
+ 'brand' => 'abn-amro',
+ 'title' => __( 'ABN Amro', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'asnb',
+ 'brand' => 'asn-bank',
+ 'title' => __( 'ASN Bank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'bunq',
+ 'brand' => 'bunq',
+ 'title' => __( 'bunq', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'hand',
+ 'brand' => 'handelsbanken',
+ 'title' => __( 'Handelsbanken', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'ingb',
+ 'brand' => 'ing',
+ 'title' => __( 'ING Bank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'knab',
+ 'brand' => 'knab',
+ 'title' => __( 'Knab', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'moyo',
+ 'brand' => 'moneyou',
+ 'title' => __( 'Moneyou', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'rabo',
+ 'brand' => 'rabobank',
+ 'title' => __( 'Rabobank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'rbrb',
+ 'brand' => 'regiobank',
+ 'title' => __( 'RegioBank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'snsb',
+ 'brand' => 'sns',
+ 'title' => __( 'SNS Bank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'trio',
+ 'brand' => 'triodos-bank',
+ 'title' => __( 'Triodos Bank', 'pronamic_ideal' ),
+ ],
+ [
+ 'bic' => 'fvlb',
+ 'brand' => 'van-lanschot',
+ 'title' => __( 'Van Lanschot', 'pronamic_ideal' ),
+ ],
+ ];
+ }
+
+ /**
+ * Get card.
+ *
+ * @param string $bic_or_brand 4-letter ISO 9362 Bank Identifier Code (BIC) or brand name.
+ * @return array|null
+ */
+ public function get_card( $bic_or_brand ) {
+ // Use lowercase BIC or brand without spaces.
+ $bic_or_brand = \strtolower( $bic_or_brand );
+
+ $bic_or_brand = \str_replace( ' ', '-', $bic_or_brand );
+
+ // Try to find card.
+ $cards = \wp_list_filter(
+ $this->cards,
+ [
+ 'bic' => $bic_or_brand,
+ 'brand' => $bic_or_brand,
+ ],
+ 'OR'
+ );
+
+ $card = \array_shift( $cards );
+
+ // Return card details.
+ if ( ! empty( $card ) ) {
+ return $card;
+ }
+
+ // No matching card.
+ return null;
+ }
+
+ /**
+ * Get card logo URL.
+ *
+ * @param string $brand Brand.
+ *
+ * @return string|null
+ */
+ public function get_card_logo_url( $brand ) {
+ return sprintf(
+ 'https://cdn.wp-pay.org/jsdelivr.net/npm/@wp-pay/logos@1.16.0/dist/cards/%1$s/card-%1$s-logo-_x80.svg',
+ $brand
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/ContactName.php b/packages/wp-pay/core/src/ContactName.php
new file mode 100644
index 0000000..3bf6274
--- /dev/null
+++ b/packages/wp-pay/core/src/ContactName.php
@@ -0,0 +1,319 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use InvalidArgumentException;
+use stdClass;
+
+/**
+ * Personal Name
+ *
+ * @link https://en.wikipedia.org/wiki/Personal_name
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.4.0
+ */
+class ContactName {
+ /**
+ * Full Name.
+ *
+ * @var string|null
+ */
+ private $full_name;
+
+ /**
+ * Prefix.
+ *
+ * @var string|null
+ *
+ * @link https://en.wikipedia.org/wiki/Personal_name
+ * @link https://en.wikipedia.org/wiki/Suffix_(name)
+ */
+ private $prefix;
+
+ /**
+ * Initials.
+ *
+ * @var string|null
+ *
+ * @link https://nl.wikipedia.org/wiki/Voorletter
+ */
+ private $initials;
+
+ /**
+ * First name.
+ *
+ * @var string|null
+ *
+ * @link https://en.wikipedia.org/wiki/Personal_name
+ */
+ private $first_name;
+
+ /**
+ * Middle name.
+ *
+ * @var string|null
+ *
+ * @link https://en.wikipedia.org/wiki/Middle_name
+ * @link https://en.wikipedia.org/wiki/Tussenvoegsel
+ */
+ private $middle_name;
+
+ /**
+ * Last name.
+ *
+ * @var string|null
+ *
+ * @link https://en.wikipedia.org/wiki/Personal_name
+ * @link https://en.wikipedia.org/wiki/Surname
+ */
+ private $last_name;
+
+ /**
+ * Suffix.
+ *
+ * @var string|null
+ *
+ * @link https://en.wikipedia.org/wiki/Personal_name
+ * @link https://en.wikipedia.org/wiki/Suffix_(name)
+ */
+ private $suffix;
+
+ /**
+ * Get full name.
+ *
+ * @return string|null
+ */
+ public function get_full_name() {
+ return $this->full_name;
+ }
+
+ /**
+ * Set full name.
+ *
+ * @param string|null $full_name Full name.
+ * @return void
+ */
+ public function set_full_name( $full_name ) {
+ $this->full_name = $full_name;
+ }
+
+ /**
+ * Get prefix.
+ *
+ * @return string|null
+ */
+ public function get_prefix() {
+ return $this->prefix;
+ }
+
+ /**
+ * Set prefix.
+ *
+ * @param string|null $prefix Prefix.
+ * @return void
+ */
+ public function set_prefix( $prefix ) {
+ $this->prefix = $prefix;
+ }
+
+ /**
+ * Get initials.
+ *
+ * @return string|null
+ */
+ public function get_initials() {
+ return $this->initials;
+ }
+
+ /**
+ * Set initials.
+ *
+ * @param string|null $initials Initials.
+ * @return void
+ */
+ public function set_initials( $initials ) {
+ $this->initials = $initials;
+ }
+
+ /**
+ * Get first name.
+ *
+ * @return string|null
+ */
+ public function get_first_name() {
+ return $this->first_name;
+ }
+
+ /**
+ * Set first name.
+ *
+ * @param string|null $first_name First name.
+ * @return void
+ */
+ public function set_first_name( $first_name ) {
+ $this->first_name = $first_name;
+ }
+
+ /**
+ * Get middle name.
+ *
+ * @return string|null
+ */
+ public function get_middle_name() {
+ return $this->middle_name;
+ }
+
+ /**
+ * Set middle name.
+ *
+ * @param string|null $middle_name Middle name.
+ * @return void
+ */
+ public function set_middle_name( $middle_name ) {
+ $this->middle_name = $middle_name;
+ }
+
+ /**
+ * Get last name.
+ *
+ * @return string|null
+ */
+ public function get_last_name() {
+ return $this->last_name;
+ }
+
+ /**
+ * Set last name.
+ *
+ * @param string|null $last_name Last name.
+ * @return void
+ */
+ public function set_last_name( $last_name ) {
+ $this->last_name = $last_name;
+ }
+
+ /**
+ * Get suffix.
+ *
+ * @return string|null
+ */
+ public function get_suffix() {
+ return $this->suffix;
+ }
+
+ /**
+ * Set suffix.
+ *
+ * @param string|null $suffix Suffix.
+ * @return void
+ */
+ public function set_suffix( $suffix ) {
+ $this->suffix = $suffix;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'full_name' => $this->get_full_name(),
+ 'prefix' => $this->get_prefix(),
+ 'initials' => $this->get_initials(),
+ 'first_name' => $this->get_first_name(),
+ 'middle_name' => $this->get_middle_name(),
+ 'last_name' => $this->get_last_name(),
+ 'suffix' => $this->get_suffix(),
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create contact name from object.
+ *
+ * @param mixed $json JSON.
+ * @return ContactName
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $name = new self();
+
+ if ( property_exists( $json, 'full_name' ) ) {
+ $name->set_full_name( $json->full_name );
+ }
+
+ if ( property_exists( $json, 'prefix' ) ) {
+ $name->set_prefix( $json->prefix );
+ }
+
+ if ( property_exists( $json, 'initials' ) ) {
+ $name->set_initials( $json->initials );
+ }
+
+ if ( property_exists( $json, 'first_name' ) ) {
+ $name->set_first_name( $json->first_name );
+ }
+
+ if ( property_exists( $json, 'middle_name' ) ) {
+ $name->set_middle_name( $json->middle_name );
+ }
+
+ if ( property_exists( $json, 'last_name' ) ) {
+ $name->set_last_name( $json->last_name );
+ }
+
+ if ( property_exists( $json, 'suffix' ) ) {
+ $name->set_suffix( $json->suffix );
+ }
+
+ return $name;
+ }
+
+ /**
+ * Create string representation of personal name.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = [
+ $this->get_prefix(),
+ $this->get_first_name(),
+ $this->get_middle_name(),
+ $this->get_last_name(),
+ $this->get_suffix(),
+ ];
+
+ $pieces = array_filter( $pieces );
+ $pieces = array_map( 'trim', $pieces );
+ $pieces = array_filter( $pieces );
+
+ $string = implode( ' ', $pieces );
+
+ if ( empty( $string ) ) {
+ $string = (string) $this->get_full_name();
+ }
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/ContactNameHelper.php b/packages/wp-pay/core/src/ContactNameHelper.php
new file mode 100644
index 0000000..26130cc
--- /dev/null
+++ b/packages/wp-pay/core/src/ContactNameHelper.php
@@ -0,0 +1,144 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use DateTime;
+use Pronamic\WordPress\Pay\Core\Util as Core_Util;
+
+/**
+ * Contact name helper
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.0.8
+ */
+class ContactNameHelper {
+ /**
+ * Complement name.
+ *
+ * @param ContactName $name Contact name to complement.
+ * @return void
+ */
+ public static function complement_name( ContactName $name ) {
+ // Name.
+ if ( \is_user_logged_in() ) {
+ $user = \wp_get_current_user();
+
+ if ( null === $name->get_first_name() && ! empty( $user->user_firstname ) ) {
+ $name->set_first_name( $user->user_firstname );
+ }
+
+ if ( null === $name->get_last_name() && ! empty( $user->user_lastname ) ) {
+ $name->set_last_name( $user->user_lastname );
+ }
+ }
+
+ // Initials.
+ if ( null === $name->get_initials() ) {
+ // First and middle name could contain multiple names.
+ $names = [];
+
+ $first_name = $name->get_first_name();
+
+ if ( null !== $first_name ) {
+ $names = \array_merge( $names, \explode( ' ', $first_name ) );
+ }
+
+ $middle_name = $name->get_middle_name();
+
+ if ( null !== $middle_name ) {
+ $names = \array_merge( $names, \explode( ' ', $middle_name ) );
+ }
+
+ $names = \array_map( 'trim', $names );
+
+ $names = \array_filter( $names );
+
+ if ( \count( $names ) > 0 ) {
+ $initials = array_map(
+ function ( $name ) {
+ return self::string_to_uppercase( \mb_substr( $name, 0, 1 ) ) . '.';
+ },
+ $names
+ );
+
+ $name->set_initials( implode( '', $initials ) );
+ }
+ }
+
+ /*
+ * Parse full name.
+ *
+ * @link https://github.com/dschnelldavis/parse-full-name
+ * @link https://github.com/joshfraser/PHP-Name-Parser
+ * @link https://github.com/jasonpriem/HumanNameParser.php
+ * @todo
+ */
+ }
+
+ /**
+ * Convert string to uppercase.
+ *
+ * @param string $value String.
+ * @return string
+ */
+ private static function string_to_uppercase( $value ) {
+ if ( \function_exists( 'mb_strtoupper' ) ) {
+ return \mb_strtoupper( $value );
+ }
+
+ return \strtoupper( $value );
+ }
+
+ /**
+ * Anonymize customer.
+ *
+ * @param ContactName $name Contact name to anonymize.
+ * @return void
+ */
+ public static function anonymize_name( ContactName $name ) {
+ $name->set_full_name( PrivacyManager::anonymize_data( 'text', $name->get_full_name() ) );
+ $name->set_first_name( PrivacyManager::anonymize_data( 'text', $name->get_first_name() ) );
+ $name->set_middle_name( PrivacyManager::anonymize_data( 'text', $name->get_middle_name() ) );
+ $name->set_last_name( PrivacyManager::anonymize_data( 'text', $name->get_last_name() ) );
+ }
+
+ /**
+ * Create a contact name from an array.
+ *
+ * @param array $data Data.
+ * @return ContactName|null
+ */
+ public static function from_array( $data ) {
+ $data = \array_filter(
+ $data,
+ function ( $value ) {
+ return ( null !== $value ) && ( '' !== $value );
+ }
+ );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ $name = new ContactName();
+
+ if ( \array_key_exists( 'first_name', $data ) ) {
+ $name->set_first_name( $data['first_name'] );
+ }
+
+ if ( \array_key_exists( 'last_name', $data ) ) {
+ $name->set_last_name( $data['last_name'] );
+ }
+
+ return $name;
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/Gateway.php b/packages/wp-pay/core/src/Core/Gateway.php
new file mode 100644
index 0000000..f4678be
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/Gateway.php
@@ -0,0 +1,354 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+use Pronamic\WordPress\Html\Element;
+use Pronamic\WordPress\Pay\Core\Util as Core_Util;
+use Pronamic\WordPress\Pay\Fields\Field;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Refunds\Refund;
+use Pronamic\WordPress\Pay\Subscriptions\Subscription;
+use Pronamic\WordPress\Pay\Util as PayUtil;
+use WP_Error;
+
+/**
+ * Title: Gateway
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.5.1
+ * @since 1.0.0
+ */
+abstract class Gateway {
+ /**
+ * Method indicator for an gateway which works through an HTML form
+ *
+ * @var int
+ */
+ const METHOD_HTML_FORM = 1;
+
+ /**
+ * Method indicator for an gateway which works through an HTTP redirect
+ *
+ * @var int
+ */
+ const METHOD_HTTP_REDIRECT = 2;
+
+ /**
+ * Indicator for test mode
+ *
+ * @var string
+ */
+ const MODE_TEST = 'test';
+
+ /**
+ * Indicator for live mode
+ *
+ * @var string
+ */
+ const MODE_LIVE = 'live';
+
+ /**
+ * The method of this gateway
+ *
+ * @var int
+ */
+ private $method;
+
+ use ModeTrait;
+
+ /**
+ * Supported features.
+ *
+ * Possible values:
+ * - payment_status_request Gateway can request current payment status.
+ */
+ use SupportsTrait;
+
+ /**
+ * Payment methods.
+ *
+ * @var PaymentMethodsCollection
+ */
+ protected $payment_methods;
+
+ /**
+ * Construct gateway.
+ */
+ public function __construct() {
+ $this->payment_methods = new PaymentMethodsCollection();
+ }
+
+ /**
+ * Register payment method.
+ *
+ * @param PaymentMethod $payment_method Payment method.
+ * @return void
+ */
+ protected function register_payment_method( PaymentMethod $payment_method ) {
+ $this->payment_methods->add( $payment_method );
+ }
+
+ /**
+ * Get payment method by ID.
+ *
+ * @param string $id ID.
+ * @return PaymentMethod|null
+ */
+ public function get_payment_method( $id ) {
+ return $this->payment_methods->get( $id );
+ }
+
+ /**
+ * First payment method field.
+ *
+ * @param string $payment_method_id Payment method ID.
+ * @param class-string $field_class Field class.
+ * @return Field|null
+ */
+ public function first_payment_method_field( $payment_method_id, $field_class ) {
+ $payment_method = $this->get_payment_method( $payment_method_id );
+
+ if ( null === $payment_method ) {
+ return null;
+ }
+
+ $fields = $payment_method->get_fields();
+
+ foreach ( $fields as $field ) {
+ if ( $field instanceof $field_class ) {
+ return $field;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Get payment methods.
+ *
+ * @param array $args Query arguments.
+ * @return PaymentMethodsCollection
+ */
+ public function get_payment_methods( array $args = [] ): PaymentMethodsCollection {
+ return $this->payment_methods->query( $args );
+ }
+
+ /**
+ * Set the method.
+ *
+ * @param int $method HTML form or HTTP redirect method.
+ * @return void
+ */
+ public function set_method( $method ) {
+ $this->method = $method;
+ }
+
+ /**
+ * Check if this gateway works trough an HTTP redirect
+ *
+ * @return boolean true if an HTTP redirect is required, false otherwise
+ */
+ public function is_http_redirect() {
+ return self::METHOD_HTTP_REDIRECT === $this->method;
+ }
+
+ /**
+ * Check if this gateway works through an HTML form
+ *
+ * @return boolean true if an HTML form is required, false otherwise
+ */
+ public function is_html_form() {
+ return self::METHOD_HTML_FORM === $this->method;
+ }
+
+ /**
+ * Custom payment redirect.
+ * Intended to be overridden by gateway.
+ *
+ * @param Payment $payment Payment.
+ *
+ * @return void
+ */
+ public function payment_redirect( Payment $payment ) {
+ }
+
+ /**
+ * Get available payment methods.
+ * Intended to be overridden by gateway if active payment methods for account can be determined.
+ *
+ * @since 1.3.0
+ * @return array|null
+ * @deprecated
+ */
+ public function get_available_payment_methods() {
+ return null;
+ }
+
+ /**
+ * Get the payment methods transient
+ *
+ * @since 1.3.0
+ * @param bool $update_active_methods Whether active payment methods option should be updated.
+ * @return array|null
+ * @deprecated
+ */
+ public function get_transient_available_payment_methods( $update_active_methods = true ) {
+ // Transient name.
+ $transient = 'pronamic_gateway_payment_methods_' . md5( serialize( $this ) );
+
+ $methods = get_transient( $transient );
+
+ if ( is_wp_error( $methods ) || false === $methods ) {
+ $methods = $this->get_available_payment_methods();
+
+ if ( is_array( $methods ) ) {
+ set_transient( $transient, $methods, DAY_IN_SECONDS );
+
+ if ( $update_active_methods ) {
+ PaymentMethods::update_active_payment_methods();
+ }
+ }
+ }
+
+ if ( empty( $methods ) ) {
+ return null;
+ }
+
+ return $methods;
+ }
+
+ /**
+ * Start transaction/payment
+ *
+ * @param Payment $payment The payment to start up at this gateway.
+ * @return void
+ */
+ public function start( Payment $payment ) {
+ }
+
+ /**
+ * Create refund.
+ *
+ * @param Refund $refund Reund.
+ * @return void
+ * @throws \Exception Throws an exception if the refund could not be processed.
+ */
+ public function create_refund( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is required for function signature.
+ Refund $refund
+ ) {
+ throw new \Exception( 'Gateway does not support refunds.' );
+ }
+
+ /**
+ * Redirect to the gateway action URL.
+ *
+ * @param Payment $payment The payment to redirect for.
+ * @return void
+ * @throws \Exception Throws exception when action URL for HTTP redirect is empty.
+ */
+ public function redirect( Payment $payment ) {
+ switch ( $this->method ) {
+ case self::METHOD_HTTP_REDIRECT:
+ $this->redirect_via_http( $payment );
+
+ break;
+ case self::METHOD_HTML_FORM:
+ $this->redirect_via_html( $payment );
+
+ break;
+ default:
+ // No idea how to redirect to the gateway.
+ }
+ }
+
+ /**
+ * Redirect via HTTP.
+ *
+ * @param Payment $payment The payment to redirect for.
+ * @return void
+ * @throws \Exception When payment action URL is empty.
+ */
+ public function redirect_via_http( Payment $payment ) {
+ if ( headers_sent() ) {
+ $this->redirect_via_html( $payment );
+ }
+
+ $action_url = $payment->get_action_url();
+
+ if ( empty( $action_url ) ) {
+ throw new \Exception( 'Action URL is empty, can not redirect.' );
+ }
+
+ // Redirect, See Other.
+ // https://en.wikipedia.org/wiki/HTTP_303.
+ wp_redirect( $action_url, 303 );
+
+ exit;
+ }
+
+ /**
+ * Redirect via HTML.
+ *
+ * @param Payment $payment The payment to redirect for.
+ * @return void
+ */
+ public function redirect_via_html( Payment $payment ) {
+ if ( headers_sent() ) {
+ $this->output_form( $payment );
+ } else {
+ Core_Util::no_cache();
+
+ include __DIR__ . '/../../views/redirect-via-html.php';
+ }
+
+ exit;
+ }
+
+ /**
+ * Output form.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ * @throws \Exception When payment action URL is empty.
+ */
+ public function output_form( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is used in include.
+ Payment $payment
+ ) {
+ include __DIR__ . '/../../views/form.php';
+ }
+
+ /**
+ * Get output inputs.
+ *
+ * @param Payment $payment Payment.
+ *
+ * @return array
+ * @since 1.2.0
+ */
+ public function get_output_fields( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is required for function signature.
+ Payment $payment
+ ) {
+ return [];
+ }
+
+ /**
+ * Update status of the specified payment
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public function update_status( Payment $payment ) {
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/GatewayConfig.php b/packages/wp-pay/core/src/Core/GatewayConfig.php
new file mode 100644
index 0000000..a044faf
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/GatewayConfig.php
@@ -0,0 +1,25 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Title: Gateway config
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.0.8
+ * @since 1.0.0
+ */
+abstract class GatewayConfig {
+
+}
diff --git a/packages/wp-pay/core/src/Core/IdTrait.php b/packages/wp-pay/core/src/Core/IdTrait.php
new file mode 100644
index 0000000..4c11876
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/IdTrait.php
@@ -0,0 +1,47 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Id Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ * @link https://github.com/search?q=%22trait+IdTrait%22+language%3APHP&type=Code
+ */
+trait IdTrait {
+ /**
+ * ID.
+ *
+ * @var string|null
+ */
+ private $id;
+
+ /**
+ * Get the ID.
+ *
+ * @return int|null
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Set the ID.
+ *
+ * @param int $id Unique ID.
+ * @return void
+ */
+ public function set_id( $id ) {
+ $this->id = $id;
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/ModeTrait.php b/packages/wp-pay/core/src/Core/ModeTrait.php
new file mode 100644
index 0000000..55f9e8b
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/ModeTrait.php
@@ -0,0 +1,56 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Mode Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ * @link https://github.com/search?q=%22trait+VersionTrait%22+language%3APHP&type=Code
+ */
+trait ModeTrait {
+ /**
+ * Mode.
+ *
+ * @var string
+ */
+ protected $mode = 'live';
+
+ /**
+ * Set mode.
+ *
+ * @param string $mode Mode.
+ * @return void
+ * @throws \InvalidArgumentException Throws invalid argument exception when mode is not a string or not one of the mode constants.
+ */
+ public function set_mode( $mode ) {
+ if ( ! is_string( $mode ) ) {
+ throw new \InvalidArgumentException( 'Mode must be a string.' );
+ }
+
+ if ( ! in_array( $mode, [ Gateway::MODE_TEST, Gateway::MODE_LIVE ], true ) ) {
+ throw new \InvalidArgumentException( 'Invalid mode.' );
+ }
+
+ $this->mode = $mode;
+ }
+
+ /**
+ * Get mode.
+ *
+ * @return string
+ */
+ public function get_mode() {
+ return $this->mode;
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/PaymentMethod.php b/packages/wp-pay/core/src/Core/PaymentMethod.php
new file mode 100644
index 0000000..681d8ee
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/PaymentMethod.php
@@ -0,0 +1,142 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+use Pronamic\WordPress\Pay\Fields\Field;
+
+/**
+ * Payment method class
+ */
+class PaymentMethod {
+ /**
+ * ID.
+ *
+ * @var string
+ */
+ private $id;
+
+ /**
+ * Name.
+ *
+ * @var string
+ */
+ public $name;
+
+ /**
+ * Descriptions.
+ *
+ * @var array
+ */
+ public $descriptions = [];
+
+ /**
+ * Status.
+ *
+ * @var string
+ */
+ private $status;
+
+ /**
+ * Fields.
+ *
+ * @var Field[]
+ */
+ private $fields = [];
+
+ /**
+ * Images.
+ *
+ * @var array
+ */
+ public $images = [];
+
+ /**
+ * Supports.
+ */
+ use SupportsTrait;
+
+ /**
+ * Construct payment method.
+ *
+ * @param string $id ID.
+ */
+ public function __construct( $id ) {
+ $this->id = $id;
+ $this->name = (string) PaymentMethods::get_name( $id );
+ $this->status = '';
+ }
+
+ /**
+ * Get ID.
+ *
+ * @return string
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set name.
+ *
+ * @param string $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get status.
+ *
+ * @return string
+ */
+ public function get_status() {
+ return $this->status;
+ }
+
+ /**
+ * Set status.
+ *
+ * @param string $status Status.
+ * @return void
+ */
+ public function set_status( $status ) {
+ $this->status = $status;
+ }
+
+ /**
+ * Add field.
+ *
+ * @param Field $field Field.
+ * @return void
+ */
+ public function add_field( Field $field ) {
+ $this->fields[] = $field;
+ }
+
+ /**
+ * Get fields.
+ *
+ * @return Field[]
+ */
+ public function get_fields() {
+ return $this->fields;
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/PaymentMethods.php b/packages/wp-pay/core/src/Core/PaymentMethods.php
new file mode 100644
index 0000000..72359b1
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/PaymentMethods.php
@@ -0,0 +1,692 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+use Pronamic\WordPress\Pay\Plugin;
+use WP_Post;
+use WP_Query;
+
+/**
+ * Title: WordPress pay payment methods
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.7.1
+ * @since 1.0.1
+ */
+class PaymentMethods {
+ /**
+ * AfterPay (afterpay.nl).
+ *
+ * @deprecated Use `AFTERPAY_NL` or `AFTERPAY_COM` instead.
+ * @var string
+ * @since 2.1.0
+ */
+ const AFTERPAY = 'afterpay';
+
+ /**
+ * AfterPay (afterpay.nl).
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/339
+ * @link https://www.afterpay.nl/
+ * @deprecated Use `RIVERTY` instead, see https://github.com/pronamic/wp-pronamic-pay/issues/339 for details.
+ * @var string
+ */
+ const AFTERPAY_NL = 'afterpay_nl';
+
+ /**
+ * Afterpay (afterpay.com).
+ *
+ * @link https://www.afterpay.com/
+ * @var string
+ */
+ const AFTERPAY_COM = 'afterpay_com';
+
+ /**
+ * Alipay
+ *
+ * @var string
+ * @since 2.0.0
+ */
+ const ALIPAY = 'alipay';
+
+ /**
+ * American Express.
+ *
+ * @var string
+ * @since 3.0.1
+ */
+ const AMERICAN_EXPRESS = 'american_express';
+
+ /**
+ * Apple Pay
+ *
+ * @var string
+ * @since 2.2.8
+ */
+ const APPLE_PAY = 'apple_pay';
+
+ /**
+ * Bancontact
+ *
+ * @var string
+ * @since 1.3.7
+ */
+ const BANCONTACT = 'bancontact';
+
+ /**
+ * Bank transfer
+ *
+ * @var string
+ */
+ const BANK_TRANSFER = 'bank_transfer';
+
+ /**
+ * Constant for the Belfius Direct Net method.
+ *
+ * @since 1.3.11
+ * @var string
+ */
+ const BELFIUS = 'belfius';
+
+ /**
+ * Billie
+ *
+ * @var string
+ */
+ const BILLIE = 'billie';
+
+ /**
+ * Billink
+ *
+ * @since 2.0.9
+ * @var string
+ */
+ const BILLINK = 'billink';
+
+ /**
+ * Bitcoin
+ *
+ * @since 1.3.9
+ * @var string
+ */
+ const BITCOIN = 'bitcoin';
+
+ /**
+ * BLIK
+ *
+ * @link https://blik.com/
+ * @var string
+ */
+ const BLIK = 'blik';
+
+ /**
+ * Bunq
+ *
+ * @link https://www.sisow.nl/news/00009
+ * @link https://plugins.trac.wordpress.org/browser/sisow-for-woocommerce/tags/4.7.2/includes/classes/Sisow/Gateway/Bunq.php
+ * @since 1.3.13
+ * @var string
+ */
+ const BUNQ = 'bunq';
+
+ /**
+ * Constant for the In3 payment method.
+ *
+ * @var string
+ * @since 2.1.0
+ */
+ const IN3 = 'in3';
+
+ /**
+ * Capayable.
+ *
+ * @var string
+ * @since 2.0.9
+ */
+ const CAPAYABLE = 'capayable';
+
+ /**
+ * Card.
+ *
+ * @link https://github.com/pronamic/pronamic-pay/issues/82
+ * @var string
+ */
+ const CARD = 'card';
+
+ /**
+ * Credit Card
+ *
+ * @var string
+ */
+ const CREDIT_CARD = 'credit_card';
+
+ /**
+ * Direct Debit
+ *
+ * @var string
+ */
+ const DIRECT_DEBIT = 'direct_debit';
+
+ /**
+ * Constant for the Direct Debit mandate via Bancontact payment method.
+ *
+ * @var string
+ * @since 1.3.13
+ */
+ const DIRECT_DEBIT_BANCONTACT = 'direct_debit_bancontact';
+
+ /**
+ * Constant for the Direct Debit mandate via iDEAL payment method.
+ *
+ * @var string
+ * @since 1.3.9
+ */
+ const DIRECT_DEBIT_IDEAL = 'direct_debit_ideal';
+
+ /**
+ * Constant for the Direct Debit mandate via SOFORT payment method.
+ *
+ * @var string
+ * @since 1.3.15
+ */
+ const DIRECT_DEBIT_SOFORT = 'direct_debit_sofort';
+
+ /**
+ * Constant for the EPS payment method.
+ *
+ * @var string
+ * @since 2.1.7
+ */
+ const EPS = 'eps';
+
+ /**
+ * Constant for the Focum payment method.
+ *
+ * @var string
+ * @since 2.1.0
+ */
+ const FOCUM = 'focum';
+
+ /**
+ * Constant for the iDEAL payment method.
+ *
+ * @var string
+ */
+ const IDEAL = 'ideal';
+
+ /**
+ * Constant for the iDEAL payment method.
+ *
+ * @var string
+ */
+ const IDEALQR = 'idealqr';
+
+ /**
+ * Constant for the Giropay payment method.
+ *
+ * @var string
+ */
+ const GIROPAY = 'giropay';
+
+ /**
+ * Constant for the Google Pay payment method.
+ *
+ * @var string
+ */
+ const GOOGLE_PAY = 'google_pay';
+
+ /**
+ * Constant for the KBC/CBC Payment Button method.
+ *
+ * @since 1.3.11
+ * @var string
+ */
+ const KBC = 'kbc';
+
+ /**
+ * Constant for the Klarna Pay Later payment method.
+ *
+ * Klarna Pay Later is not one specific payment method, but a category with a number of pay later payment methods.
+ *
+ * @link https://docs.klarna.com/klarna-payments/in-depth-knowledge/payment-method-grouping/
+ * @since 2.1.0
+ * @var string
+ */
+ const KLARNA_PAY_LATER = 'klarna_pay_later';
+
+ /**
+ * Constant for the Klarna Pay Now payment method.
+ *
+ * Klarna Pay Now is not one specific payment method, but a category with a number of pay later payment methods.
+ *
+ * @link https://docs.klarna.com/klarna-payments/in-depth-knowledge/payment-method-grouping/
+ * @since 4.1.0
+ * @var string
+ */
+ const KLARNA_PAY_NOW = 'klarna_pay_now';
+
+ /**
+ * Constant for the Klarna Pay Over Time payment method.
+ *
+ * Klarna Pay Over Time is not one specific payment method, but a category with a number of pay over time payment methods.
+ * Klarna Pay Over Time is also known as Klarna Slice It, some payment providers also use this naming convention.
+ *
+ * @link https://docs.klarna.com/klarna-payments/in-depth-knowledge/payment-method-grouping/
+ * @since 4.1.0
+ * @var string
+ */
+ const KLARNA_PAY_OVER_TIME = 'klarna_pay_over_time';
+
+ /**
+ * Constant for the Maestro payment method.
+ *
+ * @var string
+ * @since 1.3.10
+ */
+ const MAESTRO = 'maestro';
+
+ /**
+ * Constant for the Mastercard payment method.
+ *
+ * @link https://www.mastercard.nl/
+ * @var string
+ * @since 3.0.1
+ */
+ const MASTERCARD = 'mastercard';
+
+ /**
+ * MB WAY
+ *
+ * @since unreleased
+ * @link https://www.mbway.pt/
+ * @var string
+ */
+ const MB_WAY = 'mb_way';
+
+ /**
+ * Bancontact/Mister Cash
+ *
+ * @deprecated "Bancontact/Mister Cash" was renamed to just "Bancontact".
+ * @var string
+ */
+ const MISTER_CASH = 'mister_cash';
+
+ /**
+ * MobilePay
+ *
+ * @link https://www.mobilepay.dk/
+ * @var string
+ */
+ const MOBILEPAY = 'mobilepay';
+
+ /**
+ * MyBank.
+ *
+ * @link https://github.com/mollie/mollie-api-php/blob/ed5b2ba1dc8f30a4674f10ca78ad547c2df91008/src/Types/PaymentMethod.php#L114-L117
+ * @link https://github.com/mollie/WooCommerce/blob/bda9155ac19e1c576f19f436d74fe3f7fe845298/src/PaymentMethods/Mybank.php#L7
+ * @link https://mybank.eu/
+ * @var string
+ */
+ const MYBANK = 'mybank';
+
+ /**
+ * Constant for the Payconiq method.
+ *
+ * @since 2.0.0
+ * @var string
+ */
+ const PAYCONIQ = 'payconiq';
+
+ /**
+ * PayPal
+ *
+ * @var string
+ * @since 1.3.7
+ */
+ const PAYPAL = 'paypal';
+
+ /**
+ * Przelewy24
+ *
+ * @since 2.5.0
+ * @var string
+ */
+ const PRZELEWY24 = 'przelewy24';
+
+ /**
+ * Riverty.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/339
+ * @since 4.6.0
+ * @var string
+ */
+ const RIVERTY = 'riverty';
+
+ /**
+ * Santander
+ *
+ * @var string
+ * @since 2.6.0
+ */
+ const SANTANDER = 'santander';
+
+ /**
+ * SOFORT Banking
+ *
+ * @var string
+ * @since 1.0.1
+ */
+ const SOFORT = 'sofort';
+
+ /**
+ * SprayPay
+ *
+ * @var string
+ * @since 2.8.0
+ */
+ const SPRAYPAY = 'spraypay';
+
+ /**
+ * Swish
+ *
+ * @var string
+ * @since 2.6.3
+ */
+ const SWISH = 'swish';
+
+ /**
+ * TWINT
+ *
+ * @var string
+ * @since unreleased
+ */
+ const TWINT = 'twint';
+
+ /**
+ * Constant for the V PAY payment method.
+ *
+ * @link https://en.wikipedia.org/wiki/V_Pay
+ * @var string
+ * @since 3.0.1
+ */
+ const V_PAY = 'v_pay';
+
+ /**
+ * Vipps
+ *
+ * @var string
+ * @since 2.6.3
+ */
+ const VIPPS = 'vipps';
+
+ /**
+ * Constant for the Visa payment method.
+ *
+ * @link https://www.visa.nl/
+ * @var string
+ * @since 3.0.1
+ */
+ const VISA = 'visa';
+
+ /**
+ * Get payment methods
+ *
+ * @since 1.3.0
+ * @return array
+ */
+ public static function get_payment_methods() {
+ $payment_methods = [
+ self::AFTERPAY_NL => _x( 'AfterPay', 'afterpay.nl', 'pronamic_ideal' ),
+ self::AFTERPAY_COM => _x( 'Afterpay', 'afterpay.com', 'pronamic_ideal' ),
+ self::ALIPAY => __( 'Alipay', 'pronamic_ideal' ),
+ self::AMERICAN_EXPRESS => __( 'American Express', 'pronamic_ideal' ),
+ self::APPLE_PAY => __( 'Apple Pay', 'pronamic_ideal' ),
+ self::BANCONTACT => __( 'Bancontact', 'pronamic_ideal' ),
+ self::BANK_TRANSFER => __( 'Bank Transfer', 'pronamic_ideal' ),
+ self::BELFIUS => __( 'Belfius Direct Net', 'pronamic_ideal' ),
+ self::BILLIE => __( 'Billie', 'pronamic_ideal' ),
+ self::BILLINK => __( 'Billink', 'pronamic_ideal' ),
+ self::BITCOIN => __( 'Bitcoin', 'pronamic_ideal' ),
+ self::BLIK => __( 'BLIK', 'pronamic_ideal' ),
+ self::BUNQ => __( 'Bunq', 'pronamic_ideal' ),
+ self::CAPAYABLE => __( 'Capayable', 'pronamic_ideal' ),
+ self::IN3 => __( 'In3', 'pronamic_ideal' ),
+ self::CARD => __( 'Card', 'pronamic_ideal' ),
+ self::CREDIT_CARD => __( 'Credit Card', 'pronamic_ideal' ),
+ self::DIRECT_DEBIT => __( 'Direct Debit', 'pronamic_ideal' ),
+ self::DIRECT_DEBIT_BANCONTACT => sprintf(
+ /* translators: %s: payment method */
+ __( 'Direct Debit (mandate via %s)', 'pronamic_ideal' ),
+ __( 'Bancontact', 'pronamic_ideal' )
+ ),
+ self::DIRECT_DEBIT_IDEAL => sprintf(
+ /* translators: %s: payment method */
+ __( 'Direct Debit (mandate via %s)', 'pronamic_ideal' ),
+ __( 'iDEAL', 'pronamic_ideal' )
+ ),
+ self::DIRECT_DEBIT_SOFORT => sprintf(
+ /* translators: %s: payment method */
+ __( 'Direct Debit (mandate via %s)', 'pronamic_ideal' ),
+ __( 'SOFORT', 'pronamic_ideal' )
+ ),
+ self::EPS => __( 'EPS', 'pronamic_ideal' ),
+ self::FOCUM => __( 'Focum', 'pronamic_ideal' ),
+ self::GIROPAY => __( 'Giropay', 'pronamic_ideal' ),
+ self::GOOGLE_PAY => __( 'Google Pay', 'pronamic_ideal' ),
+ self::IDEAL => __( 'iDEAL', 'pronamic_ideal' ),
+ self::IDEALQR => __( 'iDEAL QR', 'pronamic_ideal' ),
+ self::KBC => __( 'KBC/CBC Payment Button', 'pronamic_ideal' ),
+ self::KLARNA_PAY_LATER => __( 'Klarna Pay Later', 'pronamic_ideal' ),
+ self::KLARNA_PAY_NOW => __( 'Klarna Pay Now', 'pronamic_ideal' ),
+ self::KLARNA_PAY_OVER_TIME => __( 'Klarna Pay Over Time', 'pronamic_ideal' ),
+ self::MAESTRO => __( 'Maestro', 'pronamic_ideal' ),
+ self::MASTERCARD => __( 'Mastercard', 'pronamic_ideal' ),
+ self::MB_WAY => __( 'MB WAY', 'pronamic_ideal' ),
+ self::MOBILEPAY => __( 'MobilePay', 'pronamic_ideal' ),
+ self::PAYCONIQ => __( 'Payconiq', 'pronamic_ideal' ),
+ self::PAYPAL => __( 'PayPal', 'pronamic_ideal' ),
+ self::PRZELEWY24 => __( 'Przelewy24', 'pronamic_ideal' ),
+ self::RIVERTY => __( 'Riverty', 'pronamic_ideal' ),
+ self::SANTANDER => __( 'Santander', 'pronamic_ideal' ),
+ self::SOFORT => __( 'SOFORT Banking', 'pronamic_ideal' ),
+ self::SPRAYPAY => __( 'SprayPay', 'pronamic_ideal' ),
+ self::SWISH => __( 'Swish', 'pronamic_ideal' ),
+ self::TWINT => __( 'TWINT', 'pronamic_ideal' ),
+ self::V_PAY => __( 'V PAY', 'pronamic_ideal' ),
+ self::VIPPS => __( 'Vipps', 'pronamic_ideal' ),
+ self::VISA => __( 'Visa', 'pronamic_ideal' ),
+ ];
+
+ return $payment_methods;
+ }
+
+ /**
+ * Get payment method name
+ *
+ * @since 1.3.0
+ *
+ * @param string|null $method Method to get the name for.
+ * @param string|null $fallback Default name to return if method was not found.
+ *
+ * @return string|null
+ */
+ public static function get_name( $method = null, $fallback = null ) {
+ $payment_methods = self::get_payment_methods();
+
+ if ( null !== $method && array_key_exists( $method, $payment_methods ) ) {
+ return $payment_methods[ $method ];
+ }
+
+ if ( null === $fallback ) {
+ return $method;
+ }
+
+ return $fallback;
+ }
+
+ /**
+ * Get icon URL.
+ *
+ * @param string|null $method Payment method.
+ * @param string|null $size Icon size.
+ * @return string|null
+ */
+ public static function get_icon_url( $method = null, $size = null ) {
+ // Check method.
+ if ( empty( $method ) || 'void' === $method ) {
+ return null;
+ }
+
+ // Size.
+ if ( empty( $size ) ) {
+ $size = '640x360';
+ }
+
+ return \sprintf(
+ 'https://cdn.wp-pay.org/jsdelivr.net/npm/@wp-pay/logos@1.16.0/dist/methods/%1$s/method-%1$s-%2$s.svg',
+ \str_replace( '_', '-', $method ),
+ $size
+ );
+ }
+
+ /**
+ * Maybe update active payment methods.
+ *
+ * @return void
+ */
+ public static function maybe_update_active_payment_methods() {
+ $payment_methods = get_option( 'pronamic_pay_active_payment_methods' );
+
+ // Update active payment methods option if necessary.
+ if ( ! is_array( $payment_methods ) ) {
+ self::update_active_payment_methods();
+ }
+ }
+
+ /**
+ * Update active payment methods option.
+ *
+ * @since 2.0.0
+ * @return void
+ */
+ public static function update_active_payment_methods() {
+ $active_payment_methods = [];
+
+ $query = new WP_Query(
+ [
+ 'post_type' => 'pronamic_gateway',
+ 'nopaging' => true,
+ 'fields' => 'ids',
+ ]
+ );
+
+ foreach ( $query->posts as $config_id ) {
+ if ( $config_id instanceof WP_Post ) {
+ $config_id = $config_id->ID;
+ }
+
+ $gateway = Plugin::get_gateway( $config_id );
+
+ if ( null === $gateway ) {
+ continue;
+ }
+
+ $payment_methods = $gateway->get_payment_methods(
+ [
+ 'status' => [ '', 'active' ],
+ ]
+ );
+
+ foreach ( $payment_methods as $payment_method ) {
+ $id = $payment_method->get_id();
+
+ if ( ! array_key_exists( $id, $active_payment_methods ) ) {
+ $active_payment_methods[ $id ] = [];
+ }
+
+ $active_payment_methods[ $id ][] = $config_id;
+ }
+ }
+
+ update_option( 'pronamic_pay_active_payment_methods', $active_payment_methods );
+ }
+
+ /**
+ * Get active payment methods.
+ *
+ * @return array
+ */
+ public static function get_active_payment_methods() {
+ self::maybe_update_active_payment_methods();
+
+ $payment_methods = [];
+
+ $active_methods = get_option( 'pronamic_pay_active_payment_methods' );
+
+ if ( is_array( $active_methods ) ) {
+ $payment_methods = array_keys( $active_methods );
+ }
+
+ return $payment_methods;
+ }
+
+ /**
+ * Get config IDs for payment method.
+ *
+ * @param string $payment_method Payment method.
+ *
+ * @return array
+ */
+ public static function get_config_ids( $payment_method = null ) {
+ self::maybe_update_active_payment_methods();
+
+ $config_ids = [];
+
+ $active_methods = get_option( 'pronamic_pay_active_payment_methods' );
+
+ // Make sure active payments methods is an array.
+ if ( ! is_array( $active_methods ) ) {
+ return $config_ids;
+ }
+
+ // Get config IDs for payment method.
+ if ( isset( $active_methods[ $payment_method ] ) ) {
+ $config_ids = $active_methods[ $payment_method ];
+ }
+
+ // Get all config IDs if payment method is empty.
+ if ( empty( $payment_method ) ) {
+ foreach ( $active_methods as $method_config_ids ) {
+ $config_ids = array_merge( $config_ids, $method_config_ids );
+ }
+
+ $config_ids = array_unique( $config_ids );
+ }
+
+ return $config_ids;
+ }
+
+ /**
+ * Check if payment method is active.
+ *
+ * @param string $payment_method Payment method.
+ *
+ * @since 2.0.0
+ *
+ * @return bool
+ */
+ public static function is_active( $payment_method = null ) {
+ return in_array( $payment_method, self::get_active_payment_methods(), true );
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/PaymentMethodsCollection.php b/packages/wp-pay/core/src/Core/PaymentMethodsCollection.php
new file mode 100644
index 0000000..c28c1f1
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/PaymentMethodsCollection.php
@@ -0,0 +1,148 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Payment methods collection class
+ *
+ * @implements IteratorAggregate
+ */
+class PaymentMethodsCollection implements IteratorAggregate, Countable {
+ /**
+ * Items.
+ *
+ * @var PaymentMethod[]
+ */
+ private $items = [];
+
+ /**
+ * Add payment method.
+ *
+ * @param PaymentMethod $payment_method Payment method.
+ * @return void
+ */
+ public function add( PaymentMethod $payment_method ) {
+ $id = $payment_method->get_id();
+
+ $this->items[ $id ] = $payment_method;
+ }
+
+ /**
+ * Get payment method by ID.
+ *
+ * @param string $id ID.
+ * @return PaymentMethod|null
+ */
+ public function get( $id ) {
+ if ( array_key_exists( $id, $this->items ) ) {
+ return $this->items[ $id ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Query items.
+ *
+ * @param array $args Arguments.
+ * @return self
+ */
+ public function query( $args ) {
+ $items = $this->items;
+
+ if ( \array_key_exists( 'id', $args ) ) {
+ $id_list = \wp_parse_list( $args['id'] );
+
+ $items = \array_filter(
+ $items,
+ function ( $payment_method ) use ( $id_list ) {
+ return \in_array( $payment_method->get_id(), $id_list, true );
+ }
+ );
+ }
+
+ if ( \array_key_exists( 'status', $args ) ) {
+ $status_list = \wp_parse_list( $args['status'] );
+
+ $items = \array_filter(
+ $items,
+ function ( $payment_method ) use ( $status_list ) {
+ return \in_array( $payment_method->get_status(), $status_list, true );
+ }
+ );
+ }
+
+ if ( \array_key_exists( 'supports', $args ) ) {
+ $feature = $args['supports'];
+
+ $items = \array_filter(
+ $items,
+ function ( $payment_method ) use ( $feature ) {
+ return $payment_method->supports( $feature );
+ }
+ );
+ }
+
+ $collection = new self();
+
+ $collection->items = $items;
+
+ return $collection;
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable {
+ return new ArrayIterator( $this->items );
+ }
+
+ /**
+ * Get array.
+ *
+ * @return array
+ */
+ public function get_array() {
+ return $this->items;
+ }
+
+ /**
+ * Count items.
+ *
+ * @return int
+ */
+ public function count(): int {
+ return count( $this->items );
+ }
+
+ /**
+ * Is active.
+ *
+ * @param string $id Payment method ID.
+ * @return bool True if status is active, false otherwise.
+ */
+ public function is_active( $id ) {
+ $payment_method = $this->get( $id );
+
+ if ( null === $payment_method ) {
+ return false;
+ }
+
+ return ( 'active' === $payment_method->get_status() );
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/SupportsTrait.php b/packages/wp-pay/core/src/Core/SupportsTrait.php
new file mode 100644
index 0000000..2f95300
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/SupportsTrait.php
@@ -0,0 +1,43 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Supports trait class
+ */
+trait SupportsTrait {
+ /**
+ * Supported features.
+ *
+ * @var array
+ */
+ protected $supports = [];
+
+ /**
+ * Add support.
+ *
+ * @param string $feature Feature.
+ * @return void
+ */
+ public function add_support( $feature ) {
+ $this->supports[] = $feature;
+ }
+
+ /**
+ * Check if supports a given feature.
+ *
+ * @param string $feature The feature to check.
+ * @return bool True if supported, false otherwise.
+ */
+ public function supports( $feature ) {
+ return in_array( $feature, $this->supports, true );
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/TimestampsTrait.php b/packages/wp-pay/core/src/Core/TimestampsTrait.php
new file mode 100644
index 0000000..1d82d72
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/TimestampsTrait.php
@@ -0,0 +1,86 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Timestamps Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ * @link https://github.com/laravel/framework/blob/v7.27.0/src/Illuminate/Database/Eloquent/Concerns/HasTimestamps.php
+ */
+trait TimestampsTrait {
+ /**
+ * Created At.
+ *
+ * @var \DateTime|null
+ */
+ private $created_at;
+
+ /**
+ * Updated At.
+ *
+ * @var \DateTime|null
+ */
+ private $updated_at;
+
+ /**
+ * Set created at.
+ *
+ * @param \DateTime|null $created_at Created at.
+ * @return void
+ */
+ public function set_created_at( $created_at ) {
+ $this->created_at = $created_at;
+ }
+
+ /**
+ * Get created at.
+ *
+ * @return \DateTime|null
+ */
+ public function get_created_at() {
+ return $this->created_at;
+ }
+
+ /**
+ * Set updated at.
+ *
+ * @param \DateTime|null $updated_at Updated at.
+ * @return void
+ */
+ public function set_updated_at( $updated_at ) {
+ $this->updated_at = $updated_at;
+ }
+
+ /**
+ * Get updated at.
+ *
+ * @return \DateTime|null
+ */
+ public function get_updated_at() {
+ return $this->updated_at;
+ }
+
+ /**
+ * Touch.
+ *
+ * @return void
+ */
+ public function touch() {
+ if ( null === $this->created_at ) {
+ $this->created_at = new \DateTime();
+ }
+
+ $this->updated_at = new \DateTime();
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/Util.php b/packages/wp-pay/core/src/Core/Util.php
new file mode 100644
index 0000000..68c8f81
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/Util.php
@@ -0,0 +1,185 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Util as Pay_Util;
+
+/**
+ * Title: WordPress utility class
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.0.0
+ */
+class Util {
+ /**
+ * No cache.
+ *
+ * @return void
+ */
+ public static function no_cache() {
+ // @link https://github.com/woothemes/woocommerce/blob/2.3.11/includes/class-wc-cache-helper.php
+ // @link https://www.w3-edge.com/products/w3-total-cache/
+ $do_not_constants = [
+ 'DONOTCACHEPAGE',
+ 'DONOTCACHEDB',
+ 'DONOTMINIFY',
+ 'DONOTCDN',
+ 'DONOTCACHEOBJECT',
+ ];
+
+ foreach ( $do_not_constants as $do_not_constant ) {
+ if ( ! defined( $do_not_constant ) ) {
+ define( $do_not_constant, true );
+ }
+ }
+
+ nocache_headers();
+ }
+
+ /**
+ * String to interval period (user input string).
+ *
+ * @since 2.0.3
+ * @param string $interval Interval user input string.
+ * @return string|null
+ */
+ public static function string_to_interval_period( $interval ) {
+ if ( ! is_string( $interval ) ) {
+ return null;
+ }
+
+ $interval = trim( $interval );
+
+ // Check last character for period.
+ $interval_char = strtoupper( substr( $interval, - 1, 1 ) );
+
+ if ( in_array( $interval_char, [ 'D', 'W', 'M', 'Y' ], true ) ) {
+ return $interval_char;
+ }
+
+ // Find interval period by counting string replacements.
+ $periods = [
+ 'D' => [ 'D', 'day' ],
+ 'W' => [ 'W', 'week' ],
+ 'M' => [ 'M', 'month' ],
+ 'Y' => [ 'Y', 'year' ],
+ ];
+
+ foreach ( $periods as $interval_period => $search ) {
+ $count = 0;
+
+ str_ireplace( $search, '', $interval, $count );
+
+ if ( $count > 0 ) {
+ return $interval_period;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Convert the specified period to a single char notation.
+ *
+ * @since 1.3.9
+ *
+ * @param string $period The period value to convert to a single character/string value.
+ *
+ * @return string
+ */
+ public static function to_period( $period ) {
+ if ( false !== strpos( $period, 'day' ) || false !== strpos( $period, 'daily' ) ) {
+ return 'D';
+ }
+
+ if ( false !== strpos( $period, 'week' ) ) {
+ return 'W';
+ }
+
+ if ( false !== strpos( $period, 'month' ) ) {
+ return 'M';
+ }
+
+ if ( false !== strpos( $period, 'year' ) ) {
+ return 'Y';
+ }
+
+ return $period;
+ }
+
+ /**
+ * Get remote address.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.8/wp-admin/includes/class-wp-community-events.php#L210-L274
+ * @since 2.1.0
+ * @return mixed|null
+ */
+ public static function get_remote_address() {
+ // In order of preference, with the best ones for this purpose first.
+ $headers = [
+ 'HTTP_CLIENT_IP',
+ 'HTTP_X_FORWARDED_FOR',
+ 'HTTP_X_FORWARDED',
+ 'HTTP_X_CLUSTER_CLIENT_IP',
+ 'HTTP_FORWARDED_FOR',
+ 'HTTP_FORWARDED',
+ 'REMOTE_ADDR',
+ ];
+
+ foreach ( $headers as $header ) {
+ if ( isset( $_SERVER[ $header ] ) ) {
+ /*
+ * HTTP_X_FORWARDED_FOR can contain a chain of comma-separated
+ * addresses. The first one is the original client. It can't be
+ * trusted for authenticity, but we don't need to for this purpose.
+ */
+ $addresses = explode( ',', \sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
+
+ $addresses = array_slice( $addresses, 0, 1 );
+
+ foreach ( $addresses as $address ) {
+ $address = trim( $address );
+
+ $address = filter_var( $address, FILTER_VALIDATE_IP );
+
+ if ( false === $address ) {
+ continue;
+ }
+
+ return $address;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Method exists
+ *
+ * This helper function was created to fix an issue with `method_exists` calls
+ * and non existing classes.
+ *
+ * @param string $class_name Class name to check for the specified method.
+ * @param string $method_name Method name to check for existence.
+ *
+ * @return boolean
+ */
+ public static function class_method_exists( $class_name, $method_name ) {
+ return class_exists( $class_name ) && method_exists( $class_name, $method_name );
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/VersionTrait.php b/packages/wp-pay/core/src/Core/VersionTrait.php
new file mode 100644
index 0000000..b4ddca4
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/VersionTrait.php
@@ -0,0 +1,47 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Core;
+
+/**
+ * Version Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ * @link https://github.com/search?q=%22trait+VersionTrait%22+language%3APHP&type=Code
+ */
+trait VersionTrait {
+ /**
+ * Version.
+ *
+ * @var string|null
+ */
+ private $version;
+
+ /**
+ * Set version.
+ *
+ * @param string|null $version Version.
+ * @return void
+ */
+ public function set_version( $version ) {
+ $this->version = $version;
+ }
+
+ /**
+ * Get version.
+ *
+ * @return string|null
+ */
+ public function get_version() {
+ return $this->version;
+ }
+}
diff --git a/packages/wp-pay/core/src/Core/XML/Util.php b/packages/wp-pay/core/src/Core/XML/Util.php
new file mode 100644
index 0000000..044006a
--- /dev/null
+++ b/packages/wp-pay/core/src/Core/XML/Util.php
@@ -0,0 +1,69 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core\XML
+ */
+
+namespace Pronamic\WordPress\Pay\Core\XML;
+
+use DOMDocument;
+use DOMNode;
+use DOMText;
+
+/**
+ * Title: XML utility class
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.2.1
+ */
+class Util {
+ /**
+ * Create and add an element with the specified name and value to the specified parent.
+ *
+ * @param DOMDocument $document DOM document to add the specified node to.
+ * @param DOMNode $node DOM node to add a new element to.
+ * @param string $name Name of the new DOM element to add.
+ * @param string $value Value of the new DOM element to add.
+ *
+ * @return \DOMElement
+ */
+ public static function add_element( DOMDocument $document, DOMNode $node, $name, $value = null ) {
+ $element = $document->createElement( $name );
+
+ if ( null !== $value ) {
+ $element->appendChild( new DOMText( $value ) );
+ }
+
+ $node->appendChild( $element );
+
+ return $element;
+ }
+
+ /**
+ * Add the specified elements to the parent node.
+ *
+ * @param DOMDocument $document DOM document to add the specified node to.
+ * @param DOMNode $node DOM node to add a new element to.
+ * @param array $elements The elements (name => value pairs) to add.
+ * @return void
+ */
+ public static function add_elements( DOMDocument $document, DOMNode $node, array $elements = [] ) {
+ foreach ( $elements as $name => $value ) {
+ $element = $document->createElement( $name );
+
+ if ( null !== $value ) {
+ $element->appendChild( new DOMText( $value ) );
+ }
+
+ $node->appendChild( $element );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Country.php b/packages/wp-pay/core/src/Country.php
new file mode 100644
index 0000000..8f9cacb
--- /dev/null
+++ b/packages/wp-pay/core/src/Country.php
@@ -0,0 +1,157 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use InvalidArgumentException;
+use stdClass;
+
+/**
+ * Country
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.1.6
+ */
+class Country {
+ /**
+ * Code.
+ *
+ * @var string|null
+ */
+ private $code;
+
+ /**
+ * Name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * Get code.
+ *
+ * @return string|null
+ */
+ public function get_code() {
+ return $this->code;
+ }
+
+ /**
+ * Set code.
+ *
+ * @throws InvalidArgumentException Thrown when country code length is not equal to 2.
+ *
+ * @param string|null $code Code.
+ * @return void
+ */
+ public function set_code( $code ) {
+ if ( null !== $code && 2 !== strlen( $code ) ) {
+ throw new InvalidArgumentException(
+ \sprintf(
+ 'Given country code `%s` not ISO 3166-1 alpha-2 value.',
+ \esc_html( $code )
+ )
+ );
+ }
+
+ $this->code = $code;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set name.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'code' => $this->code,
+ 'name' => $this->name,
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create from object.
+ *
+ * @param mixed $json JSON.
+ * @return Country
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ $country = new self();
+
+ if ( isset( $json->code ) ) {
+ $country->set_code( $json->code );
+ }
+
+ if ( isset( $json->name ) ) {
+ $country->set_name( $json->name );
+ }
+
+ return $country;
+ }
+
+ /**
+ * Create string representation of personal name.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $values = [
+ $this->code,
+ $this->name,
+ ];
+
+ $values = array_filter( $values );
+
+ return implode( ' - ', $values );
+ }
+
+ /**
+ * Anonymize.
+ *
+ * @return void
+ */
+ public function anonymize() {
+ $this->set_code( PrivacyManager::anonymize_data( 'text', $this->get_code() ) );
+ $this->set_name( PrivacyManager::anonymize_data( 'text', $this->get_name() ) );
+ }
+}
diff --git a/packages/wp-pay/core/src/CreditCard.php b/packages/wp-pay/core/src/CreditCard.php
new file mode 100644
index 0000000..039d445
--- /dev/null
+++ b/packages/wp-pay/core/src/CreditCard.php
@@ -0,0 +1,176 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Credit card class
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.4.0
+ */
+class CreditCard {
+ /**
+ * Credit card number.
+ *
+ * @var string|null
+ */
+ private $number;
+
+ /**
+ * Credit card expiration month.
+ *
+ * @var int|null
+ */
+ private $expiration_month;
+
+ /**
+ * Credit card expiration year.
+ *
+ * @var int|null
+ */
+ private $expiration_year;
+
+ /**
+ * Credit card security code.
+ *
+ * @var string|null
+ */
+ private $security_code;
+
+ /**
+ * Credit card holder name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * Constructs and initializes a credit card object.
+ */
+ public function __construct() {
+ }
+
+ /**
+ * Get credit card number.
+ *
+ * @return string|null
+ */
+ public function get_number() {
+ return $this->number;
+ }
+
+ /**
+ * Set credit card number.
+ *
+ * @param string|null $number Number.
+ * @return void
+ */
+ public function set_number( $number ) {
+ $this->number = $number;
+ }
+
+ /**
+ * Get expiration month.
+ *
+ * @return int|null
+ */
+ public function get_expiration_month() {
+ return $this->expiration_month;
+ }
+
+ /**
+ * Set expiration month
+ *
+ * @param int|null $month Month.
+ * @return void
+ */
+ public function set_expiration_month( $month ) {
+ $this->expiration_month = $month;
+ }
+
+ /**
+ * Get expiration year.
+ *
+ * @return int|null
+ */
+ public function get_expiration_year() {
+ return $this->expiration_year;
+ }
+
+ /**
+ * Set expiration year
+ *
+ * @param int|null $year Year.
+ * @return void
+ */
+ public function set_expiration_year( $year ) {
+ $this->expiration_year = $year;
+ }
+
+ /**
+ * Get expiration date.
+ *
+ * @link http://php.net/manual/en/datetime.formats.relative.php
+ * @link http://php.net/manual/en/datetime.setdate.php
+ * @return \DateTime|null
+ */
+ public function get_expiration_date() {
+ if ( empty( $this->expiration_year ) || empty( $this->expiration_month ) ) {
+ return null;
+ }
+
+ $date = new \DateTime();
+
+ $date->setDate( $this->expiration_year, $this->expiration_month, 1 );
+ $date->setTime( 0, 0 );
+
+ return $date;
+ }
+
+ /**
+ * Get security code.
+ *
+ * @return string|null
+ */
+ public function get_security_code() {
+ return $this->security_code;
+ }
+
+ /**
+ * Set security code.
+ *
+ * @param string|null $security_code Security code.
+ * @return void
+ */
+ public function set_security_code( $security_code ) {
+ $this->security_code = $security_code;
+ }
+
+ /**
+ * Get credit card holder name.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set credit card holder name.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+}
diff --git a/packages/wp-pay/core/src/Customer.php b/packages/wp-pay/core/src/Customer.php
new file mode 100644
index 0000000..792ccdf
--- /dev/null
+++ b/packages/wp-pay/core/src/Customer.php
@@ -0,0 +1,437 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\VatNumbers\VatNumber;
+
+/**
+ * Contact.
+ *
+ * @author Reüel van der Steege.
+ * @version 2.4.0
+ * @since 2.1.0
+ */
+class Customer {
+ /**
+ * Contact name.
+ *
+ * @var ContactName|null
+ */
+ private $name;
+
+ /**
+ * Gender.
+ *
+ * @var string|null
+ */
+ private $gender;
+
+ /**
+ * Date of birth.
+ *
+ * @var DateTime|null
+ */
+ private $birth_date;
+
+ /**
+ * Email address.
+ *
+ * @var string|null
+ */
+ private $email;
+
+ /**
+ * Telephone number.
+ *
+ * @var string|null
+ */
+ private $phone;
+
+ /**
+ * IP address.
+ *
+ * @var string|null
+ */
+ private $ip_address;
+
+ /**
+ * User agent.
+ *
+ * @var string|null
+ */
+ private $user_agent;
+
+ /**
+ * Language.
+ *
+ * @var string|null
+ */
+ private $language;
+
+ /**
+ * Locale.
+ *
+ * @var string|null
+ */
+ private $locale;
+
+ /**
+ * WordPress user ID.
+ *
+ * @var integer|null
+ */
+ private $user_id;
+
+ /**
+ * Company name.
+ *
+ * @var string|null
+ */
+ private $company_name;
+
+ /**
+ * VAT Number.
+ *
+ * @var VatNumber|null
+ */
+ private $vat_number;
+
+ /**
+ * Get contact name.
+ *
+ * @return ContactName|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set contact name.
+ *
+ * @param ContactName|null $name Contact name.
+ * @return void
+ */
+ public function set_name( ContactName $name = null ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get gender.
+ *
+ * @return string|null
+ */
+ public function get_gender() {
+ return $this->gender;
+ }
+
+ /**
+ * Set gender.
+ *
+ * @param string|null $gender Gender.
+ * @return void
+ */
+ public function set_gender( $gender ) {
+ $this->gender = $gender;
+ }
+
+ /**
+ * Get birth date.
+ *
+ * @return DateTime|null
+ */
+ public function get_birth_date() {
+ return $this->birth_date;
+ }
+
+ /**
+ * Set birth date.
+ *
+ * @param DateTime|null $birth_date Date of birth.
+ * @return void
+ */
+ public function set_birth_date( $birth_date ) {
+ $this->birth_date = $birth_date;
+ }
+
+ /**
+ * Get email.
+ *
+ * @return string|null
+ */
+ public function get_email() {
+ return $this->email;
+ }
+
+ /**
+ * Set email address.
+ *
+ * @param string|null $email Email address.
+ * @return void
+ */
+ public function set_email( $email ) {
+ $this->email = $email;
+ }
+
+ /**
+ * Get phone.
+ *
+ * @return string|null
+ */
+ public function get_phone() {
+ return $this->phone;
+ }
+
+ /**
+ * Set phone.
+ *
+ * @param string|null $phone Telephone number.
+ * @return void
+ */
+ public function set_phone( $phone ) {
+ $this->phone = $phone;
+ }
+
+ /**
+ * Get ip address.
+ *
+ * @return string|null
+ */
+ public function get_ip_address() {
+ return $this->ip_address;
+ }
+
+ /**
+ * Set ip address.
+ *
+ * @param string|null $ip_address IP address.
+ * @return void
+ */
+ public function set_ip_address( $ip_address ) {
+ $this->ip_address = $ip_address;
+ }
+
+ /**
+ * Get user agent.
+ *
+ * @return string|null
+ */
+ public function get_user_agent() {
+ return $this->user_agent;
+ }
+
+ /**
+ * Set user agent.
+ *
+ * @param string|null $user_agent User agent.
+ * @return void
+ */
+ public function set_user_agent( $user_agent ) {
+ $this->user_agent = $user_agent;
+ }
+
+ /**
+ * Get language.
+ *
+ * @return string|null
+ */
+ public function get_language() {
+ return $this->language;
+ }
+
+ /**
+ * Set language.
+ *
+ * @param string|null $language Language.
+ * @return void
+ */
+ public function set_language( $language ) {
+ $this->language = $language;
+ }
+
+ /**
+ * Get locale.
+ *
+ * @return string|null
+ */
+ public function get_locale() {
+ return $this->locale;
+ }
+
+ /**
+ * Set locale.
+ *
+ * @param string|null $locale Locale.
+ * @return void
+ */
+ public function set_locale( $locale ) {
+ $this->locale = $locale;
+ }
+
+ /**
+ * Get WordPress user ID.
+ *
+ * @return int|null
+ */
+ public function get_user_id() {
+ return $this->user_id;
+ }
+
+ /**
+ * Set WordPress user ID.
+ *
+ * @param int|null $user_id WordPress user ID.
+ * @return void
+ */
+ public function set_user_id( $user_id ) {
+ $this->user_id = $user_id;
+ }
+
+ /**
+ * Get company name.
+ *
+ * @return string|null
+ */
+ public function get_company_name() {
+ return $this->company_name;
+ }
+
+ /**
+ * Set company name.
+ *
+ * @param string|null $company_name Company name.
+ * @return void
+ */
+ public function set_company_name( $company_name = null ) {
+ $this->company_name = $company_name;
+ }
+
+ /**
+ * Get VAT number.
+ *
+ * @return VatNumber|null
+ */
+ public function get_vat_number() {
+ return $this->vat_number;
+ }
+
+ /**
+ * Set VAT number.
+ *
+ * @param VatNumber|string|null $vat_number VAT number.
+ * @return void
+ */
+ public function set_vat_number( $vat_number = null ) {
+ if ( \is_string( $vat_number ) ) {
+ $vat_number = new VatNumber( $vat_number );
+ }
+
+ $this->vat_number = $vat_number;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'name' => ( null === $this->name ) ? null : $this->name->get_json(),
+ 'gender' => $this->get_gender(),
+ 'birth_date' => ( null === $this->birth_date ) ? null : $this->birth_date->format( DATE_RFC3339 ),
+ 'email' => $this->get_email(),
+ 'phone' => $this->get_phone(),
+ 'ip_address' => $this->get_ip_address(),
+ 'user_agent' => $this->get_user_agent(),
+ 'language' => $this->get_language(),
+ 'locale' => $this->get_locale(),
+ 'user_id' => $this->get_user_id(),
+ 'company_name' => $this->get_company_name(),
+ 'vat_number' => ( null === $this->vat_number ) ? null : $this->vat_number->get_json(),
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create address from object.
+ *
+ * @param mixed $json JSON.
+ * @return Customer
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $customer = new self();
+
+ $properties = (array) $json;
+
+ foreach ( $properties as $key => $value ) {
+ $method = sprintf( 'set_%s', $key );
+
+ $callable = [ $customer, $method ];
+
+ if ( is_callable( $callable ) ) {
+ if ( 'name' === $key ) {
+ $value = ContactName::from_json( $value );
+ }
+
+ if ( 'birth_date' === $key ) {
+ $value = new DateTime( $value );
+ }
+
+ if ( 'vat_number' === $key ) {
+ $value = VatNumber::from_json( $value );
+ }
+
+ call_user_func( $callable, $value );
+ }
+ }
+
+ return $customer;
+ }
+
+ /**
+ * Create string representation of customer.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = [
+ $this->get_name(),
+ $this->get_email(),
+ $this->get_phone(),
+ $this->get_gender(),
+ ( null === $this->birth_date ) ? null : $this->birth_date->format( DATE_RFC3339 ),
+ $this->get_user_agent(),
+ $this->get_ip_address(),
+ $this->get_language(),
+ $this->get_locale(),
+ ];
+
+ $pieces = array_map( 'strval', $pieces );
+
+ $pieces = array_filter( $pieces );
+
+ $string = implode( PHP_EOL, $pieces );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/CustomerHelper.php b/packages/wp-pay/core/src/CustomerHelper.php
new file mode 100644
index 0000000..cbc93fa
--- /dev/null
+++ b/packages/wp-pay/core/src/CustomerHelper.php
@@ -0,0 +1,263 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Core\Util as Core_Util;
+use Pronamic\WordPress\Pay\VatNumbers\VatNumberViesValidator;
+
+/**
+ * Customer helper
+ *
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 2.1.0
+ */
+class CustomerHelper {
+ /**
+ * Complement customer.
+ *
+ * @param Customer $customer Customer to complement.
+ * @return void
+ */
+ public static function complement_customer( Customer $customer ) {
+ // Name.
+ if ( null === $customer->get_name() && is_user_logged_in() ) {
+ $user = wp_get_current_user();
+
+ $data = [
+ 'first_name' => $user->user_firstname,
+ 'last_name' => $user->user_lastname,
+ ];
+
+ $data = array_map( 'trim', $data );
+ $data = array_filter( $data );
+
+ if ( ! empty( $data ) ) {
+ $name = new ContactName();
+
+ $customer->set_name( $name );
+ }
+ }
+
+ // User ID.
+ if ( null === $customer->get_user_id() && is_user_logged_in() ) {
+ $customer->set_user_id( \get_current_user_id() );
+ }
+
+ // Name.
+ $name = $customer->get_name();
+
+ if ( null !== $name ) {
+ ContactNameHelper::complement_name( $name );
+ }
+
+ // VAT Number validity.
+ $vat_number = $customer->get_vat_number();
+
+ if ( null !== $vat_number ) {
+ $vat_number_validity = $vat_number->get_validity();
+
+ if ( null === $vat_number_validity ) {
+ try {
+ $vat_number_validity = VatNumberViesValidator::validate( $vat_number );
+ } catch ( \Exception $e ) {
+ // On exceptions we have no VAT number validity info, no problem.
+ $vat_number_validity = null;
+ }
+
+ $vat_number->set_validity( $vat_number_validity );
+ }
+ }
+
+ // Locale.
+ if ( null === $customer->get_locale() ) {
+ $locales = [];
+
+ // User locale.
+ if ( is_user_logged_in() ) {
+ $user = wp_get_current_user();
+
+ $locales[] = $user->locale;
+ }
+
+ $http_locale = self::locale_accept_from_http();
+
+ if ( '' !== $http_locale ) {
+ $locales[] = $http_locale;
+ }
+
+ // Site locale.
+ $locales[] = get_locale();
+
+ // Find first valid locale.
+ $locales = array_filter( $locales );
+
+ $locale = reset( $locales );
+
+ if ( ! empty( $locale ) ) {
+ $customer->set_locale( $locale );
+ }
+ }
+
+ // Language.
+ $locale = $customer->get_locale();
+
+ if ( null === $customer->get_language() && null !== $locale ) {
+ $language = substr( $locale, 0, 2 );
+
+ $customer->set_language( $language );
+ }
+
+ /**
+ * User Agent.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.4/wp-includes/comment.php#L1962-L1965
+ */
+ if ( null === $customer->get_user_agent() && isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
+ $user_agent = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
+
+ if ( false !== $user_agent ) {
+ $customer->set_user_agent( $user_agent );
+ }
+ }
+
+ // User IP.
+ if ( null === $customer->get_ip_address() ) {
+ // IP (@link https://github.com/WordPress/WordPress/blob/4.9.4/wp-includes/comment.php#L1957-L1960).
+ $ip_address = Core_Util::get_remote_address();
+
+ if ( ! empty( $ip_address ) ) {
+ $customer->set_ip_address( $ip_address );
+ }
+ }
+
+ // Gender.
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $gender = \array_key_exists( 'pronamic_pay_gender', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_gender'] ) ) : null;
+
+ if ( null === $customer->get_gender() && null !== $gender && Gender::is_valid( $gender ) ) {
+ $customer->set_gender( $gender );
+ }
+
+ // Birth date.
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $birth_date_string = \array_key_exists( 'pronamic_pay_birth_date', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_birth_date'] ) ) : null;
+
+ if ( null === $customer->get_birth_date() && ! empty( $birth_date_string ) ) {
+ $birth_date = DateTime::create_from_format( 'Y-m-d', $birth_date_string );
+
+ if ( false !== $birth_date ) {
+ $customer->set_birth_date( $birth_date );
+ }
+ }
+ }
+
+ /**
+ * Locale accept from HTTP.
+ *
+ * @return string
+ */
+ private static function locale_accept_from_http() {
+ if ( ! \function_exists( '\locale_accept_from_http' ) ) {
+ return '';
+ }
+
+ if ( ! \array_key_exists( 'HTTP_ACCEPT_LANGUAGE', $_SERVER ) ) {
+ return '';
+ }
+
+ $accept_language = \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) );
+
+ if ( '' === $accept_language ) {
+ return '';
+ }
+
+ /**
+ * Please note that `locale_accept_from_http` can also return `false`,
+ * this is not documented on PHP.net.
+ *
+ * @link https://www.php.net/manual/en/locale.acceptfromhttp.php
+ * @link https://github.com/php/php-src/blob/php-7.3.5/ext/intl/locale/locale_methods.c#L1578-L1631
+ */
+ $http_locale = \locale_accept_from_http( $accept_language );
+
+ if ( false === $http_locale ) {
+ return '';
+ }
+
+ return $http_locale;
+ }
+
+ /**
+ * Anonymize customer.
+ *
+ * @param Customer $customer Customer to anonymize.
+ * @return void
+ */
+ public static function anonymize_customer( Customer $customer ) {
+ $customer->set_gender( PrivacyManager::anonymize_data( 'text', $customer->get_gender() ) );
+ $customer->set_birth_date( null );
+ $customer->set_email( PrivacyManager::anonymize_data( 'email_mask', $customer->get_email() ) );
+ $customer->set_phone( PrivacyManager::anonymize_data( 'phone', $customer->get_phone() ) );
+ $customer->set_ip_address( PrivacyManager::anonymize_ip( $customer->get_ip_address() ) );
+ $customer->set_user_agent( PrivacyManager::anonymize_data( 'text', $customer->get_user_agent() ) );
+
+ $name = $customer->get_name();
+
+ if ( null !== $name ) {
+ ContactNameHelper::anonymize_name( $name );
+ }
+ }
+
+ /**
+ * Create a customer from an array.
+ *
+ * @param array $data Data.
+ * @return Customer|null
+ */
+ public static function from_array( $data ) {
+ $data = \array_filter(
+ $data,
+ function ( $value ) {
+ return ( null !== $value ) && ( '' !== $value );
+ }
+ );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ $customer = new Customer();
+
+ if ( \array_key_exists( 'name', $data ) ) {
+ $name = $data['name'];
+
+ if ( $name instanceof ContactName ) {
+ $customer->set_name( $name );
+ }
+ }
+
+ if ( \array_key_exists( 'email', $data ) ) {
+ $customer->set_email( $data['email'] );
+ }
+
+ if ( \array_key_exists( 'phone', $data ) ) {
+ $customer->set_phone( $data['phone'] );
+ }
+
+ if ( \array_key_exists( 'user_id', $data ) ) {
+ $customer->set_user_id( \intval( $data['user_id'] ) );
+ }
+
+ return $customer;
+ }
+}
diff --git a/packages/wp-pay/core/src/Dependencies/Dependencies.php b/packages/wp-pay/core/src/Dependencies/Dependencies.php
new file mode 100644
index 0000000..ba54e81
--- /dev/null
+++ b/packages/wp-pay/core/src/Dependencies/Dependencies.php
@@ -0,0 +1,59 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Dependencies
+ */
+
+namespace Pronamic\WordPress\Pay\Dependencies;
+
+/**
+ * Dependencies
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+class Dependencies {
+ /**
+ * Dependencies.
+ *
+ * @var array
+ */
+ private $dependencies;
+
+ /**
+ * Construct.
+ */
+ public function __construct() {
+ $this->dependencies = [];
+ }
+
+ /**
+ * Add dependency.
+ *
+ * @param Dependency $dependency The dependency to add.
+ * @return void
+ */
+ public function add( Dependency $dependency ) {
+ $this->dependencies[] = $dependency;
+ }
+
+ /**
+ * Are met.
+ *
+ * @return bool True if dependencies are met, false otherwise.
+ */
+ public function are_met() {
+ foreach ( $this->dependencies as $dependency ) {
+ if ( ! $dependency->is_met() ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/packages/wp-pay/core/src/Dependencies/Dependency.php b/packages/wp-pay/core/src/Dependencies/Dependency.php
new file mode 100644
index 0000000..b219fec
--- /dev/null
+++ b/packages/wp-pay/core/src/Dependencies/Dependency.php
@@ -0,0 +1,27 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Dependencies
+ */
+
+namespace Pronamic\WordPress\Pay\Dependencies;
+
+/**
+ * Dependency
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+abstract class Dependency {
+ /**
+ * Is met.
+ *
+ * @return bool True if dependency is met, false otherwise.
+ */
+ abstract public function is_met();
+}
diff --git a/packages/wp-pay/core/src/Dependencies/PhpDependency.php b/packages/wp-pay/core/src/Dependencies/PhpDependency.php
new file mode 100644
index 0000000..56a1e85
--- /dev/null
+++ b/packages/wp-pay/core/src/Dependencies/PhpDependency.php
@@ -0,0 +1,52 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Dependencies
+ */
+
+namespace Pronamic\WordPress\Pay\Dependencies;
+
+/**
+ * PHP Dependency
+ *
+ * @link https://github.com/Yoast/yoast-acf-analysis/blob/2.3.0/inc/dependencies/dependency-yoast-seo.php
+ * @link https://github.com/dsawardekar/wp-requirements/blob/0.3.0/lib/Requirements.php#L104-L118
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+class PhpDependency extends Dependency {
+ /**
+ * Minimum PHP version.
+ *
+ * @var string
+ */
+ private $minimum_version;
+
+ /**
+ * Construct PHP dependency.
+ *
+ * @param string $minimum_version Minimum PHP version.
+ */
+ public function __construct( $minimum_version ) {
+ $this->minimum_version = $minimum_version;
+ }
+
+ /**
+ * Is met.
+ *
+ * @link https://github.com/dsawardekar/wp-requirements/blob/0.3.0/lib/Requirements.php#L104-L118
+ * @return bool True if dependency is met, false otherwise.
+ */
+ public function is_met() {
+ return \version_compare(
+ \strval( \phpversion() ),
+ $this->minimum_version,
+ '>='
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/Dependencies/PhpExtensionDependency.php b/packages/wp-pay/core/src/Dependencies/PhpExtensionDependency.php
new file mode 100644
index 0000000..afab5a9
--- /dev/null
+++ b/packages/wp-pay/core/src/Dependencies/PhpExtensionDependency.php
@@ -0,0 +1,47 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Dependencies
+ */
+
+namespace Pronamic\WordPress\Pay\Dependencies;
+
+/**
+ * PHP Extension Dependency
+ *
+ * @link https://github.com/Yoast/yoast-acf-analysis/blob/2.3.0/inc/dependencies/dependency-yoast-seo.php
+ * @link https://github.com/dsawardekar/wp-requirements/blob/0.3.0/lib/Requirements.php#L104-L118
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+class PhpExtensionDependency extends Dependency {
+ /**
+ * Required PHP extension.
+ *
+ * @var string
+ */
+ private $required_extension;
+
+ /**
+ * Construct PHP extension dependency.
+ *
+ * @param string $required_extension Required PHP extension.
+ */
+ public function __construct( $required_extension ) {
+ $this->required_extension = $required_extension;
+ }
+
+ /**
+ * Is met.
+ *
+ * @return bool True if dependency is met, false otherwise.
+ */
+ public function is_met() {
+ return \extension_loaded( $this->required_extension );
+ }
+}
diff --git a/packages/wp-pay/core/src/Dependencies/WordPressDependency.php b/packages/wp-pay/core/src/Dependencies/WordPressDependency.php
new file mode 100644
index 0000000..f412b74
--- /dev/null
+++ b/packages/wp-pay/core/src/Dependencies/WordPressDependency.php
@@ -0,0 +1,54 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Dependencies
+ */
+
+namespace Pronamic\WordPress\Pay\Dependencies;
+
+/**
+ * WordPress Dependency
+ *
+ * @link https://github.com/Yoast/yoast-acf-analysis/blob/2.3.0/inc/dependencies/dependency-yoast-seo.php
+ * @link https://github.com/dsawardekar/wp-requirements/blob/0.3.0/lib/Requirements.php#L104-L118
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+class WordPressDependency extends Dependency {
+ /**
+ * Minimum WordPress version.
+ *
+ * @var string
+ */
+ private $minimum_version;
+
+ /**
+ * Construct WordPress dependency.
+ *
+ * @param string $minimum_version Minimum WordPress version.
+ */
+ public function __construct( $minimum_version ) {
+ $this->minimum_version = $minimum_version;
+ }
+
+ /**
+ * Is met.
+ *
+ * @link https://codex.wordpress.org/Global_Variables
+ * @return bool True if dependency is met, false otherwise.
+ */
+ public function is_met() {
+ global $wp_version;
+
+ return \version_compare(
+ $wp_version,
+ $this->minimum_version,
+ '>='
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/CachedCallbackOptions.php b/packages/wp-pay/core/src/Fields/CachedCallbackOptions.php
new file mode 100644
index 0000000..6131d77
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/CachedCallbackOptions.php
@@ -0,0 +1,88 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use ArrayIterator;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Cached callback options class
+ *
+ * @phpstan-implements IteratorAggregate
+ */
+class CachedCallbackOptions implements IteratorAggregate {
+ /**
+ * Cache key.
+ *
+ * @var string
+ */
+ private $cache_key;
+
+ /**
+ * Callback.
+ *
+ * @var callable: array
+ */
+ private $callback;
+
+ /**
+ * Construct cached callback options.
+ *
+ * @param callable $callback Callback.
+ * @param string $cache_key Cache key.
+ */
+ public function __construct( $callback, $cache_key ) {
+ $this->callback = $callback;
+ $this->cache_key = $cache_key;
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable {
+ $options = $this->get_transient_options();
+
+ return new ArrayIterator( $options );
+ }
+
+ /**
+ * Get callback options.
+ *
+ * @return array
+ */
+ private function get_callback_options() {
+ return \call_user_func( $this->callback );
+ }
+
+ /**
+ * Get transient options.
+ *
+ * @return array
+ */
+ private function get_transient_options() {
+ if ( '' === $this->cache_key ) {
+ return $this->get_callback_options();
+ }
+
+ $options = \get_transient( $this->cache_key );
+
+ if ( false === $options ) {
+ $options = $this->get_callback_options();
+
+ \set_transient( $this->cache_key, $options, \DAY_IN_SECONDS );
+ }
+
+ return $options;
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/DateField.php b/packages/wp-pay/core/src/Fields/DateField.php
new file mode 100644
index 0000000..9479b0c
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/DateField.php
@@ -0,0 +1,50 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Date field class
+ */
+class DateField extends Field {
+ /**
+ * Get element.
+ *
+ * @return Element|null
+ */
+ protected function get_element() {
+ $element = new Element(
+ 'input',
+ [
+ 'type' => 'date',
+ 'id' => $this->get_id(),
+ 'name' => $this->get_id(),
+ ]
+ );
+
+ return $element;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ $data = parent::jsonSerialize();
+
+ $data['type'] = 'date';
+
+ return $data;
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/Field.php b/packages/wp-pay/core/src/Fields/Field.php
new file mode 100644
index 0000000..b499e84
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/Field.php
@@ -0,0 +1,165 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use JsonSerializable;
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Field class
+ */
+class Field implements JsonSerializable {
+ /**
+ * ID.
+ *
+ * @var string
+ */
+ private $id;
+
+ /**
+ * Label.
+ *
+ * @var string
+ */
+ private $label = '';
+
+ /**
+ * Required.
+ *
+ * @var bool
+ */
+ private $required = false;
+
+ /**
+ * Meta key.
+ *
+ * @var string
+ */
+ public $meta_key = '';
+
+ /**
+ * Construct field.
+ *
+ * @param string $id ID.
+ */
+ public function __construct( $id ) {
+ $this->id = $id;
+
+ $this->setup();
+ }
+
+ /**
+ * Setup field.
+ *
+ * @return void
+ */
+ protected function setup() {
+ }
+
+ /**
+ * Get ID.
+ *
+ * @return string
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Get label.
+ *
+ * @return string
+ */
+ public function get_label(): string {
+ return $this->label;
+ }
+
+ /**
+ * Set label.
+ *
+ * @param string $label Label.
+ */
+ public function set_label( string $label ): void {
+ $this->label = $label;
+ }
+
+ /**
+ * Set required.
+ *
+ * @param bool $required Required.
+ */
+ public function set_required( bool $required ): void {
+ $this->required = $required;
+ }
+
+ /**
+ * Is required.
+ *
+ * @return bool
+ */
+ public function is_required(): bool {
+ return $this->required;
+ }
+
+ /**
+ * Get element.
+ *
+ * @return Element|null
+ */
+ protected function get_element() {
+ return null;
+ }
+
+ /**
+ * Render.
+ *
+ * @return string
+ */
+ public function render() {
+ $element = $this->get_element();
+
+ if ( null === $element ) {
+ return '';
+ }
+
+ return $element->render();
+ }
+
+ /**
+ * Output.
+ *
+ * @return void
+ */
+ public function output() {
+ $element = $this->get_element();
+
+ if ( null === $element ) {
+ return;
+ }
+
+ $element->output();
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return [
+ 'type' => '',
+ 'id' => $this->id,
+ 'label' => $this->label,
+ 'required' => $this->required,
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/IDealIssuerSelectField.php b/packages/wp-pay/core/src/Fields/IDealIssuerSelectField.php
new file mode 100644
index 0000000..d8205bf
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/IDealIssuerSelectField.php
@@ -0,0 +1,48 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+/**
+ * Select field iDEAL issuer class
+ */
+class IDealIssuerSelectField extends SelectField {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ protected function setup() {
+ parent::setup();
+
+ $this->meta_key = 'issuer';
+
+ $this->set_label( \__( 'Bank', 'pronamic_ideal' ) );
+ }
+
+ /**
+ * Get options.
+ *
+ * @return array
+ */
+ public function get_options() {
+ $options = parent::get_options();
+
+ return [
+ /**
+ * The list should be accompanied by the instruction phrase "Kies uw bank" (UK: "Choose your bank"). In
+ * case of an HTML , the first element in the list states this instruction phrase and is selected by default (to prevent accidental Issuer selection).
+ */
+ new SelectFieldOption( '', __( '— Choose your bank —', 'pronamic_ideal' ) ),
+
+ ...$options,
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/SelectField.php b/packages/wp-pay/core/src/Fields/SelectField.php
new file mode 100644
index 0000000..1cd81fd
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/SelectField.php
@@ -0,0 +1,112 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Select field class
+ *
+ * @link https://developer.wordpress.org/block-editor/reference-guides/components/select-control/
+ * @link https://github.com/WordPress/gutenberg/tree/trunk/packages/components/src/select-control
+ */
+class SelectField extends Field {
+ /**
+ * Options.
+ *
+ * @var iterable
+ */
+ private $options = [];
+
+ /**
+ * Get options.
+ *
+ * @return iterable
+ */
+ public function get_options() {
+ return $this->options;
+ }
+
+ /**
+ * Set options.
+ *
+ * @param iterable $options Options.
+ * @return void
+ */
+ public function set_options( $options ) {
+ $this->options = $options;
+ }
+
+ /**
+ * Get flat options.
+ *
+ * @return iterable
+ */
+ public function get_flat_options() {
+ $options = [];
+
+ foreach ( $this->get_options() as $child ) {
+ if ( $child instanceof SelectFieldOption ) {
+ $options[] = $child;
+ }
+
+ if ( $child instanceof SelectFieldOptionGroup ) {
+ foreach ( $child->options as $option ) {
+ $options[] = $option;
+ }
+ }
+ }
+
+ return $options;
+ }
+
+ /**
+ * Get element.
+ *
+ * @return Element|null
+ */
+ protected function get_element() {
+ $element = new Element(
+ 'select',
+ [
+ 'id' => $this->get_id(),
+ 'name' => $this->get_id(),
+ ]
+ );
+
+ foreach ( $this->get_options() as $child ) {
+ $element->children[] = $child->get_element();
+ }
+
+ return $element;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ $data = parent::jsonSerialize();
+
+ $data['type'] = 'select';
+ $data['options'] = [];
+
+ try {
+ $data['options'] = $this->get_flat_options();
+ } catch ( \Exception $e ) {
+ $data['error'] = $e->getMessage();
+ }
+
+ return $data;
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/SelectFieldOption.php b/packages/wp-pay/core/src/Fields/SelectFieldOption.php
new file mode 100644
index 0000000..a997c02
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/SelectFieldOption.php
@@ -0,0 +1,78 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use JsonSerializable;
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Select field option class
+ *
+ * @link https://developer.wordpress.org/block-editor/reference-guides/components/select-control/#options
+ * @link https://github.com/WordPress/gutenberg/tree/trunk/packages/components/src/select-control#options
+ */
+class SelectFieldOption implements JsonSerializable {
+ /**
+ * Value.
+ *
+ * @var string
+ */
+ public $value;
+
+ /**
+ * Label.
+ *
+ * @var string
+ */
+ public $label;
+
+ /**
+ * Construct select field option.
+ *
+ * @param string $value Value.
+ * @param string $label Label.
+ */
+ public function __construct( string $value, string $label ) {
+ $this->value = $value;
+ $this->label = $label;
+ }
+
+ /**
+ * Get element.
+ *
+ * @return Element
+ */
+ public function get_element() {
+ $element = new Element(
+ 'option',
+ [
+ 'value' => $this->value,
+ ]
+ );
+
+ $element->children[] = $this->label;
+
+ return $element;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @link https://developer.wordpress.org/block-editor/reference-guides/components/select-control/
+ * @return array
+ */
+ public function jsonSerialize(): array {
+ return [
+ 'value' => $this->value,
+ 'label' => $this->label,
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/SelectFieldOptionGroup.php b/packages/wp-pay/core/src/Fields/SelectFieldOptionGroup.php
new file mode 100644
index 0000000..b362a07
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/SelectFieldOptionGroup.php
@@ -0,0 +1,61 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Select field option group class
+ */
+class SelectFieldOptionGroup {
+ /**
+ * Label.
+ *
+ * @var string
+ */
+ private $label;
+
+ /**
+ * Options.
+ *
+ * @var SelectFieldOption[]
+ */
+ public $options = [];
+
+ /**
+ * Construct select field option group.
+ *
+ * @param string $label Label.
+ */
+ public function __construct( $label ) {
+ $this->label = $label;
+ }
+
+ /**
+ * Get element.
+ *
+ * @return Element
+ */
+ public function get_element() {
+ $element = new Element(
+ 'optgroup',
+ [
+ 'label' => $this->label,
+ ]
+ );
+
+ foreach ( $this->options as $option ) {
+ $element->children[] = $option->get_element();
+ }
+
+ return $element;
+ }
+}
diff --git a/packages/wp-pay/core/src/Fields/TextField.php b/packages/wp-pay/core/src/Fields/TextField.php
new file mode 100644
index 0000000..ebee7e3
--- /dev/null
+++ b/packages/wp-pay/core/src/Fields/TextField.php
@@ -0,0 +1,50 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Fields;
+
+use Pronamic\WordPress\Html\Element;
+
+/**
+ * Text field class
+ */
+class TextField extends Field {
+ /**
+ * Get element.
+ *
+ * @return Element|null
+ */
+ protected function get_element() {
+ $element = new Element(
+ 'input',
+ [
+ 'type' => 'text',
+ 'id' => $this->get_id(),
+ 'name' => $this->get_id(),
+ ]
+ );
+
+ return $element;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ $data = parent::jsonSerialize();
+
+ $data['type'] = 'input';
+
+ return $data;
+ }
+}
diff --git a/packages/wp-pay/core/src/GatewayIntegrations.php b/packages/wp-pay/core/src/GatewayIntegrations.php
new file mode 100644
index 0000000..a5bbabc
--- /dev/null
+++ b/packages/wp-pay/core/src/GatewayIntegrations.php
@@ -0,0 +1,84 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use ArrayIterator;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Title: WordPress gateway integrations class.
+ *
+ * @author Reüel van der Steege
+ * @version 2.2.6
+ * @since 1.0.0
+ * @implements IteratorAggregate
+ */
+class GatewayIntegrations implements IteratorAggregate {
+ /**
+ * Integrations.
+ *
+ * @var AbstractGatewayIntegration[]
+ */
+ private $integrations = [];
+
+ /**
+ * Construct gateway integrations.
+ *
+ * @param array $integrations Integrations.
+ */
+ public function __construct( $integrations ) {
+ foreach ( $integrations as $integration ) {
+ if ( is_string( $integration ) && class_exists( $integration ) ) {
+ $integration = new $integration();
+ }
+
+ /**
+ * Invalid integrations are ignored for now.
+ *
+ * @todo Consider throwing exception?
+ */
+ if ( ! ( $integration instanceof AbstractGatewayIntegration ) ) {
+ continue;
+ }
+
+ /**
+ * Only add active integrations.
+ */
+ if ( $integration->is_active() ) {
+ $this->integrations[ $integration->get_id() ] = $integration;
+ }
+ }
+ }
+
+ /**
+ * Get integration by ID.
+ *
+ * @param string $id Integration ID.
+ * @return AbstractGatewayIntegration|null
+ */
+ public function get_integration( $id ) {
+ if ( array_key_exists( $id, $this->integrations ) ) {
+ return $this->integrations[ $id ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return \ArrayIterator
+ */
+ public function getIterator(): Traversable {
+ return new ArrayIterator( $this->integrations );
+ }
+}
diff --git a/packages/wp-pay/core/src/GatewayPostType.php b/packages/wp-pay/core/src/GatewayPostType.php
new file mode 100644
index 0000000..bfe6e5b
--- /dev/null
+++ b/packages/wp-pay/core/src/GatewayPostType.php
@@ -0,0 +1,284 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Title: WordPress gateway post type
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since ?
+ */
+class GatewayPostType {
+ /**
+ * Post type.
+ *
+ * @var string
+ */
+ const POST_TYPE = 'pronamic_gateway';
+
+ /**
+ * Constructs and initializes a gateway post type object.
+ */
+ public function __construct() {
+ /**
+ * Priority of the initial post types function should be set to < 10.
+ *
+ * @link https://core.trac.wordpress.org/ticket/28488
+ * @link https://core.trac.wordpress.org/changeset/29318
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.0/wp-includes/post.php#L167
+ */
+ add_action( 'init', [ $this, 'register_gateway_post_type' ], 0 ); // Highest priority.
+
+ add_action( 'save_post_' . self::POST_TYPE, [ $this, 'maybe_set_default_gateway' ] );
+
+ // REST API.
+ add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
+ }
+
+ /**
+ * Register post types.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.6.1/wp-includes/post.php#L1277-L1300
+ * @return void
+ */
+ public function register_gateway_post_type() {
+ register_post_type(
+ 'pronamic_gateway',
+ [
+ 'label' => __( 'Payment Gateway Configurations', 'pronamic_ideal' ),
+ 'labels' => [
+ 'name' => __( 'Payment Gateway Configurations', 'pronamic_ideal' ),
+ 'singular_name' => __( 'Payment Gateway Configuration', 'pronamic_ideal' ),
+ 'add_new' => __( 'Add New', 'pronamic_ideal' ),
+ 'add_new_item' => __( 'Add New Payment Gateway Configuration', 'pronamic_ideal' ),
+ 'edit_item' => __( 'Edit Payment Gateway Configuration', 'pronamic_ideal' ),
+ 'new_item' => __( 'New Payment Gateway Configuration', 'pronamic_ideal' ),
+ 'all_items' => __( 'All Payment Gateway Configurations', 'pronamic_ideal' ),
+ 'view_item' => __( 'View Payment Gateway Configuration', 'pronamic_ideal' ),
+ 'search_items' => __( 'Search Payment Gateway Configurations', 'pronamic_ideal' ),
+ 'not_found' => __( 'No payment gateway configurations found.', 'pronamic_ideal' ),
+ 'not_found_in_trash' => __( 'No payment gateway configurations found in Trash.', 'pronamic_ideal' ),
+ 'menu_name' => __( 'Configurations', 'pronamic_ideal' ),
+ 'filter_items_list' => __( 'Filter payment gateway configurations list', 'pronamic_ideal' ),
+ 'items_list_navigation' => __( 'Payment gateway configurations list navigation', 'pronamic_ideal' ),
+ 'items_list' => __( 'Payment gateway configurations list', 'pronamic_ideal' ),
+
+ /*
+ * New Post Type Labels in 5.0.
+ * @link https://make.wordpress.org/core/2018/12/05/new-post-type-labels-in-5-0/
+ */
+ 'item_published' => __( 'Payment gateway configuration published.', 'pronamic_ideal' ),
+ 'item_published_privately' => __( 'Payment gateway configuration published privately.', 'pronamic_ideal' ),
+ 'item_reverted_to_draft' => __( 'Payment gateway configuration reverted to draft.', 'pronamic_ideal' ),
+ 'item_scheduled' => __( 'Payment gateway configuration scheduled.', 'pronamic_ideal' ),
+ 'item_updated' => __( 'Payment gateway configuration updated.', 'pronamic_ideal' ),
+ ],
+ 'public' => false,
+ 'publicly_queryable' => false,
+ 'show_ui' => true,
+ 'show_in_nav_menus' => false,
+ 'show_in_menu' => false,
+ 'show_in_admin_bar' => false,
+ 'hierarchical' => true,
+ 'supports' => [
+ 'title',
+ 'revisions',
+ ],
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'capabilities' => self::get_capabilities(),
+ // Don't map meta capabilities since we only use the `manage_options` capability for this post type.
+ 'map_meta_cap' => false,
+ ]
+ );
+ }
+
+ /**
+ * Maybe set the default gateway.
+ *
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public function maybe_set_default_gateway( $post_id ) {
+ // Don't set the default gateway if the post is not published.
+ if ( 'publish' !== get_post_status( $post_id ) ) {
+ return;
+ }
+
+ // Don't set the default gateway if there is already a published gateway set.
+ $config_id = get_option( 'pronamic_pay_config_id' );
+
+ if ( ! empty( $config_id ) && 'publish' === get_post_status( $config_id ) ) {
+ return;
+ }
+
+ // Update.
+ update_option( 'pronamic_pay_config_id', $post_id );
+ }
+
+ /**
+ * Get capabilities for this post type.
+ *
+ * @return array
+ */
+ public static function get_capabilities() {
+ return [
+ 'edit_post' => 'manage_options',
+ 'read_post' => 'manage_options',
+ 'delete_post' => 'manage_options',
+ 'edit_posts' => 'manage_options',
+ 'edit_others_posts' => 'manage_options',
+ 'publish_posts' => 'manage_options',
+ 'read_private_posts' => 'manage_options',
+ 'read' => 'manage_options',
+ 'delete_posts' => 'manage_options',
+ 'delete_private_posts' => 'manage_options',
+ 'delete_published_posts' => 'manage_options',
+ 'delete_others_posts' => 'manage_options',
+ 'edit_private_posts' => 'manage_options',
+ 'edit_published_posts' => 'manage_options',
+ 'create_posts' => 'manage_options',
+ ];
+ }
+
+ /**
+ * REST API init.
+ *
+ * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/
+ * @link https://developer.wordpress.org/reference/hooks/rest_api_init/
+ *
+ * @return void
+ */
+ public function rest_api_init() {
+ \register_rest_route(
+ 'pronamic-pay/v1',
+ '/gateways/(?P\d+)',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'rest_api_gateway' ],
+ 'permission_callback' => function () {
+ return \current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'config_id' => [
+ 'description' => __( 'Gateway configuration ID.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ ],
+ ]
+ );
+
+ register_rest_route(
+ 'pronamic-pay/v1',
+ '/gateways/(?P\d+)/admin',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'rest_api_gateway_admin' ],
+ 'permission_callback' => function () {
+ return current_user_can( 'manage_options' );
+ },
+ 'args' => [
+ 'config_id' => [
+ 'description' => __( 'Gateway configuration ID.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ 'gateway_id' => [
+ 'description' => __( 'Gateway ID.', 'pronamic_ideal' ),
+ 'type' => 'string',
+ ],
+ 'gateway_mode' => [
+ 'description' => __( 'Gateway mode.', 'pronamic_ideal' ),
+ 'type' => 'string',
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * REST API gateway.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return object
+ */
+ public function rest_api_gateway( \WP_REST_Request $request ) {
+ $config_id = $request->get_param( 'config_id' );
+
+ // Gateway.
+ $gateway = Plugin::get_gateway( $config_id );
+
+ if ( null === $gateway ) {
+ return new \WP_Error(
+ 'pronamic-pay-gateway-not-found',
+ \sprintf(
+ /* translators: %s: Gateway configuration ID */
+ \__( 'Could not find gateway with ID `%s`.', 'pronamic_ideal' ),
+ $config_id
+ ),
+ $config_id
+ );
+ }
+
+ return $gateway;
+ }
+
+ /**
+ * REST API gateway.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return object
+ */
+ public function rest_api_gateway_admin( \WP_REST_Request $request ) {
+ $config_id = $request->get_param( 'config_id' );
+ $gateway_id = $request->get_param( 'gateway_id' );
+ $gateway_mode = $request->get_param( 'gateway_mode' );
+
+ // Gateway.
+ $args = [
+ 'gateway_id' => $gateway_id,
+ ];
+
+ $gateway = Plugin::get_gateway( $config_id, $args );
+
+ if ( empty( $gateway ) ) {
+ return new \WP_Error(
+ 'pronamic-pay-gateway-not-found',
+ sprintf(
+ /* translators: %s: Gateway configuration ID */
+ __( 'Could not find gateway with ID `%s`.', 'pronamic_ideal' ),
+ $config_id
+ ),
+ $config_id
+ );
+ }
+
+ // Settings.
+ ob_start();
+
+ $plugin = \pronamic_pay_plugin();
+
+ require __DIR__ . '/../views/meta-box-gateway-settings.php';
+
+ $meta_box_settings = ob_get_clean();
+
+ // Object.
+ return (object) [
+ 'config_id' => $config_id,
+ 'gateway_id' => $gateway_id,
+ 'gateway_mode' => $gateway_mode,
+ 'meta_boxes' => (object) [
+ 'settings' => $meta_box_settings,
+ ],
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Gateways/GatewaysDataStoreCPT.php b/packages/wp-pay/core/src/Gateways/GatewaysDataStoreCPT.php
new file mode 100644
index 0000000..6999f1e
--- /dev/null
+++ b/packages/wp-pay/core/src/Gateways/GatewaysDataStoreCPT.php
@@ -0,0 +1,86 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Gateways
+ */
+
+namespace Pronamic\WordPress\Pay\Gateways;
+
+use Pronamic\WordPress\Pay\AbstractDataStoreCPT;
+use Pronamic\WordPress\Pay\Core\Gateway;
+
+/**
+ * Title: Gateways data store CPT
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Reüel van der Steege
+ * @version 4.0.0
+ * @since 4.0.0
+ */
+class GatewaysDataStoreCPT extends AbstractDataStoreCPT {
+ /**
+ * Gateways.
+ *
+ * @var Gateway[]
+ */
+ private $gateways;
+
+ /**
+ * Construct gateways data store CPT object.
+ */
+ public function __construct() {
+ $this->meta_key_prefix = '_pronamic_gateway_';
+
+ $this->gateways = [];
+ }
+
+ /**
+ * Get gateway by ID.
+ *
+ * @param int $post_id Gateway configuration post ID.
+ * @return Gateway|null
+ */
+ public function get_gateway( $post_id ) {
+ if ( ! isset( $this->gateways[ $post_id ] ) ) {
+ // Check post type.
+ $post_type = get_post_type( $post_id );
+
+ if ( 'pronamic_gateway' !== $post_type ) {
+ return null;
+ }
+
+ // Check if trashed.
+ if ( 'trash' === get_post_status( $post_id ) ) {
+ return null;
+ }
+
+ // Get integration.
+ $gateway_id = \get_post_meta( $post_id, '_pronamic_gateway_id', true );
+
+ if ( empty( $gateway_id ) ) {
+ return null;
+ }
+
+ $integration = pronamic_pay_plugin()->gateway_integrations->get_integration( $gateway_id );
+
+ if ( null === $integration ) {
+ return null;
+ }
+
+ // Get gateway from integration for configuration post ID.
+ $gateway = $integration->get_gateway( $post_id );
+
+ if ( null !== $gateway ) {
+ $this->gateways[ $post_id ] = $gateway;
+ }
+ }
+
+ return $this->gateways[ $post_id ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Gender.php b/packages/wp-pay/core/src/Gender.php
new file mode 100644
index 0000000..2a0a18c
--- /dev/null
+++ b/packages/wp-pay/core/src/Gender.php
@@ -0,0 +1,61 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Gender.
+ *
+ * @author Remco Tolsma
+ * @since 2.1.0
+ * @version 2.0.8
+ */
+class Gender {
+ /**
+ * Female.
+ *
+ * @var string
+ */
+ const FEMALE = 'F';
+
+ /**
+ * Male.
+ *
+ * @var string
+ */
+ const MALE = 'M';
+
+ /**
+ * Other.
+ *
+ * @link https://en.wikipedia.org/wiki/Legal_recognition_of_non-binary_gender
+ *
+ * @var string
+ */
+ const OTHER = 'X';
+
+ /**
+ * Check if value is valid.
+ *
+ * @param string $gender Gender.
+ * @return boolean True if valid, false otherwise.
+ */
+ public static function is_valid( $gender ) {
+ return in_array(
+ $gender,
+ [
+ self::FEMALE,
+ self::MALE,
+ self::OTHER,
+ ],
+ true
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/HomeUrlController.php b/packages/wp-pay/core/src/HomeUrlController.php
new file mode 100644
index 0000000..c8d976e
--- /dev/null
+++ b/packages/wp-pay/core/src/HomeUrlController.php
@@ -0,0 +1,174 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Home URL Controller class
+ */
+class HomeUrlController {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ \add_action( 'init', [ $this, 'init' ] );
+
+ \add_action( 'admin_init', [ $this, 'admin_init' ] );
+
+ \add_action( 'admin_notices', [ $this, 'admin_notices' ] );
+ }
+
+ /**
+ * Initialize.
+ *
+ * @return void
+ */
+ public function init() {
+ $option = \get_option( 'pronamic_pay_home_url', null );
+
+ if ( null === $option ) {
+ \update_option( 'pronamic_pay_home_url', \get_option( 'home' ) );
+ }
+
+ \register_setting(
+ /**
+ * We deliberately use the 'pronamic_pay_home_url' option group
+ * here, as this setting is not visible to administrators. Using
+ * the 'pronamic_pay' option group will clear the setting after
+ * saving.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/119
+ */
+ 'pronamic_pay_home_url',
+ 'pronamic_pay_home_url',
+ [
+ 'type' => 'string',
+ 'description' => \__( 'Home URL setting to detect changes in the WordPress home URL.', 'pronamic_ideal' ),
+ 'sanitize_callback' => 'sanitize_url',
+ 'default' => \get_option( 'home' ),
+ ]
+ );
+ }
+
+ /**
+ * Admin notices.
+ *
+ * @return void
+ */
+ public function admin_notices() {
+ /**
+ * We use the `get_option( 'home' )` here and not `home_url()` to
+ * bypass the `home_url` filter. The WPML plugin hooks into the
+ * `home_url` filter and this causes the notice to be displayed
+ * unnecessarily. That's why we decided to compare on the
+ * unfiltered home URL directly from the options.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/121
+ */
+ $home_url_a = \get_option( 'home' );
+ $home_url_b = \get_option( 'pronamic_pay_home_url' );
+
+ if ( $home_url_a === $home_url_b ) {
+ return;
+ }
+
+ $dismiss_notification_url = \add_query_arg( 'pronamic_pay_dismiss_home_url_change', true );
+ $dismiss_notification_url = \wp_nonce_url( $dismiss_notification_url, 'pronamic_pay_dismiss_home_url_change', 'pronamic_pay_dismiss_home_url_change_nonce' );
+
+ ?>
+
+
+ —
+
+
+
+ %s',
+ \esc_html__( 'If you use subscriptions, you may want to update processing of recurring payments in the plugin debug settings to prevent duplicate payments being started in a development environment.', 'pronamic_ideal' )
+ );
+
+ }
+
+ ?>
+
+
+
+
+
+
+
+ false,
+ 'pronamic_pay_dismiss_home_url_change_nonce' => false,
+ 'pronamic_pay_dismissed_home_url_change' => true,
+ ],
+ \wp_get_referer()
+ );
+
+ \wp_safe_redirect( $url );
+
+ exit;
+ }
+}
diff --git a/packages/wp-pay/core/src/HouseNumber.php b/packages/wp-pay/core/src/HouseNumber.php
new file mode 100644
index 0000000..ae1d7c2
--- /dev/null
+++ b/packages/wp-pay/core/src/HouseNumber.php
@@ -0,0 +1,184 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use InvalidArgumentException;
+use stdClass;
+
+/**
+ * House number
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.1.6
+ */
+class HouseNumber {
+ /**
+ * Value.
+ *
+ * @var string|null
+ */
+ private $value;
+
+ /**
+ * Base.
+ *
+ * @var string|null
+ */
+ private $base;
+
+ /**
+ * Addition.
+ *
+ * @var string|null
+ */
+ private $addition;
+
+ /**
+ * Construct house number.
+ *
+ * @param string|null $value House number.
+ */
+ public function __construct( $value = null ) {
+ $this->set_value( $value );
+ }
+
+ /**
+ * Get value.
+ *
+ * @return string|null
+ */
+ public function get_value() {
+ return $this->value;
+ }
+
+ /**
+ * Set value.
+ *
+ * @param string|null $value Value.
+ * @return void
+ */
+ public function set_value( $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Get base.
+ *
+ * @return string|null
+ */
+ public function get_base() {
+ return $this->base;
+ }
+
+ /**
+ * Set base.
+ *
+ * @param string|null $base Base.
+ * @return void
+ */
+ public function set_base( $base ) {
+ $this->base = $base;
+ }
+
+ /**
+ * Get addition.
+ *
+ * @return string|null
+ */
+ public function get_addition() {
+ return $this->addition;
+ }
+
+ /**
+ * Set addition.
+ *
+ * @param string|null $addition Addition.
+ * @return void
+ */
+ public function set_addition( $addition ) {
+ $this->addition = $addition;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'value' => $this->value,
+ 'base' => $this->base,
+ 'addition' => $this->addition,
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create from object.
+ *
+ * @param mixed $json JSON.
+ * @return HouseNumber
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( is_string( $json ) ) {
+ return new self( $json );
+ }
+
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ $house_number = new self();
+
+ if ( isset( $json->value ) ) {
+ $house_number->set_value( $json->value );
+ }
+
+ if ( isset( $json->base ) ) {
+ $house_number->set_base( $json->base );
+ }
+
+ if ( isset( $json->addition ) ) {
+ $house_number->set_addition( $json->addition );
+ }
+
+ return $house_number;
+ }
+
+ /**
+ * Create string representation of personal name.
+ *
+ * @return string
+ */
+ public function __toString() {
+ return strval( $this->value );
+ }
+
+ /**
+ * Anonymize.
+ *
+ * @return void
+ */
+ public function anonymize() {
+ $this->set_value( PrivacyManager::anonymize_data( 'text', $this->get_value() ) );
+ $this->set_base( PrivacyManager::anonymize_data( 'text', $this->get_base() ) );
+ $this->set_addition( PrivacyManager::anonymize_data( 'text', $this->get_addition() ) );
+ }
+}
diff --git a/packages/wp-pay/core/src/LicenseManager.php b/packages/wp-pay/core/src/LicenseManager.php
new file mode 100644
index 0000000..12771b2
--- /dev/null
+++ b/packages/wp-pay/core/src/LicenseManager.php
@@ -0,0 +1,533 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeZone;
+use Pronamic\WordPress\Html\Element;
+use WP_Error;
+
+/**
+ * License Manager
+ *
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 2.0.1
+ */
+class LicenseManager {
+ /**
+ * Instance of this class.
+ *
+ * @since 4.7.1
+ * @var self
+ */
+ protected static $instance = null;
+
+ /**
+ * Return an instance of this class.
+ *
+ * @return self A single instance of this class.
+ */
+ public static function instance() {
+ // If the single instance hasn't been set, set it now.
+ if ( null === self::$instance ) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Construct license manager.
+ */
+ public function __construct() {
+ // Actions.
+ \add_action( 'admin_init', [ $this, 'admin_init' ], 9 );
+ \add_action( 'admin_notices', [ $this, 'admin_notices' ] );
+ \add_action( 'pronamic_pay_license_check', [ $this, 'license_check_event' ] );
+
+ // Filters.
+ \add_filter( sprintf( 'pre_update_option_%s', 'pronamic_pay_license_key' ), [ $this, 'pre_update_option_license_key' ], 10, 2 );
+ \add_filter( 'debug_information', [ $this, 'debug_information' ], 15 );
+ \add_filter( 'site_status_tests', [ $this, 'site_status_tests' ] );
+ }
+
+ /**
+ * Get home URL.
+ *
+ * @return string
+ */
+ private function get_home_url() {
+ /**
+ * We use the `get_option( 'home' )` here and not `home_url()` to
+ * bypass the `home_url` filter. The WPML plugin hooks into the
+ * `home_url` filter and this causes the notice to be displayed
+ * unnecessarily. That's why we decided to compare on the
+ * unfiltered home URL directly from the options.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/136
+ */
+ return \get_option( 'home' );
+ }
+
+ /**
+ * Admin initialize.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ // License key setting.
+ \add_settings_field(
+ 'pronamic_pay_license_key',
+ \__( 'Support License Key', 'pronamic_ideal' ),
+ [ $this, 'input_license_key' ],
+ 'pronamic_pay',
+ 'pronamic_pay_general',
+ [
+ 'label_for' => 'pronamic_pay_license_key',
+ 'classes' => 'regular-text code',
+ ]
+ );
+
+ // License check.
+ if ( ! \wp_next_scheduled( 'pronamic_pay_license_check' ) ) {
+ \wp_schedule_event( time(), 'daily', 'pronamic_pay_license_check' );
+ }
+ }
+
+ /**
+ * Input license key.
+ *
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function input_license_key( $args ) {
+ /**
+ * Perform license check.
+ */
+ \do_action( 'pronamic_pay_license_check' );
+
+ $args = \wp_parse_args(
+ $args,
+ [
+ 'type' => 'text',
+ 'classes' => 'regular-text',
+ ]
+ );
+
+ $name = $args['label_for'];
+
+ $element = new Element(
+ 'input',
+ [
+ 'name' => $name,
+ 'id' => $name,
+ 'type' => $args['type'],
+ 'class' => $args['classes'],
+ 'value' => \get_option( $name ),
+ ]
+ );
+
+ $element->output();
+
+ $status = \get_option( 'pronamic_pay_license_status' );
+
+ $icon = 'valid' === $status ? 'yes' : 'no';
+
+ printf( ' ', \esc_attr( $icon ) );
+ }
+
+ /**
+ * Admin notices.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.2.4/wp-admin/options.php#L205-L218
+ * @link https://github.com/easydigitaldownloads/Easy-Digital-Downloads/blob/2.4.2/includes/class-edd-license-handler.php#L309-L369
+ * @return void
+ */
+ public function admin_notices() {
+ // Show notices only to options managers (administrators).
+ if ( ! current_user_can( 'manage_options' ) ) {
+ return;
+ }
+
+ // License activation notice.
+ $data = get_transient( 'pronamic_pay_license_data' );
+
+ if ( $data ) {
+ include __DIR__ . '/../views/notice-license.php';
+
+ delete_transient( 'pronamic_pay_license_data' );
+ }
+
+ // License status notice.
+ if ( 'valid' !== get_option( 'pronamic_pay_license_status' ) ) {
+ $class = Plugin::get_number_payments() > 20 ? 'error' : 'updated';
+
+ $license = get_option( 'pronamic_pay_license_key' );
+
+ if ( '' === $license ) {
+ $notice = sprintf(
+ /* translators: 1: Pronamic Pay settings page URL, 2: The pronamicpay.com plugin page URL */
+ __( 'Pronamic Pay — You have not entered a valid support license key , please get your key at pronamicpay.com .', 'pronamic_ideal' ),
+ add_query_arg( 'page', 'pronamic_pay_settings', get_admin_url( null, 'admin.php' ) ),
+ 'https://www.pronamicpay.com/'
+ );
+ } else {
+ $notice = sprintf(
+ /* translators: 1: Pronamic Pay settings page URL, 2: The pronamicpay.com plugin page URL, 3: The pronamic.shop account page URL */
+ __( 'Pronamic Pay — You have not entered a valid support license key . Please get your key at pronamicpay.com or login to check your license status .', 'pronamic_ideal' ),
+ add_query_arg( 'page', 'pronamic_pay_settings', get_admin_url( null, 'admin.php' ) ),
+ 'https://www.pronamicpay.com/',
+ 'https://www.pronamic.shop/'
+ );
+ }
+
+ printf(
+ '',
+ esc_attr( $class ),
+ wp_kses_post( $notice )
+ );
+ }
+ }
+
+ /**
+ * Pre update option 'pronamic_pay_license_key'.
+ *
+ * @param string $newvalue New value.
+ * @param string $oldvalue Old value.
+ * @return string
+ */
+ public function pre_update_option_license_key( $newvalue, $oldvalue ) {
+ $newvalue = trim( $newvalue );
+
+ // Deactivate license on changed value.
+ if ( $newvalue !== $oldvalue ) {
+ delete_option( 'pronamic_pay_license_status' );
+
+ if ( ! empty( $oldvalue ) ) {
+ $this->deactivate_license( $oldvalue );
+ }
+ }
+
+ delete_transient( 'pronamic_pay_license_data' );
+
+ // Always try to activate the new license, it could be deactivated.
+ if ( ! empty( $newvalue ) ) {
+ $this->activate_license( $newvalue );
+ }
+
+ // Schedule daily license check.
+ $time = time() + DAY_IN_SECONDS;
+
+ wp_clear_scheduled_hook( 'pronamic_pay_license_check' );
+
+ wp_schedule_event( $time, 'daily', 'pronamic_pay_license_check' );
+
+ // Get and update license status.
+ $old_status = \get_option( 'pronamic_pay_license_status' );
+
+ $this->check_license( $newvalue );
+
+ $new_status = \get_option( 'pronamic_pay_license_status' );
+
+ // Don't show activated notice if option value and valid status have not changed.
+ if ( $oldvalue === $newvalue && $old_status === $new_status && 'valid' === $new_status ) {
+ delete_transient( 'pronamic_pay_license_data' );
+ }
+
+ return $newvalue;
+ }
+
+ /**
+ * License check event.
+ *
+ * @return void
+ */
+ public function license_check_event() {
+ $license = get_option( 'pronamic_pay_license_key' );
+ $license = strval( $license );
+
+ $this->check_license( $license );
+ }
+
+ /**
+ * Request license status.
+ *
+ * @param string $license License.
+ * @return string
+ */
+ private function request_license_status( $license ) {
+ if ( empty( $license ) ) {
+ return 'invalid';
+ }
+
+ // Request.
+ $args = [
+ 'license' => $license,
+ 'name' => 'Pronamic Pay',
+ 'url' => $this->get_home_url(),
+ ];
+
+ $args = urlencode_deep( $args );
+
+ $response = wp_remote_get(
+ add_query_arg( $args, 'https://api.pronamic.eu/licenses/check/1.0/' ),
+ [
+ 'timeout' => 20,
+ ]
+ );
+
+ // On errors we give benefit of the doubt.
+ if ( $response instanceof WP_Error ) {
+ return 'valid';
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $response ) );
+
+ if ( is_object( $data ) && isset( $data->license ) ) {
+ return $data->license;
+ }
+
+ return 'valid';
+ }
+
+ /**
+ * Check license.
+ *
+ * @param string $license License.
+ * @return void
+ */
+ public function check_license( $license ) {
+ $status = $this->request_license_status( $license );
+
+ update_option( 'pronamic_pay_license_status', $status );
+ }
+
+ /**
+ * Deactivate license.
+ *
+ * @param string $license License to deactivate.
+ * @return void
+ */
+ public function deactivate_license( $license ) {
+ $args = [
+ 'license' => $license,
+ 'name' => 'Pronamic Pay',
+ 'url' => $this->get_home_url(),
+ ];
+
+ $args = urlencode_deep( $args );
+
+ $response = wp_remote_get(
+ add_query_arg( $args, 'https://api.pronamic.eu/licenses/deactivate/1.0/' ),
+ [
+ 'timeout' => 20,
+ ]
+ );
+ }
+
+ /**
+ * Activate license.
+ *
+ * @param string $license License to activate.
+ * @return void
+ */
+ public function activate_license( $license ) {
+ // Request.
+ $args = [
+ 'license' => $license,
+ 'name' => 'Pronamic Pay',
+ 'url' => $this->get_home_url(),
+ ];
+
+ $args = urlencode_deep( $args );
+
+ $response = wp_remote_get(
+ add_query_arg( $args, 'https://api.pronamic.eu/licenses/activate/1.0/' ),
+ [
+ 'timeout' => 20,
+ ]
+ );
+
+ if ( $response instanceof WP_Error ) {
+ return;
+ }
+
+ $data = json_decode( wp_remote_retrieve_body( $response ) );
+
+ if ( $data ) {
+ set_transient( 'pronamic_pay_license_data', $data, 30 );
+ }
+ }
+
+ /**
+ * Get license status text.
+ *
+ * @return string
+ */
+ public function get_formatted_license_status() {
+ $license_status = get_option( 'pronamic_pay_license_status' );
+
+ switch ( $license_status ) {
+ case 'valid':
+ return __( 'Valid', 'pronamic_ideal' );
+
+ case 'invalid':
+ return __( 'Invalid', 'pronamic_ideal' );
+
+ case 'site_inactive':
+ return __( 'Site Inactive', 'pronamic_ideal' );
+ }
+
+ return $license_status;
+ }
+
+ /**
+ * Get next scheduled license check text.
+ *
+ * @return string
+ */
+ public function get_formatted_next_license_check() {
+ $next_license_check = esc_html__( 'Not scheduled', 'pronamic_ideal' );
+
+ $timestamp = wp_next_scheduled( 'pronamic_pay_license_check' );
+
+ if ( false !== $timestamp ) {
+ try {
+ $date = new DateTime( '@' . $timestamp, new DateTimeZone( 'UTC' ) );
+
+ $next_license_check = $date->format_i18n();
+ } catch ( \Exception $e ) {
+ return $next_license_check;
+ }
+ }
+
+ return $next_license_check;
+ }
+
+ /**
+ * Site Health debug information.
+ *
+ * @param array $debug_information Debug information.
+ * @return array
+ */
+ public function debug_information( $debug_information ) {
+ // Add debug information section.
+ if ( ! \array_key_exists( 'pronamic-pay', $debug_information ) ) {
+ $debug_information['pronamic-pay'] = [
+ 'label' => __( 'Pronamic Pay', 'pronamic_ideal' ),
+ 'fields' => [],
+ ];
+ }
+
+ $fields = [
+ // License key.
+ 'license_key' => [
+ 'label' => __( 'Support license key', 'pronamic_ideal' ),
+ 'value' => esc_html( get_option( 'pronamic_pay_license_key', __( 'No license key found', 'pronamic_ideal' ) ) ),
+ 'private' => true,
+ ],
+
+ // License status.
+ 'license_status' => [
+ 'label' => __( 'License status', 'pronamic_ideal' ),
+ 'value' => esc_html( $this->get_formatted_license_status() ),
+ ],
+
+ // Next scheduled license check.
+ 'next_license_check' => [
+ 'label' => __( 'Next scheduled license check', 'pronamic_ideal' ),
+ 'value' => esc_html( $this->get_formatted_next_license_check() ),
+ ],
+ ];
+
+ if ( \array_key_exists( 'fields', $debug_information['pronamic-pay'] ) ) {
+ $fields = \array_merge( $fields, $debug_information['pronamic-pay']['fields'] );
+ }
+
+ $debug_information['pronamic-pay']['fields'] = $fields;
+
+ return $debug_information;
+ }
+
+
+ /**
+ * Site status tests.
+ *
+ * @link https://developer.wordpress.org/reference/hooks/site_status_tests/
+ * @param array $status_tests Status tests.
+ * @return array
+ */
+ public function site_status_tests( $status_tests ) {
+ // Test valid license.
+ $status_tests['direct']['pronamic_pay_valid_license'] = [
+ 'label' => \__( 'Pronamic Pay support license key test', 'pronamic_ideal' ),
+ 'test' => [ $this, 'test_valid_license' ],
+ ];
+
+ return $status_tests;
+ }
+
+ /**
+ * Test if configuration exists.
+ *
+ * @return array|string>
+ */
+ public function test_valid_license() {
+ // Good.
+ $result = [
+ 'test' => 'pronamic_pay_valid_license',
+ 'label' => \__( 'Pronamic Pay license key is valid', 'pronamic_ideal' ),
+ 'description' => \sprintf(
+ '%s
',
+ \__( 'A valid license is required for technical support and continued plugin updates.', 'pronamic_ideal' )
+ ),
+ 'badge' => [
+ 'label' => \__( 'Security', 'pronamic_ideal' ),
+ 'color' => 'blue',
+ ],
+ 'status' => 'good',
+ 'actions' => '',
+ ];
+
+ // Recommendation.
+ if ( 'valid' !== \get_option( 'pronamic_pay_license_status' ) ) {
+ $result['status'] = 'recommended';
+ $result['label'] = \__( 'No valid license key for Pronamic Pay', 'pronamic_ideal' );
+
+ $result['actions'] = '';
+
+ if ( '' === \get_option( 'pronamic_pay_license_key' ) ) {
+ $result['actions'] .= \sprintf(
+ '%s - ',
+ \esc_url( 'https://www.pronamic.eu/plugins/pronamic-ideal/' ),
+ \__( 'Purchase license', 'pronamic_ideal' )
+ );
+ }
+
+ $result['actions'] .= \sprintf(
+ '%s - ',
+ \add_query_arg( 'page', 'pronamic_pay_settings', \get_admin_url( null, 'admin.php' ) ),
+ \__( 'License settings', 'pronamic_ideal' )
+ );
+
+ $result['actions'] .= \sprintf(
+ '%s ',
+ \esc_url( 'https://www.pronamic.eu/account/' ),
+ \__( 'Check existing license', 'pronamic_ideal' )
+ );
+
+ $result['actions'] .= '
';
+ }
+
+ return $result;
+ }
+}
diff --git a/packages/wp-pay/core/src/MergeTags/MergeTag.php b/packages/wp-pay/core/src/MergeTags/MergeTag.php
new file mode 100644
index 0000000..979de1c
--- /dev/null
+++ b/packages/wp-pay/core/src/MergeTags/MergeTag.php
@@ -0,0 +1,59 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Gateways
+ */
+
+namespace Pronamic\WordPress\Pay\MergeTags;
+
+/**
+ * Merge Tag class
+ */
+class MergeTag {
+ /**
+ * Slug of this merge tag.
+ *
+ * @var string
+ */
+ private $slug;
+
+ /**
+ * Resolver.
+ *
+ * @var callable
+ */
+ private $resolver;
+
+ /**
+ * Construct merge tag.
+ *
+ * @param string $slug Slug.
+ * @param callable $resolver Resolver.
+ */
+ public function __construct( $slug, $resolver ) {
+ $this->slug = $slug;
+ $this->resolver = $resolver;
+ }
+
+ /**
+ * Get slug.
+ *
+ * @return string
+ */
+ public function get_slug() {
+ return $this->slug;
+ }
+
+ /**
+ * Resolve.
+ *
+ * @return string
+ */
+ public function resolve() {
+ return \call_user_func( $this->resolver );
+ }
+}
diff --git a/packages/wp-pay/core/src/MergeTags/MergeTagsController.php b/packages/wp-pay/core/src/MergeTags/MergeTagsController.php
new file mode 100644
index 0000000..08aeee1
--- /dev/null
+++ b/packages/wp-pay/core/src/MergeTags/MergeTagsController.php
@@ -0,0 +1,77 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Gateways
+ */
+
+namespace Pronamic\WordPress\Pay\MergeTags;
+
+/**
+ * Merge Tags Controller class
+ */
+class MergeTagsController {
+ /**
+ * Merge tags.
+ *
+ * @var MergeTag[]
+ */
+ private $merge_tags = [];
+
+ /**
+ * Add merge tag.
+ *
+ * @param MergeTag $merge_tag Merge tag.
+ * @return void
+ */
+ public function add_merge_tag( MergeTag $merge_tag ) {
+ $this->merge_tags[] = $merge_tag;
+ }
+
+ /**
+ * Get merge tags.
+ *
+ * @return MergeTag[]
+ */
+ private function get_merge_tags() {
+ $merge_tags = $this->merge_tags;
+ $controller = $this;
+
+ /**
+ * Filter merge tags.
+ *
+ * @param MergeTag[] $merge_tags Merge tags.
+ * @param MergeTagsController $controller Merge tags controller.
+ */
+ $merge_tags = \apply_filters(
+ 'pronamic_pay_merge_tags',
+ $merge_tags,
+ $controller
+ );
+
+ return $merge_tags;
+ }
+
+ /**
+ * Format string.
+ *
+ * @param string $value Value.
+ * @return string
+ */
+ public function format_string( $value ) {
+ $replace_pairs = [];
+
+ foreach ( $this->get_merge_tags() as $merge_tag ) {
+ $from = '{' . $merge_tag->get_slug() . '}';
+
+ $replace_pairs[ $from ] = $merge_tag->resolve();
+ }
+
+ $value = \strtr( $value, $replace_pairs );
+
+ return $value;
+ }
+}
diff --git a/packages/wp-pay/core/src/MoneyJsonTransformer.php b/packages/wp-pay/core/src/MoneyJsonTransformer.php
new file mode 100644
index 0000000..8444d0f
--- /dev/null
+++ b/packages/wp-pay/core/src/MoneyJsonTransformer.php
@@ -0,0 +1,74 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+
+/**
+ * Money JSON transformer
+ *
+ * @author Remco Tolsma
+ * @version 2.1.0
+ * @since 2.1.0
+ */
+class MoneyJsonTransformer {
+ /**
+ * Convert JSON to money object.
+ *
+ * @param mixed $json JSON.
+ * @return Money
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ // Default arguments.
+ $value = 0;
+ $currency = null;
+ $tax_value = null;
+ $tax_percentage = null;
+
+ $money = new Money();
+
+ if ( \property_exists( $json, 'value' ) ) {
+ $value = $json->value;
+ }
+
+ if ( \property_exists( $json, 'currency' ) ) {
+ $currency = $json->currency;
+ }
+
+ if ( \property_exists( $json, 'tax_value' ) ) {
+ $tax_value = $json->tax_value;
+ }
+
+ if ( \property_exists( $json, 'tax_percentage' ) ) {
+ $tax_percentage = $json->tax_percentage;
+ }
+
+ /**
+ * In older versions of this library the currency could be empty,
+ * for backward compatibility we fall back to the euro.
+ */
+ if ( null === $currency ) {
+ $currency = 'EUR';
+ }
+
+ if ( ! empty( $tax_value ) || ! empty( $tax_percentage ) ) {
+ return new TaxedMoney( $value, $currency, $tax_value, $tax_percentage );
+ }
+
+ return new Money( $value, $currency );
+ }
+}
diff --git a/packages/wp-pay/core/src/PagesController.php b/packages/wp-pay/core/src/PagesController.php
new file mode 100644
index 0000000..b2f87e1
--- /dev/null
+++ b/packages/wp-pay/core/src/PagesController.php
@@ -0,0 +1,277 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Pages Controller class
+ */
+class PagesController {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ \add_action( 'init', [ $this, 'init' ] );
+
+ \add_action( 'admin_init', [ $this, 'admin_init' ] );
+ }
+
+ /**
+ * Initialize.
+ *
+ * @return void
+ */
+ public function init() {
+ // Pages.
+ $pages = $this->get_pages();
+
+ foreach ( $pages as $page ) {
+ \register_setting(
+ 'pronamic_pay',
+ $page['option_name'],
+ [
+ 'type' => 'integer',
+ 'sanitize_callback' => [ Settings::class, 'sanitize_published_post_id' ],
+ ]
+ );
+ }
+ }
+
+ /**
+ * Admin initialize.
+ *
+ * @return void
+ */
+ public function admin_init() {
+ $this->maybe_create_pages();
+
+ \add_settings_section(
+ 'pronamic_pay_pages',
+ __( 'Payment Status Pages', 'pronamic_ideal' ),
+ [ $this, 'settings_section' ],
+ 'pronamic_pay'
+ );
+
+ foreach ( $this->get_pages() as $page ) {
+ \add_settings_field(
+ $page['option_name'],
+ $page['post_title'],
+ [ $this, 'input_page' ],
+ 'pronamic_pay',
+ 'pronamic_pay_pages',
+ [
+ 'label_for' => $page['option_name'],
+ ]
+ );
+ }
+ }
+
+ /**
+ * Settings section.
+ *
+ * @return void
+ */
+ public function settings_section() {
+ echo '';
+ \esc_html_e( 'The page an user will get redirected to after payment, based on the payment status.', 'pronamic_ideal' );
+ echo '
';
+
+ $pages = $this->get_pages();
+
+ $statuses = \array_map(
+ function ( $page ) {
+ $option_name = $page['option_name'];
+
+ $page_id = \get_option( $option_name );
+
+ return \get_post_status( $page_id );
+ },
+ $pages
+ );
+
+ if ( \in_array( false, $statuses, true ) ) {
+ \submit_button(
+ \__( 'Set default pages', 'pronamic_ideal' ),
+ '',
+ 'pronamic_pay_create_pages',
+ false
+ );
+ }
+ }
+
+ /**
+ * Input page.
+ *
+ * @param array $args Arguments.
+ * @return void
+ */
+ public function input_page( $args ) {
+ $name = $args['label_for'];
+
+ $selected = \get_option( $name, '' );
+
+ if ( false === $selected ) {
+ $selected = '';
+ }
+
+ \wp_dropdown_pages(
+ [
+ 'name' => \esc_attr( $name ),
+ 'post_type' => \esc_attr( 'page' ),
+ 'selected' => \esc_attr( $selected ),
+ 'show_option_none' => \esc_attr( \__( '— Select Page —', 'pronamic_ideal' ) ),
+ 'class' => 'regular-text',
+ ]
+ );
+ }
+
+ /**
+ * Maybe create pages.
+ *
+ * @return void
+ */
+ public function maybe_create_pages() {
+ if ( ! \array_key_exists( 'pronamic_pay_create_pages', $_POST ) ) {
+ return;
+ }
+
+ if ( ! \check_admin_referer( 'pronamic_pay_settings', 'pronamic_pay_nonce' ) ) {
+ return;
+ }
+
+ $pages = $this->get_pages();
+
+ $url_args = [
+ 'page' => 'pronamic_pay_settings',
+ 'message' => 'pages-generated',
+ ];
+
+ try {
+ $this->create_pages( $pages );
+ } catch ( \Exception $e ) {
+ $url_args = [
+ 'page' => 'pronamic_pay_settings',
+ 'message' => 'pages-not-generated',
+ ];
+ }
+
+ $url = \add_query_arg(
+ $url_args,
+ admin_url( 'admin.php' )
+ );
+
+ \wp_safe_redirect( $url );
+
+ exit;
+ }
+
+ /**
+ * Create pages.
+ *
+ * @param array $pages Pages.
+ * @return void
+ * @throws \Exception When creating page fails.
+ */
+ private function create_pages( $pages ) {
+ foreach ( $pages as $page ) {
+ // Check if page already exists.
+ $page_id = \get_option( $page['option_name'] );
+
+ if ( false !== \get_post_status( $page_id ) ) {
+ continue;
+ }
+
+ $post = [
+ 'post_title' => $page['post_title'],
+ 'post_name' => $page['post_name'],
+ 'post_content' => $page['post_content'],
+ 'post_status' => 'publish',
+ 'post_type' => 'page',
+ 'comment_status' => 'closed',
+ ];
+
+ $result = \wp_insert_post( $post, true );
+
+ if ( $result instanceof \WP_Error ) {
+ throw new \Exception( \esc_html( $result->get_error_message() ) );
+ }
+
+ \update_post_meta( $result, '_yoast_wpseo_meta-robots-noindex', true );
+
+ \update_option( $page['option_name'], $result );
+ }
+ }
+
+ /**
+ * Get pages.
+ *
+ * @return array
+ */
+ public function get_pages() {
+ return [
+ [
+ 'post_title' => \__( 'Payment Completed', 'pronamic_ideal' ),
+ 'post_name' => \__( 'payment-completed', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'The payment has been successfully completed.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_completed_page_id',
+ ],
+ [
+ 'post_title' => \__( 'Payment Canceled', 'pronamic_ideal' ),
+ 'post_name' => \__( 'payment-canceled', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'You have canceled the payment.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_cancel_page_id',
+ ],
+ [
+ 'post_title' => \__( 'Payment Expired', 'pronamic_ideal' ),
+ 'post_name' => \__( 'payment-expired', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'Your payment session has expired.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_expired_page_id',
+ ],
+ [
+ 'post_title' => \__( 'Payment Error', 'pronamic_ideal' ),
+ 'post_name' => \__( 'payment-error', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'An error has occurred during payment.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_error_page_id',
+ ],
+ [
+ 'post_title' => \__( 'Payment Status Unknown', 'pronamic_ideal' ),
+ 'post_name' => \__( 'payment-unknown', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'The payment status is unknown.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_unknown_page_id',
+ ],
+ [
+ 'post_title' => \__( 'Subscription Canceled', 'pronamic_ideal' ),
+ 'post_name' => \__( 'subscription-canceled', 'pronamic_ideal' ),
+ 'post_content' => \sprintf(
+ '%s
',
+ \__( 'The subscription has been canceled.', 'pronamic_ideal' )
+ ),
+ 'option_name' => 'pronamic_pay_subscription_canceled_page_id',
+ ],
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/FailureReason.php b/packages/wp-pay/core/src/Payments/FailureReason.php
new file mode 100644
index 0000000..6e11ac2
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/FailureReason.php
@@ -0,0 +1,141 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Failure reason.
+ *
+ * @author Reüel van der Steege
+ * @since 2.2.8
+ * @version 2.2.8
+ */
+class FailureReason {
+ /**
+ * Code.
+ *
+ * @var string|null
+ */
+ private $code;
+
+ /**
+ * Message.
+ *
+ * @var string|null
+ */
+ private $message;
+
+ /**
+ * Get code.
+ *
+ * @return string|null
+ */
+ public function get_code() {
+ return $this->code;
+ }
+
+ /**
+ * Set code.
+ *
+ * @param string|null $code Code.
+ * @return void
+ */
+ public function set_code( $code ) {
+ $this->code = $code;
+ }
+
+ /**
+ * Get message.
+ *
+ * @return string|null
+ */
+ public function get_message() {
+ return $this->message;
+ }
+
+ /**
+ * Set message.
+ *
+ * @param string|null $message Message.
+ * @return void
+ */
+ public function set_message( $message ) {
+ $this->message = $message;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'code' => $this->get_code(),
+ 'message' => $this->get_message(),
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create failure reason from object.
+ *
+ * @param mixed $json JSON.
+ * @return FailureReason
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! \is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $failure_reason = new self();
+
+ if ( isset( $json->code ) ) {
+ $failure_reason->set_code( $json->code );
+ }
+
+ if ( isset( $json->message ) ) {
+ $failure_reason->set_message( $json->message );
+ }
+
+ return $failure_reason;
+ }
+
+ /**
+ * To string.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $code = $this->get_code();
+ $message = $this->get_message();
+
+ if ( null !== $code && null !== $message ) {
+ return sprintf( '%1$s (`%2$s`)', $message, $code );
+ }
+
+ if ( null !== $code ) {
+ return $code;
+ }
+
+ if ( null !== $message ) {
+ return $message;
+ }
+
+ return '';
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/LegacyPaymentsDataStoreCPT.php b/packages/wp-pay/core/src/Payments/LegacyPaymentsDataStoreCPT.php
new file mode 100644
index 0000000..2d9b28e
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/LegacyPaymentsDataStoreCPT.php
@@ -0,0 +1,354 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Pay\Banks\BankAccountDetails;
+use Pronamic\WordPress\Pay\AbstractDataStoreCPT;
+use Pronamic\WordPress\Pay\Address;
+use Pronamic\WordPress\Pay\ContactName;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * Title: Payments data store CPT
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @see https://woocommerce.com/2017/04/woocommerce-3-0-release/
+ * @see https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.1.0
+ */
+class LegacyPaymentsDataStoreCPT extends AbstractDataStoreCPT {
+ /**
+ * Get contact name from legacy meta.
+ *
+ * @param PaymentInfo $payment The payment info to read.
+ * @return ContactName|null
+ */
+ private function get_contact_name_from_legacy_meta( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return null;
+ }
+
+ $data = [
+ 'full_name' => $this->get_meta_string( $id, 'customer_name' ),
+ 'first_name' => $this->get_meta_string( $id, 'first_name' ),
+ 'last_name' => $this->get_meta_string( $id, 'last_name' ),
+ ];
+
+ $data = array_filter( $data );
+ $data = array_map( 'trim', $data );
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ // Bail out if there is no name data.
+ return null;
+ }
+
+ $name = new ContactName();
+
+ if ( isset( $data['full_name'] ) ) {
+ $name->set_full_name( $data['full_name'] );
+ }
+
+ if ( isset( $data['first_name'] ) ) {
+ $name->set_first_name( $data['first_name'] );
+ }
+
+ if ( isset( $data['last_name'] ) ) {
+ $name->set_last_name( $data['last_name'] );
+ }
+
+ return $name;
+ }
+
+ /**
+ * Maybe create customer from legacy meta.
+ *
+ * @param PaymentInfo $payment The payment to read.
+ * @return void
+ */
+ private function maybe_create_customer_from_legacy_meta( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $data = [
+ 'full_name' => $this->get_meta_string( $id, 'customer_name' ),
+ 'first_name' => $this->get_meta_string( $id, 'first_name' ),
+ 'last_name' => $this->get_meta_string( $id, 'last_name' ),
+ 'email' => $this->get_meta_string( $id, 'email' ),
+ 'phone' => $this->get_meta_string( $id, 'telephone_number' ),
+ 'ip_address' => $this->get_meta_string( $id, 'user_ip' ),
+ 'user_agent' => $this->get_meta_string( $id, 'user_agent' ),
+ 'language' => $this->get_meta_string( $id, 'language' ),
+ 'locale' => $this->get_meta_string( $id, 'locale' ),
+ ];
+
+ $data = array_filter( $data );
+ $data = array_map( 'trim', $data );
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ // Bail out if there is no customer data.
+ return;
+ }
+
+ // Build customer from legacy meta data.
+ $customer = $payment->get_customer();
+
+ if ( null === $customer ) {
+ $customer = new Customer();
+ }
+
+ $payment->set_customer( $customer );
+
+ // Customer name.
+ if ( null === $customer->get_name() ) {
+ $customer->set_name( $this->get_contact_name_from_legacy_meta( $payment ) );
+ }
+
+ if ( null === $customer->get_email() && isset( $data['email'] ) ) {
+ $customer->set_email( $data['email'] );
+ }
+
+ if ( null === $customer->get_phone() && isset( $data['phone'] ) ) {
+ $customer->set_phone( $data['phone'] );
+ }
+
+ if ( null === $customer->get_ip_address() && isset( $data['ip_address'] ) ) {
+ $customer->set_ip_address( $data['ip_address'] );
+ }
+
+ if ( null === $customer->get_user_agent() && isset( $data['user_agent'] ) ) {
+ $customer->set_user_agent( $data['user_agent'] );
+ }
+
+ if ( null === $customer->get_language() && isset( $data['language'] ) ) {
+ $customer->set_language( $data['language'] );
+ }
+
+ if ( null === $customer->get_locale() && isset( $data['locale'] ) ) {
+ $customer->set_locale( $data['locale'] );
+ }
+ }
+
+ /**
+ * Maybe create billing address from legacy meta.
+ *
+ * @param PaymentInfo $payment The payment to read.
+ * @return void
+ */
+ private function maybe_create_billing_address_from_legacy_meta( $payment ) {
+ if ( null !== $payment->get_billing_address() ) {
+ // Bail out if there is already a billing address.
+ return;
+ }
+
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $data = [
+ 'line_1' => $this->get_meta_string( $id, 'address' ),
+ 'postal_code' => $this->get_meta_string( $id, 'zip' ),
+ 'city' => $this->get_meta_string( $id, 'city' ),
+ 'country' => $this->get_meta_string( $id, 'country' ),
+ 'email' => $this->get_meta_string( $id, 'email' ),
+ 'phone' => $this->get_meta_string( $id, 'telephone_number' ),
+ ];
+
+ $data = array_filter( $data );
+ $data = array_map( 'trim', $data );
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ // Bail out if there is no address data.
+ return;
+ }
+
+ $address = new Address();
+
+ $payment->set_billing_address( $address );
+
+ $address->set_name( $this->get_contact_name_from_legacy_meta( $payment ) );
+
+ if ( isset( $data['line_1'] ) ) {
+ $address->set_line_1( $data['line_1'] );
+ }
+
+ if ( isset( $data['postal_code'] ) ) {
+ $address->set_postal_code( $data['postal_code'] );
+ }
+
+ if ( isset( $data['city'] ) ) {
+ $address->set_city( $data['city'] );
+ }
+
+ if ( isset( $data['country'] ) ) {
+ if ( 2 === strlen( $data['country'] ) ) {
+ $address->set_country_code( $data['country'] );
+ } else {
+ $address->set_country_name( $data['country'] );
+ }
+ }
+
+ if ( isset( $data['email'] ) ) {
+ $address->set_email( $data['email'] );
+ }
+
+ if ( isset( $data['phone'] ) ) {
+ $address->set_phone( $data['phone'] );
+ }
+ }
+
+ /**
+ * Maybe create consumer bank details from legacy meta.
+ *
+ * @param PaymentInfo $payment The payment to read.
+ * @return void
+ */
+ private function maybe_create_consumer_bank_details_from_legacy_meta( $payment ) {
+ if ( null !== $payment->get_consumer_bank_details() ) {
+ // Bail out if there is already a billing consumer_bank_details.
+ return;
+ }
+
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $data = [
+ 'consumer_name' => $this->get_meta_string( $id, 'consumer_name' ),
+ 'consumer_account_number' => $this->get_meta_string( $id, 'consumer_account_number' ),
+ 'consumer_iban' => $this->get_meta_string( $id, 'consumer_iban' ),
+ 'consumer_bic' => $this->get_meta_string( $id, 'consumer_bic' ),
+ 'consumer_city' => $this->get_meta_string( $id, 'consumer_city' ),
+ ];
+
+ $data = array_filter( $data );
+ $data = array_map( 'trim', $data );
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ // Bail out if there is no consumer data.
+ return;
+ }
+
+ $consumer_bank_details = new BankAccountDetails();
+
+ $payment->set_consumer_bank_details( $consumer_bank_details );
+
+ if ( isset( $data['consumer_name'] ) ) {
+ $consumer_bank_details->set_name( $data['consumer_name'] );
+ }
+
+ if ( isset( $data['consumer_account_number'] ) ) {
+ $consumer_bank_details->set_account_number( $data['consumer_account_number'] );
+ }
+
+ if ( isset( $data['consumer_iban'] ) ) {
+ $consumer_bank_details->set_iban( $data['consumer_iban'] );
+ }
+
+ if ( isset( $data['consumer_bic'] ) ) {
+ $consumer_bank_details->set_bic( $data['consumer_bic'] );
+ }
+
+ if ( isset( $data['consumer_city'] ) ) {
+ $consumer_bank_details->set_city( $data['consumer_city'] );
+ }
+ }
+
+ /**
+ * Read post meta.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/abstracts/abstract-wc-data.php#L462-L507
+ * @param PaymentInfo $payment The payment to read.
+ * @return void
+ */
+ protected function read_post_meta( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ // General.
+ $config_id = $this->get_meta_int( $id, 'config_id' );
+
+ $payment->config_id = $config_id;
+ $payment->source = $this->get_meta_string( $id, 'source' );
+ $payment->source_id = $this->get_meta_string( $id, 'source_id' );
+
+ // Order ID.
+ if ( empty( $payment->order_id ) ) {
+ $payment->order_id = $this->get_meta_string( $id, 'order_id' );
+ }
+
+ // Key.
+ if ( empty( $payment->key ) ) {
+ $payment->key = $this->get_meta_string( $id, 'key' );
+ }
+
+ // Description.
+ $description = $payment->get_description();
+
+ if ( empty( $description ) ) {
+ $description = $this->get_meta_string( $id, 'description' );
+
+ $payment->set_description( $description );
+ }
+
+ // Payment method.
+ $payment_method = $payment->get_payment_method();
+
+ if ( empty( $payment_method ) ) {
+ $payment_method = $this->get_meta_string( $id, 'method' );
+
+ $payment->set_payment_method( $payment_method );
+ }
+
+ /**
+ * Clarify difference between afterpay.nl and afterpay.com.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/282
+ */
+ if ( PaymentMethods::AFTERPAY === $payment_method ) {
+ $payment->set_payment_method( PaymentMethods::AFTERPAY_NL );
+ }
+
+ // Version.
+ $meta_version = $this->get_meta_string( $id, 'version' );
+
+ if ( ! empty( $meta_version ) ) {
+ $payment->set_version( $meta_version );
+ }
+
+ // Other.
+ $this->maybe_create_customer_from_legacy_meta( $payment );
+ $this->maybe_create_billing_address_from_legacy_meta( $payment );
+ $this->maybe_create_consumer_bank_details_from_legacy_meta( $payment );
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/Payment.php b/packages/wp-pay/core/src/Payments/Payment.php
new file mode 100644
index 0000000..667b243
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/Payment.php
@@ -0,0 +1,931 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use InvalidArgumentException;
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Address;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Refunds\Refund;
+use Pronamic\WordPress\Pay\Subscriptions\Subscription;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPeriod;
+
+/**
+ * Payment
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 1.0.0
+ */
+class Payment extends PaymentInfo {
+ /**
+ * The total amount of this payment.
+ *
+ * @var Money
+ */
+ private $total_amount;
+
+ /**
+ * Refunded amount.
+ *
+ * @var Money
+ */
+ private $refunded_amount;
+
+ /**
+ * Charged back amount.
+ *
+ * @var Money|null
+ */
+ private $charged_back_amount;
+
+ /**
+ * The status of this payment.
+ *
+ * @var string|null
+ */
+ public $status;
+
+ /**
+ * Failure reason.
+ *
+ * @var FailureReason|null
+ */
+ public $failure_reason;
+
+ /**
+ * The email of the user who started this payment.
+ *
+ * @var string|null
+ */
+ public $email;
+
+ /**
+ * The action URL for this payment.
+ *
+ * @var string|null
+ */
+ private $action_url;
+
+ /**
+ * The date this payment expires.
+ *
+ * @var DateTime|null
+ */
+ private $expiry_date;
+
+ /**
+ * Subscriptions.
+ *
+ * @var Subscription[]
+ */
+ private $subscriptions;
+
+ /**
+ * Subscription periods.
+ *
+ * @since 2.5.0
+ * @var SubscriptionPeriod[]|null
+ */
+ private $periods;
+
+ /**
+ * Customer.
+ *
+ * @var Customer|null
+ */
+ public $customer;
+
+ /**
+ * Billing address.
+ *
+ * @var Address|null
+ */
+ public $billing_address;
+
+ /**
+ * Shipping address.
+ *
+ * @var Address|null
+ */
+ public $shipping_address;
+
+ /**
+ * Payment lines.
+ *
+ * @var PaymentLines|null
+ */
+ public $lines;
+
+ /**
+ * Refunds.
+ *
+ * @var Refund[]
+ */
+ public $refunds = [];
+
+ /**
+ * Slug.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/146
+ * @var string
+ */
+ private $slug = '';
+
+ /**
+ * Construct and initialize payment object.
+ *
+ * @param integer $post_id A payment post ID or null.
+ */
+ public function __construct( $post_id = null ) {
+ parent::__construct( $post_id );
+
+ $this->meta_key_prefix = '_pronamic_payment_';
+ $this->subscriptions = [];
+
+ $this->set_status( PaymentStatus::OPEN );
+
+ $this->set_total_amount( new Money() );
+
+ $this->refunded_amount = new Money();
+
+ if ( null !== $post_id ) {
+ pronamic_pay_plugin()->payments_data_store->read( $this );
+ }
+ }
+
+ /**
+ * Save payment.
+ *
+ * @return void
+ */
+ public function save() {
+ pronamic_pay_plugin()->payments_data_store->save( $this );
+ }
+
+ /**
+ * Add a note to this payment.
+ *
+ * @link https://developer.wordpress.org/reference/functions/wp_insert_comment/
+ * @param string $note The note to add.
+ * @return int The new comment's ID.
+ * @throws \Exception Throws exception when adding note fails.
+ */
+ public function add_note( $note ) {
+ global $wpdb;
+
+ if ( null === $this->id ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not add note "%s" to payment without ID.',
+ \esc_html( $note )
+ )
+ );
+ }
+
+ $commentdata = [
+ 'comment_post_ID' => $this->id,
+ 'comment_content' => $note,
+ 'comment_type' => 'payment_note',
+ 'user_id' => \get_current_user_id(),
+ ];
+
+ $result = \wp_insert_comment( $commentdata );
+
+ if ( false === $result ) {
+ /**
+ * Should we throw an exception or handle this in some other way?
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/337
+ * @todo
+ */
+ throw new \Exception(
+ \sprintf(
+ 'Could not add note "%s" to payment with ID "%s", last database error: "%s".',
+ \esc_html( $note ),
+ \esc_html( (string) $this->id ),
+ \esc_html( $wpdb->last_error )
+ )
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Set the transaction ID.
+ *
+ * @param string|null $transaction_id Transaction ID.
+ * @return void
+ */
+ public function set_transaction_id( $transaction_id ) {
+ $this->transaction_id = $transaction_id;
+ }
+
+ /**
+ * Get the payment transaction ID.
+ *
+ * @return string|null
+ */
+ public function get_transaction_id() {
+ return $this->transaction_id;
+ }
+
+ /**
+ * Get total amount.
+ *
+ * @return Money
+ */
+ public function get_total_amount() {
+ return $this->total_amount;
+ }
+
+ /**
+ * Set total amount.
+ *
+ * @param Money $total_amount Total amount.
+ * @return void
+ */
+ public function set_total_amount( Money $total_amount ) {
+ $this->total_amount = $total_amount;
+ }
+
+ /**
+ * Get refunded amount.
+ *
+ * @return Money
+ */
+ public function get_refunded_amount() {
+ return $this->refunded_amount;
+ }
+
+ /**
+ * Set refunded amount.
+ *
+ * @param Money $refunded_amount Refunded amount.
+ * @return void
+ */
+ public function set_refunded_amount( $refunded_amount ) {
+ $this->refunded_amount = $refunded_amount;
+ }
+
+ /**
+ * Get charged back amount.
+ *
+ * @return Money|null
+ */
+ public function get_charged_back_amount(): ?Money {
+ return $this->charged_back_amount;
+ }
+
+ /**
+ * Set charged back amount.
+ *
+ * @param Money|null $charged_back_amount Charged back amount.
+ * @return void
+ */
+ public function set_charged_back_amount( ?Money $charged_back_amount ) {
+ $this->charged_back_amount = $charged_back_amount;
+ }
+
+ /**
+ * Get the payment status.
+ *
+ * @todo Constant?
+ * @return string|null
+ */
+ public function get_status() {
+ return $this->status;
+ }
+
+ /**
+ * Get payment status label.
+ *
+ * @return string|null
+ */
+ public function get_status_label() {
+ return pronamic_pay_plugin()->payments_data_store->get_meta_status_label( $this->status );
+ }
+
+ /**
+ * Set the payment status.
+ *
+ * @param string|null $status Status.
+ * @return void
+ */
+ public function set_status( $status ) {
+ $this->status = $status;
+ }
+
+ /**
+ * Get failure reason.
+ *
+ * @return FailureReason|null
+ */
+ public function get_failure_reason() {
+ return $this->failure_reason;
+ }
+
+ /**
+ * Set failure reason.
+ *
+ * @param FailureReason|null $failure_reason Failure reason.
+ * @return void
+ */
+ public function set_failure_reason( FailureReason $failure_reason = null ) {
+ $this->failure_reason = $failure_reason;
+ }
+
+ /**
+ * Get the pay redirect URL.
+ *
+ * @return string
+ */
+ public function get_pay_redirect_url() {
+ $url = add_query_arg(
+ [
+ 'payment_redirect' => $this->id,
+ 'key' => $this->key,
+ ],
+ home_url( '/' )
+ );
+
+ return $url;
+ }
+
+ /**
+ * Get the return URL for this payment. This URL is passed to the payment providers / gateways
+ * so they know where they should return users to.
+ *
+ * @return string
+ */
+ public function get_return_url() {
+ $home_url = home_url( '/' );
+
+ /**
+ * Polylang compatibility.
+ *
+ * @link https://github.com/polylang/polylang/blob/2.6.8/include/api.php#L97-L111
+ */
+ if ( \function_exists( '\pll_home_url' ) ) {
+ $home_url = \pll_home_url();
+ }
+
+ $url = add_query_arg(
+ [
+ 'payment' => $this->id,
+ 'key' => $this->key,
+ ],
+ $home_url
+ );
+
+ return $url;
+ }
+
+ /**
+ * Get action URL.
+ *
+ * @return string|null
+ */
+ public function get_action_url() {
+ return $this->action_url;
+ }
+
+ /**
+ * Set the action URL.
+ *
+ * @param string|null $action_url Action URL.
+ * @return void
+ */
+ public function set_action_url( $action_url ) {
+ $this->action_url = $action_url;
+ }
+
+ /**
+ * Get expiry date.
+ *
+ * @return DateTime|null
+ */
+ public function get_expiry_date() {
+ return $this->expiry_date;
+ }
+
+ /**
+ * Set expiry date.
+ *
+ * @param DateTime|null $expiry_date Expiry date.
+ * @return void
+ */
+ public function set_expiry_date( $expiry_date ) {
+ $this->expiry_date = $expiry_date;
+ }
+
+ /**
+ * Get the return redirect URL for this payment. This URL is used after a user is returned
+ * from a payment provider / gateway to WordPress. It allows WordPress payment extensions
+ * to redirect users to the correct URL.
+ *
+ * @return string
+ */
+ public function get_return_redirect_url() {
+ $url = home_url( '/' );
+
+ $payment = $this;
+
+ /**
+ * Filters the payment return redirect URL.
+ *
+ * @param string $text Redirect URL.
+ * @param Payment $payment Payment.
+ */
+ $url = apply_filters( 'pronamic_payment_redirect_url', $url, $payment );
+
+ return $url;
+ }
+
+ /**
+ * Get edit payment URL.
+ *
+ * @link https://docs.woocommerce.com/wc-apidocs/source-class-WC_Order.html#1538-1546
+ *
+ * @return string
+ */
+ public function get_edit_payment_url() {
+ $url = add_query_arg(
+ [
+ 'action' => 'edit',
+ 'post' => $this->get_id(),
+ ],
+ admin_url( 'post.php' )
+ );
+
+ return $url;
+ }
+
+ /**
+ * Get the source text of this payment.
+ *
+ * @return string
+ */
+ public function get_source_text() {
+ $pieces = [
+ \ucfirst( (string) $this->get_source() ),
+ $this->get_source_id(),
+ ];
+
+ $pieces = array_filter( $pieces );
+
+ $text = implode( ' ', $pieces );
+
+ $source = $this->get_source();
+
+ $payment = $this;
+
+ if ( null !== $source ) {
+ /**
+ * Filters the payment source text by plugin integration source.
+ *
+ * @param string $text Source text.
+ * @param Payment $payment Payment.
+ */
+ $text = apply_filters( 'pronamic_payment_source_text_' . $source, $text, $payment );
+ }
+
+ /**
+ * Filters the payment source text.
+ *
+ * @param string $text Source text.
+ * @param Payment $payment Payment.
+ */
+ $text = apply_filters( 'pronamic_payment_source_text', $text, $payment );
+
+ return $text;
+ }
+
+ /**
+ * Get source description.
+ *
+ * @return string
+ */
+ public function get_source_description() {
+ $payment = $this;
+
+ $source = $payment->get_source();
+
+ $description = (string) $source;
+
+ /**
+ * Filters the payment source description.
+ *
+ * @param string $description Source description.
+ * @param Payment $payment Payment.
+ */
+ $description = apply_filters( 'pronamic_payment_source_description', $description, $payment );
+
+ if ( null !== $source ) {
+ /**
+ * Filters the payment source description by plugin integration source.
+ *
+ * @param string $description Source description.
+ * @param Payment $payment Payment.
+ */
+ $description = apply_filters( 'pronamic_payment_source_description_' . $source, $description, $payment );
+ }
+
+ return $description;
+ }
+
+ /**
+ * Get the source link for this payment.
+ *
+ * @return string|null
+ */
+ public function get_source_link() {
+ $url = null;
+
+ $payment = $this;
+
+ $source = $payment->get_source();
+
+ /**
+ * Filters the payment source URL.
+ *
+ * @param null|string $url Source URL.
+ * @param Payment $payment Payment.
+ */
+ $url = apply_filters( 'pronamic_payment_source_url', $url, $payment );
+
+ if ( null !== $source ) {
+ /**
+ * Filters the payment source URL by plugin integration source.
+ *
+ * @param null|string $url Source URL.
+ * @param Payment $payment Payment.
+ */
+ $url = apply_filters( 'pronamic_payment_source_url_' . $source, $url, $payment );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get provider link for this payment.
+ *
+ * @return null|string
+ */
+ public function get_provider_link() {
+ $url = null;
+
+ $payment = $this;
+
+ /**
+ * Filters the payment provider URL.
+ *
+ * @param null|string $url Provider URL.
+ * @param Payment $payment Payment.
+ */
+ $url = apply_filters( 'pronamic_payment_provider_url', $url, $payment );
+
+ if ( null === $this->id ) {
+ return $url;
+ }
+
+ $config_id = get_post_meta( $this->id, '_pronamic_payment_config_id', true );
+
+ if ( empty( $config_id ) ) {
+ return $url;
+ }
+
+ $gateway_id = get_post_meta( intval( $config_id ), '_pronamic_gateway_id', true );
+
+ if ( ! empty( $gateway_id ) ) {
+ /**
+ * Filters the payment provider URL by gateway identifier.
+ *
+ * @param null|string $url Provider URL.
+ * @param Payment $payment Payment.
+ */
+ $url = apply_filters( 'pronamic_payment_provider_url_' . $gateway_id, $url, $payment );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get subscription.
+ *
+ * @deprecated Use `get_subscriptions()`.
+ * @return Subscription|null
+ */
+ public function get_subscription() {
+ $first = \reset( $this->subscriptions );
+
+ if ( false === $first ) {
+ return null;
+ }
+
+ return $first;
+ }
+
+ /**
+ * Get subscriptions.
+ *
+ * @return Subscription[]
+ */
+ public function get_subscriptions() {
+ return $this->subscriptions;
+ }
+
+ /**
+ * Connect subscription to this payment.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ */
+ public function add_subscription( Subscription $subscription ) {
+ if ( \in_array( $subscription, $this->subscriptions, true ) ) {
+ return;
+ }
+
+ $this->subscriptions[] = $subscription;
+ }
+
+ /**
+ * Format string
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/v2.2.3/includes/abstracts/abstract-wc-email.php#L187-L195
+ *
+ * @param string $value The string to format.
+ * @return string
+ */
+ public function format_string( $value ) {
+ $merge_tags_controller = new PaymentMergeTagsController( $this );
+
+ return $merge_tags_controller->format_string( $value );
+ }
+
+ /**
+ * Get payment email.
+ *
+ * @return string|null
+ */
+ public function get_email() {
+ return $this->email;
+ }
+
+ /**
+ * Get subscription periods.
+ *
+ * @since 2.5.0
+ * @return SubscriptionPeriod[]|null
+ */
+ public function get_periods() {
+ return $this->periods;
+ }
+
+ /**
+ * Add subscription period.
+ *
+ * @since 2.5.0
+ * @param SubscriptionPeriod $period Subscription period.
+ * @return void
+ */
+ public function add_period( SubscriptionPeriod $period ) {
+ if ( null === $this->periods ) {
+ $this->periods = [];
+ }
+
+ $this->add_subscription( $period->get_phase()->get_subscription() );
+
+ $this->periods[] = $period;
+ }
+
+ /**
+ * Get slug.
+ *
+ * @return string
+ */
+ public function get_slug() {
+ return $this->slug;
+ }
+
+ /**
+ * Set slug.
+ *
+ * @param string $slug Slug.
+ * @return void
+ */
+ public function set_slug( $slug ) {
+ $this->slug = $slug;
+ }
+
+ /**
+ * Create payment from object.
+ *
+ * @param mixed $json JSON.
+ * @param Payment|null $payment Payment.
+ * @return Payment
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json, $payment = null ) {
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( null === $payment ) {
+ $payment = new self();
+ }
+
+ PaymentInfoHelper::from_json( $json, $payment );
+
+ if ( isset( $json->slug ) ) {
+ $payment->set_slug( $json->slug );
+ }
+
+ if ( isset( $json->action_url ) ) {
+ $payment->set_action_url( $json->action_url );
+ }
+
+ if ( isset( $json->total_amount ) ) {
+ $payment->set_total_amount( MoneyJsonTransformer::from_json( $json->total_amount ) );
+ }
+
+ if ( isset( $json->refunded_amount ) ) {
+ $payment->set_refunded_amount( MoneyJsonTransformer::from_json( $json->refunded_amount ) );
+ }
+
+ if ( isset( $json->charged_back_amount ) ) {
+ $payment->set_charged_back_amount( MoneyJsonTransformer::from_json( $json->charged_back_amount ) );
+ }
+
+ if ( isset( $json->expiry_date ) ) {
+ $payment->set_expiry_date( new DateTime( $json->expiry_date ) );
+ }
+
+ if ( isset( $json->status ) ) {
+ $payment->set_status( $json->status );
+ }
+
+ if ( isset( $json->periods ) ) {
+ foreach ( $json->periods as $json_period ) {
+ try {
+ $payment->add_period( SubscriptionPeriod::from_json( $json_period ) );
+ } catch ( \Exception $exception ) {
+ // For now we temporarily ignore subscription period exception due to changes in the JSON schema.
+ continue;
+ }
+ }
+ }
+
+ if ( isset( $json->subscriptions ) ) {
+ foreach ( $json->subscriptions as $json_subscription ) {
+ if ( \property_exists( $json_subscription, 'id' ) ) {
+ $subscription = \get_pronamic_subscription( $json_subscription->id );
+
+ if ( null !== $subscription ) {
+ $payment->add_subscription( $subscription );
+ }
+ }
+ }
+ }
+
+ if ( isset( $json->failure_reason ) ) {
+ $payment->set_failure_reason( FailureReason::from_json( $json->failure_reason ) );
+ }
+
+ if ( isset( $json->origin_id ) ) {
+ $payment->set_origin_id( $json->origin_id );
+ }
+
+ if ( isset( $json->transaction_id ) ) {
+ $payment->set_transaction_id( $json->transaction_id );
+ }
+
+ if ( isset( $json->refunds ) ) {
+ foreach ( $json->refunds as $json_refund ) {
+ $payment->refunds[] = Refund::from_json( $json_refund, $payment );
+ }
+ }
+
+ return $payment;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object
+ */
+ public function get_json() {
+ $object = PaymentInfoHelper::to_json( $this );
+
+ $properties = (array) $object;
+
+ $properties['slug'] = $this->slug;
+
+ // Action URL.
+ if ( null !== $this->action_url ) {
+ $properties['action_url'] = $this->action_url;
+ }
+
+ // Expiry date.
+ $expiry_date = $this->get_expiry_date();
+
+ if ( null !== $expiry_date ) {
+ $properties['expiry_date'] = $expiry_date->format( \DATE_ATOM );
+ }
+
+ // Total amount.
+ $total_amount = $this->get_total_amount();
+
+ if ( null !== $total_amount ) {
+ $properties['total_amount'] = $total_amount->jsonSerialize();
+ }
+
+ // Refunded amount.
+ if ( ! $this->refunded_amount->is_zero() ) {
+ $properties['refunded_amount'] = $this->refunded_amount->jsonSerialize();
+ }
+
+ // Charged back amount.
+ $charged_back_amount = $this->get_charged_back_amount();
+
+ if ( null !== $charged_back_amount ) {
+ $properties['charged_back_amount'] = $charged_back_amount->jsonSerialize();
+ }
+
+ // Subscriptions.
+ $subscriptions = $this->get_subscriptions();
+
+ if ( \count( $subscriptions ) > 0 ) {
+ $properties['subscriptions'] = [];
+
+ foreach ( $subscriptions as $subscription ) {
+ $properties['subscriptions'][] = (object) [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/%s/%s/%d',
+ 'pronamic-pay/v1',
+ 'subscriptions',
+ $subscription->get_id()
+ )
+ ),
+ 'id' => $subscription->get_id(),
+ ];
+ }
+ }
+
+ // Periods.
+ $periods = $this->get_periods();
+
+ if ( null !== $periods ) {
+ foreach ( $periods as $period ) {
+ $properties['periods'][] = $period->to_json();
+ }
+ }
+
+ // Status.
+ if ( null !== $this->get_status() ) {
+ $properties['status'] = $this->get_status();
+ }
+
+ // Failure reason.
+ $failure_reason = $this->get_failure_reason();
+
+ if ( null !== $failure_reason ) {
+ $properties['failure_reason'] = $failure_reason->get_json();
+ }
+
+ // Origin ID.
+ $origin_id = $this->get_origin_id();
+
+ if ( null !== $origin_id ) {
+ $properties['origin_id'] = $origin_id;
+ }
+
+ // Transaction ID.
+ $transaction_id = $this->get_transaction_id();
+
+ if ( null !== $transaction_id ) {
+ $properties['transaction_id'] = $transaction_id;
+ }
+
+ // Refunds.
+ if ( \count( $this->refunds ) > 0 ) {
+ $properties['refunds'] = $this->refunds;
+ }
+
+ $object = (object) $properties;
+
+ return $object;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentInfo.php b/packages/wp-pay/core/src/Payments/PaymentInfo.php
new file mode 100644
index 0000000..5412f6a
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentInfo.php
@@ -0,0 +1,552 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Pay\Banks\BankAccountDetails;
+use Pronamic\WordPress\Pay\Banks\BankTransferDetails;
+use Pronamic\WordPress\Pay\Core\Gateway;
+use Pronamic\WordPress\Pay\Address;
+use Pronamic\WordPress\Pay\CreditCard;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\Plugin;
+use WP_Post;
+
+/**
+ * Payment info
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 1.0.0
+ */
+abstract class PaymentInfo {
+ use \Pronamic\WordPress\Pay\Core\TimestampsTrait;
+
+ use \Pronamic\WordPress\Pay\Core\VersionTrait;
+
+ use \Pronamic\WordPress\Pay\Core\ModeTrait;
+
+ use \Pronamic\WordPress\Pay\Privacy\AnonymizedTrait;
+
+ use \Pronamic\WordPress\Pay\Payments\PaymentInfoTrait;
+
+ use \Pronamic\WordPress\Pay\Payments\SourceTrait;
+
+ /**
+ * The post object.
+ *
+ * @var WP_Post|array|null
+ */
+ public $post;
+
+ /**
+ * The date of this payment info.
+ *
+ * @var DateTime
+ */
+ public $date;
+
+ /**
+ * The unique ID of this payment info.
+ *
+ * @var int|null
+ */
+ protected $id;
+
+ /**
+ * The title of this payment info.
+ *
+ * @var string|null
+ */
+ public $title;
+
+ /**
+ * The configuration ID.
+ *
+ * @var int|null
+ */
+ public $config_id;
+
+ /**
+ * The key of this payment info, used in URL's for security.
+ *
+ * @var string|null
+ */
+ public $key;
+
+ /**
+ * Origin post ID.
+ *
+ * @var int|null
+ */
+ private $origin_id;
+
+ /**
+ * The order ID of this payment.
+ *
+ * @todo Is this required/used?
+ * @var string|null
+ */
+ public $order_id;
+
+ /**
+ * The transaction ID of this payment.
+ *
+ * @var string|null
+ */
+ public $transaction_id;
+
+ /**
+ * The shipping amount of this payment.
+ *
+ * @var Money|null
+ */
+ private $shipping_amount;
+
+ /**
+ * The description of this payment.
+ *
+ * @var string|null
+ */
+ private $description;
+
+ /**
+ * Bank transfer recipient details.
+ *
+ * @var BankTransferDetails|null
+ */
+ private $bank_transfer_recipient_details;
+
+ /**
+ * Consumer bank details.
+ *
+ * @var BankAccountDetails|null
+ */
+ private $consumer_bank_details;
+
+ /**
+ * Payment method.
+ *
+ * @var string|null
+ */
+ private $payment_method;
+
+ /**
+ * Customer.
+ *
+ * @var Customer|null
+ */
+ public $customer;
+
+ /**
+ * Billing address.
+ *
+ * @var Address|null
+ */
+ public $billing_address;
+
+ /**
+ * Shipping address.
+ *
+ * @var Address|null
+ */
+ public $shipping_address;
+
+ /**
+ * Payment lines.
+ *
+ * @var PaymentLines|null
+ */
+ public $lines;
+
+ /**
+ * Credit card
+ *
+ * @deprecated
+ * @var CreditCard|null
+ */
+ private $credit_card;
+
+ /**
+ * Meta.
+ *
+ * @var array
+ */
+ public $meta;
+
+ /**
+ * Meta key prefix.
+ *
+ * @var string
+ */
+ public $meta_key_prefix = '_pronamic_pay_';
+
+ /**
+ * Construct and initialize payment object.
+ *
+ * @param integer $post_id A payment post ID or null.
+ */
+ public function __construct( $post_id = null ) {
+ $this->id = $post_id;
+ $this->date = new DateTime();
+ $this->meta = [];
+
+ $this->touch();
+ }
+
+ /**
+ * Get the ID of this payment.
+ *
+ * @return int|null
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Set the ID of this payment.
+ *
+ * @param int $id Unique ID.
+ * @return void
+ */
+ public function set_id( $id ) {
+ $this->id = $id;
+ }
+
+ /**
+ * Get payment date.
+ *
+ * @return DateTime
+ */
+ public function get_date() {
+ return $this->date;
+ }
+
+ /**
+ * Set payment date.
+ *
+ * @param DateTime $date Date.
+ * @return void
+ */
+ public function set_date( $date ) {
+ $this->date = $date;
+ }
+
+ /**
+ * Get origin post ID.
+ *
+ * @return int|null
+ */
+ public function get_origin_id() {
+ return $this->origin_id;
+ }
+
+ /**
+ * Set origin post ID.
+ *
+ * @param int|null $origin_id Origin post ID.
+ * @return void
+ */
+ public function set_origin_id( $origin_id ) {
+ $this->origin_id = $origin_id;
+ }
+
+ /**
+ * Get the config ID of this payment.
+ *
+ * @return int|null
+ */
+ public function get_config_id() {
+ return $this->config_id;
+ }
+
+ /**
+ * Set the config ID of this payment.
+ *
+ * @param int|null $config_id Config ID.
+ * @return void
+ */
+ public function set_config_id( $config_id ) {
+ $this->config_id = $config_id;
+ }
+
+ /**
+ * Get gateway.
+ *
+ * @return Gateway|null
+ */
+ public function get_gateway() {
+ $config_id = $this->get_config_id();
+
+ if ( null === $config_id ) {
+ return null;
+ }
+
+ return \pronamic_pay_plugin()->gateways_data_store->get_gateway( $config_id );
+ }
+
+ /**
+ * Get customer.
+ *
+ * @return Customer|null
+ */
+ public function get_customer() {
+ return $this->customer;
+ }
+
+ /**
+ * Set customer.
+ *
+ * @param Customer|null $customer Contact.
+ * @return void
+ */
+ public function set_customer( $customer ) {
+ $this->customer = $customer;
+ }
+
+ /**
+ * Get billing address.
+ *
+ * @return Address|null
+ */
+ public function get_billing_address() {
+ return $this->billing_address;
+ }
+
+ /**
+ * Set billing address.
+ *
+ * @param Address|null $billing_address Billing address.
+ * @return void
+ */
+ public function set_billing_address( $billing_address ) {
+ $this->billing_address = $billing_address;
+ }
+
+ /**
+ * Get shipping address.
+ *
+ * @return Address|null
+ */
+ public function get_shipping_address() {
+ return $this->shipping_address;
+ }
+
+ /**
+ * Set shipping address.
+ *
+ * @param Address|null $shipping_address Shipping address.
+ * @return void
+ */
+ public function set_shipping_address( $shipping_address ) {
+ $this->shipping_address = $shipping_address;
+ }
+
+ /**
+ * Get payment lines.
+ *
+ * @return PaymentLines|null
+ */
+ public function get_lines() {
+ return $this->lines;
+ }
+
+ /**
+ * Set payment lines.
+ *
+ * @param PaymentLines|null $lines Payment lines.
+ * @return void
+ */
+ public function set_lines( PaymentLines $lines = null ) {
+ $this->lines = $lines;
+ }
+
+ /**
+ * Get the order ID of this payment.
+ *
+ * @return string|null
+ */
+ public function get_order_id() {
+ return $this->order_id;
+ }
+
+ /**
+ * Get the shipping amount.
+ *
+ * @return Money|null
+ */
+ public function get_shipping_amount() {
+ return $this->shipping_amount;
+ }
+
+ /**
+ * Set the shipping amount.
+ *
+ * @param Money|null $shipping_amount Money object.
+ * @return void
+ */
+ public function set_shipping_amount( Money $shipping_amount = null ) {
+ $this->shipping_amount = $shipping_amount;
+ }
+
+ /**
+ * Get the payment description.
+ *
+ * @return string|null
+ */
+ public function get_description() {
+ return $this->description;
+ }
+
+ /**
+ * Set the payment description.
+ *
+ * @param string|null $description Description.
+ * @return void
+ */
+ public function set_description( $description ) {
+ $this->description = $description;
+ }
+
+ /**
+ * Get the payment method.
+ *
+ * @return string|null
+ */
+ public function get_payment_method() {
+ return $this->payment_method;
+ }
+
+ /**
+ * Set the payment method.
+ *
+ * @param string|null $payment_method Payment method.
+ * @return void
+ */
+ public function set_payment_method( $payment_method ) {
+ $this->payment_method = $payment_method;
+ }
+
+ /**
+ * Get the meta value of this specified meta key.
+ *
+ * @param string $key Meta key.
+ * @return mixed
+ */
+ public function get_meta( $key ) {
+ if ( \array_key_exists( $key, $this->meta ) ) {
+ return $this->meta[ $key ];
+ }
+
+ if ( null === $this->id ) {
+ return null;
+ }
+
+ $key = $this->meta_key_prefix . $key;
+
+ $meta_values = \get_post_meta( $this->id, $key, false );
+
+ if ( \is_array( $meta_values ) && 0 === \count( $meta_values ) ) {
+ return null;
+ }
+
+ return \get_post_meta( $this->id, $key, true );
+ }
+
+ /**
+ * Set meta data.
+ *
+ * @param string $key A meta key.
+ * @param mixed $value A meta value.
+ * @return void
+ */
+ public function set_meta( $key, $value ) {
+ $this->meta[ $key ] = $value;
+ }
+
+ /**
+ * Delete meta data.
+ *
+ * @param string $key Meta key.
+ * @return void
+ */
+ public function delete_meta( $key ) {
+ unset( $this->meta[ $key ] );
+ }
+
+ /**
+ * Get consumer bank details.
+ *
+ * @return BankAccountDetails|null
+ */
+ public function get_consumer_bank_details() {
+ return $this->consumer_bank_details;
+ }
+
+ /**
+ * Set consumer bank details.
+ *
+ * @param BankAccountDetails|null $bank_details Consumer bank details.
+ * @return void
+ */
+ public function set_consumer_bank_details( $bank_details ) {
+ $this->consumer_bank_details = $bank_details;
+ }
+
+ /**
+ * Get bank transfer details.
+ *
+ * @return BankTransferDetails|null
+ */
+ public function get_bank_transfer_recipient_details() {
+ return $this->bank_transfer_recipient_details;
+ }
+
+ /**
+ * Set bank transfer details.
+ *
+ * @param BankTransferDetails|null $bank_transfer Bank transfer details.
+ * @return void
+ */
+ public function set_bank_transfer_recipient_details( $bank_transfer ) {
+ $this->bank_transfer_recipient_details = $bank_transfer;
+ }
+
+ /**
+ * Set the credit card to use for this payment.
+ *
+ * @param CreditCard|null $credit_card Credit Card.
+ * @return void
+ */
+ public function set_credit_card( $credit_card ) {
+ $this->credit_card = $credit_card;
+ }
+
+ /**
+ * Get the credit card to use for this payment.
+ *
+ * @return CreditCard|null
+ */
+ public function get_credit_card() {
+ return $this->credit_card;
+ }
+
+ /**
+ * Get the unique key.
+ *
+ * @return string|null
+ */
+ public function get_key() {
+ return $this->key;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentInfoHelper.php b/packages/wp-pay/core/src/Payments/PaymentInfoHelper.php
new file mode 100644
index 0000000..5873f72
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentInfoHelper.php
@@ -0,0 +1,268 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Banks\BankAccountDetails;
+use Pronamic\WordPress\Pay\Banks\BankTransferDetails;
+use Pronamic\WordPress\Pay\Address;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * Payment info helper
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.1.0
+ */
+class PaymentInfoHelper {
+ /**
+ * Convert payment info to JSON.
+ *
+ * @param PaymentInfo $payment_info Payment info.
+ * @return object
+ */
+ public static function to_json( PaymentInfo $payment_info ) {
+ $object = (object) [];
+
+ $id = $payment_info->get_id();
+
+ if ( null !== $id ) {
+ $object->id = $id;
+ }
+
+ if ( null !== $payment_info->order_id ) {
+ $object->order_id = $payment_info->order_id;
+ }
+
+ $key = $payment_info->key;
+
+ if ( null !== $key ) {
+ $object->key = $key;
+ }
+
+ $description = $payment_info->get_description();
+
+ if ( null !== $description ) {
+ $object->description = $description;
+ }
+
+ $payment_method = $payment_info->get_payment_method();
+
+ if ( null !== $payment_method ) {
+ $object->payment_method = $payment_method;
+ }
+
+ $origin_id = $payment_info->get_origin_id();
+
+ if ( null !== $origin_id ) {
+ $object->origin_id = $origin_id;
+ }
+
+ $shipping_amount = $payment_info->get_shipping_amount();
+
+ if ( null !== $shipping_amount ) {
+ $object->shipping_amount = $shipping_amount->jsonSerialize();
+ }
+
+ $customer = $payment_info->get_customer();
+
+ if ( null !== $customer ) {
+ $object->customer = $customer->get_json();
+ }
+
+ $billing_address = $payment_info->get_billing_address();
+
+ if ( null !== $billing_address ) {
+ $object->billing_address = $billing_address->get_json();
+ }
+
+ $shipping_address = $payment_info->get_shipping_address();
+
+ if ( null !== $shipping_address ) {
+ $object->shipping_address = $shipping_address->get_json();
+ }
+
+ $lines = $payment_info->get_lines();
+
+ if ( null !== $lines ) {
+ $object->lines = $lines->get_json();
+ }
+
+ // Consumer bank details.
+ $consumer_bank_details = $payment_info->get_consumer_bank_details();
+
+ if ( null !== $consumer_bank_details ) {
+ $object->consumer_bank_details = $consumer_bank_details->get_json();
+ }
+
+ // Bank transfer recipient details.
+ $bank_transfer_recipient_details = $payment_info->get_bank_transfer_recipient_details();
+
+ if ( null !== $bank_transfer_recipient_details ) {
+ $object->bank_transfer_recipient_details = $bank_transfer_recipient_details->get_json();
+ }
+
+ $mode = $payment_info->get_mode();
+
+ if ( null !== $mode ) {
+ $object->mode = $mode;
+ }
+
+ if ( $payment_info->is_anonymized() ) {
+ $object->anonymized = $payment_info->is_anonymized();
+ }
+
+ $version = $payment_info->get_version();
+
+ if ( null !== $version ) {
+ $object->version = $version;
+ }
+
+ $meta = $payment_info->meta;
+
+ if ( ! empty( $meta ) ) {
+ $object->meta = (object) $meta;
+ }
+
+ $source_key = $payment_info->get_source();
+ $source_value = $payment_info->get_source_id();
+
+ if ( null !== $source_key || null !== $source_value ) {
+ $object->source = (object) [
+ 'key' => $source_key,
+ 'value' => $source_value,
+ ];
+ }
+
+ $config_id = $payment_info->get_config_id();
+
+ if ( null !== $config_id ) {
+ $object->gateway = (object) [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/%s/%s/%d',
+ 'pronamic-pay/v1',
+ 'gateways',
+ $config_id
+ )
+ ),
+ 'post_id' => $config_id,
+ 'gateway_id' => \get_post_meta( $config_id, '_pronamic_gateway_id', true ),
+ ];
+ }
+
+ return $object;
+ }
+
+ /**
+ * Convert JSON to payment info object.
+ *
+ * @param object $json JSON.
+ * @param PaymentInfo $payment_info Payment info object.
+ * @return PaymentInfo
+ */
+ public static function from_json( $json, PaymentInfo $payment_info ) {
+ if ( isset( $json->id ) ) {
+ $payment_info->set_id( $json->id );
+ }
+
+ if ( isset( $json->order_id ) ) {
+ $payment_info->order_id = $json->order_id;
+ }
+
+ if ( isset( $json->description ) ) {
+ $payment_info->set_description( $json->description );
+ }
+
+ if ( isset( $json->payment_method ) ) {
+ $payment_info->set_payment_method( $json->payment_method );
+ }
+
+ if ( isset( $json->origin_id ) ) {
+ $payment_info->set_origin_id( $json->origin_id );
+ }
+
+ if ( isset( $json->shipping_amount ) ) {
+ $payment_info->set_shipping_amount( MoneyJsonTransformer::from_json( $json->shipping_amount ) );
+ }
+
+ if ( isset( $json->customer ) ) {
+ $payment_info->set_customer( Customer::from_json( $json->customer ) );
+ }
+
+ if ( isset( $json->billing_address ) ) {
+ $payment_info->set_billing_address( Address::from_json( $json->billing_address ) );
+ }
+
+ if ( isset( $json->shipping_address ) ) {
+ $payment_info->set_shipping_address( Address::from_json( $json->shipping_address ) );
+ }
+
+ if ( isset( $json->lines ) ) {
+ $payment_info->set_lines( PaymentLines::from_json( $json->lines ) );
+ }
+
+ if ( isset( $json->consumer_bank_details ) ) {
+ $payment_info->set_consumer_bank_details( BankAccountDetails::from_json( $json->consumer_bank_details ) );
+ }
+
+ if ( isset( $json->bank_transfer_recipient_details ) ) {
+ $payment_info->set_bank_transfer_recipient_details( BankTransferDetails::from_json( $json->bank_transfer_recipient_details ) );
+ }
+
+ if ( isset( $json->lines ) ) {
+ $payment_info->set_lines( PaymentLines::from_json( $json->lines, $payment_info ) );
+ }
+
+ if ( isset( $json->mode ) ) {
+ $payment_info->set_mode( $json->mode );
+ }
+
+ if ( isset( $json->anonymized ) ) {
+ $payment_info->set_anonymized( $json->anonymized );
+ }
+
+ if ( isset( $json->version ) ) {
+ $payment_info->set_version( $json->version );
+ }
+
+ if ( isset( $json->meta ) ) {
+ foreach ( $json->meta as $key => $value ) {
+ $payment_info->meta[ $key ] = $value;
+ }
+ }
+
+ if ( isset( $json->source ) && \is_object( $json->source ) ) {
+ if ( isset( $json->source->key ) ) {
+ $payment_info->set_source( $json->source->key );
+ }
+
+ if ( isset( $json->source->value ) ) {
+ $payment_info->set_source_id( $json->source->value );
+ }
+ }
+
+ if ( isset( $json->gateway ) && \is_object( $json->gateway ) ) {
+ if ( isset( $json->gateway->post_id ) ) {
+ $payment_info->set_config_id( $json->gateway->post_id );
+ }
+ }
+
+ if ( isset( $json->key ) ) {
+ $payment_info->key = $json->key;
+ }
+
+ return $payment_info;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentInfoTrait.php b/packages/wp-pay/core/src/Payments/PaymentInfoTrait.php
new file mode 100644
index 0000000..b5e1ece
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentInfoTrait.php
@@ -0,0 +1,22 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Payment info
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ */
+trait PaymentInfoTrait {
+
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentLine.php b/packages/wp-pay/core/src/Payments/PaymentLine.php
new file mode 100644
index 0000000..3e28fe9
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentLine.php
@@ -0,0 +1,557 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use InvalidArgumentException;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+
+/**
+ * Payment line.
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.1.0
+ */
+class PaymentLine {
+ /**
+ * The ID.
+ *
+ * @var string|null
+ */
+ private $id;
+
+ /**
+ * The payment type.
+ *
+ * @see PaymentLineType
+ *
+ * @var string
+ */
+ private $type;
+
+ /**
+ * SKU.
+ *
+ * @var string|null
+ */
+ private $sku;
+
+ /**
+ * Name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * The description.
+ *
+ * @var string|null
+ */
+ private $description;
+
+ /**
+ * The quantity.
+ *
+ * @var int|null
+ */
+ private $quantity;
+
+ /**
+ * The unit price of this payment line.
+ *
+ * @var Money|null
+ */
+ private $unit_price;
+
+ /**
+ * The discount amount of this payment line, no tax included.
+ *
+ * @var Money|null
+ */
+ private $discount_amount;
+
+ /**
+ * Total amount of this payment line.
+ *
+ * @var Money
+ */
+ private $total_amount;
+
+ /**
+ * Product URL.
+ *
+ * @var string|null
+ */
+ private $product_url;
+
+ /**
+ * Image url.
+ *
+ * @var string|null
+ */
+ private $image_url;
+
+ /**
+ * Product category.
+ *
+ * @var string|null
+ */
+ private $product_category;
+
+ /**
+ * Payment
+ *
+ * @var Payment|null
+ */
+ private $payment;
+
+ /**
+ * Meta.
+ *
+ * @var array
+ */
+ public array $meta;
+
+ /**
+ * Payment line constructor.
+ */
+ public function __construct() {
+ $this->set_total_amount( new Money() );
+
+ $this->meta = [];
+ }
+
+ /**
+ * Get the id / identifier of this payment line.
+ *
+ * @return string|null
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Set the id / identifier of this payment line.
+ *
+ * @param string|null $id Number.
+ * @return void
+ */
+ public function set_id( $id ) {
+ $this->id = $id;
+ }
+
+ /**
+ * Get type.
+ *
+ * @return string
+ */
+ public function get_type() {
+ return $this->type;
+ }
+
+ /**
+ * Set type.
+ *
+ * @param string $type Type.
+ * @return void
+ */
+ public function set_type( $type ) {
+ $this->type = $type;
+ }
+
+ /**
+ * Get the SKU of this payment line.
+ *
+ * @return string|null
+ */
+ public function get_sku() {
+ return $this->sku;
+ }
+
+ /**
+ * Set the SKU of this payment line.
+ *
+ * @param string|null $sku SKU.
+ * @return void
+ */
+ public function set_sku( $sku ) {
+ $this->sku = $sku;
+ }
+
+ /**
+ * Get the name of this payment line.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set the name of this payment line.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get the description of this payment line.
+ *
+ * @return string|null
+ */
+ public function get_description() {
+ return $this->description;
+ }
+
+ /**
+ * Set the description of this payment line.
+ *
+ * @param string|null $description Description.
+ * @return void
+ */
+ public function set_description( $description ) {
+ $this->description = $description;
+ }
+
+ /**
+ * Get the quantity of this payment line.
+ *
+ * @return int|null
+ */
+ public function get_quantity() {
+ return $this->quantity;
+ }
+
+ /**
+ * Set the quantity of this payment line.
+ *
+ * @param int|null $quantity Quantity.
+ * @return void
+ */
+ public function set_quantity( $quantity ) {
+ $this->quantity = $quantity;
+ }
+
+ /**
+ * Get unit price.
+ *
+ * @return Money|null
+ */
+ public function get_unit_price() {
+ return $this->unit_price;
+ }
+
+ /**
+ * Set unit price.
+ *
+ * @param Money|null $price Unit price.
+ * @return void
+ */
+ public function set_unit_price( Money $price = null ) {
+ $this->unit_price = ( null === $price ? null : $price );
+ }
+
+ /**
+ * Get discount amount, should not contain any tax.
+ *
+ * @return Money|null
+ */
+ public function get_discount_amount() {
+ return $this->discount_amount;
+ }
+
+ /**
+ * Set discount amount, should not contain any tax.
+ *
+ * @param Money $discount_amount Discount amount.
+ * @return void
+ */
+ public function set_discount_amount( Money $discount_amount = null ) {
+ $this->discount_amount = $discount_amount;
+ }
+
+ /**
+ * Get tax amount.
+ *
+ * @return Money|null
+ */
+ public function get_tax_amount() {
+ if ( ! $this->total_amount instanceof TaxedMoney ) {
+ return null;
+ }
+
+ $tax_value = $this->total_amount->get_tax_value();
+
+ if ( null === $tax_value ) {
+ return null;
+ }
+
+ return new Money(
+ $tax_value,
+ $this->get_total_amount()->get_currency()
+ );
+ }
+
+ /**
+ * Get total amount.
+ *
+ * @return Money
+ */
+ public function get_total_amount() {
+ return $this->total_amount;
+ }
+
+ /**
+ * Set total amount.
+ *
+ * @param Money $total_amount Total amount.
+ * @return void
+ */
+ public function set_total_amount( Money $total_amount ) {
+ $this->total_amount = $total_amount;
+ }
+
+ /**
+ * Get product URL.
+ *
+ * @return string|null
+ */
+ public function get_product_url() {
+ return $this->product_url;
+ }
+
+ /**
+ * Set product URL.
+ *
+ * @param string|null $product_url Product URL.
+ * @return void
+ */
+ public function set_product_url( $product_url = null ) {
+ $this->product_url = $product_url;
+ }
+
+ /**
+ * Get image URL.
+ *
+ * @return null|string
+ */
+ public function get_image_url() {
+ return $this->image_url;
+ }
+
+ /**
+ * Set image URL.
+ *
+ * @param null|string $image_url Image url.
+ * @return void
+ */
+ public function set_image_url( $image_url ) {
+ $this->image_url = $image_url;
+ }
+
+ /**
+ * Get product category.
+ *
+ * @return null|string
+ */
+ public function get_product_category() {
+ return $this->product_category;
+ }
+
+ /**
+ * Set product category.
+ *
+ * @param null|string $product_category Product category.
+ * @return void
+ */
+ public function set_product_category( $product_category ) {
+ $this->product_category = $product_category;
+ }
+
+ /**
+ * Get payment.
+ *
+ * @return null|Payment
+ */
+ public function get_payment() {
+ return $this->payment;
+ }
+
+ /**
+ * Set payment.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public function set_payment( Payment $payment ) {
+ $this->payment = $payment;
+ }
+
+ /**
+ * Get the meta value of this specified meta key.
+ *
+ * @param string $key Meta key.
+ * @return mixed
+ */
+ public function get_meta( $key ) {
+ if ( \array_key_exists( $key, $this->meta ) ) {
+ return $this->meta[ $key ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Set meta data.
+ *
+ * @param string $key A meta key.
+ * @param mixed $value A meta value.
+ * @return void
+ */
+ public function set_meta( $key, $value ) {
+ $this->meta[ $key ] = $value;
+ }
+
+ /**
+ * Delete meta data.
+ *
+ * @param string $key Meta key.
+ * @return void
+ */
+ public function delete_meta( $key ) {
+ unset( $this->meta[ $key ] );
+ }
+
+ /**
+ * Create payment line from object.
+ *
+ * @param mixed $json JSON.
+ * @return PaymentLine
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $line = new self();
+
+ if ( property_exists( $json, 'id' ) ) {
+ $line->set_id( $json->id );
+ }
+
+ if ( property_exists( $json, 'type' ) ) {
+ $line->set_type( $json->type );
+ }
+
+ if ( property_exists( $json, 'sku' ) ) {
+ $line->set_sku( $json->sku );
+ }
+
+ if ( property_exists( $json, 'name' ) ) {
+ $line->set_name( $json->name );
+ }
+
+ if ( property_exists( $json, 'description' ) ) {
+ $line->set_description( $json->description );
+ }
+
+ if ( property_exists( $json, 'quantity' ) ) {
+ $line->set_quantity( $json->quantity );
+ }
+
+ if ( isset( $json->unit_price ) ) {
+ $line->set_unit_price( MoneyJsonTransformer::from_json( $json->unit_price ) );
+ }
+
+ if ( isset( $json->discount_amount ) ) {
+ $line->set_discount_amount( MoneyJsonTransformer::from_json( $json->discount_amount ) );
+ }
+
+ if ( isset( $json->total_amount ) ) {
+ $line->set_total_amount( MoneyJsonTransformer::from_json( $json->total_amount ) );
+ }
+
+ if ( property_exists( $json, 'product_url' ) ) {
+ $line->set_product_url( $json->product_url );
+ }
+
+ if ( property_exists( $json, 'image_url' ) ) {
+ $line->set_image_url( $json->image_url );
+ }
+
+ if ( property_exists( $json, 'product_category' ) ) {
+ $line->set_product_category( $json->product_category );
+ }
+
+ if ( property_exists( $json, 'meta' ) ) {
+ $line->meta = (array) $json->meta;
+ }
+
+ return $line;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object
+ */
+ public function get_json() {
+ $properties = [
+ 'id' => $this->get_id(),
+ 'type' => $this->get_type(),
+ 'sku' => $this->get_sku(),
+ 'name' => $this->get_name(),
+ 'description' => $this->get_description(),
+ 'quantity' => $this->get_quantity(),
+ 'unit_price' => ( null === $this->unit_price ) ? null : $this->unit_price->jsonSerialize(),
+ 'discount_amount' => ( null === $this->discount_amount ) ? null : $this->discount_amount->jsonSerialize(),
+ 'total_amount' => $this->total_amount->jsonSerialize(),
+ 'product_url' => $this->get_product_url(),
+ 'image_url' => $this->get_image_url(),
+ 'product_category' => $this->get_product_category(),
+ 'meta' => $this->meta,
+ ];
+
+ $properties = array_filter( $properties );
+
+ return (object) $properties;
+ }
+
+ /**
+ * Create string representation of the payment line.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $parts = [
+ $this->get_id(),
+ $this->get_description(),
+ $this->get_quantity(),
+ ];
+
+ $parts = array_map( 'strval', $parts );
+
+ $parts = array_map( 'trim', $parts );
+
+ $parts = array_filter( $parts );
+
+ $string = implode( ' - ', $parts );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentLineType.php b/packages/wp-pay/core/src/Payments/PaymentLineType.php
new file mode 100644
index 0000000..1ae2e12
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentLineType.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Payment line type.
+ *
+ * @author Reüel van der Steege
+ * @version 2.1.0
+ * @since 2.1.0
+ */
+class PaymentLineType {
+ /**
+ * Constant for 'digital' type.
+ *
+ * @var string
+ */
+ const DIGITAL = 'digital';
+
+ /**
+ * Constant for 'discount' type.
+ *
+ * @var string
+ */
+ const DISCOUNT = 'discount';
+
+ /**
+ * Constant for 'fee' type.
+ *
+ * @var string
+ */
+ const FEE = 'fee';
+
+ /**
+ * Constant for 'physical' type.
+ *
+ * @var string
+ */
+ const PHYSICAL = 'physical';
+
+ /**
+ * Constant for 'shipping' type.
+ *
+ * @var string
+ */
+ const SHIPPING = 'shipping';
+
+ /**
+ * Constant for 'tax' type.
+ *
+ * @var string
+ */
+ const TAX = 'tax';
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentLines.php b/packages/wp-pay/core/src/Payments/PaymentLines.php
new file mode 100644
index 0000000..dab7a93
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentLines.php
@@ -0,0 +1,252 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use Traversable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+
+/**
+ * Payment lines
+ *
+ * @author Remco Tolsma
+ * @version 2.5.1
+ * @since 2.1.0
+ * @implements \IteratorAggregate
+ */
+class PaymentLines implements Countable, IteratorAggregate {
+ /**
+ * The lines.
+ *
+ * @var array
+ */
+ private $lines;
+
+ /**
+ * Constructs and initialize a payment lines object.
+ */
+ public function __construct() {
+ $this->lines = [];
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable {
+ return new ArrayIterator( $this->lines );
+ }
+
+ /**
+ * Get array.
+ *
+ * @return array
+ */
+ public function get_array() {
+ return $this->lines;
+ }
+
+ /**
+ * Get name.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay-woocommerce/issues/43
+ * @return string
+ */
+ public function get_name() {
+ $names = \array_map(
+ function ( PaymentLine $line ) {
+ return (string) $line->get_name();
+ },
+ $this->get_array()
+ );
+
+ return \implode( ', ', $names );
+ }
+
+ /**
+ * Add line.
+ *
+ * @param PaymentLine $line The line to add.
+ * @return void
+ */
+ public function add_line( PaymentLine $line ) {
+ $this->lines[] = $line;
+ }
+
+ /**
+ * New line.
+ *
+ * @return PaymentLine
+ */
+ public function new_line() {
+ $line = new PaymentLine();
+
+ $this->add_line( $line );
+
+ return $line;
+ }
+
+ /**
+ * Count lines.
+ *
+ * @return int
+ */
+ public function count(): int {
+ return count( $this->lines );
+ }
+
+ /**
+ * Calculate the total amount of all lines.
+ *
+ * @return TaxedMoney
+ */
+ public function get_amount() {
+ $total = new Money();
+ $tax = new Money();
+ $currency = null;
+
+ foreach ( $this->lines as $line ) {
+ // Total.
+ $line_total = $line->get_total_amount();
+
+ $total = $total->add( $line_total );
+
+ // Tax.
+ if ( $line_total instanceof TaxedMoney ) {
+ $line_tax = $line_total->get_tax_amount();
+
+ if ( null !== $line_tax ) {
+ $tax = $tax->add( $line_tax );
+ }
+ }
+
+ // Currency.
+ if ( null === $currency ) {
+ $currency = $line_total->get_currency();
+ }
+ }
+
+ // Currency.
+ if ( null === $currency ) {
+ $currency = 'EUR';
+ }
+
+ // Return payment lines amount.
+ return new TaxedMoney(
+ $total->get_value(),
+ $currency,
+ $tax->get_value()
+ );
+ }
+
+ /**
+ * Get first line with the specified ID.
+ *
+ * @param string $id ID.
+ * @return null|PaymentLine
+ */
+ public function first( $id ) {
+ $lines = \array_filter(
+ $this->lines,
+ function ( PaymentLine $line ) use ( $id ) {
+ return ( $id === $line->get_id() );
+ }
+ );
+
+ $line = \reset( $lines );
+
+ if ( false === $line ) {
+ return null;
+ }
+
+ return $line;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return array
+ */
+ public function get_json() {
+ $objects = array_map(
+ /**
+ * Get JSON for payment line.
+ *
+ * @param PaymentLine $line Payment line.
+ * @return object
+ */
+ function ( PaymentLine $line ) {
+ return $line->get_json();
+ },
+ $this->lines
+ );
+
+ return $objects;
+ }
+
+ /**
+ * Create items from object.
+ *
+ * @param mixed $json JSON.
+ * @param PaymentInfo|null $payment_info Payment info.
+ *
+ * @return PaymentLines
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an array.
+ */
+ public static function from_json( $json, PaymentInfo $payment_info = null ) {
+ if ( ! is_array( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $object = new self();
+
+ $lines = array_map(
+ /**
+ * Get payment line from object.
+ *
+ * @param object $value Object.
+ * @return PaymentLine
+ */
+ function ( $value ) {
+ return PaymentLine::from_json( $value );
+ },
+ $json
+ );
+
+ foreach ( $lines as $line ) {
+ // Set payment.
+ if ( $payment_info instanceof Payment ) {
+ $line->set_payment( $payment_info );
+ }
+
+ $object->add_line( $line );
+ }
+
+ return $object;
+ }
+
+ /**
+ * Create string representation the payment lines.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = array_map( 'strval', $this->lines );
+
+ $string = implode( PHP_EOL, $pieces );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentMergeTagsController.php b/packages/wp-pay/core/src/Payments/PaymentMergeTagsController.php
new file mode 100644
index 0000000..d3318f3
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentMergeTagsController.php
@@ -0,0 +1,59 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Gateways
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\Pay\MergeTags\MergeTagsController;
+use Pronamic\WordPress\Pay\MergeTags\MergeTag;
+
+/**
+ * Payment Merge Tags Controller class
+ */
+class PaymentMergeTagsController extends MergeTagsController {
+ /**
+ * Construct payment merge tags controllers.
+ *
+ * @param Payment $payment Payment.
+ */
+ public function __construct( Payment $payment ) {
+ $this->add_merge_tag(
+ new MergeTag(
+ 'payment_id',
+ function () use ( $payment ) {
+ return $payment->get_id();
+ }
+ )
+ );
+
+ $this->add_merge_tag(
+ new MergeTag(
+ 'order_id',
+ function () use ( $payment ) {
+ return $payment->get_order_id();
+ }
+ )
+ );
+
+ $this->add_merge_tag(
+ new MergeTag(
+ 'payment_lines_name',
+ function () use ( $payment ) {
+ $lines = $payment->get_lines();
+
+ if ( null === $lines ) {
+ return '';
+ }
+
+ return $lines->get_name();
+ }
+ )
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentPostType.php b/packages/wp-pay/core/src/Payments/PaymentPostType.php
new file mode 100644
index 0000000..3f61b9b
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentPostType.php
@@ -0,0 +1,266 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Title: WordPress iDEAL post types
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.7.1
+ * @since 3.7.0
+ */
+class PaymentPostType {
+ /**
+ * Constructs and initializes an post types object
+ */
+ public function __construct() {
+ /**
+ * Priority of the initial post types function should be set to < 10
+ *
+ * @link https://core.trac.wordpress.org/ticket/28488
+ * @link https://core.trac.wordpress.org/changeset/29318
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.0/wp-includes/post.php#L167
+ */
+ add_action( 'init', [ $this, 'register_payment_post_type' ], 0 ); // Highest priority.
+ add_action( 'init', [ $this, 'register_post_status' ], 9 );
+ }
+
+ /**
+ * Register post types.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.6.1/wp-includes/post.php#L1277-L1300
+ * @return void
+ */
+ public function register_payment_post_type() {
+ register_post_type(
+ 'pronamic_payment',
+ [
+ 'label' => __( 'Payments', 'pronamic_ideal' ),
+ 'labels' => [
+ 'name' => __( 'Payments', 'pronamic_ideal' ),
+ 'singular_name' => __( 'Payment', 'pronamic_ideal' ),
+ 'add_new' => __( 'Add New', 'pronamic_ideal' ),
+ 'add_new_item' => __( 'Add New Payment', 'pronamic_ideal' ),
+ 'edit_item' => __( 'Edit Payment', 'pronamic_ideal' ),
+ 'new_item' => __( 'New Payment', 'pronamic_ideal' ),
+ 'all_items' => __( 'All Payments', 'pronamic_ideal' ),
+ 'view_item' => __( 'View Payment', 'pronamic_ideal' ),
+ 'search_items' => __( 'Search Payments', 'pronamic_ideal' ),
+ 'not_found' => __( 'No payments found.', 'pronamic_ideal' ),
+ 'not_found_in_trash' => __( 'No payments found in Trash.', 'pronamic_ideal' ),
+ 'menu_name' => __( 'Payments', 'pronamic_ideal' ),
+ 'filter_items_list' => __( 'Filter payments list', 'pronamic_ideal' ),
+ 'items_list_navigation' => __( 'Payments list navigation', 'pronamic_ideal' ),
+ 'items_list' => __( 'Payments list', 'pronamic_ideal' ),
+
+ /*
+ * New Post Type Labels in 5.0.
+ * @link https://make.wordpress.org/core/2018/12/05/new-post-type-labels-in-5-0/
+ */
+ 'item_published' => __( 'Payment published.', 'pronamic_ideal' ),
+ 'item_published_privately' => __( 'Payment published privately.', 'pronamic_ideal' ),
+ 'item_reverted_to_draft' => __( 'Payment reverted to draft.', 'pronamic_ideal' ),
+ 'item_scheduled' => __( 'Payment scheduled.', 'pronamic_ideal' ),
+ 'item_updated' => __( 'Payment updated.', 'pronamic_ideal' ),
+ ],
+ 'public' => false,
+ 'publicly_queryable' => false,
+ 'show_ui' => true,
+ 'show_in_nav_menus' => false,
+ 'show_in_menu' => false,
+ 'show_in_admin_bar' => false,
+ 'show_in_rest' => true,
+ 'rest_base' => 'pronamic-payments',
+ 'supports' => [
+ 'pronamic_pay_payment',
+ ],
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'capabilities' => self::get_capabilities(),
+ 'map_meta_cap' => true,
+ ]
+ );
+ }
+
+ /**
+ * Get payment states.
+ *
+ * @return array
+ */
+ public static function get_payment_states() {
+ return [
+ 'payment_pending' => _x( 'Pending', 'Payment status', 'pronamic_ideal' ),
+ 'payment_on_hold' => _x( 'On Hold', 'Payment status', 'pronamic_ideal' ),
+ 'payment_completed' => _x( 'Completed', 'Payment status', 'pronamic_ideal' ),
+ 'payment_cancelled' => _x( 'Cancelled', 'Payment status', 'pronamic_ideal' ),
+ 'payment_refunded' => _x( 'Refunded', 'Payment status', 'pronamic_ideal' ),
+ 'payment_failed' => _x( 'Failed', 'Payment status', 'pronamic_ideal' ),
+ 'payment_expired' => _x( 'Expired', 'Payment status', 'pronamic_ideal' ),
+ 'payment_authorized' => _x( 'Authorized', 'Payment status', 'pronamic_ideal' ),
+ ];
+ }
+
+ /**
+ * Register our custom post statuses, used for order status.
+ *
+ * @return void
+ */
+ public function register_post_status() {
+ /**
+ * Payment post statuses
+ */
+ register_post_status(
+ 'payment_pending',
+ [
+ 'label' => _x( 'Pending', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Pending (%s) ', 'Pending (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_reserved',
+ [
+ 'label' => _x( 'Reserved', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Reserved (%s) ', 'Reserved (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_on_hold',
+ [
+ 'label' => _x( 'On Hold', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'On Hold (%s) ', 'On Hold (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_completed',
+ [
+ 'label' => _x( 'Completed', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Completed (%s) ', 'Completed (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_cancelled',
+ [
+ 'label' => _x( 'Cancelled', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Cancelled (%s) ', 'Cancelled (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_refunded',
+ [
+ 'label' => _x( 'Refunded', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Refunded (%s) ', 'Refunded (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_failed',
+ [
+ 'label' => _x( 'Failed', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Failed (%s) ', 'Failed (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_expired',
+ [
+ 'label' => _x( 'Expired', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Expired (%s) ', 'Expired (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'payment_authorized',
+ [
+ 'label' => _x( 'Authorized', 'Payment status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Authorized (%s) ', 'Authorized (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+ }
+
+ /**
+ * Get capabilities for this post type.
+ *
+ * @return array
+ */
+ public static function get_capabilities() {
+ return [
+ 'edit_post' => 'edit_payment',
+ 'read_post' => 'read_payment',
+ 'delete_post' => 'delete_payment',
+ 'edit_posts' => 'edit_payments',
+ 'edit_others_posts' => 'edit_others_payments',
+ 'publish_posts' => 'publish_payments',
+ 'read_private_posts' => 'read_private_payments',
+ 'read' => 'read',
+ 'delete_posts' => 'delete_payments',
+ 'delete_private_posts' => 'delete_private_payments',
+ 'delete_published_posts' => 'delete_published_payments',
+ 'delete_others_posts' => 'delete_others_payments',
+ 'edit_private_posts' => 'edit_private_payments',
+ 'edit_published_posts' => 'edit_published_payments',
+ 'create_posts' => 'create_payments',
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentStatus.php b/packages/wp-pay/core/src/Payments/PaymentStatus.php
new file mode 100644
index 0000000..1fdd274
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentStatus.php
@@ -0,0 +1,86 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Title: WordPress pay payment statuses constants
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Remco Tolsma
+ * @version 2.7.1
+ * @since 1.0.0
+ */
+class PaymentStatus {
+ /**
+ * Status indicator for success
+ *
+ * @var string
+ */
+ const SUCCESS = 'Success';
+
+ /**
+ * Status indicator for cancelled
+ *
+ * @var string
+ */
+ const CANCELLED = 'Cancelled';
+
+ /**
+ * Status indicator for expired
+ *
+ * @var string
+ */
+ const EXPIRED = 'Expired';
+
+ /**
+ * Status indicator for failure
+ *
+ * @var string
+ */
+ const FAILURE = 'Failure';
+
+ /**
+ * Status indicator for on hold
+ *
+ * @var string
+ */
+ const ON_HOLD = 'On Hold';
+
+ /**
+ * Status indicator for open
+ *
+ * @var string
+ */
+ const OPEN = 'Open';
+
+ /**
+ * Status indicator for refunded
+ *
+ * @var string
+ */
+ const REFUNDED = 'Refunded';
+
+ /**
+ * Status indicator for completed
+ *
+ * @var string
+ */
+ const COMPLETED = 'Completed';
+
+ /**
+ * Status indicator for authorized
+ *
+ * @var string
+ */
+ const AUTHORIZED = 'Authorized';
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentsDataStoreCPT.php b/packages/wp-pay/core/src/Payments/PaymentsDataStoreCPT.php
new file mode 100644
index 0000000..abddb9a
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentsDataStoreCPT.php
@@ -0,0 +1,897 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeZone;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPeriod;
+
+/**
+ * Title: Payments data store CPT
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @see https://woocommerce.com/2017/04/woocommerce-3-0-release/
+ * @see https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/
+ * @author Remco Tolsma
+ * @version 2.7.1
+ * @since 3.7.0
+ */
+class PaymentsDataStoreCPT extends LegacyPaymentsDataStoreCPT {
+ /**
+ * Payments.
+ *
+ * @var array
+ */
+ private $payments;
+
+ /**
+ * Status map.
+ *
+ * @var array
+ */
+ private $status_map;
+
+ /**
+ * Construct payments data store CPT object.
+ */
+ public function __construct() {
+ $this->meta_key_prefix = '_pronamic_payment_';
+
+ $this->register_meta();
+
+ $this->payments = [];
+
+ $this->status_map = [
+ PaymentStatus::CANCELLED => 'payment_cancelled',
+ PaymentStatus::EXPIRED => 'payment_expired',
+ PaymentStatus::FAILURE => 'payment_failed',
+ PaymentStatus::REFUNDED => 'payment_refunded',
+ PaymentStatus::SUCCESS => 'payment_completed',
+ PaymentStatus::OPEN => 'payment_pending',
+ PaymentStatus::ON_HOLD => 'payment_on_hold',
+ PaymentStatus::AUTHORIZED => 'payment_authorized',
+ ];
+ }
+
+ /**
+ * Preserves the initial JSON post_content passed to save into the post.
+ *
+ * This is needed to prevent KSES and other {@see 'content_save_pre'} filters
+ * from corrupting JSON data.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/160
+ * @link https://developer.wordpress.org/reference/hooks/wp_insert_post_data/
+ * @param array $data An array of slashed and processed post data.
+ * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
+ * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as originally passed to wp_insert_post().
+ * @return array Filtered post data.
+ */
+ public function preserve_post_content( $data, $postarr, $unsanitized_postarr ) {
+ if ( ! \array_key_exists( 'post_type', $data ) ) {
+ return $data;
+ }
+
+ if ( 'pronamic_payment' !== $data['post_type'] ) {
+ return $data;
+ }
+
+ if ( ! \array_key_exists( 'post_content', $unsanitized_postarr ) ) {
+ return $data;
+ }
+
+ $data['post_content'] = $unsanitized_postarr['post_content'];
+
+ return $data;
+ }
+
+ /**
+ * Get payment by ID.
+ *
+ * @param int $id Payment ID.
+ * @return Payment|null
+ */
+ public function get_payment( $id ) {
+ if ( \array_key_exists( $id, $this->payments ) ) {
+ return $this->payments[ $id ];
+ }
+
+ if ( empty( $id ) ) {
+ return null;
+ }
+
+ $id = (int) $id;
+
+ $post_type = \get_post_type( $id );
+
+ if ( 'pronamic_payment' !== $post_type ) {
+ return null;
+ }
+
+ $payment = new Payment();
+
+ $payment->set_id( $id );
+
+ $this->payments[ $id ] = $payment;
+
+ $this->read( $payment );
+
+ return $this->payments[ $id ];
+ }
+
+ /**
+ * Get post status from meta status.
+ *
+ * @param string|null $meta_status Meta status.
+ * @return string|null
+ */
+ private function get_post_status_from_meta_status( $meta_status ) {
+ if ( null === $meta_status ) {
+ return null;
+ }
+
+ if ( isset( $this->status_map[ $meta_status ] ) ) {
+ return $this->status_map[ $meta_status ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get post data.
+ *
+ * @param Payment $payment Payment.
+ * @param array $data Post data.
+ * @return array
+ * @throws \Exception Throws an exception if an error occurs while encoding the payment to JSON.
+ */
+ private function get_post_data( Payment $payment, $data ) {
+ $json_string = \wp_json_encode( $payment->get_json() );
+
+ if ( false === $json_string ) {
+ throw new \Exception( 'Error occurred while encoding the payment to JSON.' );
+ }
+
+ $data['post_content'] = \wp_slash( $json_string );
+ $data['post_mime_type'] = 'application/json';
+ $data['post_name'] = $payment->get_slug();
+
+ $status = $this->get_post_status_from_meta_status( $payment->get_status() );
+
+ if ( null !== $status ) {
+ $data['post_status'] = $status;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Create payment.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L47-L76
+ *
+ * @param Payment $payment The payment to create in this data store.
+ * @return bool
+ * @throws \Exception Throws exception when create fails.
+ */
+ public function create( Payment $payment ) {
+ /**
+ * Pre-create payment.
+ *
+ * @param Payment $payment Payment.
+ */
+ \do_action( 'pronamic_pay_pre_create_payment', $payment );
+
+ $customer = $payment->get_customer();
+
+ $customer_user_id = null === $customer ? 0 : $customer->get_user_id();
+
+ $result = \wp_insert_post(
+ $this->get_post_data(
+ $payment,
+ [
+ 'post_type' => 'pronamic_payment',
+ 'post_date_gmt' => $this->get_mysql_utc_date( $payment->date ),
+ 'post_title' => \sprintf(
+ 'Payment %s',
+ $payment->get_key()
+ ),
+ 'post_author' => null === $customer_user_id ? 0 : $customer_user_id,
+ ]
+ ),
+ true
+ );
+
+ if ( \is_wp_error( $result ) ) {
+ throw new \Exception( 'Could not create payment' );
+ }
+
+ $payment->set_id( $result );
+ $payment->post = \get_post( $result );
+
+ $this->payments[ $result ] = $payment;
+
+ /**
+ * New payment created.
+ *
+ * @param Payment $payment Payment.
+ */
+ do_action( 'pronamic_pay_new_payment', $payment );
+
+ return true;
+ }
+
+ /**
+ * Update payment.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L113-L154
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ *
+ * @param Payment $payment The payment to update in this data store.
+ * @return bool
+ * @throws \Exception Throws exception when update fails.
+ */
+ public function update( Payment $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return false;
+ }
+
+ $result = \wp_update_post(
+ $this->get_post_data(
+ $payment,
+ [
+ 'ID' => $id,
+ ]
+ ),
+ true
+ );
+
+ if ( is_wp_error( $result ) ) {
+ throw new \Exception( 'Could not update payment' );
+ }
+
+ $payment->post = \get_post( $result );
+
+ $this->payments[ $result ] = $payment;
+
+ return true;
+ }
+
+ /**
+ * Save payment.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L113-L154
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ * @param Payment $payment The payment to save in this data store.
+ * @return boolean True if saved, false otherwise.
+ */
+ public function save( $payment ) {
+ $id = $payment->get_id();
+
+ \add_filter( 'wp_insert_post_data', [ $this, 'preserve_post_content' ], 5, 3 );
+
+ $result = empty( $id ) ? $this->create( $payment ) : $this->update( $payment );
+
+ \remove_filter( 'wp_insert_post_data', [ $this, 'preserve_post_content' ], 5 );
+
+ $this->update_post_meta( $payment );
+
+ /**
+ * Payment updated.
+ *
+ * @param Payment $payment Payment.
+ */
+ do_action( 'pronamic_pay_update_payment', $payment );
+
+ return $result;
+ }
+
+ /**
+ * Read payment.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/abstracts/abstract-wc-order.php#L85-L111
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L78-L111
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L81-L136
+ * @link https://developer.wordpress.org/reference/functions/get_post/
+ * @link https://developer.wordpress.org/reference/classes/wp_post/
+ *
+ * @param Payment $payment The payment to read from this data store.
+ * @return void
+ * @throws \Exception Throws exception if payment date can not be set.
+ */
+ public function read( Payment $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $payment->post = get_post( $id );
+ $payment->title = get_the_title( $id );
+ $payment->date = new DateTime(
+ get_post_field( 'post_date_gmt', $id, 'raw' ),
+ new DateTimeZone( 'UTC' )
+ );
+
+ $content = get_post_field( 'post_content', $id, 'raw' );
+
+ $json = json_decode( $content );
+
+ if ( is_object( $json ) ) {
+ Payment::from_json( $json, $payment );
+ }
+
+ $payment->set_slug( get_post_field( 'post_name', $id, 'raw' ) );
+
+ // Set user ID from `post_author` field if not set from payment JSON.
+ $customer = $payment->get_customer();
+
+ if ( null === $customer ) {
+ $customer = new Customer();
+
+ $payment->set_customer( $customer );
+ }
+
+ if ( null === $customer->get_user_id() ) {
+ $post_author = intval( get_post_field( 'post_author', $id, 'raw' ) );
+
+ if ( ! empty( $post_author ) ) {
+ $customer->set_user_id( $post_author );
+ }
+ }
+
+ $this->read_post_meta( $payment );
+ }
+
+ /**
+ * Get meta status label.
+ *
+ * @param string|null $meta_status The payment meta status to get the status label for.
+ * @return string|null
+ */
+ public function get_meta_status_label( $meta_status ) {
+ $post_status = $this->get_post_status_from_meta_status( $meta_status );
+
+ if ( empty( $post_status ) ) {
+ return null;
+ }
+
+ $status_object = get_post_status_object( $post_status );
+
+ if ( isset( $status_object, $status_object->label ) ) {
+ return $status_object->label;
+ }
+
+ return null;
+ }
+
+ /**
+ * Register meta.
+ *
+ * @return void
+ */
+ private function register_meta() {
+ $this->register_meta_key(
+ 'config_id',
+ [
+ 'label' => __( 'Config ID', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'key',
+ [
+ 'label' => __( 'Key', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'method',
+ [
+ 'label' => __( 'Method', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'currency',
+ [
+ 'label' => __( 'Currency', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'amount',
+ [
+ 'label' => __( 'Amount', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'issuer',
+ [
+ 'label' => __( 'Issuer', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'order_id',
+ [
+ 'label' => __( 'Order ID', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'transaction_id',
+ [
+ 'label' => __( 'Transaction ID', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'entrance_code',
+ [
+ 'label' => __( 'Entrance Code', 'pronamic_ideal' ),
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'action_url',
+ [
+ 'label' => __( 'Action URL', 'pronamic_ideal' ),
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'source',
+ [
+ 'label' => __( 'Source', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'source_id',
+ [
+ 'label' => __( 'Source ID', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'description',
+ [
+ 'label' => __( 'Description', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'language',
+ [
+ 'label' => __( 'Language', 'pronamic_ideal' ),
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'locale',
+ [
+ 'label' => __( 'Locale', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'email',
+ [
+ 'label' => __( 'Email', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'anonymize',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'status',
+ [
+ 'label' => __( 'Status', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'customer_name',
+ [
+ 'label' => __( 'Customer Name', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'address',
+ [
+ 'label' => __( 'Address', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'postal_code',
+ [
+ 'label' => __( 'Postal Code', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'city',
+ [
+ 'label' => __( 'City', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'country',
+ [
+ 'label' => __( 'Country', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'telephone_number',
+ [
+ 'label' => __( 'Telephone Number', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'consumer_name',
+ [
+ 'label' => __( 'Consumer Name', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'consumer_account_number',
+ [
+ 'label' => __( 'Consumer Account Number', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'consumer_iban',
+ [
+ 'label' => __( 'Consumer IBAN', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'consumer_bic',
+ [
+ 'label' => __( 'Consumer BIC', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'consumer_city',
+ [
+ 'label' => __( 'Consumer City', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'subscription_id',
+ [
+ 'label' => __( 'Subscription ID', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'recurring_type',
+ [
+ 'label' => __( 'Recurring Type', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'recurring',
+ [
+ 'label' => __( 'Recurring', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'start_date',
+ [
+ 'label' => __( 'Start Date', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'end_date',
+ [
+ 'label' => __( 'End Date', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ ]
+ );
+
+ $this->register_meta_key(
+ 'user_agent',
+ [
+ 'label' => __( 'User Agent', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'user_ip',
+ [
+ 'label' => __( 'User IP', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+ }
+
+ /**
+ * Read post meta.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/abstracts/abstract-wc-data.php#L462-L507
+ * @param Payment $payment The payment to read.
+ * @return void
+ */
+ protected function read_post_meta( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $payment->status = $this->get_meta_string( $id, 'status' );
+
+ // Action URL.
+ $action_url = $payment->get_action_url();
+
+ if ( empty( $action_url ) ) {
+ $action_url = $this->get_meta_string( $id, 'action_url' );
+
+ $payment->set_action_url( $action_url );
+ }
+
+ // Legacy.
+ parent::read_post_meta( $payment );
+
+ // Transaction ID.
+ if ( empty( $payment->transaction_id ) ) {
+ $payment->transaction_id = $this->get_meta_string( $id, 'transaction_id' );
+ }
+
+ // Amount.
+ $amount = $payment->get_meta( 'amount' );
+
+ $amount_value = $payment->get_total_amount()->get_value();
+
+ if ( empty( $amount_value ) && ! empty( $amount ) ) {
+ $payment->set_total_amount(
+ new Money(
+ $amount,
+ $payment->get_meta( 'currency' )
+ )
+ );
+ }
+
+ // Subscription.
+ $subscription_id = $this->get_meta_int( $id, 'subscription_id' );
+
+ if ( ! empty( $subscription_id ) ) {
+ $subscription = \get_pronamic_subscription( $subscription_id );
+
+ if ( null !== $subscription ) {
+ $payment->add_subscription( $subscription );
+ }
+ }
+
+ // Meta.
+ $keys = [
+ '_pronamic_payment_issuer' => 'issuer',
+ ];
+
+ foreach ( $keys as $post_meta_key => $payment_meta_key ) {
+ $payment_meta_value = $payment->get_meta( $payment_meta_key );
+ $post_meta_value = \get_post_meta( $id, $post_meta_key, true );
+
+ if ( empty( $payment_meta_value ) && ! empty( $post_meta_value ) ) {
+ $payment->set_meta( $payment_meta_key, $post_meta_value );
+ }
+ }
+
+ // Legacy periods.
+ $periods = $payment->get_periods();
+
+ if ( null === $periods ) {
+ $start_date = \get_post_meta( $id, '_pronamic_payment_start_date', true );
+ $end_date = \get_post_meta( $id, '_pronamic_payment_end_date', true );
+
+ if ( ! empty( $start_date ) && ! empty( $end_date ) ) {
+ $subscriptions = $payment->get_subscriptions();
+
+ $subscription = reset( $subscriptions );
+
+ if ( false !== $subscription ) {
+ $phases = $subscription->get_phases();
+
+ $phase = reset( $phases );
+
+ if ( false !== $phase ) {
+ $period = new SubscriptionPeriod(
+ $phase,
+ new DateTime( $start_date ),
+ new DateTime( $end_date ),
+ $payment->get_total_amount()
+ );
+
+ $payment->add_period( $period );
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Update payment post meta.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ * @param Payment $payment The payment to update.
+ * @return void
+ */
+ private function update_post_meta( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $customer = $payment->get_customer();
+
+ $this->update_meta( $id, 'config_id', $payment->config_id );
+ $this->update_meta( $id, 'source', $payment->source );
+ $this->update_meta( $id, 'source_id', $payment->source_id );
+ $this->update_meta( $id, 'email', ( null === $customer ? null : $customer->get_email() ) );
+ $this->update_meta( $id, 'purchase_id', $payment->get_meta( 'purchase_id' ) );
+ $this->update_meta( $id, 'transaction_id', $payment->get_transaction_id() );
+ $this->update_meta( $id, 'version', $payment->get_version() );
+
+ // Subscriptions.
+ $meta_key = $this->get_meta_key( 'subscription_id' );
+
+ $subscriptions_ids = \get_post_meta( $id, $meta_key );
+
+ foreach ( $payment->get_subscriptions() as $subscription ) {
+ $subscription_id = $subscription->get_id();
+
+ if ( ! in_array( $subscription_id, $subscriptions_ids, true ) ) {
+ \add_post_meta( $id, $meta_key, $subscription_id, false );
+ }
+ }
+
+ $this->update_meta_status( $payment );
+ }
+
+ /**
+ * Update meta status.
+ *
+ * @param Payment $payment The payment to update the status for.
+ * @return void
+ */
+ public function update_meta_status( $payment ) {
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ // Clean post cache to prevent duplicate status updates.
+ \clean_post_cache( $id );
+
+ $previous_status = $this->get_meta( $id, 'status' );
+
+ $this->update_meta( $id, 'status', $payment->status );
+
+ if ( $previous_status !== $payment->status ) {
+ if ( empty( $previous_status ) ) {
+ $previous_status = null;
+ }
+
+ $can_redirect = false;
+
+ $source = $payment->source;
+
+ $updated_status = $payment->status;
+
+ $old_status = empty( $previous_status ) ? 'unknown' : strtolower( $previous_status );
+
+ $new_status = empty( $updated_status ) ? 'unknown' : strtolower( $updated_status );
+
+ /**
+ * Payment status updated for plugin integration source from old to new status.
+ *
+ * [`{$source}`](https://github.com/pronamic/wp-pronamic-pay/wiki#sources)
+ * [`{$old_status}`](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status)
+ * [`{$new_status}`](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status)
+ *
+ * @param Payment $payment Payment.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the payment update.
+ * @param null|string $previous_status Previous [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status).
+ * @param null|string $updated_status Updated [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status).
+ */
+ do_action( 'pronamic_payment_status_update_' . $source . '_' . $old_status . '_to_' . $new_status, $payment, $can_redirect, $previous_status, $updated_status );
+
+ /**
+ * Payment status updated for plugin integration source.
+ *
+ * [`{$source}`](https://github.com/pronamic/wp-pronamic-pay/wiki#sources)
+ *
+ * @param Payment $payment Payment.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the payment update.
+ * @param null|string $previous_status Previous [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status).
+ * @param null|string $updated_status Updated [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status)).
+ */
+ do_action( 'pronamic_payment_status_update_' . $source, $payment, $can_redirect, $previous_status, $updated_status );
+
+ /**
+ * Payment status updated.
+ *
+ * @param Payment $payment Payment.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the payment update.
+ * @param null|string $previous_status Previous [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status).
+ * @param null|string $updated_status Updated [payment status](https://github.com/pronamic/wp-pronamic-pay/wiki#payment-status).
+ */
+ do_action( 'pronamic_payment_status_update', $payment, $can_redirect, $previous_status, $updated_status );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentsModule.php b/packages/wp-pay/core/src/Payments/PaymentsModule.php
new file mode 100644
index 0000000..41eddc4
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentsModule.php
@@ -0,0 +1,278 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\Pay\Core\Util;
+use Pronamic\WordPress\Pay\Plugin;
+use WP_CLI;
+
+/**
+ * Payments Module
+ *
+ * @link https://woocommerce.com/2017/04/woocommerce-3-0-release/
+ * @link https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.0.1
+ */
+class PaymentsModule {
+ /**
+ * Plugin.
+ *
+ * @var Plugin $plugin
+ */
+ public $plugin;
+
+ /**
+ * Privacy.
+ *
+ * @var PaymentsPrivacy
+ */
+ public $privacy;
+
+ /**
+ * Status checker.
+ *
+ * @var StatusChecker
+ */
+ public $status_checker;
+
+ /**
+ * Construct and initialize a payments module object.
+ *
+ * @param Plugin $plugin The plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Payments privacy exporters and erasers.
+ $this->privacy = new PaymentsPrivacy();
+
+ // Exclude payment notes.
+ add_filter( 'comments_clauses', [ $this, 'exclude_payment_comment_notes' ], 10, 2 );
+
+ // Payment redirect URL.
+ add_filter( 'pronamic_payment_redirect_url', [ $this, 'payment_redirect_url' ], 5, 2 );
+
+ // Listen to payment status changes so we can log these in a note.
+ add_action( 'pronamic_payment_status_update', [ $this, 'log_payment_status_update' ], 10, 4 );
+
+ // REST API.
+ add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
+
+ // Payment Status Checker.
+ $this->status_checker = new StatusChecker();
+
+ // CLI.
+ if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ WP_CLI::add_command(
+ 'pay payment status',
+ function ( $args ) {
+ foreach ( $args as $id ) {
+ $payment = get_pronamic_payment( $id );
+
+ if ( null === $payment ) {
+ WP_CLI::error(
+ \sprintf(
+ 'Cannot find payment based on ID %s.',
+ $id
+ )
+ );
+ }
+
+ WP_CLI::log(
+ \sprintf(
+ 'Check the status (current: %s) of payment with ID %s…',
+ $payment->get_status(),
+ $id
+ )
+ );
+
+ Plugin::update_payment( $payment, false );
+
+ WP_CLI::log(
+ \sprintf(
+ 'Checked the status (current: %s) of payment with ID %s.',
+ $payment->get_status(),
+ $id
+ )
+ );
+ }
+ }
+ );
+ }
+ }
+
+ /**
+ * Comments clauses.
+ *
+ * @param array $clauses Array with query clauses for the comments query.
+ * @param \WP_Comment_Query $query A WordPress comment query object.
+ *
+ * @return array
+ */
+ public function exclude_payment_comment_notes( $clauses, $query ) {
+ $type = $query->query_vars['type'];
+
+ // Ignore payment notes comments if it's not specifically requested.
+ if ( 'payment_note' !== $type ) {
+ $clauses['where'] .= " AND comment_type != 'payment_note'";
+ }
+
+ return $clauses;
+ }
+
+ /**
+ * Payment redirect URL filter.
+ *
+ * @param string $url A payment redirect URL.
+ * @param Payment $payment The payment to get a redirect URL for.
+ *
+ * @return string
+ */
+ public function payment_redirect_url( $url, $payment ) {
+ $page_id = null;
+
+ switch ( $payment->status ) {
+ case PaymentStatus::CANCELLED:
+ $page_id = pronamic_pay_get_page_id( 'cancel' );
+
+ break;
+ case PaymentStatus::EXPIRED:
+ $page_id = pronamic_pay_get_page_id( 'expired' );
+
+ break;
+ case PaymentStatus::FAILURE:
+ $page_id = pronamic_pay_get_page_id( 'error' );
+
+ break;
+ case PaymentStatus::OPEN:
+ $page_id = pronamic_pay_get_page_id( 'unknown' );
+
+ break;
+ case PaymentStatus::SUCCESS:
+ $page_id = pronamic_pay_get_page_id( 'completed' );
+
+ break;
+ default:
+ $page_id = pronamic_pay_get_page_id( 'unknown' );
+
+ break;
+ }
+
+ if ( ! empty( $page_id ) ) {
+ $page_url = get_permalink( $page_id );
+
+ if ( false !== $page_url ) {
+ $url = $page_url;
+ }
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get payment status update note.
+ *
+ * @param string|null $old_status Old meta status.
+ * @param string $new_status New meta status.
+ * @return string
+ */
+ private function get_payment_status_update_note( $old_status, $new_status ) {
+ $old_label = $this->plugin->payments_data_store->get_meta_status_label( $old_status );
+ $new_label = $this->plugin->payments_data_store->get_meta_status_label( $new_status );
+
+ if ( null === $old_status ) {
+ return sprintf(
+ /* translators: 1: new status */
+ __( 'Payment created with status "%1$s".', 'pronamic_ideal' ),
+ esc_html( empty( $new_label ) ? $new_status : $new_label )
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: old status, 2: new status */
+ __( 'Payment status changed from "%1$s" to "%2$s".', 'pronamic_ideal' ),
+ esc_html( empty( $old_label ) ? $old_status : $old_label ),
+ esc_html( empty( $new_label ) ? $new_status : $new_label )
+ );
+ }
+
+ /**
+ * Payment status update.
+ *
+ * @param Payment $payment The status updated payment.
+ * @param bool $can_redirect Whether or not redirects should be performed.
+ * @param string|null $old_status Old meta status.
+ * @param string $new_status New meta status.
+ *
+ * @return void
+ */
+ public function log_payment_status_update( $payment, $can_redirect, $old_status, $new_status ) {
+ $note = $this->get_payment_status_update_note( $old_status, $new_status );
+
+ $payment->add_note( $note );
+ }
+
+ /**
+ * REST API init.
+ *
+ * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/
+ * @link https://developer.wordpress.org/reference/hooks/rest_api_init/
+ *
+ * @return void
+ */
+ public function rest_api_init() {
+ \register_rest_route(
+ 'pronamic-pay/v1',
+ '/payments/(?P\d+)',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'rest_api_payment' ],
+ 'permission_callback' => function () {
+ return \current_user_can( 'edit_payments' );
+ },
+ 'args' => [
+ 'payment_id' => [
+ 'description' => __( 'Payment ID.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * REST API payment.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return object
+ */
+ public function rest_api_payment( \WP_REST_Request $request ) {
+ $payment_id = $request->get_param( 'payment_id' );
+
+ $payment = \get_pronamic_payment( $payment_id );
+
+ if ( null === $payment ) {
+ return new \WP_Error(
+ 'pronamic-pay-payment-not-found',
+ \sprintf(
+ /* translators: %s: payment ID */
+ \__( 'Could not find payment with ID `%s`.', 'pronamic_ideal' ),
+ $payment_id
+ ),
+ $payment_id
+ );
+ }
+
+ return $payment->get_json();
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/PaymentsPrivacy.php b/packages/wp-pay/core/src/Payments/PaymentsPrivacy.php
new file mode 100644
index 0000000..8e90387
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/PaymentsPrivacy.php
@@ -0,0 +1,241 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\Pay\AddressHelper;
+use Pronamic\WordPress\Pay\CustomerHelper;
+
+/**
+ * Payments Privacy class.
+ *
+ * @author Reüel van der Steege
+ * @version 2.1.0
+ * @since 2.0.2
+ */
+class PaymentsPrivacy {
+ /**
+ * Payments privacy constructor.
+ */
+ public function __construct() {
+ // Register exporters.
+ add_action( 'pronamic_pay_privacy_register_exporters', [ $this, 'register_exporters' ] );
+
+ // Register erasers.
+ add_action( 'pronamic_pay_privacy_register_erasers', [ $this, 'register_erasers' ] );
+ }
+
+ /**
+ * Register privacy exporters.
+ *
+ * @param \Pronamic\WordPress\Pay\PrivacyManager $privacy_manager Privacy manager.
+ *
+ * @return void
+ */
+ public function register_exporters( $privacy_manager ) {
+ // Payments export.
+ $privacy_manager->add_exporter(
+ 'payments',
+ __( 'Payments', 'pronamic_ideal' ),
+ [ $this, 'payments_export' ]
+ );
+ }
+
+ /**
+ * Register privacy erasers.
+ *
+ * @param \Pronamic\WordPress\Pay\PrivacyManager $privacy_manager Privacy manager.
+ *
+ * @return void
+ */
+ public function register_erasers( $privacy_manager ) {
+ // Payments anonymizer.
+ $privacy_manager->add_eraser(
+ 'payments',
+ __( 'Payments', 'pronamic_ideal' ),
+ [ $this, 'payments_anonymizer' ]
+ );
+ }
+
+ /**
+ * Payments exporter.
+ *
+ * @param string $email_address Email address.
+ * @return array
+ */
+ public function payments_export( $email_address ) {
+ // Payments data store.
+ $data_store = pronamic_pay_plugin()->payments_data_store;
+
+ // Privacy manager.
+ $privacy_manager = pronamic_pay_plugin()->privacy_manager;
+
+ // Get payments.
+ // @todo use paging.
+ $payments = get_pronamic_payments_by_meta(
+ $data_store->meta_key_prefix . 'email',
+ $email_address
+ );
+
+ // Get registered meta keys for export.
+ $meta_keys = wp_list_filter(
+ $data_store->get_registered_meta(),
+ [
+ 'privacy_export' => true,
+ ]
+ );
+
+ $items = [];
+
+ // Loop payments.
+ foreach ( $payments as $payment ) {
+ $export_data = [];
+
+ $id = $payment->get_id();
+
+ if ( empty( $id ) ) {
+ continue;
+ }
+
+ $payment_meta = get_post_meta( $id );
+
+ // Get payment meta.
+ foreach ( $meta_keys as $meta_key => $meta_options ) {
+ $meta_key = $data_store->meta_key_prefix . $meta_key;
+
+ if ( ! array_key_exists( $meta_key, $payment_meta ) ) {
+ continue;
+ }
+
+ // Add export value.
+ $export_data[] = $privacy_manager->export_meta( $meta_key, $meta_options, $payment_meta );
+ }
+
+ // Add item to export data.
+ if ( ! empty( $export_data ) ) {
+ $items[] = [
+ 'group_id' => 'pronamic-pay-payments',
+ 'group_label' => __( 'Payments', 'pronamic_ideal' ),
+ 'item_id' => 'pronamic-pay-payment-' . $id,
+ 'data' => $export_data,
+ ];
+ }
+ }
+
+ // Return export data.
+ return [
+ 'data' => $items,
+ 'done' => true,
+ ];
+ }
+
+ /**
+ * Payments anonymizer.
+ *
+ * @param string $email_address Email address.
+ * @return array
+ */
+ public function payments_anonymizer( $email_address ) {
+ // Payments data store.
+ $data_store = pronamic_pay_plugin()->payments_data_store;
+
+ // Privacy manager.
+ $privacy_manager = pronamic_pay_plugin()->privacy_manager;
+
+ // Return values.
+ $items_removed = false;
+ $items_retained = false;
+ $messages = [];
+
+ // Get payments.
+ // @todo use paging.
+ $payments = get_pronamic_payments_by_meta(
+ $data_store->meta_key_prefix . 'email',
+ $email_address
+ );
+
+ // Get registered meta keys for erasure.
+ $meta_keys = wp_list_filter(
+ $data_store->get_registered_meta(),
+ [
+ 'privacy_erasure' => null,
+ ],
+ 'NOT'
+ );
+
+ // Loop payments.
+ foreach ( $payments as $payment ) {
+ $payment_id = $payment->get_id();
+
+ if ( empty( $payment_id ) ) {
+ continue;
+ }
+
+ $payment_meta = get_post_meta( $payment_id );
+
+ // Get payment meta.
+ foreach ( $meta_keys as $meta_key => $meta_options ) {
+ $meta_key = $data_store->meta_key_prefix . $meta_key;
+
+ if ( ! array_key_exists( $meta_key, $payment_meta ) ) {
+ continue;
+ }
+
+ $action = ( isset( $meta_options['privacy_erasure'] ) ? $meta_options['privacy_erasure'] : null );
+
+ $privacy_manager->erase_meta( $payment_id, $meta_key, $action );
+ }
+
+ // Customer.
+ $customer = $payment->get_customer();
+
+ if ( null !== $customer ) {
+ CustomerHelper::anonymize_customer( $customer );
+ }
+
+ // Billing Address.
+ $address = $payment->get_billing_address();
+
+ if ( null !== $address ) {
+ AddressHelper::anonymize_address( $address );
+ }
+
+ // Shipping Address.
+ $address = $payment->get_shipping_address();
+
+ if ( null !== $address ) {
+ AddressHelper::anonymize_address( $address );
+ }
+
+ // Set anonymized.
+ $payment->set_anonymized( true );
+
+ // Save.
+ $payment->save();
+
+ // Add payment note.
+ $payment->add_note( __( 'Payment anonymized for personal data erasure request.', 'pronamic_ideal' ) );
+
+ // Add message.
+ /* translators: %s: Payment ID */
+ $messages[] = sprintf( __( 'Payment ID %s anonymized.', 'pronamic_ideal' ), $payment_id );
+
+ $items_removed = true;
+ }
+
+ // Return results.
+ return [
+ 'items_removed' => $items_removed,
+ 'items_retained' => $items_retained,
+ 'messages' => $messages,
+ 'done' => true,
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/SourceTrait.php b/packages/wp-pay/core/src/Payments/SourceTrait.php
new file mode 100644
index 0000000..399defa
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/SourceTrait.php
@@ -0,0 +1,76 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+/**
+ * Source Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ */
+trait SourceTrait {
+ /**
+ * Identifier for the source which started this payment info.
+ * For example: 'woocommerce', 'gravityforms', 'easydigitaldownloads', etc.
+ *
+ * @var string|null
+ */
+ public $source;
+
+ /**
+ * Unique ID at the source which started this payment info, for example:
+ * - WooCommerce order ID.
+ * - Easy Digital Downloads payment ID.
+ * - Gravity Forms entry ID.
+ *
+ * @var string|int|null
+ */
+ public $source_id;
+
+ /**
+ * Get the source identifier of this payment.
+ *
+ * @return string|null
+ */
+ public function get_source() {
+ return $this->source;
+ }
+
+ /**
+ * Set the source of this payment.
+ *
+ * @param string|null $source Source.
+ * @return void
+ */
+ public function set_source( $source ) {
+ $this->source = $source;
+ }
+
+ /**
+ * Get the source ID of this payment.
+ *
+ * @return string|int|null
+ */
+ public function get_source_id() {
+ return $this->source_id;
+ }
+
+ /**
+ * Set the source ID of this payment.
+ *
+ * @param string|int|null $source_id Source ID.
+ * @return void
+ */
+ public function set_source_id( $source_id ) {
+ $this->source_id = $source_id;
+ }
+}
diff --git a/packages/wp-pay/core/src/Payments/StatusChecker.php b/packages/wp-pay/core/src/Payments/StatusChecker.php
new file mode 100644
index 0000000..44a4aa6
--- /dev/null
+++ b/packages/wp-pay/core/src/Payments/StatusChecker.php
@@ -0,0 +1,238 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Payments;
+
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * Status Checker
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.0.0
+ */
+class StatusChecker {
+ /**
+ * Construct a status checker.
+ */
+ public function __construct() {
+ // Payment status check events are scheduled when payments are started.
+ add_action( 'pronamic_pay_payment_status_check', [ $this, 'check_status' ], 10, 2 );
+
+ // Clear scheduled status checks.
+ add_action( 'pronamic_payment_status_update', [ $this, 'maybe_clear_scheduled_status_check' ], 10, 1 );
+ add_action( 'trashed_post', [ $this, 'clear_scheduled_status_check' ], 10, 1 );
+ add_action( 'delete_post', [ $this, 'clear_scheduled_status_check' ], 10, 1 );
+ }
+
+ /**
+ * Schedule event.
+ *
+ * @param Payment $payment The payment to schedule the status check event.
+ * @return void
+ */
+ public static function schedule_event( $payment ) {
+ /*
+ * Schedule status requests
+ * http://pronamic.nl/wp-content/uploads/2011/12/iDEAL_Advanced_PHP_EN_V2.2.pdf (page 19)
+ *
+ * @todo
+ * Considering the number of status requests per transaction:
+ * - Maximum of five times per transaction;
+ * - Maximum of two times during the expirationPeriod;
+ * - After the expirationPeriod not more often than once per 60 minutes;
+ * - No status request after a final status has been received for a transaction;
+ * - No status request for transactions older than 7 days.
+ */
+
+ // Bail if payment already has a final status (e.g. failed payments).
+ $status = $payment->get_status();
+
+ if ( ! empty( $status ) && PaymentStatus::OPEN !== $status ) {
+ return;
+ }
+
+ // Get delay seconds for first status check.
+ $delay = self::get_delay_seconds( 1, $payment );
+
+ \as_schedule_single_action(
+ time() + $delay,
+ 'pronamic_pay_payment_status_check',
+ [
+ 'payment_id' => $payment->get_id(),
+ 'try' => 1,
+ ],
+ 'pronamic-pay'
+ );
+ }
+
+ /**
+ * Get the delay seconds for the specified try.
+ *
+ * @param int $attempt Which try/round to get the delay seconds for.
+ * @param Payment $payment Payment.
+ *
+ * @return int
+ */
+ private static function get_delay_seconds( $attempt, $payment ) {
+ if ( \in_array(
+ $payment->get_payment_method(),
+ [
+ PaymentMethods::AFTERPAY_NL,
+ PaymentMethods::BANK_TRANSFER,
+ PaymentMethods::DIRECT_DEBIT,
+ PaymentMethods::KLARNA_PAY_LATER,
+ ],
+ true
+ ) ) {
+ switch ( $attempt ) {
+ case 1:
+ return 15 * MINUTE_IN_SECONDS;
+
+ case 2:
+ return 5 * DAY_IN_SECONDS;
+
+ case 3:
+ return 10 * DAY_IN_SECONDS;
+
+ case 4:
+ default:
+ return 14 * DAY_IN_SECONDS;
+ }
+ }
+
+ // Delays for regular payments.
+ switch ( $attempt ) {
+ case 1:
+ return 15 * MINUTE_IN_SECONDS;
+
+ case 2:
+ return 30 * MINUTE_IN_SECONDS;
+
+ case 3:
+ return HOUR_IN_SECONDS;
+
+ case 4:
+ default:
+ return DAY_IN_SECONDS;
+ }
+ }
+
+ /**
+ * Check status of the specified payment.
+ *
+ * @param int $payment_id The payment ID to check.
+ * @param int $attempt The try number for this status check.
+ * @return void
+ */
+ public function check_status( $payment_id = null, $attempt = 1 ) {
+ $payment = get_pronamic_payment( $payment_id );
+
+ // No payment found, unable to check status.
+ if ( null === $payment ) {
+ return;
+ }
+
+ // http://pronamic.nl/wp-content/uploads/2011/12/iDEAL_Advanced_PHP_EN_V2.2.pdf (page 19)
+ // - No status request after a final status has been received for a transaction.
+ if ( ! empty( $payment->status ) && PaymentStatus::OPEN !== $payment->status ) {
+ return;
+ }
+
+ // Add note.
+ $note = sprintf(
+ /* translators: %s: Pronamic Pay */
+ __( 'Payment status check at gateway by %s.', 'pronamic_ideal' ),
+ __( 'Pronamic Pay', 'pronamic_ideal' )
+ );
+
+ $payment->add_note( $note );
+
+ // Update payment.
+ Plugin::update_payment( $payment, false );
+
+ // Limit number of tries.
+ if ( 4 === $attempt ) {
+ return;
+ }
+
+ // Schedule check if no final status has been received.
+ $status = $payment->get_status();
+
+ if ( empty( $status ) || PaymentStatus::OPEN === $status ) {
+ $next_attempt = ( $attempt + 1 );
+
+ // Get delay seconds for next status check.
+ $delay = self::get_delay_seconds( $next_attempt, $payment );
+
+ \as_schedule_single_action(
+ time() + $delay,
+ 'pronamic_pay_payment_status_check',
+ [
+ 'payment_id' => $payment->get_id(),
+ 'try' => $next_attempt,
+ ],
+ 'pronamic-pay'
+ );
+ }
+ }
+
+ /**
+ * Maybe clear scheduled status check.
+ *
+ * @param Payment $payment Payment to maybe clear scheduled status checks for.
+ *
+ * @return void
+ */
+ public function maybe_clear_scheduled_status_check( $payment ) {
+ $status = $payment->get_status();
+
+ // Bail if payment does not have a final payment status.
+ if ( empty( $status ) || PaymentStatus::OPEN === $status ) {
+ return;
+ }
+
+ // Check payment.
+ $payment_id = $payment->get_id();
+
+ if ( null === $payment_id ) {
+ return;
+ }
+
+ // Clear scheduled status check.
+ $this->clear_scheduled_status_check( $payment_id );
+ }
+
+ /**
+ * Clear scheduled status check.
+ *
+ * @param int $post_id Post ID.
+ * @return void
+ */
+ public function clear_scheduled_status_check( $post_id ) {
+ // Check post type.
+ if ( 'pronamic_payment' !== \get_post_type( $post_id ) ) {
+ return;
+ }
+
+ // Unschedule action for all 4 tries.
+ $args = [
+ 'payment_id' => $post_id,
+ ];
+
+ foreach ( range( 1, 4 ) as $attempt ) {
+ $args['try'] = $attempt;
+
+ \as_unschedule_action( 'pronamic_pay_payment_status_check', $args );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Plugin.php b/packages/wp-pay/core/src/Plugin.php
new file mode 100644
index 0000000..2cc50fa
--- /dev/null
+++ b/packages/wp-pay/core/src/Plugin.php
@@ -0,0 +1,1699 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\Http\Facades\Http;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Admin\AdminModule;
+use Pronamic\WordPress\Pay\Banks\BankAccountDetails;
+use Pronamic\WordPress\Pay\Core\Gateway;
+use Pronamic\WordPress\Pay\Core\PaymentMethod;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Core\PaymentMethodsCollection;
+use Pronamic\WordPress\Pay\Core\Util as Core_Util;
+use Pronamic\WordPress\Pay\Gateways\GatewaysDataStoreCPT;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentPostType;
+use Pronamic\WordPress\Pay\Payments\PaymentsDataStoreCPT;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Payments\StatusChecker;
+use Pronamic\WordPress\Pay\Refunds\Refund;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPostType;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionsDataStoreCPT;
+use Pronamic\WordPress\Pay\Webhooks\WebhookLogger;
+use WP_Error;
+use WP_Query;
+
+/**
+ * Plugin
+ *
+ * @author Remco Tolsma
+ * @version 2.5.1
+ * @since 2.0.1
+ */
+class Plugin {
+ /**
+ * Version.
+ *
+ * @var string
+ */
+ private $version = '';
+
+ /**
+ * The root file of this WordPress plugin
+ *
+ * @var string
+ */
+ public static $file;
+
+ /**
+ * The plugin dirname
+ *
+ * @var string
+ */
+ public static $dirname;
+
+ /**
+ * The timezone
+ *
+ * @var string
+ */
+ const TIMEZONE = 'UTC';
+
+ /**
+ * Instance.
+ *
+ * @var Plugin|null
+ */
+ protected static $instance;
+
+ /**
+ * Instance.
+ *
+ * @param string|array|object $args The plugin arguments.
+ *
+ * @return Plugin
+ */
+ public static function instance( $args = [] ) {
+ if ( is_null( self::$instance ) ) {
+ self::$instance = new self( $args );
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Plugin settings.
+ *
+ * @var Settings
+ */
+ public $settings;
+
+ /**
+ * Gateway data storing.
+ *
+ * @var GatewaysDataStoreCPT
+ */
+ public $gateways_data_store;
+
+ /**
+ * Payment data storing.
+ *
+ * @var PaymentsDataStoreCPT
+ */
+ public $payments_data_store;
+
+ /**
+ * Subscription data storing.
+ *
+ * @var SubscriptionsDataStoreCPT
+ */
+ public $subscriptions_data_store;
+
+ /**
+ * Gateway post type.
+ *
+ * @var GatewayPostType
+ */
+ public $gateway_post_type;
+
+ /**
+ * Payment post type.
+ *
+ * @var PaymentPostType
+ */
+ public $payment_post_type;
+
+ /**
+ * Subscription post type.
+ *
+ * @var SubscriptionPostType
+ */
+ public $subscription_post_type;
+
+ /**
+ * Privacy manager.
+ *
+ * @var PrivacyManager
+ */
+ public $privacy_manager;
+
+ /**
+ * Admin module.
+ *
+ * @var AdminModule
+ */
+ public $admin;
+
+ /**
+ * Blocks module.
+ *
+ * @var Blocks\BlocksModule
+ */
+ public $blocks_module;
+
+ /**
+ * Tracking module.
+ *
+ * @var TrackingModule
+ */
+ public $tracking_module;
+
+ /**
+ * Payments module.
+ *
+ * @var Payments\PaymentsModule
+ */
+ public $payments_module;
+
+ /**
+ * Subscriptions module.
+ *
+ * @var Subscriptions\SubscriptionsModule
+ */
+ public $subscriptions_module;
+
+ /**
+ * Gateway integrations.
+ *
+ * @var GatewayIntegrations
+ */
+ public $gateway_integrations;
+
+ /**
+ * Integrations
+ *
+ * @var AbstractIntegration[]
+ */
+ public $integrations;
+
+ /**
+ * Webhook logger.
+ *
+ * @var WebhookLogger
+ */
+ private $webhook_logger;
+
+ /**
+ * Options.
+ *
+ * @var array
+ */
+ private $options;
+
+ /**
+ * Plugin integrations.
+ *
+ * @var array
+ */
+ public $plugin_integrations;
+
+ /**
+ * Pronamic service URL.
+ *
+ * @var string|null
+ */
+ private static $pronamic_service_url;
+
+ /**
+ * Payment methods.
+ *
+ * @var PaymentMethodsCollection
+ */
+ private $payment_methods;
+
+ /**
+ * Construct and initialize an Pronamic Pay plugin object.
+ *
+ * @param string|array|object $args The plugin arguments.
+ */
+ public function __construct( $args = [] ) {
+ $args = wp_parse_args(
+ $args,
+ [
+ 'file' => null,
+ 'options' => [],
+ ]
+ );
+
+ // Version from plugin file header.
+ if ( null !== $args['file'] ) {
+ $file_data = get_file_data( $args['file'], [ 'Version' => 'Version' ] );
+
+ if ( \array_key_exists( 'Version', $file_data ) ) {
+ $this->version = $file_data['Version'];
+ }
+ }
+
+ // Backward compatibility.
+ self::$file = $args['file'];
+ self::$dirname = dirname( self::$file );
+
+ // Options.
+ $this->options = $args['options'];
+
+ // Integrations.
+ $this->integrations = [];
+
+ add_action( 'plugins_loaded', [ $this, 'plugins_loaded' ], 0 );
+
+ // Register styles.
+ add_action( 'init', [ $this, 'register_styles' ], 9 );
+
+ // If WordPress is loaded check on returns and maybe redirect requests.
+ add_action( 'wp_loaded', [ $this, 'handle_returns' ], 10 );
+ add_action( 'wp_loaded', [ $this, 'maybe_redirect' ], 10 );
+
+ // Default date time format.
+ add_filter( 'pronamic_datetime_default_format', [ $this, 'datetime_format' ], 10, 1 );
+
+ /**
+ * Pronamic service URL.
+ */
+ if ( \array_key_exists( 'pronamic_service_url', $args ) ) {
+ self::$pronamic_service_url = $args['pronamic_service_url'];
+ }
+
+ /**
+ * Action scheduler.
+ *
+ * @link https://actionscheduler.org/
+ */
+ if ( ! \array_key_exists( 'action_scheduler', $args ) ) {
+ $args['action_scheduler'] = self::$dirname . '/wp-content/plugins/action-scheduler/action-scheduler.php';
+ }
+
+ require_once $args['action_scheduler'];
+
+ /**
+ * Get Buy Now, Pay Later disclaimer.
+ *
+ * @link https://github.com/pronamic/pronamic-pay/issues/70
+ * @param string $provider Provider.
+ * @return string
+ */
+ /* translators: %s: provider */
+ $bnpl_disclaimer_template = \__( 'You must be at least 18+ to use this service. If you pay on time, you will avoid additional costs and ensure that you can use %s services again in the future. By continuing, you accept the Terms and Conditions and confirm that you have read the Privacy Statement and Cookie Statement.', 'pronamic_ideal' );
+
+ /**
+ * Payment methods.
+ */
+ $this->payment_methods = new PaymentMethodsCollection();
+
+ // AfterPay.nl.
+ $payment_method_afterpay_nl = new PaymentMethod( PaymentMethods::AFTERPAY_NL );
+
+ $payment_method_afterpay_nl->descriptions = [
+ /**
+ * AfterPay method description.
+ *
+ * @link https://www.afterpay.nl/en/customers/where-can-i-pay-with-afterpay
+ */
+ 'default' => \__( 'AfterPay is one of the largest and most popular post-payment system in the Benelux. Millions of Dutch and Belgians use AfterPay to pay for products.', 'pronamic_ideal' ),
+ ];
+
+ $payment_method_afterpay_nl->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/afterpay-nl/method-afterpay-nl-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_afterpay_nl );
+
+ // AfterPay.com.
+ $payment_method_afterpay_com = new PaymentMethod( PaymentMethods::AFTERPAY_COM );
+
+ $payment_method_afterpay_com->descriptions = [
+ /**
+ * Afterpay method description.
+ *
+ * @link https://en.wikipedia.org/wiki/Afterpay
+ * @link https://docs.adyen.com/payment-methods/afterpaytouch
+ */
+ 'default' => \__( 'Afterpay is a popular buy now, pay later service in Australia, New Zealand, the United States, and Canada.', 'pronamic_ideal' ),
+ ];
+
+ $payment_method_afterpay_com->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/afterpay-com/method-afterpay-com-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_afterpay_com );
+
+ // Alipay.
+ $payment_method_alipay = new PaymentMethod( PaymentMethods::ALIPAY );
+
+ $payment_method_alipay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/alipay/method-alipay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_alipay );
+
+ // American Express.
+ $payment_method_american_express = new PaymentMethod( PaymentMethods::AMERICAN_EXPRESS );
+
+ $payment_method_american_express->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/american-express/method-american-express-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_american_express );
+
+ // Apple Pay.
+ $payment_method_apple_pay = new PaymentMethod( PaymentMethods::APPLE_PAY );
+
+ $payment_method_apple_pay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/apple-pay/method-apple-pay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_apple_pay );
+
+ // Bancontact.
+ $payment_method_bancontact = new PaymentMethod( PaymentMethods::BANCONTACT );
+
+ $payment_method_bancontact->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/bancontact/method-bancontact-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_bancontact );
+
+ // Bank Transfer.
+ $payment_method_bank_transfer = new PaymentMethod( PaymentMethods::BANK_TRANSFER );
+
+ $payment_method_bank_transfer->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/bank-transfer/method-bank-transfer-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_bank_transfer );
+
+ // Belfius Direct Net.
+ $payment_method_belfius = new PaymentMethod( PaymentMethods::BELFIUS );
+
+ $payment_method_belfius->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/belfius/method-belfius-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_belfius );
+
+ // Billie.
+ $payment_method_billie = new PaymentMethod( PaymentMethods::BILLIE );
+
+ $payment_method_billie->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/billie/method-billie-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_billie );
+
+ // Billink.
+ $payment_method_billink = new PaymentMethod( PaymentMethods::BILLINK );
+
+ $payment_method_billie->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/billink/method-billink-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_billink );
+
+ // Bitcoin.
+ $payment_method_bitcoin = new PaymentMethod( PaymentMethods::BITCOIN );
+
+ $payment_method_bitcoin->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/bitcoin/method-bitcoin-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_bitcoin );
+
+ // BLIK.
+ $payment_method_blik = new PaymentMethod( PaymentMethods::BLIK );
+
+ $payment_method_blik->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/blik/method-blik-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_blik );
+
+ // Bunq.
+ $payment_method_bunq = new PaymentMethod( PaymentMethods::BUNQ );
+
+ $payment_method_bunq->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/bunq/method-bunq-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_bunq );
+
+ // In3.
+ $payment_method_in3 = new PaymentMethod( PaymentMethods::IN3 );
+
+ $payment_method_in3->descriptions = [
+ 'customer' => \sprintf( $bnpl_disclaimer_template, $payment_method_in3->name ),
+ ];
+
+ $payment_method_in3->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/in3/method-in3-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_in3 );
+
+ // Capayable.
+ $payment_method_capayable = new PaymentMethod( PaymentMethods::CAPAYABLE );
+
+ $payment_method_capayable->images = [];
+
+ $this->payment_methods->add( $payment_method_capayable );
+
+ // Card.
+ $payment_method_card = new PaymentMethod( PaymentMethods::CARD );
+
+ $payment_method_card->descriptions = [
+ 'default' => \__( 'The most popular payment method in the world. Offers customers a safe and trusted way to pay online. Customers can pay for their order quickly and easily with their card, without having to worry about their security. It is possible to charge a payment surcharge for card costs.', 'pronamic_ideal' ),
+ ];
+
+ $payment_method_card->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/credit-card/method-credit-card-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_card );
+
+ // Credit card.
+ $payment_method_credit_card = new PaymentMethod( PaymentMethods::CREDIT_CARD );
+
+ $payment_method_credit_card->descriptions = [
+ 'default' => \__( 'The most popular payment method in the world. Offers customers a safe and trusted way to pay online. Customers can pay for their order quickly and easily with their credit card, without having to worry about their security. It is possible to charge a payment surcharge for credit card costs.', 'pronamic_ideal' ),
+ ];
+
+ $payment_method_credit_card->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/credit-card/method-credit-card-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_credit_card );
+
+ // Direct debit.
+ $payment_method_direct_debit = new PaymentMethod( PaymentMethods::DIRECT_DEBIT );
+
+ $payment_method_direct_debit->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/direct-debit/method-direct-debit-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_direct_debit );
+
+ /* translators: %s: payment method */
+ $description_template = \__( 'By using this payment method you authorize us via %s to debit payments from your bank account.', 'pronamic_ideal' );
+
+ // Direct debit (mandate via Bancontact).
+ $payment_method_direct_debit_bancontact = new PaymentMethod( PaymentMethods::DIRECT_DEBIT_BANCONTACT );
+
+ $payment_method_direct_debit_bancontact->descriptions = [
+ 'customer' => \sprintf( $description_template, $payment_method_direct_debit_bancontact->name ),
+ ];
+
+ $payment_method_direct_debit_bancontact->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/direct-debit-bancontact/method-direct-debit-bancontact-wc-107x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_direct_debit_bancontact );
+
+ // Direct debit (mandate via Bancontact).
+ $payment_method_direct_debit_ideal = new PaymentMethod( PaymentMethods::DIRECT_DEBIT_IDEAL );
+
+ $payment_method_direct_debit_ideal->descriptions = [
+ 'customer' => \sprintf( $description_template, $payment_method_direct_debit_ideal->name ),
+ ];
+
+ $payment_method_direct_debit_ideal->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/direct-debit-ideal/method-direct-debit-ideal-wc-107x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_direct_debit_ideal );
+
+ // Direct debit (mandate via SOFORT).
+ $payment_method_direct_debit_sofort = new PaymentMethod( PaymentMethods::DIRECT_DEBIT_SOFORT );
+
+ $payment_method_direct_debit_sofort->descriptions = [
+ 'customer' => \sprintf( $description_template, $payment_method_direct_debit_sofort->name ),
+ ];
+
+ $payment_method_direct_debit_sofort->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/direct-debit-sofort/method-direct-debit-sofort-wc-107x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_direct_debit_sofort );
+
+ // EPS.
+ $payment_method_eps = new PaymentMethod( PaymentMethods::EPS );
+
+ $payment_method_eps->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/eps/method-eps-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_eps );
+
+ // Focum.
+ $payment_method_focum = new PaymentMethod( PaymentMethods::FOCUM );
+
+ $payment_method_eps->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/focum/method-focum-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_focum );
+
+ // IDEAL.
+ $payment_method_ideal = new PaymentMethod( PaymentMethods::IDEAL );
+
+ $payment_method_ideal->descriptions = [
+ 'customer' => \__( 'With iDEAL you can easily pay online in the secure environment of your own bank.', 'pronamic_ideal' ),
+ ];
+
+ $payment_method_ideal->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/ideal/method-ideal-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_ideal );
+
+ // IDEAL QR.
+ $payment_method_ideal_qr = new PaymentMethod( PaymentMethods::IDEALQR );
+
+ $payment_method_ideal_qr->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/ideal-qr/method-ideal-qr-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_ideal_qr );
+
+ // Giropay.
+ $payment_method_giropay = new PaymentMethod( PaymentMethods::GIROPAY );
+
+ $payment_method_giropay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/giropay/method-giropay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_giropay );
+
+ // Google Pay.
+ $payment_method_google_pay = new PaymentMethod( PaymentMethods::GOOGLE_PAY );
+
+ $payment_method_google_pay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/google-pay/method-google-pay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_google_pay );
+
+ // KBC/CBC Payment Button.
+ $payment_method_kbc = new PaymentMethod( PaymentMethods::KBC );
+
+ $payment_method_kbc->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/kbc/method-kbc-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_kbc );
+
+ // Klarna Pay Later.
+ $payment_method_klarna_pay_later = new PaymentMethod( PaymentMethods::KLARNA_PAY_LATER );
+
+ $payment_method_klarna_pay_later->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/klarna-pay-later/method-klarna-pay-later-wc-51x32.svg',
+ ];
+
+ $payment_method_klarna_pay_later->descriptions = [
+ 'customer' => \sprintf( $bnpl_disclaimer_template, $payment_method_klarna_pay_later->name ),
+ ];
+
+ $this->payment_methods->add( $payment_method_klarna_pay_later );
+
+ // Klarna Pay Now.
+ $payment_method_klarna_pay_now = new PaymentMethod( PaymentMethods::KLARNA_PAY_NOW );
+
+ $payment_method_klarna_pay_now->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/klarna-pay-now/method-klarna-pay-now-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_klarna_pay_now );
+
+ // Klarna Pay Over Time.
+ $payment_method_klarna_pay_over_time = new PaymentMethod( PaymentMethods::KLARNA_PAY_OVER_TIME );
+
+ $payment_method_klarna_pay_over_time->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/klarna-pay-over-time/method-klarna-pay-over-time-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_klarna_pay_over_time );
+
+ // Maestro.
+ $payment_method_maestro = new PaymentMethod( PaymentMethods::MAESTRO );
+
+ $payment_method_maestro->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/maestro/method-maestro-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_maestro );
+
+ // Mastercard.
+ $payment_method_mastercard = new PaymentMethod( PaymentMethods::MASTERCARD );
+
+ $payment_method_mastercard->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/mastercard/method-mastercard-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_mastercard );
+
+ // MB WAY.
+ $payment_method_mb_way = new PaymentMethod( PaymentMethods::MB_WAY );
+
+ $payment_method_mb_way->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/mb-way/method-mb-way-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_mb_way );
+
+ // MyBank.
+ $payment_method_mybank = new PaymentMethod( PaymentMethods::MYBANK );
+
+ $this->payment_methods->add( $payment_method_mybank );
+
+ // Payconiq.
+ $payment_method_payconiq = new PaymentMethod( PaymentMethods::PAYCONIQ );
+
+ $payment_method_payconiq->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/payconiq/method-payconiq-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_payconiq );
+
+ // PayPal.
+ $payment_method_paypal = new PaymentMethod( PaymentMethods::PAYPAL );
+
+ $payment_method_paypal->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/paypal/method-paypal-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_paypal );
+
+ // Przelewy24.
+ $payment_method_przelewy24 = new PaymentMethod( PaymentMethods::PRZELEWY24 );
+
+ $payment_method_przelewy24->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/przelewy24/method-przelewy24-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_przelewy24 );
+
+ // Riverty.
+ $payment_method_riverty = new PaymentMethod( PaymentMethods::RIVERTY );
+
+ $payment_method_riverty->descriptions = [
+ 'default' => \__( 'Riverty (formerly AfterPay) is a payment service that allows customers to pay after receiving the product.', 'pronamic_ideal' ),
+ 'customer' => \sprintf( $bnpl_disclaimer_template, $payment_method_riverty->name ),
+ ];
+
+ $payment_method_riverty->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/riverty/method-riverty-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_riverty );
+
+ // Santander.
+ $payment_method_santander = new PaymentMethod( PaymentMethods::SANTANDER );
+
+ $payment_method_santander->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/santander/method-santander-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_santander );
+
+ // SOFORT Banking.
+ $payment_method_sofort = new PaymentMethod( PaymentMethods::SOFORT );
+
+ $payment_method_sofort->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/sofort/method-sofort-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_sofort );
+
+ // SprayPay.
+ $payment_method_spraypay = new PaymentMethod( PaymentMethods::SPRAYPAY );
+
+ $payment_method_spraypay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/spraypay/method-spraypay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_spraypay );
+
+ // Swish.
+ $payment_method_swish = new PaymentMethod( PaymentMethods::SWISH );
+
+ $payment_method_swish->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/swish/method-swish-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_swish );
+
+ // TWINT.
+ $payment_method_twint = new PaymentMethod( PaymentMethods::TWINT );
+
+ $payment_method_twint->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/twint/method-twint-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_twint );
+
+ // V PAY.
+ $payment_method_v_pay = new PaymentMethod( PaymentMethods::V_PAY );
+
+ $payment_method_v_pay->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/v-pay/method-v-pay-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_v_pay );
+
+ // Vipps.
+ $payment_method_vipps = new PaymentMethod( PaymentMethods::VIPPS );
+
+ $payment_method_vipps->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/vipps/method-vipps-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_vipps );
+
+ // Visa.
+ $payment_method_visa = new PaymentMethod( PaymentMethods::VISA );
+
+ $payment_method_visa->images = [
+ 'woocommerce' => __DIR__ . '/../images/dist/methods/visa/method-visa-wc-51x32.svg',
+ ];
+
+ $this->payment_methods->add( $payment_method_visa );
+ }
+
+ /**
+ * Get payment methods.
+ *
+ * @param array $args Query arguments.
+ * @return PaymentMethodsCollection
+ */
+ public function get_payment_methods( $args = [] ) {
+ return $this->payment_methods->query( $args );
+ }
+
+ /**
+ * Get the version number of this plugin.
+ *
+ * @return string The version number of this plugin.
+ */
+ public function get_version() {
+ return $this->version;
+ }
+
+ /**
+ * Get plugin file path.
+ *
+ * @return string
+ */
+ public function get_file() {
+ return self::$file;
+ }
+
+ /**
+ * Get option.
+ *
+ * @param string $option Name of option to retrieve.
+ * @return string|null
+ */
+ public function get_option( $option ) {
+ if ( array_key_exists( $option, $this->options ) ) {
+ return $this->options[ $option ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the plugin dir path.
+ *
+ * @return string
+ */
+ public function get_plugin_dir_path() {
+ return plugin_dir_path( $this->get_file() );
+ }
+
+ /**
+ * Update payment.
+ *
+ * @param Payment $payment The payment to update.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the payment update.
+ * @return void
+ */
+ public static function update_payment( $payment = null, $can_redirect = true ) {
+ if ( empty( $payment ) ) {
+ return;
+ }
+
+ // Gateway.
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ return;
+ }
+
+ // Update status.
+ try {
+ $gateway->update_status( $payment );
+
+ // Update payment in data store.
+ $payment->save();
+ } catch ( \Exception $error ) {
+ $message = $error->getMessage();
+
+ // Maybe include error code in message.
+ $code = $error->getCode();
+
+ if ( $code > 0 ) {
+ $message = \sprintf( '%s: %s', $code, $message );
+ }
+
+ // Add note.
+ $payment->add_note( $message );
+ }
+
+ // Maybe redirect.
+ if ( ! $can_redirect ) {
+ return;
+ }
+
+ /*
+ * If WordPress is doing cron we can't redirect.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-ideal/commit/bb967a3e7804ecfbd83dea110eb8810cbad097d7
+ * @link https://github.com/pronamic/wp-pronamic-ideal/commit/3ab4a7c1fc2cef0b6f565f8205da42aa1203c3c5
+ */
+ if ( \wp_doing_cron() ) {
+ return;
+ }
+
+ /*
+ * If WordPress CLI is running we can't redirect.
+ *
+ * @link https://basecamp.com/1810084/projects/10966871/todos/346407847
+ * @link https://github.com/woocommerce/woocommerce/blob/3.5.3/includes/class-woocommerce.php#L381-L383
+ */
+ if ( defined( 'WP_CLI' ) && WP_CLI ) {
+ return;
+ }
+
+ // Redirect.
+ $url = $payment->get_return_redirect_url();
+
+ wp_redirect( $url );
+
+ exit;
+ }
+
+ /**
+ * Handle returns.
+ *
+ * @return void
+ */
+ public function handle_returns() {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ if (
+ ! \array_key_exists( 'payment', $_GET )
+ ||
+ ! \array_key_exists( 'key', $_GET )
+ ) {
+ return;
+ }
+
+ $payment_id = (int) $_GET['payment'];
+
+ $payment = get_pronamic_payment( $payment_id );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ // Check if payment key is valid.
+ $key = \sanitize_text_field( \wp_unslash( $_GET['key'] ) );
+
+ if ( $key !== $payment->key ) {
+ wp_safe_redirect( home_url() );
+
+ exit;
+ }
+
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ // Check if we should redirect.
+ $should_redirect = true;
+
+ /**
+ * Filter whether or not to allow redirects on payment return.
+ *
+ * @param bool $should_redirect Flag to indicate if redirect is allowed on handling payment return.
+ * @param Payment $payment Payment.
+ */
+ $should_redirect = apply_filters( 'pronamic_pay_return_should_redirect', $should_redirect, $payment );
+
+ try {
+ self::update_payment( $payment, $should_redirect );
+ } catch ( \Exception $e ) {
+ self::render_exception( $e );
+
+ exit;
+ }
+ }
+
+ /**
+ * Maybe redirect.
+ *
+ * @return void
+ */
+ public function maybe_redirect() {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ if ( ! \array_key_exists( 'payment_redirect', $_GET ) || ! \array_key_exists( 'key', $_GET ) ) {
+ return;
+ }
+
+ // Get payment.
+ $payment_id = (int) $_GET['payment_redirect'];
+
+ $payment = get_pronamic_payment( $payment_id );
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ // Validate key.
+ $key = \sanitize_text_field( \wp_unslash( $_GET['key'] ) );
+
+ if ( $key !== $payment->key || empty( $payment->key ) ) {
+ return;
+ }
+
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+
+ Core_Util::no_cache();
+
+ $gateway = $payment->get_gateway();
+
+ if ( null !== $gateway ) {
+ // Give gateway a chance to handle redirect.
+ $gateway->payment_redirect( $payment );
+
+ // Handle HTML form redirect.
+ if ( $gateway->is_html_form() ) {
+ $gateway->redirect( $payment );
+ }
+ }
+
+ // Redirect to payment action URL.
+ $action_url = $payment->get_action_url();
+
+ if ( ! empty( $action_url ) ) {
+ wp_redirect( $action_url );
+
+ exit;
+ }
+ }
+
+ /**
+ * Get number payments.
+ *
+ * @link https://developer.wordpress.org/reference/functions/wp_count_posts/
+ *
+ * @return int|false
+ */
+ public static function get_number_payments() {
+ $number = false;
+
+ $count = wp_count_posts( 'pronamic_payment' );
+
+ if ( isset( $count->payment_completed ) ) {
+ $number = intval( $count->payment_completed );
+ }
+
+ return $number;
+ }
+
+ /**
+ * Plugins loaded.
+ *
+ * @link https://developer.wordpress.org/reference/hooks/plugins_loaded/
+ * @return void
+ */
+ public function plugins_loaded() {
+ // Settings.
+ $this->settings = new Settings( $this );
+
+ // Data Stores.
+ $this->gateways_data_store = new GatewaysDataStoreCPT();
+ $this->payments_data_store = new PaymentsDataStoreCPT();
+ $this->subscriptions_data_store = new SubscriptionsDataStoreCPT();
+
+ // Post Types.
+ $this->gateway_post_type = new GatewayPostType();
+ $this->payment_post_type = new PaymentPostType();
+ $this->subscription_post_type = new SubscriptionPostType();
+
+ // Privacy Manager.
+ $this->privacy_manager = new PrivacyManager();
+
+ // Webhook Logger.
+ $this->webhook_logger = new WebhookLogger();
+ $this->webhook_logger->setup();
+
+ // Modules.
+ $this->payments_module = new Payments\PaymentsModule( $this );
+ $this->subscriptions_module = new Subscriptions\SubscriptionsModule( $this );
+ $this->tracking_module = new TrackingModule();
+
+ // Blocks module.
+ if ( function_exists( 'register_block_type' ) ) {
+ $this->blocks_module = new Blocks\BlocksModule();
+ $this->blocks_module->setup();
+ }
+
+ // Admin.
+ if ( is_admin() ) {
+ $this->admin = new Admin\AdminModule( $this );
+ }
+
+ new Admin\Install( $this );
+
+ $controllers = [
+ new PagesController(),
+ new HomeUrlController(),
+ new ActionSchedulerController(),
+ ];
+
+ foreach ( $controllers as $controller ) {
+ $controller->setup();
+ }
+
+ $gateways = [];
+
+ /**
+ * Filters the gateway integrations.
+ *
+ * @param AbstractGatewayIntegration[] $gateways Gateway integrations.
+ */
+ $gateways = apply_filters( 'pronamic_pay_gateways', $gateways );
+
+ $this->gateway_integrations = new GatewayIntegrations( $gateways );
+
+ foreach ( $this->gateway_integrations as $integration ) {
+ $integration->setup();
+ }
+
+ $plugin_integrations = [];
+
+ /**
+ * Filters the plugin integrations.
+ *
+ * @param AbstractPluginIntegration[] $plugin_integrations Plugin integrations.
+ */
+ $this->plugin_integrations = apply_filters( 'pronamic_pay_plugin_integrations', $plugin_integrations );
+
+ foreach ( $this->plugin_integrations as $integration ) {
+ $integration->setup();
+ }
+
+ // Integrations.
+ $gateway_integrations = \iterator_to_array( $this->gateway_integrations );
+
+ $this->integrations = array_merge( $gateway_integrations, $this->plugin_integrations );
+
+ // Maybes.
+ PaymentMethods::maybe_update_active_payment_methods();
+
+ // Filters.
+ \add_filter( 'pronamic_payment_redirect_url', [ $this, 'payment_redirect_url' ], 10, 2 );
+
+ // Actions.
+ \add_action( 'pronamic_pay_pre_create_payment', [ __CLASS__, 'complement_payment' ], 10, 1 );
+ }
+
+ /**
+ * Default date time format.
+ *
+ * @param string $format Format.
+ *
+ * @return string
+ */
+ public function datetime_format( $format ) {
+ $format = _x( 'D j M Y \a\t H:i', 'default datetime format', 'pronamic_ideal' );
+
+ return $format;
+ }
+
+ /**
+ * Get default error message.
+ *
+ * @return string
+ */
+ public static function get_default_error_message() {
+ return __( 'Something went wrong with the payment. Please try again or pay another way.', 'pronamic_ideal' );
+ }
+
+ /**
+ * Register styles.
+ *
+ * @since 2.1.6
+ * @return void
+ */
+ public function register_styles() {
+ $min = \SCRIPT_DEBUG ? '' : '.min';
+
+ \wp_register_style(
+ 'pronamic-pay-redirect',
+ \plugins_url( 'css/redirect' . $min . '.css', __DIR__ ),
+ [],
+ $this->get_version()
+ );
+ }
+
+ /**
+ * Get config select options.
+ *
+ * @param null|string $payment_method The gateway configuration options for the specified payment method.
+ *
+ * @return array
+ */
+ public static function get_config_select_options( $payment_method = null ) {
+ $args = [
+ 'post_type' => 'pronamic_gateway',
+ 'orderby' => 'post_title',
+ 'order' => 'ASC',
+ 'nopaging' => true,
+ ];
+
+ if ( null !== $payment_method ) {
+ $config_ids = PaymentMethods::get_config_ids( $payment_method );
+
+ $args['post__in'] = empty( $config_ids ) ? [ 0 ] : $config_ids;
+ }
+
+ $query = new WP_Query( $args );
+
+ $options = [ __( '— Select Configuration —', 'pronamic_ideal' ) ];
+
+ foreach ( $query->posts as $post ) {
+ if ( ! \is_object( $post ) ) {
+ continue;
+ }
+
+ $id = $post->ID;
+
+ $options[ $id ] = \get_the_title( $id );
+ }
+
+ return $options;
+ }
+
+ /**
+ * Render exception.
+ *
+ * @param \Exception $exception An exception.
+ * @return void
+ */
+ public static function render_exception( // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Parameter is used in include.
+ \Exception $exception
+ ) {
+ include __DIR__ . '/../views/exception.php';
+ }
+
+ /**
+ * Get gateway.
+ *
+ * @link https://wordpress.org/support/article/post-status/#default-statuses
+ *
+ * @param int $config_id A gateway configuration ID.
+ * @param array $args Extra arguments.
+ *
+ * @return null|Gateway
+ */
+ public static function get_gateway( $config_id, $args = [] ) {
+ // Get gateway from data store.
+ $gateway = \pronamic_pay_plugin()->gateways_data_store->get_gateway( $config_id );
+
+ // Use gateway identifier from arguments to get new gateway.
+ if ( null === $gateway && ! empty( $args ) ) {
+ // Get integration.
+ $args = wp_parse_args(
+ $args,
+ [
+ 'gateway_id' => \get_post_meta( $config_id, '_pronamic_gateway_id', true ),
+ ]
+ );
+
+ $integration = pronamic_pay_plugin()->gateway_integrations->get_integration( $args['gateway_id'] );
+
+ // Get new gateway.
+ if ( null !== $integration ) {
+ $gateway = $integration->get_gateway( $config_id );
+ }
+ }
+
+ return $gateway;
+ }
+
+ /**
+ * Complement payment.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public static function complement_payment( Payment $payment ) {
+ // Key.
+ if ( null === $payment->key ) {
+ $payment->key = uniqid( 'pay_' );
+ }
+
+ $origin_id = $payment->get_origin_id();
+
+ if ( null === $origin_id ) {
+ // Queried object.
+ $queried_object = \get_queried_object();
+ $queried_object_id = \get_queried_object_id();
+
+ if ( null !== $queried_object && $queried_object_id > 0 ) {
+ $origin_id = $queried_object_id;
+ }
+
+ // Referer.
+ $referer = \wp_get_referer();
+
+ if ( null === $origin_id && false !== $referer ) {
+ $referer_host = \wp_parse_url( $referer, \PHP_URL_HOST );
+
+ if ( null === $referer_host ) {
+ $referer = \home_url( $referer );
+ }
+
+ $post_id = \url_to_postid( $referer );
+
+ if ( $post_id > 0 ) {
+ $origin_id = $post_id;
+ }
+ }
+
+ // Set origin ID.
+ $payment->set_origin_id( $origin_id );
+ }
+
+ // Customer.
+ $customer = $payment->get_customer();
+
+ if ( null === $customer ) {
+ $customer = new Customer();
+
+ $payment->set_customer( $customer );
+ }
+
+ CustomerHelper::complement_customer( $customer );
+
+ // Billing address.
+ $billing_address = $payment->get_billing_address();
+
+ if ( null !== $billing_address ) {
+ AddressHelper::complement_address( $billing_address );
+ }
+
+ // Shipping address.
+ $shipping_address = $payment->get_shipping_address();
+
+ if ( null !== $shipping_address ) {
+ AddressHelper::complement_address( $shipping_address );
+ }
+
+ // Version.
+ if ( null === $payment->get_version() ) {
+ $payment->set_version( pronamic_pay_plugin()->get_version() );
+ }
+
+ // Post data.
+ self::process_payment_post_data( $payment );
+
+ // Gender.
+ if ( null !== $customer->get_gender() ) {
+ $payment->delete_meta( 'gender' );
+ }
+
+ // Date of birth.
+ if ( null !== $customer->get_birth_date() ) {
+ $payment->delete_meta( 'birth_date' );
+ }
+
+ /**
+ * If an issuer has been specified and the payment
+ * method is unknown, we set the payment method to
+ * iDEAL. This may not be correct in all cases,
+ * but for now Pronamic Pay works this way.
+ *
+ * @link https://github.com/wp-pay-extensions/gravityforms/blob/2.4.0/src/Processor.php#L251-L256
+ * @link https://github.com/wp-pay-extensions/contact-form-7/blob/1.0.0/src/Pronamic.php#L181-L187
+ * @link https://github.com/wp-pay-extensions/formidable-forms/blob/2.1.0/src/Extension.php#L318-L329
+ * @link https://github.com/wp-pay-extensions/ninjaforms/blob/1.2.0/src/PaymentGateway.php#L80-L83
+ * @link https://github.com/wp-pay/core/blob/2.4.0/src/Forms/FormProcessor.php#L131-L134
+ */
+ $issuer = $payment->get_meta( 'issuer' );
+
+ $payment_method = $payment->get_payment_method();
+
+ if ( null !== $issuer && null === $payment_method ) {
+ $payment->set_payment_method( PaymentMethods::IDEAL );
+ }
+
+ // Consumer bank details.
+ $consumer_bank_details_name = $payment->get_meta( 'consumer_bank_details_name' );
+ $consumer_bank_details_iban = $payment->get_meta( 'consumer_bank_details_iban' );
+
+ if ( null !== $consumer_bank_details_name || null !== $consumer_bank_details_iban ) {
+ $consumer_bank_details = $payment->get_consumer_bank_details();
+
+ if ( null === $consumer_bank_details ) {
+ $consumer_bank_details = new BankAccountDetails();
+ }
+
+ if ( null === $consumer_bank_details->get_name() ) {
+ $consumer_bank_details->set_name( $consumer_bank_details_name );
+ }
+
+ if ( null === $consumer_bank_details->get_iban() ) {
+ $consumer_bank_details->set_iban( $consumer_bank_details_iban );
+ }
+
+ $payment->set_consumer_bank_details( $consumer_bank_details );
+ }
+
+ // Payment lines payment.
+ $lines = $payment->get_lines();
+
+ if ( null !== $lines ) {
+ foreach ( $lines as $line ) {
+ $line->set_payment( $payment );
+ }
+ }
+ }
+
+ /**
+ * Process payment input data.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ private static function process_payment_post_data( Payment $payment ) {
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ return;
+ }
+
+ $payment_method = $payment->get_payment_method();
+
+ if ( null === $payment_method ) {
+ return;
+ }
+
+ $payment_method = $gateway->get_payment_method( $payment_method );
+
+ if ( null === $payment_method ) {
+ return;
+ }
+
+ foreach ( $payment_method->get_fields() as $field ) {
+ $id = $field->get_id();
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ if ( \array_key_exists( $id, $_POST ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing
+ $value = \sanitize_text_field( \wp_unslash( $_POST[ $id ] ) );
+
+ if ( '' !== $field->meta_key ) {
+ $payment->set_meta( $field->meta_key, $value );
+ }
+ }
+ }
+ }
+
+ /**
+ * Get default gateway configuration ID.
+ *
+ * @return int|null
+ */
+ private static function get_default_config_id() {
+ $value = (int) \get_option( 'pronamic_pay_config_id' );
+
+ if ( 0 === $value ) {
+ return null;
+ }
+
+ if ( 'publish' !== \get_post_status( $value ) ) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Start payment.
+ *
+ * @param Payment $payment The payment to start at the specified gateway.
+ * @return Payment
+ * @throws \Exception Throws exception if gateway payment start fails.
+ */
+ public static function start_payment( Payment $payment ) {
+ // Set default or filtered config ID.
+ $config_id = $payment->get_config_id();
+
+ if ( null === $config_id ) {
+ $config_id = self::get_default_config_id();
+ }
+
+ /**
+ * Filters the payment gateway configuration ID.
+ *
+ * @param null|int $config_id Gateway configuration ID.
+ * @param Payment $payment Payment.
+ */
+ $config_id = \apply_filters( 'pronamic_payment_gateway_configuration_id', $config_id, $payment );
+
+ if ( null !== $config_id ) {
+ $payment->set_config_id( $config_id );
+ }
+
+ /**
+ * Merge tags.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/358
+ * @link https://github.com/pronamic/wp-pronamic-pay-woocommerce/issues/43
+ */
+ $payment->set_description( $payment->format_string( (string) $payment->get_description() ) );
+
+ // Save payment.
+ $payment->save();
+
+ // Periods.
+ $periods = $payment->get_periods();
+
+ if ( null !== $periods ) {
+ foreach ( $periods as $period ) {
+ $subscription = $period->get_phase()->get_subscription();
+
+ $subscription->set_next_payment_date( \max( $subscription->get_next_payment_date(), $period->get_end_date() ) );
+ }
+ }
+
+ // Subscriptions.
+ $subscriptions = $payment->get_subscriptions();
+
+ foreach ( $subscriptions as $subscription ) {
+ $subscription->save();
+ }
+
+ // Gateway.
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ $payment->add_note(
+ \sprintf(
+ /* translators: %d: Gateway configuration ID */
+ \__( 'Payment failed because gateway configuration with ID `%d` does not exist.', 'pronamic_ideal' ),
+ $config_id
+ )
+ );
+
+ $payment->set_status( PaymentStatus::FAILURE );
+
+ $payment->save();
+
+ return $payment;
+ }
+
+ // Mode.
+ $payment->set_mode( $gateway->get_mode() );
+
+ // Subscriptions.
+ $subscriptions = $payment->get_subscriptions();
+
+ // Start payment at the gateway.
+ try {
+ self::pronamic_service( $payment );
+
+ $gateway->start( $payment );
+ } catch ( \Exception $exception ) {
+ $message = $exception->getMessage();
+
+ // Maybe include error code in message.
+ $code = $exception->getCode();
+
+ if ( $code > 0 ) {
+ $message = \sprintf( '%s: %s', $code, $message );
+ }
+
+ $payment->add_note( $message );
+
+ $payment->set_status( PaymentStatus::FAILURE );
+
+ throw $exception;
+ } finally {
+ $payment->save();
+ }
+
+ // Schedule payment status check.
+ if ( $gateway->supports( 'payment_status_request' ) ) {
+ StatusChecker::schedule_event( $payment );
+ }
+
+ return $payment;
+ }
+
+ /**
+ * The Pronamic Pay service forms an abstraction layer for the various supported
+ * WordPress plugins and Payment Service Providers (PSP. Optionally, a risk analysis
+ * can be performed before payment.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ private static function pronamic_service( Payment $payment ) {
+ if ( null === self::$pronamic_service_url ) {
+ return;
+ }
+
+ try {
+ $body = [
+ 'license' => \get_option( 'pronamic_pay_license_key' ),
+ 'payment' => \wp_json_encode( $payment->get_json() ),
+ ];
+
+ $map = [
+ 'query' => 'GET',
+ 'body' => 'POST',
+ 'server' => 'SERVER',
+ ];
+
+ foreach ( $map as $parameter => $key ) {
+ $name = '_' . $key;
+
+ $body[ $parameter ] = $GLOBALS[ $name ];
+ }
+
+ $response = Http::post(
+ self::$pronamic_service_url,
+ [
+ 'body' => $body,
+ ]
+ );
+
+ $data = $response->json();
+
+ if ( ! \is_object( $data ) ) {
+ return;
+ }
+
+ if ( \property_exists( $data, 'id' ) ) {
+ $payment->set_meta( 'pronamic_pay_service_id', $data->id );
+ }
+
+ if ( \property_exists( $data, 'risk_score' ) ) {
+ $payment->set_meta( 'pronamic_pay_risk_score', $data->risk_score );
+ }
+ } catch ( \Exception $e ) {
+ return;
+ }
+ }
+
+ /**
+ * Create refund.
+ *
+ * @param Refund $refund Refund.
+ * @return void
+ * @throws \Exception Throws exception on error.
+ */
+ public static function create_refund( Refund $refund ) {
+ $payment = $refund->get_payment();
+
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ throw new \Exception(
+ \esc_html__( 'Unable to process refund as gateway could not be found.', 'pronamic_ideal' )
+ );
+ }
+
+ try {
+ $gateway->create_refund( $refund );
+
+ $payment->refunds[] = $refund;
+
+ $refunded_amount = $payment->get_refunded_amount();
+
+ $refunded_amount = $refunded_amount->add( $refund->get_amount() );
+
+ $payment->set_refunded_amount( $refunded_amount );
+ } catch ( \Exception $exception ) {
+ $payment->add_note( $exception->getMessage() );
+
+ throw $exception;
+ } finally {
+ $payment->save();
+ }
+ }
+
+ /**
+ * Payment redirect URL.
+ *
+ * @param string $url Redirect URL.
+ * @param Payment $payment Payment.
+ * @return string
+ */
+ public function payment_redirect_url( $url, Payment $payment ) {
+ $source = $payment->get_source();
+
+ /**
+ * Filters the payment redirect URL by plugin integration source.
+ *
+ * @param string $url Redirect URL.
+ * @param Payment $payment Payment.
+ */
+ $url = \apply_filters( 'pronamic_payment_redirect_url_' . $source, $url, $payment );
+
+ return $url;
+ }
+
+ /**
+ * Is debug mode.
+ *
+ * @link https://github.com/easydigitaldownloads/easy-digital-downloads/blob/2.9.26/includes/misc-functions.php#L26-L38
+ * @return bool True if debug mode is enabled, false otherwise.
+ */
+ public function is_debug_mode() {
+ $value = \get_option( 'pronamic_pay_debug_mode', false );
+
+ if ( defined( 'PRONAMIC_PAY_DEBUG' ) && PRONAMIC_PAY_DEBUG ) {
+ $value = true;
+ }
+
+ return (bool) $value;
+ }
+}
diff --git a/packages/wp-pay/core/src/Privacy/AnonymizedTrait.php b/packages/wp-pay/core/src/Privacy/AnonymizedTrait.php
new file mode 100644
index 0000000..b094852
--- /dev/null
+++ b/packages/wp-pay/core/src/Privacy/AnonymizedTrait.php
@@ -0,0 +1,46 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Privacy;
+
+/**
+ * Anonymized Trait
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ */
+trait AnonymizedTrait {
+ /**
+ * Is anonymized.
+ *
+ * @var bool|null
+ */
+ private $anonymized;
+
+ /**
+ * Is anonymized?
+ *
+ * @return bool
+ */
+ public function is_anonymized() {
+ return ( true === $this->anonymized );
+ }
+
+ /**
+ * Set anonymized.
+ *
+ * @param bool|null $anonymized Anonymized.
+ * @return void
+ */
+ public function set_anonymized( $anonymized ) {
+ $this->anonymized = $anonymized;
+ }
+}
diff --git a/packages/wp-pay/core/src/PrivacyManager.php b/packages/wp-pay/core/src/PrivacyManager.php
new file mode 100644
index 0000000..f3d8f3a
--- /dev/null
+++ b/packages/wp-pay/core/src/PrivacyManager.php
@@ -0,0 +1,306 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Exception;
+
+/**
+ * Class PrivacyManager
+ *
+ * @version 2.0.5
+ */
+class PrivacyManager {
+ /**
+ * Exporters.
+ *
+ * @var array
+ */
+ private $exporters = [];
+
+ /**
+ * Erasers.
+ *
+ * @var array
+ */
+ private $erasers = [];
+
+ /**
+ * Privacy manager constructor.
+ */
+ public function __construct() {
+ // Filters.
+ add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'register_exporters' ], 10 );
+ add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'register_erasers' ], 10 );
+ add_filter( 'wp_privacy_anonymize_data', [ $this, 'anonymize_custom_data_types' ], 10, 3 );
+ }
+
+ /**
+ * Register exporters.
+ *
+ * @param array $exporters Privacy exporters.
+ * @return array
+ */
+ public function register_exporters( $exporters ) {
+ $privacy_manager = $this;
+
+ /**
+ * Register privacy exporters.
+ *
+ * @param PrivacyManager $privacy_manager Privacy manager.
+ */
+ do_action( 'pronamic_pay_privacy_register_exporters', $privacy_manager );
+
+ foreach ( $this->exporters as $id => $exporter ) {
+ $exporters[ $id ] = $exporter;
+ }
+
+ return $exporters;
+ }
+
+ /**
+ * Register erasers.
+ *
+ * @param array $erasers Privacy erasers.
+ * @return array
+ */
+ public function register_erasers( $erasers ) {
+ $privacy_manager = $this;
+
+ /**
+ * Register privacy erasers.
+ *
+ * @param PrivacyManager $privacy_manager Privacy manager.
+ */
+ do_action( 'pronamic_pay_privacy_register_erasers', $privacy_manager );
+
+ foreach ( $this->erasers as $id => $eraser ) {
+ $erasers[ $id ] = $eraser;
+ }
+
+ return $erasers;
+ }
+
+ /**
+ * Add exporter.
+ *
+ * @param string $id ID of the exporter.
+ * @param string $name Exporter name.
+ * @param array $callback Exporter callback.
+ * @return void
+ */
+ public function add_exporter( $id, $name, $callback ) {
+ $id = 'pronamic-pay-' . $id;
+
+ $this->exporters[ $id ] = [
+ 'exporter_friendly_name' => $name,
+ 'callback' => $callback,
+ ];
+ }
+
+ /**
+ * Add eraser.
+ *
+ * @param string $id ID of the eraser.
+ * @param string $name Eraser name.
+ * @param array $callback Eraser callback.
+ * @return void
+ */
+ public function add_eraser( $id, $name, $callback ) {
+ $id = 'pronamic-pay-' . $id;
+
+ $this->erasers[ $id ] = [
+ 'eraser_friendly_name' => $name,
+ 'callback' => $callback,
+ ];
+ }
+
+ /**
+ * Export meta.
+ *
+ * @param string $meta_key Meta key.
+ * @param array $meta_options Registered meta options.
+ * @param array $meta_values Array with all post meta for item.
+ *
+ * @return array
+ */
+ public function export_meta( $meta_key, $meta_options, $meta_values ) {
+ // Label.
+ $label = $meta_key;
+
+ if ( isset( $meta_options['label'] ) ) {
+ $label = $meta_options['label'];
+ }
+
+ // Meta value.
+ $meta_value = $meta_values[ $meta_key ];
+
+ if ( 1 === count( $meta_value ) ) {
+ $meta_value = array_shift( $meta_value );
+ } else {
+ $meta_value = wp_json_encode( $meta_value );
+ }
+
+ // Return export data.
+ return [
+ 'name' => $label,
+ 'value' => $meta_value,
+ ];
+ }
+
+ /**
+ * Erase meta.
+ *
+ * @param int $post_id ID of the post.
+ * @param string $meta_key Meta key to erase.
+ * @param string $action Action 'erase' or 'anonymize'.
+ * @return void
+ */
+ public function erase_meta( $post_id, $meta_key, $action = 'erase' ) {
+ switch ( $action ) {
+ case 'erase':
+ delete_post_meta( $post_id, $meta_key );
+
+ break;
+ case 'anonymize':
+ $meta_value = get_post_meta( $post_id, $meta_key, true );
+
+ // Mask email addresses.
+ if ( false !== strpos( $meta_value, '@' ) ) {
+ $meta_value = self::mask_email( $meta_value );
+ }
+
+ update_post_meta( $post_id, $meta_key, $meta_value );
+
+ break;
+ }
+ }
+
+ /**
+ * Mask email address.
+ *
+ * @param string $email Email address.
+ * @return string
+ */
+ public static function mask_email( $email ) {
+ // Is this an email address?
+ if ( ! is_string( $email ) || false === strpos( $email, '@' ) ) {
+ return $email;
+ }
+
+ $parts = explode( '@', $email );
+
+ // Local part.
+ $local = $parts[0];
+
+ if ( strlen( $local ) > 2 ) {
+ $local = sprintf(
+ '%1$s%2$s%3$s',
+ substr( $local, 0, 1 ),
+ str_repeat( '*', ( strlen( $local ) - 2 ) ),
+ substr( $local, - 1 )
+ );
+ }
+
+ // Domain part.
+ $domain_parts = explode( '.', $parts[1] );
+
+ $domain = [];
+
+ foreach ( $domain_parts as $part ) {
+ if ( strlen( $part ) <= 2 ) {
+ $domain[] = $part;
+
+ continue;
+ }
+
+ $domain[] = sprintf(
+ '%1$s%2$s%3$s',
+ substr( $part, 0, 1 ),
+ str_repeat( '*', ( strlen( $part ) - 2 ) ),
+ substr( $part, - 1 )
+ );
+ }
+
+ // Combine local and domain part.
+ $email = sprintf(
+ '%1$s@%2$s',
+ $local,
+ implode( '.', $domain )
+ );
+
+ return $email;
+ }
+
+ /**
+ * Anonymize data.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.8/wp-includes/functions.php#L5932-L5978
+ *
+ * @param string $type The type of data to be anonymized.
+ * @param string|null $data Optional The data to be anonymized.
+ * @return string|null The anonymous data for the requested type.
+ */
+ public static function anonymize_data( $type, $data = null ) {
+ if ( null === $data ) {
+ return null;
+ }
+
+ return wp_privacy_anonymize_data( $type, $data );
+ }
+
+ /**
+ * Anonymize IPv4 or IPv6 address.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.9.8/wp-includes/functions.php#L5862-L5930
+ *
+ * @param string|null $ip_addr The IPv4 or IPv6 address to be anonymized.
+ * @param bool $ipv6_fallback Optional. Whether to return the original IPv6 address if the needed functions
+ * to anonymize it are not present. Default false, return `::` (unspecified address).
+ * @return string|null The anonymized IP address.
+ */
+ public static function anonymize_ip( $ip_addr, $ipv6_fallback = false ) {
+ if ( null === $ip_addr ) {
+ return null;
+ }
+
+ return wp_privacy_anonymize_ip( $ip_addr, $ipv6_fallback );
+ }
+
+ /**
+ * Anonymize custom data types.
+ *
+ * @param string $anonymous Anonymized data.
+ * @param string $type Type of the data.
+ * @param string $data Original data.
+ *
+ * @return string Anonymized string.
+ *
+ * @throws Exception When error occurs anonymize phone.
+ */
+ public static function anonymize_custom_data_types( $anonymous, $type, $data ) {
+ switch ( $type ) {
+ case 'email_mask':
+ $anonymous = self::mask_email( $data );
+
+ break;
+ case 'phone':
+ $anonymous = preg_replace( '/\d/u', '0', $data );
+
+ if ( null === $anonymous ) {
+ throw new Exception( 'Could not anonymize phone number.' );
+ }
+
+ break;
+ }
+
+ return $anonymous;
+ }
+}
diff --git a/packages/wp-pay/core/src/Refunds/Refund.php b/packages/wp-pay/core/src/Refunds/Refund.php
new file mode 100644
index 0000000..23db7ea
--- /dev/null
+++ b/packages/wp-pay/core/src/Refunds/Refund.php
@@ -0,0 +1,216 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Refunds
+ */
+
+namespace Pronamic\WordPress\Pay\Refunds;
+
+use JsonSerializable;
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\DateTime\DateTimeInterface;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use WP_User;
+
+/**
+ * Title: Refund
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Reüel van der Steege
+ * @version 4.9.0
+ * @since 4.9.0
+ */
+class Refund implements JsonSerializable {
+ /**
+ * Created at.
+ *
+ * @var DateTimeInterface
+ */
+ public DateTimeInterface $created_at;
+
+ /**
+ * Created by.
+ *
+ * @var WP_User
+ */
+ public WP_User $created_by;
+
+ /**
+ * Payment.
+ *
+ * @var Payment Payment.
+ */
+ private Payment $payment;
+
+ /**
+ * Amount to refund.
+ *
+ * @var Money Amount.
+ */
+ public Money $amount;
+
+ /**
+ * Description.
+ *
+ * @var string
+ */
+ private string $description = '';
+
+ /**
+ * Refund lines.
+ *
+ * @var RefundLines
+ */
+ public RefundLines $lines;
+
+ /**
+ * Payment service provider ID.
+ *
+ * @var string
+ */
+ public string $psp_id = '';
+
+ /**
+ * Metadata.
+ *
+ * @var array
+ */
+ public array $meta = [];
+
+ /**
+ * Construct a refund.
+ *
+ * @param Payment $payment Payment.
+ * @param Money $amount Amount to refund.
+ */
+ public function __construct( Payment $payment, Money $amount ) {
+ $this->created_at = new DateTimeImmutable();
+ $this->created_by = new WP_User();
+ $this->payment = $payment;
+ $this->amount = $amount;
+ $this->lines = new RefundLines();
+ }
+
+ /**
+ * Get payment.
+ *
+ * @return Payment
+ */
+ public function get_payment(): Payment {
+ return $this->payment;
+ }
+
+ /**
+ * Get amount to refund.
+ *
+ * @return Money
+ */
+ public function get_amount(): Money {
+ return $this->amount;
+ }
+
+ /**
+ * Get description.
+ *
+ * @return string
+ */
+ public function get_description(): string {
+ return $this->description;
+ }
+
+ /**
+ * Set description.
+ *
+ * @param string $description Description.
+ * @return void
+ */
+ public function set_description( string $description ): void {
+ $this->description = $description;
+ }
+
+ /**
+ * Get refund lines.
+ *
+ * @return RefundLines
+ */
+ public function get_lines(): RefundLines {
+ return $this->lines;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return object
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return (object) [
+ 'created_at' => $this->created_at->format( \DATE_ATOM ),
+ 'created_by' => $this->created_by->ID,
+ 'amount' => $this->amount,
+ 'description' => $this->description,
+ 'lines' => $this->lines,
+ 'psp_id' => $this->psp_id,
+ 'meta' => $this->meta,
+ ];
+ }
+
+ /**
+ * Get refund from JSON.
+ *
+ * @param object $json JSON.
+ * @param Payment $payment Payment.
+ * @return Refund
+ * @throws \InvalidArgumentException Throws invalid argument exception if the JSON object is invalid.
+ */
+ public static function from_json( $json, Payment $payment ) {
+ if ( ! \property_exists( $json, 'amount' ) ) {
+ throw new \InvalidArgumentException( 'The JSON object must contain the `amount` property.' );
+ }
+
+ $refund = new self(
+ $payment,
+ MoneyJsonTransformer::from_json( $json->amount )
+ );
+
+ if ( \property_exists( $json, 'created_at' ) ) {
+ $refund->created_at = new DateTimeImmutable( $json->created_at );
+ }
+
+ $refund->created_by = new WP_User();
+
+ if ( \property_exists( $json, 'created_by' ) ) {
+ $user = \get_user_by( 'id', $json->created_by );
+
+ if ( false !== $user ) {
+ $refund->created_by = $user;
+ }
+ }
+
+ if ( \property_exists( $json, 'description' ) ) {
+ $refund->description = $json->description;
+ }
+
+ if ( isset( $json->lines ) ) {
+ $refund->lines = RefundLines::from_json( $json->lines, $refund );
+ }
+
+ if ( \property_exists( $json, 'psp_id' ) ) {
+ $refund->psp_id = $json->psp_id;
+ }
+
+ if ( \property_exists( $json, 'meta' ) ) {
+ $refund->meta = (array) $json->meta;
+ }
+
+ return $refund;
+ }
+}
diff --git a/packages/wp-pay/core/src/Refunds/RefundLine.php b/packages/wp-pay/core/src/Refunds/RefundLine.php
new file mode 100644
index 0000000..7f02c40
--- /dev/null
+++ b/packages/wp-pay/core/src/Refunds/RefundLine.php
@@ -0,0 +1,335 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Refunds;
+
+use InvalidArgumentException;
+use JsonSerializable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Number\Number;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Payments\PaymentLine;
+
+/**
+ * Refund line.
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.1.0
+ */
+class RefundLine implements JsonSerializable {
+ /**
+ * The ID.
+ *
+ * @var string
+ */
+ private $id = '';
+
+ /**
+ * The quantity.
+ *
+ * @var Number
+ */
+ private $quantity;
+
+ /**
+ * Total amount of this payment line.
+ *
+ * @var Money
+ */
+ private $total_amount;
+
+ /**
+ * Payment line.
+ *
+ * @var PaymentLine|null
+ */
+ private $payment_line;
+
+ /**
+ * Refund.
+ *
+ * @var Refund|null
+ */
+ private $refund;
+
+ /**
+ * Meta.
+ *
+ * @var array
+ */
+ public array $meta;
+
+ /**
+ * Payment line constructor.
+ */
+ public function __construct() {
+ $this->quantity = Number::from_int( 0 );
+
+ $this->set_total_amount( new Money() );
+
+ $this->meta = [];
+ }
+
+ /**
+ * Get the id / identifier of this payment line.
+ *
+ * @return string
+ */
+ public function get_id() {
+ return $this->id;
+ }
+
+ /**
+ * Set the id / identifier of this payment line.
+ *
+ * @param string $id Number.
+ * @return void
+ */
+ public function set_id( $id ) {
+ $this->id = $id;
+ }
+
+ /**
+ * Get the quantity of this payment line.
+ *
+ * @return Number
+ */
+ public function get_quantity() {
+ return $this->quantity;
+ }
+
+ /**
+ * Set the quantity of this payment line.
+ *
+ * @param Number $quantity Quantity.
+ * @return void
+ */
+ public function set_quantity( Number $quantity ) {
+ $this->quantity = $quantity;
+ }
+
+ /**
+ * Get tax amount.
+ *
+ * @return Money|null
+ */
+ public function get_tax_amount() {
+ if ( ! $this->total_amount instanceof TaxedMoney ) {
+ return null;
+ }
+
+ $tax_value = $this->total_amount->get_tax_value();
+
+ if ( null === $tax_value ) {
+ return null;
+ }
+
+ return new Money(
+ $tax_value,
+ $this->get_total_amount()->get_currency()
+ );
+ }
+
+ /**
+ * Get total amount.
+ *
+ * @return Money
+ */
+ public function get_total_amount() {
+ return $this->total_amount;
+ }
+
+ /**
+ * Set total amount.
+ *
+ * @param Money $total_amount Total amount.
+ * @return void
+ */
+ public function set_total_amount( Money $total_amount ) {
+ $this->total_amount = $total_amount;
+ }
+
+ /**
+ * Get refund.
+ *
+ * @return null|Refund
+ */
+ public function get_refund() {
+ return $this->refund;
+ }
+
+ /**
+ * Set refund.
+ *
+ * @param null|Refund $refund Refund.
+ * @return void
+ */
+ public function set_refund( ?Refund $refund ) {
+ $this->refund = $refund;
+ }
+
+ /**
+ * Get payment line.
+ *
+ * @return null|PaymentLine
+ */
+ public function get_payment_line() {
+ return $this->payment_line;
+ }
+
+ /**
+ * Set payment line.
+ *
+ * @param null|PaymentLine $payment_line Payment line.
+ * @return void
+ */
+ public function set_payment_line( ?PaymentLine $payment_line ) {
+ $this->payment_line = $payment_line;
+ }
+
+ /**
+ * Get the meta value of this specified meta key.
+ *
+ * @param string $key Meta key.
+ * @return mixed
+ */
+ public function get_meta( $key ) {
+ if ( \array_key_exists( $key, $this->meta ) ) {
+ return $this->meta[ $key ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Set meta data.
+ *
+ * @param string $key A meta key.
+ * @param mixed $value A meta value.
+ * @return void
+ */
+ public function set_meta( $key, $value ) {
+ $this->meta[ $key ] = $value;
+ }
+
+ /**
+ * Delete meta data.
+ *
+ * @param string $key Meta key.
+ * @return void
+ */
+ public function delete_meta( $key ) {
+ unset( $this->meta[ $key ] );
+ }
+
+ /**
+ * Create payment line from object.
+ *
+ * @param mixed $json JSON.
+ * @param Refund $refund Refund.
+ * @return self
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json, Refund $refund ) {
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $line = new self();
+
+ if ( property_exists( $json, 'id' ) ) {
+ $line->set_id( $json->id );
+ }
+
+ if ( property_exists( $json, 'quantity' ) ) {
+ $line->set_quantity( Number::from_mixed( $json->quantity ) );
+ }
+
+ if ( isset( $json->total_amount ) ) {
+ $line->set_total_amount( MoneyJsonTransformer::from_json( $json->total_amount ) );
+ }
+
+ if ( property_exists( $json, 'meta' ) ) {
+ $line->meta = (array) $json->meta;
+ }
+
+ $line->refund = $refund;
+
+ if ( \property_exists( $json, 'payment_line' ) ) {
+ $payment = $refund->get_payment();
+
+ if ( null !== $payment->lines ) {
+ $line->payment_line = $payment->lines->first( $json->payment_line->id );
+ }
+ }
+
+ return $line;
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return object
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ $properties = [
+ 'id' => $this->get_id(),
+ 'quantity' => $this->get_quantity(),
+ 'total_amount' => $this->total_amount->jsonSerialize(),
+ 'meta' => $this->meta,
+ ];
+
+ if ( null !== $this->payment_line ) {
+ $payment = $this->payment_line->get_payment();
+
+ if ( null !== $payment ) {
+ $properties['payment_line'] = [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/pronamic-pay/v1/payments/%d/lines/%d',
+ $payment->get_id(),
+ $this->payment_line->get_id()
+ )
+ ),
+ 'id' => $this->payment_line->get_id(),
+ ];
+ }
+ }
+
+ $properties = array_filter( $properties );
+
+ return (object) $properties;
+ }
+
+ /**
+ * Create string representation of the payment line.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $parts = [
+ $this->get_id(),
+ $this->get_quantity(),
+ ];
+
+ $parts = array_map( 'strval', $parts );
+
+ $parts = array_map( 'trim', $parts );
+
+ $parts = array_filter( $parts );
+
+ $string = implode( ' - ', $parts );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Refunds/RefundLines.php b/packages/wp-pay/core/src/Refunds/RefundLines.php
new file mode 100644
index 0000000..38361be
--- /dev/null
+++ b/packages/wp-pay/core/src/Refunds/RefundLines.php
@@ -0,0 +1,196 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Payments
+ */
+
+namespace Pronamic\WordPress\Pay\Refunds;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use JsonSerializable;
+use Traversable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+
+/**
+ * Refund lines
+ *
+ * @author Remco Tolsma
+ * @version 2.5.1
+ * @since 2.1.0
+ * @implements \IteratorAggregate
+ */
+class RefundLines implements Countable, IteratorAggregate, JsonSerializable {
+ /**
+ * The lines.
+ *
+ * @var array
+ */
+ private $lines;
+
+ /**
+ * Constructs and initialize a payment lines object.
+ */
+ public function __construct() {
+ $this->lines = [];
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable {
+ return new ArrayIterator( $this->lines );
+ }
+
+ /**
+ * Get array.
+ *
+ * @return array
+ */
+ public function get_array() {
+ return $this->lines;
+ }
+
+ /**
+ * Add line.
+ *
+ * @param RefundLine $line The line to add.
+ * @return void
+ */
+ public function add_line( RefundLine $line ) {
+ $this->lines[] = $line;
+ }
+
+ /**
+ * New line.
+ *
+ * @return RefundLine
+ */
+ public function new_line() {
+ $line = new RefundLine();
+
+ $this->add_line( $line );
+
+ return $line;
+ }
+
+ /**
+ * Count lines.
+ *
+ * @return int
+ */
+ public function count(): int {
+ return count( $this->lines );
+ }
+
+ /**
+ * Calculate the total amount of all lines.
+ *
+ * @return TaxedMoney
+ */
+ public function get_amount() {
+ $total = new Money();
+ $tax = new Money();
+ $currency = null;
+
+ foreach ( $this->lines as $line ) {
+ // Total.
+ $line_total = $line->get_total_amount();
+
+ $total = $total->add( $line_total );
+
+ // Tax.
+ if ( $line_total instanceof TaxedMoney ) {
+ $line_tax = $line_total->get_tax_amount();
+
+ if ( null !== $line_tax ) {
+ $tax = $tax->add( $line_tax );
+ }
+ }
+
+ // Currency.
+ if ( null === $currency ) {
+ $currency = $line_total->get_currency();
+ }
+ }
+
+ // Currency.
+ if ( null === $currency ) {
+ $currency = 'EUR';
+ }
+
+ // Return payment lines amount.
+ return new TaxedMoney(
+ $total->get_value(),
+ $currency,
+ $tax->get_value()
+ );
+ }
+
+ /**
+ * Serialize to JSON.
+ *
+ * @return array
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->lines;
+ }
+
+ /**
+ * Create items from object.
+ *
+ * @param mixed $json JSON.
+ * @param Refund $refund Refund.
+ *
+ * @return RefundLines
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an array.
+ */
+ public static function from_json( $json, Refund $refund ) {
+ if ( ! is_array( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an array.' );
+ }
+
+ $object = new self();
+
+ $lines = array_map(
+ /**
+ * Get payment line from object.
+ *
+ * @param object $value Object.
+ * @return PaymentLine
+ */
+ function ( $value ) use ( $refund ) {
+ return RefundLine::from_json( $value, $refund );
+ },
+ $json
+ );
+
+ foreach ( $lines as $line ) {
+ $object->add_line( $line );
+ }
+
+ return $object;
+ }
+
+ /**
+ * Create string representation the payment lines.
+ *
+ * @return string
+ */
+ public function __toString() {
+ $pieces = array_map( 'strval', $this->lines );
+
+ $string = implode( PHP_EOL, $pieces );
+
+ return $string;
+ }
+}
diff --git a/packages/wp-pay/core/src/Region.php b/packages/wp-pay/core/src/Region.php
new file mode 100644
index 0000000..5307486
--- /dev/null
+++ b/packages/wp-pay/core/src/Region.php
@@ -0,0 +1,195 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use InvalidArgumentException;
+use stdClass;
+
+/**
+ * Region
+ *
+ * @author Remco Tolsma
+ * @version 2.1.6
+ * @since 2.1.6
+ */
+class Region {
+ /**
+ * Value.
+ *
+ * @var string|null
+ */
+ private $value;
+
+ /**
+ * Code.
+ *
+ * @var string|null
+ */
+ private $code;
+
+ /**
+ * Name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * Construct region.
+ *
+ * @param string|null $value Value.
+ */
+ public function __construct( $value = null ) {
+ $this->set_value( $value );
+ }
+
+ /**
+ * Get value.
+ *
+ * @return string|null
+ */
+ public function get_value() {
+ return $this->value;
+ }
+
+ /**
+ * Set value.
+ *
+ * @param string|null $value Value.
+ * @return void
+ */
+ public function set_value( $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Get code.
+ *
+ * @return string|null
+ */
+ public function get_code() {
+ return $this->code;
+ }
+
+ /**
+ * Set code.
+ *
+ * @param string|null $code Code.
+ * @return void
+ */
+ public function set_code( $code ) {
+ $this->code = $code;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set name.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'value' => $this->value,
+ 'code' => $this->code,
+ 'name' => $this->name,
+ ];
+
+ $data = array_filter( $data );
+
+ if ( empty( $data ) ) {
+ return null;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create from object.
+ *
+ * @param mixed $json JSON.
+ * @return Region
+ * @throws InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( is_string( $json ) ) {
+ return new self( $json );
+ }
+
+ if ( ! is_object( $json ) ) {
+ throw new InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ $region = new self();
+
+ if ( isset( $json->value ) ) {
+ $region->set_value( $json->value );
+ }
+
+ if ( isset( $json->code ) ) {
+ $region->set_code( $json->code );
+ }
+
+ if ( isset( $json->name ) ) {
+ $region->set_name( $json->name );
+ }
+
+ return $region;
+ }
+
+ /**
+ * Create string representation.
+ *
+ * @return string
+ */
+ public function __toString() {
+ if ( is_string( $this->value ) ) {
+ return $this->value;
+ }
+
+ $values = [
+ $this->code,
+ $this->name,
+ ];
+
+ $values = array_filter( $values );
+
+ return implode( ' - ', $values );
+ }
+
+ /**
+ * Anonymize.
+ *
+ * @return void
+ */
+ public function anonymize() {
+ $this->set_value( PrivacyManager::anonymize_data( 'text', $this->get_value() ) );
+ $this->set_code( PrivacyManager::anonymize_data( 'text', $this->get_code() ) );
+ $this->set_name( PrivacyManager::anonymize_data( 'text', $this->get_name() ) );
+ }
+}
diff --git a/packages/wp-pay/core/src/Settings.php b/packages/wp-pay/core/src/Settings.php
new file mode 100644
index 0000000..2e8e3c7
--- /dev/null
+++ b/packages/wp-pay/core/src/Settings.php
@@ -0,0 +1,128 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Title: WordPress iDEAL admin
+ *
+ * @author Remco Tolsma
+ * @version 2.0.5
+ */
+class Settings {
+ /**
+ * Plugin.
+ *
+ * @var Plugin
+ */
+ private $plugin;
+
+ /**
+ * Construct and initialize settings object.
+ *
+ * @param Plugin $plugin The plugin.
+ */
+ public function __construct( $plugin ) {
+ $this->plugin = $plugin;
+
+ // Actions.
+ add_action( 'init', [ $this, 'init' ] );
+ }
+
+ /**
+ * Initialize.
+ *
+ * @link https://make.wordpress.org/core/2016/10/26/registering-your-settings-in-wordpress-4-7/
+ * @link https://github.com/WordPress/WordPress/blob/4.6/wp-admin/includes/plugin.php#L1767-L1795
+ * @link https://github.com/WordPress/WordPress/blob/4.7/wp-includes/option.php#L1849-L1925
+ * @link https://github.com/WordPress/WordPress/blob/4.7/wp-includes/option.php#L1715-L1847
+ *
+ * @return void
+ */
+ public function init() {
+ register_setting(
+ 'pronamic_pay',
+ 'pronamic_pay_license_key',
+ [
+ 'type' => 'string',
+ 'sanitize_callback' => 'sanitize_text_field',
+ ]
+ );
+
+ register_setting(
+ 'pronamic_pay',
+ 'pronamic_pay_config_id',
+ [
+ 'type' => 'integer',
+ 'sanitize_callback' => [ self::class, 'sanitize_published_post_id' ],
+ ]
+ );
+
+ register_setting(
+ 'pronamic_pay',
+ 'pronamic_pay_uninstall_clear_data',
+ [
+ 'type' => 'boolean',
+ 'default' => false,
+ ]
+ );
+
+ \register_setting(
+ 'pronamic_pay',
+ 'pronamic_pay_debug_mode',
+ [
+ 'type' => 'boolean',
+ 'description' => 'Setting that can be used to trigger the “debug” mode throughout Pronamic Pay.',
+ 'default' => false,
+ ]
+ );
+
+ \register_setting(
+ 'pronamic_pay',
+ 'pronamic_pay_subscriptions_processing_disabled',
+ [
+ 'type' => 'boolean',
+ 'description' => 'Setting that can be used to disable processing of recurring payments.',
+ 'default' => false,
+ ]
+ );
+
+ // Payment Methods.
+ $payment_methods = $this->plugin->get_payment_methods();
+
+ foreach ( $payment_methods as $payment_method ) {
+ $id = 'pronamic_pay_payment_method_' . $payment_method->get_id() . '_status';
+
+ \register_setting(
+ 'pronamic_pay',
+ $id,
+ [
+ 'type' => 'string',
+ ]
+ );
+
+ $payment_method->set_status( (string) \get_option( $id ) );
+ }
+ }
+
+ /**
+ * Sanitize published post ID.
+ *
+ * @param integer $value Check if the value is published post ID.
+ * @return int|null Post ID if value is published post ID, null otherwise.
+ */
+ public static function sanitize_published_post_id( $value ) {
+ if ( 'publish' === get_post_status( $value ) ) {
+ return $value;
+ }
+
+ return null;
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/AlignmentRule.php b/packages/wp-pay/core/src/Subscriptions/AlignmentRule.php
new file mode 100644
index 0000000..75bb220
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/AlignmentRule.php
@@ -0,0 +1,169 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Privacy
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use DateTimeImmutable;
+
+/**
+ * Alignment Rule
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ */
+class AlignmentRule {
+ /**
+ * Weekdays indexed 0 (for Sunday) through 6 (for Saturday).
+ *
+ * @var array
+ */
+ private static $weekdays = [
+ 0 => 'Sunday',
+ 1 => 'Monday',
+ 2 => 'Tuesday',
+ 3 => 'Wednesday',
+ 4 => 'Thursday',
+ 5 => 'Friday',
+ 6 => 'Saturday',
+ ];
+
+ /**
+ * Frequency.
+ *
+ * @var string
+ */
+ private $frequency;
+
+ /**
+ * Day of the week.
+ *
+ * @var string|null
+ */
+ private $by_day_of_the_week;
+
+ /**
+ * Day of the month.
+ *
+ * @var int|null
+ */
+ private $by_day_of_the_month;
+
+ /**
+ * Number of month.
+ *
+ * @var int|null
+ */
+ private $by_month;
+
+ /**
+ * Construct prorating rule.
+ *
+ * @param string $frequency Frequency.
+ */
+ public function __construct( $frequency ) {
+ $this->frequency = $frequency;
+ }
+
+ /**
+ * By numeric day of the week.
+ *
+ * @param int $number Number of day in the week (0 = Sunday).
+ * @return $this
+ */
+ public function by_numeric_day_of_the_week( $number ) {
+ $this->by_day_of_the_week = self::$weekdays[ $number ];
+
+ return $this;
+ }
+
+ /**
+ * By numeric day of the month.
+ *
+ * @param int $number Day of the month.
+ * @return $this
+ */
+ public function by_numeric_day_of_the_month( $number ) {
+ $this->by_day_of_the_month = $number;
+
+ return $this;
+ }
+
+ /**
+ * By numeric month.
+ *
+ * @param int $number Number of month.
+ * @return $this
+ */
+ public function by_numeric_month( $number ) {
+ $this->by_month = $number;
+
+ return $this;
+ }
+
+ /**
+ * Get date.
+ *
+ * @param DateTimeImmutable|null $date Date.
+ * @return DateTimeImmutable
+ * @throws \Exception Throws exception on date error.
+ */
+ public function get_date( DateTimeImmutable $date = null ) {
+ if ( null === $date ) {
+ $date = new DateTimeImmutable();
+ }
+
+ return $this->apply_properties( $date );
+ }
+
+ /**
+ * Apply properties.
+ *
+ * @param DateTimeImmutable $date Date.
+ * @return DateTimeImmutable
+ */
+ private function apply_properties( DateTimeImmutable $date ) {
+ $year = \intval( $date->format( 'Y' ) );
+ $month = \intval( $date->format( 'm' ) );
+ $day = \intval( $date->format( 'd' ) );
+
+ // 1 > null === true
+ if ( $day >= $this->by_day_of_the_month && 'W' !== $this->frequency ) {
+ ++$month;
+ }
+
+ if ( null !== $this->by_day_of_the_month ) {
+ $day = $this->by_day_of_the_month;
+ }
+
+ if ( null !== $this->by_month ) {
+ if ( $month > $this->by_month ) {
+ ++$year;
+ }
+
+ $month = $this->by_month;
+ }
+
+ $date = $date->setDate( $year, $month, $day );
+
+ // Day of the week.
+ $day_of_the_week = $this->by_day_of_the_week;
+
+ if ( null === $day_of_the_week && 'W' === $this->frequency ) {
+ $day_of_the_week = $date->format( 'l' );
+ }
+
+ if ( null !== $day_of_the_week ) {
+ $date = $date->modify( 'Next ' . $day_of_the_week );
+ }
+
+ return $date;
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/Subscription.php b/packages/wp-pay/core/src/Subscriptions/Subscription.php
new file mode 100644
index 0000000..5567c37
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/Subscription.php
@@ -0,0 +1,962 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use DateInterval;
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeInterface;
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Payments\PaymentInfo;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentInfoHelper;
+
+/**
+ * Subscription
+ *
+ * @author Remco Tolsma
+ * @version 2.7.1
+ * @since 1.0.0
+ */
+class Subscription extends PaymentInfo implements \JsonSerializable {
+ /**
+ * The status of this subscription, for example 'Success'.
+ *
+ * @todo How to reference to a class constant?
+ * @see PaymentStatus
+ *
+ * @var string|null
+ */
+ public $status;
+
+ /**
+ * Activated at.
+ *
+ * The datetime this subscription was activated or reactivated.
+ *
+ * @var DateTime
+ */
+ private $activated_at;
+
+ /**
+ * Phases.
+ *
+ * @var SubscriptionPhase[]
+ */
+ private $phases = [];
+
+ /**
+ * Next payment date.
+ *
+ * @var DateTimeImmutable|null
+ */
+ private $next_payment_date;
+
+ /**
+ * Construct and initialize subscription object.
+ *
+ * @throws \Exception Throws exception on invalid post date.
+ */
+ public function __construct() {
+ parent::__construct();
+
+ $this->activated_at = new DateTime();
+ $this->meta_key_prefix = '_pronamic_subscription_';
+ }
+
+ /**
+ * Get date interval.
+ *
+ * @link http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters
+ * @deprecated
+ * @return SubscriptionInterval|null
+ */
+ public function get_date_interval() {
+ $phase = $this->get_current_phase();
+
+ if ( null === $phase ) {
+ return null;
+ }
+
+ return $phase->get_interval();
+ }
+
+ /**
+ * Get the status of this subscription.
+ *
+ * @todo Check constant?
+ * @return string|null
+ */
+ public function get_status() {
+ return $this->status;
+ }
+
+ /**
+ * Set the status of this subscription.
+ *
+ * @todo Check constant?
+ * @param string|null $status A status string.
+ * @return void
+ */
+ public function set_status( $status ) {
+ if ( SubscriptionStatus::ACTIVE === $status && $this->status !== $status ) {
+ $this->set_activated_at( new DateTime() );
+ }
+
+ $this->status = $status;
+ }
+
+ /**
+ * Add the specified note to this subscription.
+ *
+ * @link https://developer.wordpress.org/reference/functions/wp_insert_comment/
+ * @param string $note A Note.
+ * @return int The new comment's ID.
+ * @throws \Exception Throws exception when adding note fails.
+ */
+ public function add_note( $note ) {
+ if ( null === $this->id ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not add note "%s" to subscription without ID.',
+ \esc_html( $note )
+ )
+ );
+ }
+
+ $commentdata = [
+ 'comment_post_ID' => $this->id,
+ 'comment_content' => $note,
+ 'comment_type' => 'subscription_note',
+ 'user_id' => get_current_user_id(),
+ ];
+
+ $result = wp_insert_comment( $commentdata );
+
+ if ( false === $result ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not add note "%s" to subscription with ID "%s".',
+ \esc_html( $note ),
+ \esc_html( (string) $this->id )
+ )
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * Get source text.
+ *
+ * @return string
+ */
+ public function get_source_text() {
+ $pieces = [
+ \ucfirst( (string) $this->get_source() ),
+ $this->get_source_id(),
+ ];
+
+ $pieces = array_filter( $pieces );
+
+ $text = implode( ' ', $pieces );
+
+ $source = $this->get_source();
+
+ $subscription = $this;
+
+ if ( null !== $source ) {
+ /**
+ * Filters the subscription source text by plugin integration source.
+ *
+ * @param string $text Source text.
+ * @param Subscription $subscription Subscription.
+ */
+ $text = apply_filters( 'pronamic_subscription_source_text_' . $source, $text, $subscription );
+ }
+
+ /**
+ * Filters the subscription source text.
+ *
+ * @param string $text Source text.
+ * @param Subscription $subscription Subscription.
+ */
+ $text = apply_filters( 'pronamic_subscription_source_text', $text, $subscription );
+
+ return $text;
+ }
+
+ /**
+ * Get source description.
+ *
+ * @return string
+ */
+ public function get_source_description() {
+ $subscription = $this;
+
+ $source = $subscription->get_source();
+
+ $description = (string) $source;
+
+ if ( null !== $source ) {
+ /**
+ * Filters the subscription source description by plugin integration source.
+ *
+ * @param string $description Source description.
+ * @param Subscription $subscription Subscription.
+ */
+ $description = apply_filters( 'pronamic_subscription_source_description_' . $source, $description, $subscription );
+ }
+
+ /**
+ * Filters the subscription source description.
+ *
+ * @param string $description Source description.
+ * @param Subscription $subscription Subscription.
+ */
+ $description = apply_filters( 'pronamic_subscription_source_description', $description, $subscription );
+
+ return $description;
+ }
+
+ /**
+ * Get source link for this subscription.
+ *
+ * @return string|null
+ */
+ public function get_source_link() {
+ $url = null;
+
+ $subscription = $this;
+
+ $source = $subscription->get_source();
+
+ /**
+ * Filters the subscription source URL.
+ *
+ * @param null|string $url Source URL.
+ * @param Subscription $subscription Subscription.
+ */
+ $url = apply_filters( 'pronamic_subscription_source_url', $url, $subscription );
+
+ if ( null !== $source ) {
+ /**
+ * Filters the subscription source URL by plugin integration source.
+ *
+ * @param null|string $url Source URL.
+ * @param Subscription $subscription Subscription.
+ */
+ $url = apply_filters( 'pronamic_subscription_source_url_' . $source, $url, $subscription );
+ }
+
+ return $url;
+ }
+
+ /**
+ * Get cancel URL for this subscription.
+ *
+ * @return string
+ */
+ public function get_cancel_url() {
+ $cancel_url = add_query_arg(
+ [
+ 'subscription' => $this->get_id(),
+ 'key' => $this->get_key(),
+ 'action' => 'cancel',
+ ],
+ home_url( '/' )
+ );
+
+ return $cancel_url;
+ }
+
+ /**
+ * Get renewal URL for this subscription.
+ *
+ * @return string
+ */
+ public function get_renewal_url() {
+ $renewal_url = add_query_arg(
+ [
+ 'subscription' => $this->get_id(),
+ 'key' => $this->get_key(),
+ 'action' => 'renew',
+ ],
+ home_url( '/' )
+ );
+
+ return $renewal_url;
+ }
+
+ /**
+ * Get mandate selection URL for this subscription.
+ *
+ * @return string
+ */
+ public function get_mandate_selection_url() {
+ $url = add_query_arg(
+ [
+ 'subscription' => $this->get_id(),
+ 'key' => $this->get_key(),
+ 'action' => 'mandate',
+ ],
+ home_url( '/' )
+ );
+
+ return $url;
+ }
+
+ /**
+ * Get all the payments for this subscription.
+ *
+ * @return Payment[]
+ */
+ public function get_payments() {
+ if ( null === $this->id ) {
+ return [];
+ }
+
+ $payments = get_pronamic_payments_by_meta( '_pronamic_payment_subscription_id', $this->id );
+
+ return $payments;
+ }
+
+ /**
+ * Get payments by period.
+ *
+ * @return array
+ */
+ public function get_payments_by_period() {
+ $payments = $this->get_payments();
+
+ $periods = [];
+
+ foreach ( $payments as $payment ) {
+ // Get period for this subscription.
+ $period = null;
+
+ $payment_periods = $payment->get_periods();
+
+ if ( null === $payment_periods ) {
+ continue;
+ }
+
+ foreach ( $payment_periods as $period ) {
+ if ( $this->get_id() === $period->get_phase()->get_subscription()->get_id() ) {
+ break;
+ }
+ }
+
+ if ( null === $period ) {
+ continue;
+ }
+
+ // Add period to result.
+ $start = $period->get_start_date()->getTimestamp();
+
+ if ( ! \array_key_exists( $start, $periods ) ) {
+ $periods[ $start ] = [
+ 'period' => $period,
+ 'payments' => [],
+ 'can_retry' => true,
+ ];
+ }
+
+ // Add payment to result.
+ $periods[ $start ]['payments'][ $payment->get_date()->getTimestamp() ] = $payment;
+
+ if ( \in_array( $payment->get_status(), [ PaymentStatus::OPEN, PaymentStatus::SUCCESS ], true ) ) {
+ $periods[ $start ]['can_retry'] = false;
+ }
+ }
+
+ // Sort periods and payments.
+ \krsort( $periods );
+
+ foreach ( $periods as &$period ) {
+ \ksort( $period['payments'] );
+ }
+
+ return $periods;
+ }
+
+ /**
+ * Check if the payment is the first for this subscription.
+ *
+ * @param Payment $payment Payment.
+ * @return bool True if payment is the first, false otherwise.
+ */
+ public function is_first_payment( Payment $payment ) {
+ $phases = $this->get_phases();
+
+ $phase = \reset( $phases );
+
+ if ( false === $phase ) {
+ return false;
+ }
+
+ $periods = $payment->get_periods();
+
+ if ( null === $periods ) {
+ return false;
+ }
+
+ foreach ( $periods as $period ) {
+ if ( $period->get_phase()->get_subscription() !== $this ) {
+ continue;
+ }
+
+ // Compare formatted dates instead of date objects,
+ // to account for differences in microseconds.
+ $period_start = $period->get_start_date()->format( 'Y-m-d H:i:s' );
+ $phase_start = $phase->get_start_date()->format( 'Y-m-d H:i:s' );
+
+ if ( $period_start === $phase_start ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the next payment date of this subscription.
+ *
+ * @return DateTimeImmutable|null
+ */
+ public function get_next_payment_date() {
+ return $this->next_payment_date;
+ }
+
+ /**
+ * Set the next payment date of this subscription.
+ *
+ * @param \DateTimeInterface|null $date Date.
+ * @return void
+ */
+ public function set_next_payment_date( $date ) {
+ $end_date = $this->get_end_date();
+
+ if ( null !== $end_date && $date >= $end_date ) {
+ $this->next_payment_date = null;
+
+ return;
+ }
+
+ $this->next_payment_date = ( null === $date ) ? null : DateTimeImmutable::create_from_interface( $date );
+ }
+
+ /**
+ * Get the next payment delivery date of this subscription.
+ *
+ * @return DateTimeInterface|null
+ */
+ public function get_next_payment_delivery_date() {
+ $next_payment_date = $this->get_next_payment_date();
+
+ // Check if there is next payment date.
+ if ( null === $next_payment_date ) {
+ return null;
+ }
+
+ $next_payment_delivery_date = clone $next_payment_date;
+
+ $subscription = $this;
+
+ /**
+ * Filters the subscription next payment delivery date.
+ *
+ * @param DateTimeImmutable $next_payment_delivery_date Next payment delivery date.
+ * @param Subscription $subscription Subscription.
+ * @since unreleased
+ */
+ $next_payment_delivery_date = \apply_filters( 'pronamic_pay_subscription_next_payment_delivery_date', $next_payment_delivery_date, $subscription );
+
+ return $next_payment_delivery_date;
+ }
+
+ /**
+ * Create new subscription period.
+ *
+ * @return SubscriptionPeriod|null
+ * @throws \UnexpectedValueException Throws exception when no date interval is available for this subscription.
+ */
+ public function new_period() {
+ $phase = $this->get_current_phase();
+
+ if ( null === $phase ) {
+ throw new \UnexpectedValueException( 'Cannot create new subscription period for subscription without phase.' );
+ }
+
+ return $this->next_period();
+ }
+
+ /**
+ * New subscription payment.
+ *
+ * Subscriptions lines and amount are deliberately not set in the payment for now.
+ *
+ * @return Payment
+ */
+ public function new_payment() {
+ $payment = new Payment();
+
+ $payment->order_id = $this->get_order_id();
+
+ $payment->add_subscription( $this );
+
+ $payment->set_payment_method( $this->get_payment_method() );
+
+ $payment->set_description( $this->get_description() );
+ $payment->set_config_id( $this->get_config_id() );
+ $payment->set_origin_id( $this->get_origin_id() );
+
+ $payment->set_source( $this->get_source() );
+ $payment->set_source_id( $this->get_source_id() );
+
+ $payment->set_customer( $this->get_customer() );
+ $payment->set_billing_address( $this->get_billing_address() );
+ $payment->set_shipping_address( $this->get_shipping_address() );
+
+ return $payment;
+ }
+
+ /**
+ * Get renewal period.
+ *
+ * @return SubscriptionPeriod|null
+ */
+ public function get_renewal_period() {
+ $renewal_period = null;
+
+ // Get next period for current phase.
+ $current_phase = $this->get_current_phase();
+
+ if ( null !== $current_phase ) {
+ $renewal_period = $current_phase->get_next_period();
+ }
+
+ // Check if last period failed.
+ $now = new DateTimeImmutable();
+
+ $periods = $this->get_payments_by_period();
+
+ $last_period = array_shift( $periods );
+
+ if ( null !== $last_period ) {
+ // Can period be re-tried?
+ if ( false === $last_period['can_retry'] ) {
+ return $renewal_period;
+ }
+
+ // Can payment be re-tried?
+ $payment = array_shift( $last_period['payments'] );
+
+ if ( ! \pronamic_pay_plugin()->subscriptions_module->can_retry_payment( $payment ) ) {
+ return $renewal_period;
+ }
+
+ // Is last period end date in the future?
+ if ( $last_period['period']->get_end_date() > $now ) {
+ $renewal_period = $last_period['period'];
+ }
+ }
+
+ return $renewal_period;
+ }
+
+ /**
+ * Save subscription.
+ *
+ * @return void
+ */
+ public function save() {
+ pronamic_pay_plugin()->subscriptions_data_store->save( $this );
+ }
+
+ /**
+ * Create subscription from object.
+ *
+ * @param mixed $json JSON.
+ * @param Subscription|null $subscription Subscription.
+ * @return Subscription
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json, $subscription = null ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( null === $subscription ) {
+ $subscription = new self();
+ }
+
+ PaymentInfoHelper::from_json( $json, $subscription );
+
+ if ( isset( $json->status ) ) {
+ $subscription->set_status( $json->status );
+ }
+
+ if ( isset( $json->phases ) ) {
+ foreach ( $json->phases as $json_phase ) {
+ $json_phase->subscription = $subscription;
+
+ $subscription->add_phase( SubscriptionPhase::from_json( $json_phase ) );
+ }
+ }
+
+ $activated_at = $subscription->date;
+
+ if ( property_exists( $json, 'activated_at' ) ) {
+ $activated_at = new DateTime( $json->activated_at );
+ }
+
+ $subscription->set_activated_at( $activated_at );
+
+ if ( \property_exists( $json, 'next_payment_date' ) ) {
+ $subscription->set_next_payment_date( null === $json->next_payment_date ? null : new DateTimeImmutable( $json->next_payment_date ) );
+ }
+
+ return $subscription;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object
+ */
+ public function get_json() {
+ $object = PaymentInfoHelper::to_json( $this );
+
+ $properties = (array) $object;
+
+ $properties['phases'] = $this->phases;
+
+ if ( null !== $this->get_status() ) {
+ $properties['status'] = $this->get_status();
+ }
+
+ $properties['activated_at'] = $this->get_activated_at()->format( \DATE_ATOM );
+
+ $properties['next_payment_date'] = ( null === $this->next_payment_date ) ? null : $this->next_payment_date->format( \DATE_ATOM );
+
+ $object = (object) $properties;
+
+ return $object;
+ }
+
+ /**
+ * JSON serialize.
+ *
+ * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->get_json();
+ }
+
+ /**
+ * Get activated datetime.
+ *
+ * @return DateTime
+ */
+ public function get_activated_at() {
+ return $this->activated_at;
+ }
+
+ /**
+ * Set activated datetime.
+ *
+ * @param DateTime $activated_at Activated at.
+ * @return void
+ */
+ public function set_activated_at( DateTime $activated_at ) {
+ $this->activated_at = $activated_at;
+ }
+
+ /**
+ * Get phases.
+ *
+ * @return array
+ */
+ public function get_phases() {
+ return $this->phases;
+ }
+
+ /**
+ * Set phases.
+ *
+ * @param array $phases Phases.
+ * @return void
+ */
+ public function set_phases( $phases ) {
+ $this->phases = $phases;
+ }
+
+ /**
+ * Add the specified phase to this subscription.
+ *
+ * @param SubscriptionPhase $phase Phase.
+ * @return void
+ */
+ public function add_phase( SubscriptionPhase $phase ) {
+ $this->phases[] = $phase;
+
+ if ( null === $this->next_payment_date ) {
+ $this->next_payment_date = $phase->get_start_date();
+ }
+
+ $phase->set_sequence_number( \count( $this->phases ) );
+ }
+
+ /**
+ * Create new phase for this subscription.
+ *
+ * @param \DateTimeInterface $start_date Start date.
+ * @param string $interval_spec Interval specification.
+ * @param Money $amount Amount.
+ * @return SubscriptionPhase
+ */
+ public function new_phase( $start_date, $interval_spec, $amount ) {
+ $interval = new SubscriptionInterval( $interval_spec );
+
+ $phase = new SubscriptionPhase( $this, $start_date, $interval, $amount );
+
+ $this->add_phase( $phase );
+
+ return $phase;
+ }
+
+ /**
+ * Check if all the periods within the subscription phases are created.
+ *
+ * @return bool True if all created, false otherwise.
+ */
+ public function all_periods_created() {
+ foreach ( $this->phases as $phase ) {
+ if ( ! $phase->all_periods_created() ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if this subscription is infinite.
+ *
+ * @return bool True if infinite, false otherwise.
+ */
+ public function is_infinite() {
+ foreach ( $this->phases as $phase ) {
+ if ( $phase->is_infinite() ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Get current phase or null if all completed.
+ *
+ * @return SubscriptionPhase|null
+ */
+ public function get_current_phase() {
+ $next_payment_date = $this->get_next_payment_date();
+
+ if ( null === $next_payment_date ) {
+ return null;
+ }
+
+ return $this->get_phase_for_date( $next_payment_date );
+ }
+
+ /**
+ * Get phase for date.
+ *
+ * @param DateTimeInterface $date Date.
+ * @return SubscriptionPhase|null
+ */
+ public function get_phase_for_date( DateTimeInterface $date ) {
+ foreach ( $this->phases as $phase ) {
+ if ( $phase->is_completed_to_date( $date ) ) {
+ continue;
+ }
+
+ if ( $phase->is_canceled() ) {
+ continue;
+ }
+
+ return $phase;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get period for date.
+ *
+ * @param DateTimeImmutable $date Date.
+ * @return SubscriptionPeriod|null
+ */
+ public function get_period_for_date( DateTimeImmutable $date ) {
+ $phase = $this->get_phase_for_date( $date );
+
+ if ( null === $phase ) {
+ return null;
+ }
+
+ return $phase->get_period( $date );
+ }
+
+ /**
+ * Get phase for display.
+ *
+ * @return SubscriptionPhase|null
+ */
+ public function get_display_phase() {
+ // Get first uncompleted regular phase.
+ foreach ( $this->phases as $phase ) {
+ // Skip trial phases.
+ if ( $phase->is_trial() ) {
+ continue;
+ }
+
+ // Skip prorated phases.
+ if ( $phase->is_prorated() ) {
+ continue;
+ }
+
+ if ( ! $phase->all_periods_created() ) {
+ return $phase;
+ }
+ }
+
+ // Get first regular phase.
+ foreach ( $this->phases as $phase ) {
+ // Skip trial phases.
+ if ( $phase->is_trial() ) {
+ continue;
+ }
+
+ // Skip prorated phases.
+ if ( $phase->is_prorated() ) {
+ continue;
+ }
+
+ return $phase;
+ }
+
+ // Get first phase.
+ foreach ( $this->phases as $phase ) {
+ return $phase;
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if subscription is in a trial period.
+ *
+ * @return bool True if current period definition is a trial, false otherwise.
+ */
+ public function in_trial_period() {
+ $current_phase = $this->get_current_phase();
+
+ if ( null === $current_phase ) {
+ return false;
+ }
+
+ return $current_phase->is_trial();
+ }
+
+ /**
+ * Get the next period.
+ *
+ * @return SubscriptionPeriod|null
+ */
+ public function get_next_period() {
+ $current_phase = $this->get_current_phase();
+
+ if ( null === $current_phase ) {
+ return null;
+ }
+
+ return $current_phase->get_next_period();
+ }
+
+ /**
+ * Next period.
+ *
+ * @return SubscriptionPeriod|null
+ */
+ public function next_period() {
+ $current_phase = $this->get_current_phase();
+
+ if ( null === $current_phase ) {
+ return null;
+ }
+
+ return $current_phase->next_period();
+ }
+
+ /**
+ * Get phase by sequence number.
+ *
+ * @param int $sequence_number Sequence number.
+ * @return SubscriptionPhase|null
+ */
+ public function get_phase_by_sequence_number( $sequence_number ) {
+ /**
+ * PHP arrays are zero-based indexed, sequence number starts from 1.
+ */
+ $key = $sequence_number - 1;
+
+ if ( array_key_exists( $key, $this->phases ) ) {
+ return $this->phases[ $key ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get start date.
+ *
+ * @return DateTimeImmutable|null
+ */
+ public function get_start_date() {
+ $phase = \reset( $this->phases );
+
+ if ( false === $phase ) {
+ return null;
+ }
+
+ return $phase->get_start_date();
+ }
+
+ /**
+ * Get end date.
+ *
+ * @return DateTimeImmutable|null
+ */
+ public function get_end_date() {
+ $end_phase = \end( $this->phases );
+
+ if ( false === $end_phase ) {
+ return null;
+ }
+
+ return $end_phase->get_end_date();
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionHelper.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionHelper.php
new file mode 100644
index 0000000..f31ae1a
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionHelper.php
@@ -0,0 +1,129 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+
+/**
+ * Subscription Helper
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.4.0
+ */
+class SubscriptionHelper {
+ /**
+ * Complement subscription.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ */
+ public static function complement_subscription( Subscription $subscription ) {
+ // Key.
+ if ( null === $subscription->key ) {
+ $subscription->key = uniqid( 'subscr_' );
+ }
+
+ // Status.
+ if ( null === $subscription->status ) {
+ $subscription->status = PaymentStatus::OPEN;
+ }
+ }
+
+ /**
+ * Complement subscription by payment.
+ *
+ * @param Subscription $subscription Subscription.
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public static function complement_subscription_by_payment( Subscription $subscription, Payment $payment ) {
+ // Gateway configuration ID.
+ if ( null === $subscription->config_id ) {
+ $subscription->config_id = $payment->config_id;
+ }
+
+ // Title.
+ if ( null === $subscription->title ) {
+ $subscription->title = sprintf(
+ /* translators: %s: payment title */
+ __( 'Subscription for %s', 'pronamic_ideal' ),
+ $payment->title
+ );
+ }
+
+ $customer = $subscription->get_customer();
+
+ if ( null === $customer ) {
+ $customer = new Customer();
+ }
+
+ // Customer.
+ $payment_customer = $payment->get_customer();
+
+ if ( null !== $payment_customer ) {
+ // Contact name.
+ $customer_name = $customer->get_name();
+
+ if ( null === $customer_name ) {
+ $customer->set_name( $payment_customer->get_name() );
+ }
+
+ // WordPress user ID.
+ $user_id = $customer->get_user_id();
+
+ if ( null === $user_id ) {
+ $customer->set_user_id( $payment_customer->get_user_id() );
+ }
+
+ // Email.
+ $email = $customer->get_email();
+
+ if ( null === $email ) {
+ $customer->set_email( $payment_customer->get_email() );
+ }
+
+ $subscription->set_customer( $customer );
+ }
+
+ // Origin.
+ if ( null === $subscription->get_origin_id() ) {
+ $subscription->set_origin_id( $payment->get_origin_id() );
+ }
+
+ // Source.
+ if ( empty( $subscription->source ) ) {
+ $subscription->source = $payment->source;
+ }
+
+ // Source ID.
+ if ( empty( $subscription->source_id ) ) {
+ $subscription->source_id = $payment->source_id;
+ }
+
+ // Description.
+ $description = $subscription->get_description();
+
+ if ( null === $description ) {
+ $subscription->set_description( $payment->get_description() );
+ }
+
+ // Payment method.
+ $payment_method = $subscription->get_payment_method();
+
+ if ( null === $payment_method ) {
+ $subscription->set_payment_method( $payment->get_payment_method() );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionInterval.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionInterval.php
new file mode 100644
index 0000000..b6b651a
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionInterval.php
@@ -0,0 +1,130 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+/**
+ * Subscription Interval
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.4.0
+ * @link https://github.com/briannesbitt/Carbon/blob/2.40.0/src/Carbon/CarbonInterval.php
+ * @link https://github.com/frak/s3bk/blob/master/src/S3Bk/Type/StringableInterval.php
+ * @link https://github.com/stylers-llc/laratask/blob/master/src/Support/DateInterval.php
+ */
+class SubscriptionInterval extends \DateInterval implements \JsonSerializable {
+ /**
+ * Specification.
+ *
+ * @var string
+ */
+ private $specification;
+
+ /**
+ * Construct interval.
+ *
+ * @link https://en.wikipedia.org/wiki/ISO_8601#Durations
+ * @link https://www.php.net/manual/en/dateinterval.construct.php
+ * @link https://github.com/php/php-src/blob/php-7.4.10/ext/date/php_date.c#L414-L416
+ * @param string $specification An interval specification.
+ */
+ public function __construct( $specification ) {
+ $this->specification = $specification;
+
+ parent::__construct( $specification );
+ }
+
+ /**
+ * Get specification.
+ *
+ * @return string
+ */
+ public function get_specification() {
+ return $this->specification;
+ }
+
+ /**
+ * Multiply.
+ *
+ * @param int $times Number of times to multiply with.
+ * @return SubscriptionInterval
+ * @throws \InvalidArgumentException Throws exception if times to multiply is zero.
+ */
+ public function multiply( $times ) {
+ if ( 0 === $times ) {
+ throw new \InvalidArgumentException( 'Subscription interval cannot be multiplied by 0.' );
+ }
+
+ $invert = ( $times < 0 );
+
+ $times = \absint( $times );
+
+ $interval_spec = 'P';
+
+ // Date.
+ $date = \array_filter(
+ [
+ 'Y' => $this->y * $times,
+ 'M' => $this->m * $times,
+ 'D' => $this->d * $times,
+ ]
+ );
+
+ foreach ( $date as $unit => $value ) {
+ $interval_spec .= $value . $unit;
+ }
+
+ // Time.
+ $time = \array_filter(
+ [
+ 'H' => $this->h * $times,
+ 'M' => $this->i * $times,
+ 'S' => $this->s * $times,
+ ]
+ );
+
+ if ( count( $time ) > 0 ) {
+ $interval_spec .= 'T';
+
+ foreach ( $time as $unit => $value ) {
+ $interval_spec .= $value . $unit;
+ }
+ }
+
+ // Interval.
+ $interval = new self( $interval_spec );
+
+ $interval->invert = \intval( $invert );
+
+ return $interval;
+ }
+
+ /**
+ * JSON serialize.
+ *
+ * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->specification;
+ }
+
+ /**
+ * To string.
+ *
+ * @link https://www.php.net/manual/en/language.oop5.magic.php#object.tostring
+ * @return string
+ */
+ public function __toString() {
+ return $this->specification;
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionPeriod.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionPeriod.php
new file mode 100644
index 0000000..4515aee
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionPeriod.php
@@ -0,0 +1,298 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Payments\Payment;
+
+/**
+ * Subscription Period
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.4.0
+ */
+class SubscriptionPeriod {
+ /**
+ * Phase.
+ *
+ * @var SubscriptionPhase
+ */
+ private $phase;
+
+ /**
+ * The start date of this period.
+ *
+ * @var DateTime
+ */
+ private $start_date;
+
+ /**
+ * The end date of this period.
+ *
+ * @var DateTime
+ */
+ private $end_date;
+
+ /**
+ * The amount to pay for this period.
+ *
+ * @var Money
+ */
+ private $amount;
+
+ /**
+ * Construct and initialize subscription period object.
+ *
+ * @param SubscriptionPhase $phase Subscription phase.
+ * @param \DateTimeInterface $start_date Start date.
+ * @param \DateTimeInterface $end_date End date.
+ * @param Money $amount Taxed amount.
+ */
+ public function __construct( SubscriptionPhase $phase, \DateTimeInterface $start_date, \DateTimeInterface $end_date, Money $amount ) {
+ $this->phase = $phase;
+ $this->start_date = DateTime::create_from_interface( $start_date );
+ $this->end_date = DateTime::create_from_interface( $end_date );
+ $this->amount = $amount;
+ }
+
+ /**
+ * Get phase.
+ *
+ * @return SubscriptionPhase
+ */
+ public function get_phase() {
+ return $this->phase;
+ }
+
+ /**
+ * Set phase.
+ *
+ * @param SubscriptionPhase $phase Phase.
+ * @return void
+ */
+ public function set_phase( SubscriptionPhase $phase ) {
+ $this->phase = $phase;
+ }
+
+ /**
+ * Get start date.
+ *
+ * @return DateTime
+ */
+ public function get_start_date() {
+ return $this->start_date;
+ }
+
+ /**
+ * Get end date.
+ *
+ * @return DateTime
+ */
+ public function get_end_date() {
+ return $this->end_date;
+ }
+
+ /**
+ * Get amount.
+ *
+ * @return Money
+ */
+ public function get_amount() {
+ return $this->amount;
+ }
+
+ /**
+ * Is trial period?
+ *
+ * @return bool
+ */
+ public function is_trial() {
+ return $this->phase->is_trial();
+ }
+
+ /**
+ * New payment.
+ *
+ * @return Payment
+ */
+ public function new_payment() {
+ $subscription = $this->phase->get_subscription();
+
+ $payment = $subscription->new_payment();
+
+ $payment->add_period( $this );
+
+ $payment->set_total_amount( $this->phase->get_amount() );
+
+ return $payment;
+ }
+
+ /**
+ * From JSON.
+ *
+ * @param object $json Subscription period JSON.
+ * @return SubscriptionPeriod
+ * @throws \InvalidArgumentException Throws exception on invalid JSON.
+ * @throws \Exception Throws exception on problem.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( ! isset( $json->phase ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `phase` property.' );
+ }
+
+ if ( ! isset( $json->start_date ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `start_date` property.' );
+ }
+
+ if ( ! isset( $json->end_date ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `end_date` property.' );
+ }
+
+ if ( ! isset( $json->amount ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `amount` property.' );
+ }
+
+ /**
+ * Phase.
+ */
+ if ( ! property_exists( $json->phase, 'subscription' ) ) {
+ throw new \InvalidArgumentException( 'The `phase` property must contain a `subscription` property.' );
+ }
+
+ if ( ! property_exists( $json->phase, 'sequence_number' ) ) {
+ throw new \InvalidArgumentException( 'The `phase` property must contain a `sequence_number` property.' );
+ }
+
+ /**
+ * Subscription.
+ */
+ if ( ! \property_exists( $json->phase->subscription, 'id' ) ) {
+ throw new \InvalidArgumentException( 'The `subscription` property must contain an `id` property.' );
+ }
+
+ $subscription = \get_pronamic_subscription( $json->phase->subscription->id );
+
+ if ( null === $subscription ) {
+ throw new \Exception(
+ \sprintf(
+ 'Unable to find subscription by id: %s.',
+ \esc_html( $json->phase->subscription->id )
+ )
+ );
+ }
+
+ $phase = $subscription->get_phase_by_sequence_number( $json->phase->sequence_number );
+
+ if ( null === $phase ) {
+ throw new \Exception(
+ \sprintf(
+ 'Unable to find subscription phase by sequence number: %s.',
+ \esc_html( $json->phase->sequence_number )
+ )
+ );
+ }
+
+ $start_date = new DateTime( $json->start_date );
+ $end_date = new DateTime( $json->end_date );
+
+ $amount = MoneyJsonTransformer::from_json( $json->amount );
+
+ return new self( $phase, $start_date, $end_date, $amount );
+ }
+
+ /**
+ * To JSON.
+ *
+ * @return object
+ */
+ public function to_json() {
+ $json = (object) [
+ 'phase' => (object) [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/%s/%s/%d/phases/%d',
+ 'pronamic-pay/v1',
+ 'subscriptions',
+ $this->phase->get_subscription()->get_id(),
+ $this->phase->get_sequence_number()
+ )
+ ),
+ 'subscription' => (object) [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/%s/%s/%d',
+ 'pronamic-pay/v1',
+ 'subscriptions',
+ $this->phase->get_subscription()->get_id()
+ )
+ ),
+ 'id' => $this->phase->get_subscription()->get_id(),
+ ],
+ 'sequence_number' => $this->phase->get_sequence_number(),
+ ],
+ 'start_date' => $this->start_date->format( \DATE_ATOM ),
+ 'end_date' => $this->end_date->format( \DATE_ATOM ),
+ 'amount' => $this->amount->jsonSerialize(),
+ ];
+
+ return $json;
+ }
+
+ /**
+ * Human readable range.
+ *
+ * @param null|string $format Date format.
+ * @param string $separator Period separator.
+ * @return string
+ */
+ public function human_readable_range( $format = null, $separator = '–' ) {
+ $start = $this->get_start_date();
+ $end = $this->get_end_date();
+
+ if ( null === $format ) {
+ $format = __( 'D j M Y', 'pronamic_ideal' );
+ }
+
+ $format_start = $format;
+ $format_end = $format;
+
+ // Check if year is equal.
+ if ( $start->format( 'Y' ) === $end->format( 'Y' ) ) {
+ $format_start = \str_replace( ' Y', '', $format_start );
+
+ // Check if month is equal.
+ if ( $start->format( 'm' ) === $end->format( 'm' ) ) {
+ $format_start = \str_replace( ' m', '', $format_start );
+ $format_start = \str_replace( ' M', '', $format_start );
+ }
+ }
+
+ // Check if day is equal.
+ if ( $start->format( 'D' ) === $end->format( 'D' ) ) {
+ $format_end = \str_replace( 'D ', '', $format_end );
+ }
+
+ return \sprintf(
+ '%1$s %2$s %3$s',
+ $start->format_i18n( $format_start ),
+ \esc_html( $separator ),
+ $end->format_i18n( $format_end )
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionPhase.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionPhase.php
new file mode 100644
index 0000000..f963fbb
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionPhase.php
@@ -0,0 +1,698 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\DateTime\DateTimeInterface;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+
+/**
+ * Subscription Phase
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.5.0
+ */
+class SubscriptionPhase implements \JsonSerializable {
+ /**
+ * Subscription.
+ *
+ * @var Subscription
+ */
+ private $subscription;
+
+ /**
+ * The sequence number.
+ *
+ * @var int|null
+ */
+ private $sequence_number;
+
+ /**
+ * Canceled at.
+ *
+ * @var DateTimeImmutable|null
+ */
+ private $canceled_at;
+
+ /**
+ * Amount.
+ *
+ * @var Money
+ */
+ private $amount;
+
+ /**
+ * Interval.
+ *
+ * @var SubscriptionInterval
+ */
+ private $interval;
+
+ /**
+ * The date this phase will start.
+ *
+ * @var DateTimeImmutable
+ */
+ private $start_date;
+
+ /**
+ * The date this phase will end.
+ *
+ * @var DateTimeImmutable|null
+ */
+ private $end_date;
+
+ /**
+ * Alignment rate.
+ *
+ * @var float|null
+ */
+ private $alignment_rate;
+
+ /**
+ * Proration.
+ *
+ * @var bool
+ */
+ private $is_prorated;
+
+ /**
+ * Boolean flag to indicate a trial subscription phase.
+ *
+ * @var bool
+ */
+ private $is_trial;
+
+ /**
+ * Construct subscription phase.
+ *
+ * @param Subscription $subscription Subscription.
+ * @param \DateTimeInterface $start_date Start date.
+ * @param SubscriptionInterval $interval Interval.
+ * @param Money $amount Amount.
+ * @return void
+ */
+ public function __construct( Subscription $subscription, \DateTimeInterface $start_date, SubscriptionInterval $interval, Money $amount ) {
+ $this->subscription = $subscription;
+
+ $this->set_start_date( $start_date );
+
+ $this->interval = $interval;
+ $this->amount = $amount;
+
+ $this->is_prorated = false;
+ $this->is_trial = false;
+ }
+
+ /**
+ * Get subscription.
+ *
+ * @return Subscription
+ */
+ public function get_subscription() {
+ return $this->subscription;
+ }
+
+ /**
+ * Get sequence number.
+ *
+ * @return int|null
+ */
+ public function get_sequence_number() {
+ return $this->sequence_number;
+ }
+
+ /**
+ * Set sequence number.
+ *
+ * @param int $sequence_number Sequence number.
+ * @return void
+ */
+ public function set_sequence_number( $sequence_number ) {
+ $this->sequence_number = $sequence_number;
+ }
+
+ /**
+ * Get start date.
+ *
+ * @return DateTimeImmutable
+ */
+ public function get_start_date() {
+ return $this->start_date;
+ }
+
+ /**
+ * Set start date.
+ *
+ * @param \DateTimeInterface $start_date Start date.
+ * @return void
+ */
+ public function set_start_date( $start_date ) {
+ $this->start_date = DateTimeImmutable::create_from_interface( $start_date );
+ }
+
+ /**
+ * Get end date.
+ *
+ * @return DateTimeImmutable|null
+ */
+ public function get_end_date() {
+ return $this->end_date;
+ }
+
+ /**
+ * Set end date.
+ *
+ * @param \DateTimeInterface|null $end_date End date.
+ * @return void
+ */
+ public function set_end_date( $end_date ) {
+ $this->end_date = ( null === $end_date ) ? null : DateTimeImmutable::create_from_interface( $end_date );
+ }
+
+ /**
+ * Get next date.
+ *
+ * @return DateTimeImmutable|null
+ */
+ public function get_next_date() {
+ /**
+ * Check whether all periods have been created, if so there is no next date.
+ */
+ if ( $this->all_periods_created() ) {
+ return null;
+ }
+
+ /**
+ * Check whether phase has been canceled, if so there is no next date.
+ */
+ if ( $this->is_canceled() ) {
+ return null;
+ }
+
+ /**
+ * Ok.
+ */
+ return $this->subscription->get_next_payment_date();
+ }
+
+ /**
+ * Set next date.
+ *
+ * @param \DateTimeInterface|null $next_date Next date.
+ * @return void
+ */
+ public function set_next_date( $next_date ) {
+ $this->subscription->set_next_payment_date( $next_date );
+ }
+
+ /**
+ * Check if this phase is canceled.
+ *
+ * @link https://www.grammarly.com/blog/canceled-vs-cancelled/
+ * @link https://docs.mollie.com/reference/v2/subscriptions-api/cancel-subscription
+ * @return bool True if canceled, false otherwise.
+ */
+ public function is_canceled() {
+ return ( null !== $this->canceled_at );
+ }
+
+ /**
+ * Get canceled date.
+ *
+ * @return DateTimeImmutable|null Canceled date or null if phase is not canceled (yet).
+ */
+ public function get_canceled_at() {
+ return $this->canceled_at;
+ }
+
+ /**
+ * Set canceled date.
+ *
+ * @param DateTimeImmutable|null $canceled_at Canceled date.
+ * @return void
+ */
+ public function set_canceled_at( DateTimeImmutable $canceled_at = null ) {
+ $this->canceled_at = $canceled_at;
+ }
+
+ /**
+ * Get amount.
+ *
+ * @return Money
+ */
+ public function get_amount() {
+ return $this->amount;
+ }
+
+ /**
+ * Set amount.
+ *
+ * @param Money $amount Amount.
+ * @return void
+ */
+ public function set_amount( $amount ) {
+ $this->amount = $amount;
+ }
+
+ /**
+ * Get total periods.
+ *
+ * @return int|null
+ */
+ public function get_total_periods() {
+ if ( null === $this->end_date ) {
+ return null;
+ }
+
+ $period = new \DatePeriod( $this->start_date, $this->interval, $this->end_date );
+
+ return \iterator_count( $period );
+ }
+
+ /**
+ * Set total periods.
+ *
+ * @param int|null $total_periods Total periods to create.
+ * @return void
+ */
+ public function set_total_periods( $total_periods ) {
+ $this->set_end_date( null === $total_periods ? null : $this->add_interval( $this->start_date, $total_periods ) );
+ }
+
+ /**
+ * Get periods created.
+ *
+ * @return int
+ */
+ public function get_periods_created() {
+ $next_date = $this->subscription->get_next_payment_date();
+
+ if ( null === $next_date ) {
+ return 0;
+ }
+
+ $period = new \DatePeriod(
+ new \DateTimeImmutable( $this->start_date->format( 'Y-m-d 00:00:00' ) ),
+ $this->interval,
+ new \DateTimeImmutable( $next_date->format( 'Y-m-d 00:00:00' ) )
+ );
+
+ return \iterator_count( $period );
+ }
+
+ /**
+ * Set periods created.
+ *
+ * @param int $periods_created The number of periods created.
+ * @return void
+ */
+ public function set_periods_created( $periods_created ) {
+ $this->set_next_date( $this->add_interval( $this->start_date, $periods_created ) );
+ }
+
+ /**
+ * Get the number of periods that are remaining.
+ *
+ * @return int|null
+ */
+ public function get_periods_remaining() {
+ if ( null === $this->end_date ) {
+ // Infinite.
+ return null;
+ }
+
+ $period = new \DatePeriod( $this->start_date, $this->interval, $this->end_date );
+
+ $total_periods = \iterator_count( $period );
+
+ return $total_periods - $this->get_periods_created();
+ }
+
+ /**
+ * Is alignment.
+ *
+ * @return bool
+ */
+ public function is_alignment() {
+ return ( null !== $this->alignment_rate );
+ }
+
+ /**
+ * Get alignment rate.
+ *
+ * @return float|null
+ */
+ public function get_alignment_rate() {
+ return $this->alignment_rate;
+ }
+
+ /**
+ * Set alignment rate.
+ *
+ * @param float|null $alignment_rate Alignment rate.
+ * @return void
+ */
+ public function set_alignment_rate( $alignment_rate ) {
+ $this->alignment_rate = $alignment_rate;
+ }
+
+ /**
+ * Is prorated.
+ *
+ * @return bool
+ */
+ public function is_prorated() {
+ return $this->is_prorated;
+ }
+
+ /**
+ * Set prorated.
+ *
+ * @param bool $is_prorated Proration.
+ * @return void
+ */
+ public function set_prorated( $is_prorated ) {
+ $this->is_prorated = $is_prorated;
+ }
+
+ /**
+ * Check if this phase is a trial.
+ *
+ * @return bool True if trial, false otherwise.
+ */
+ public function is_trial() {
+ return $this->is_trial;
+ }
+
+ /**
+ * Set trial.
+ *
+ * @param bool $is_trial Trial.
+ * @return void
+ */
+ public function set_trial( $is_trial ) {
+ $this->is_trial = $is_trial;
+ }
+
+ /**
+ * The subscription phase is infinite when the total periods number is undefined.
+ *
+ * @return bool True if infinite, false otherwise.
+ */
+ public function is_infinite() {
+ return ( null === $this->end_date );
+ }
+
+ /**
+ * Check if all periods are created.
+ *
+ * @return bool True if all periods are created, false otherwise.
+ */
+ public function all_periods_created() {
+ return $this->is_completed_to_date( $this->subscription->get_next_payment_date() );
+ }
+
+ /**
+ * Check if this phase is completed to date.
+ *
+ * @param DateTimeInterface|null $date Date.
+ * @return bool True if phase is completed to date, false otherwise.
+ */
+ public function is_completed_to_date( DateTimeInterface $date = null ) {
+ if ( null === $date ) {
+ return true;
+ }
+
+ if ( null === $this->end_date ) {
+ return false;
+ }
+
+ return $date >= $this->end_date;
+ }
+
+ /**
+ * Get interval.
+ *
+ * @link https://www.php.net/manual/en/class.dateinterval.php
+ * @link https://www.php.net/manual/en/dateinterval.construct.php
+ * @return SubscriptionInterval
+ */
+ public function get_interval() {
+ return $this->interval;
+ }
+
+ /**
+ * Add subscription phase interval to date.
+ *
+ * @param DateTimeImmutable $date Date to add interval period to.
+ * @param int $times Number of times to add interval.
+ * @return DateTimeImmutable
+ */
+ private function add_interval( $date, $times = 1 ) {
+ // If times is zero there is nothing to add.
+ if ( 0 === $times ) {
+ return $date;
+ }
+
+ // Multiply date interval.
+ return $date->add( $this->interval->multiply( $times ) );
+ }
+
+ /**
+ * Get period for the specified start date.
+ *
+ * @param DateTimeImmutable $start_date Start date.
+ * @return SubscriptionPeriod|null
+ */
+ public function get_period( DateTimeImmutable $start_date = null ) {
+ if ( null === $start_date ) {
+ return null;
+ }
+
+ if ( $this->start_date > $start_date ) {
+ return null;
+ }
+
+ $end_date = $this->add_interval( $start_date );
+
+ if ( null !== $this->end_date && $end_date > $this->end_date ) {
+ return null;
+ }
+
+ $period = new SubscriptionPeriod( $this, $start_date, $end_date, $this->get_amount() );
+
+ return $period;
+ }
+
+ /**
+ * Get next period.
+ *
+ * @return SubscriptionPeriod|null
+ */
+ public function get_next_period() {
+ return $this->get_period( $this->get_next_date() );
+ }
+
+ /**
+ * Next period.
+ *
+ * This method works like the PHP native `next` function, it will advance the internal
+ * pointer of this subscription phase.
+ *
+ * @return SubscriptionPeriod|null
+ */
+ public function next_period() {
+ $next_period = $this->get_next_period();
+
+ if ( null === $next_period ) {
+ return null;
+ }
+
+ $this->set_next_date( $next_period->get_end_date() );
+
+ return $next_period;
+ }
+
+ /**
+ * Get JSON object.
+ *
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return (object) [
+ 'subscription' => (object) [
+ '$ref' => \rest_url(
+ \sprintf(
+ '/%s/%s/%d',
+ 'pronamic-pay/v1',
+ 'subscriptions',
+ $this->subscription->get_id()
+ )
+ ),
+ ],
+ 'sequence_number' => $this->get_sequence_number(),
+ 'start_date' => $this->start_date->format( \DATE_ATOM ),
+ 'end_date' => ( null === $this->end_date ) ? null : $this->end_date->format( \DATE_ATOM ),
+ 'interval' => $this->interval->get_specification(),
+ 'amount' => $this->amount->jsonSerialize(),
+ // Numbers.
+ 'total_periods' => $this->get_total_periods(),
+ 'periods_created' => $this->get_periods_created(),
+ 'periods_remaining' => $this->get_periods_remaining(),
+ // Other.
+ 'canceled_at' => ( null === $this->canceled_at ) ? null : $this->canceled_at->format( \DATE_ATOM ),
+ 'alignment_rate' => $this->alignment_rate,
+ // Flags.
+ 'is_alignment' => $this->is_alignment(),
+ 'is_prorated' => $this->is_prorated(),
+ 'is_trial' => $this->is_trial(),
+ // Readonly.
+ 'is_infinite' => $this->is_infinite(),
+ 'is_canceled' => $this->is_canceled(),
+ ];
+ }
+
+ /**
+ * Create subscription phase from object.
+ *
+ * @param mixed $json JSON.
+ * @return SubscriptionPhase
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( ! isset( $json->subscription ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `subscription` property.' );
+ }
+
+ if ( ! isset( $json->start_date ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `start_date` property.' );
+ }
+
+ if ( ! isset( $json->interval ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `interval` property.' );
+ }
+
+ if ( ! isset( $json->amount ) ) {
+ throw new \InvalidArgumentException( 'Object must contain `amount` property.' );
+ }
+
+ $start_date = new DateTimeImmutable( $json->start_date );
+
+ $phase = new self(
+ $json->subscription,
+ $start_date,
+ new SubscriptionInterval( $json->interval ),
+ MoneyJsonTransformer::from_json( $json->amount )
+ );
+
+ if ( property_exists( $json, 'total_periods' ) ) {
+ $phase->set_total_periods( $json->total_periods );
+ }
+
+ if ( property_exists( $json, 'end_date' ) ) {
+ $phase->set_end_date( null === $json->end_date ? null : new DateTimeImmutable( $json->end_date ) );
+ }
+
+ if ( property_exists( $json, 'periods_created' ) ) {
+ $phase->set_periods_created( $json->periods_created );
+ }
+
+ if ( property_exists( $json, 'alignment_rate' ) ) {
+ $phase->set_alignment_rate( $json->alignment_rate );
+ }
+
+ if ( property_exists( $json, 'is_prorated' ) ) {
+ $phase->set_prorated( \boolval( $json->is_prorated ) );
+ }
+
+ if ( property_exists( $json, 'is_trial' ) ) {
+ $phase->set_trial( \boolval( $json->is_trial ) );
+ }
+
+ if ( property_exists( $json, 'canceled_at' ) ) {
+ if ( null !== $json->canceled_at ) {
+ $phase->set_canceled_at( new DateTimeImmutable( $json->canceled_at ) );
+ }
+ }
+
+ return $phase;
+ }
+
+ /**
+ * Align the phase to align date.
+ *
+ * @param self $phase The phase to align.
+ * @param \DateTimeInterface $align_date The alignment date.
+ * @return SubscriptionPhase
+ * @throws \Exception Throws exception on invalid date interval.
+ */
+ public static function align( self $phase, \DateTimeInterface $align_date ) {
+ $start_date = $phase->get_start_date();
+
+ $next_date = $start_date->add( $phase->get_interval() );
+
+ $regular_difference = $start_date->diff( $next_date, true );
+
+ /**
+ * PHPStan fix.
+ *
+ * If the DateInterval object was created by DateTime::diff(), then this is the total
+ * number of days between the start and end dates. Otherwise, days will be FALSE.
+ */
+ if ( false === $regular_difference->days ) {
+ throw new \Exception( 'Could not calculate the total number of days between the phase start date and the next period start date.' );
+ }
+
+ $alignment_difference = $start_date->diff( $align_date, true );
+
+ /**
+ * PHPStan fix.
+ *
+ * If the DateInterval object was created by DateTime::diff(), then this is the total
+ * number of days between the start and end dates. Otherwise, days will be FALSE.
+ */
+ if ( false === $alignment_difference->days ) {
+ throw new \Exception( 'Could not calculate the total number of days between the phase start date and the next alignment date.' );
+ }
+
+ $alignment_interval = new SubscriptionInterval( 'P' . $alignment_difference->days . 'D' );
+
+ $alignment_phase = new self( $phase->get_subscription(), $start_date, $alignment_interval, $phase->get_amount() );
+
+ $alignment_phase->set_total_periods( 1 );
+ $alignment_phase->set_alignment_rate( $alignment_difference->days / $regular_difference->days );
+
+ // Remove one period from regular phase.
+ $total_periods = $phase->get_total_periods();
+
+ if ( null !== $total_periods ) {
+ $phase->set_total_periods( $total_periods - 1 );
+ }
+
+ $alignment_end_date = $alignment_phase->get_end_date();
+
+ if ( null === $alignment_end_date ) {
+ throw new \Exception( 'The align phase should always end because this phase exists for one period.' );
+ }
+
+ $phase->set_start_date( $alignment_end_date );
+
+ return $alignment_phase;
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionPostType.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionPostType.php
new file mode 100644
index 0000000..befd3d5
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionPostType.php
@@ -0,0 +1,213 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\Pay\Payments\PaymentPostType;
+
+/**
+ * Title: WordPress iDEAL post types
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 1.0.0
+ */
+class SubscriptionPostType {
+ /**
+ * Constructs and initializes an post types object
+ */
+ public function __construct() {
+ /**
+ * Priority of the initial post types function should be set to < 10.
+ *
+ * @link https://core.trac.wordpress.org/ticket/28488
+ * @link https://core.trac.wordpress.org/changeset/29318
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.0/wp-includes/post.php#L167
+ */
+ add_action( 'init', [ $this, 'register_subscription_post_type' ], 0 ); // Highest priority.
+ add_action( 'init', [ $this, 'register_post_status' ], 9 );
+ }
+
+ /**
+ * Register post types.
+ *
+ * @link https://github.com/WordPress/WordPress/blob/4.6.1/wp-includes/post.php#L1277-L1300
+ * @return void
+ */
+ public function register_subscription_post_type() {
+ register_post_type(
+ 'pronamic_pay_subscr',
+ [
+ 'label' => __( 'Subscriptions', 'pronamic_ideal' ),
+ 'labels' => [
+ 'name' => __( 'Subscriptions', 'pronamic_ideal' ),
+ 'singular_name' => __( 'Subscription', 'pronamic_ideal' ),
+ 'add_new' => __( 'Add New', 'pronamic_ideal' ),
+ 'add_new_item' => __( 'Add New Subscription', 'pronamic_ideal' ),
+ 'edit_item' => __( 'Edit Subscription', 'pronamic_ideal' ),
+ 'new_item' => __( 'New Subscription', 'pronamic_ideal' ),
+ 'all_items' => __( 'All Subscriptions', 'pronamic_ideal' ),
+ 'view_item' => __( 'View Subscription', 'pronamic_ideal' ),
+ 'search_items' => __( 'Search Subscriptions', 'pronamic_ideal' ),
+ 'not_found' => __( 'No subscriptions found.', 'pronamic_ideal' ),
+ 'not_found_in_trash' => __( 'No subscriptions found in Trash.', 'pronamic_ideal' ),
+ 'menu_name' => __( 'Subscriptions', 'pronamic_ideal' ),
+ 'filter_items_list' => __( 'Filter subscriptions list', 'pronamic_ideal' ),
+ 'items_list_navigation' => __( 'Subscriptions list navigation', 'pronamic_ideal' ),
+ 'items_list' => __( 'Subscriptions list', 'pronamic_ideal' ),
+
+ /*
+ * New Post Type Labels in 5.0.
+ * @link https://make.wordpress.org/core/2018/12/05/new-post-type-labels-in-5-0/
+ */
+ 'item_published' => __( 'Subscription published.', 'pronamic_ideal' ),
+ 'item_published_privately' => __( 'Subscription published privately.', 'pronamic_ideal' ),
+ 'item_reverted_to_draft' => __( 'Subscription reverted to draft.', 'pronamic_ideal' ),
+ 'item_scheduled' => __( 'Subscription scheduled.', 'pronamic_ideal' ),
+ 'item_updated' => __( 'Subscription updated.', 'pronamic_ideal' ),
+ ],
+ 'public' => false,
+ 'publicly_queryable' => false,
+ 'show_ui' => true,
+ 'show_in_nav_menus' => false,
+ 'show_in_menu' => false,
+ 'show_in_admin_bar' => false,
+ 'show_in_rest' => true,
+ 'rest_base' => 'pronamic-subscriptions',
+ 'supports' => [
+ 'pronamic_pay_subscription',
+ ],
+ 'rewrite' => false,
+ 'query_var' => false,
+ 'capabilities' => PaymentPostType::get_capabilities(),
+ 'map_meta_cap' => true,
+ ]
+ );
+ }
+
+ /**
+ * Get subscription states.
+ *
+ * @return array
+ */
+ public static function get_states() {
+ return [
+ 'subscr_pending' => _x( 'Pending', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_cancelled' => _x( 'Cancelled', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_expired' => _x( 'Expired', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_failed' => _x( 'Failed', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_on_hold' => _x( 'On Hold', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_active' => _x( 'Active', 'Subscription status', 'pronamic_ideal' ),
+ 'subscr_completed' => _x( 'Completed', 'Subscription status', 'pronamic_ideal' ),
+ ];
+ }
+
+ /**
+ * Register our custom post statuses, used for order status.
+ *
+ * @return void
+ */
+ public function register_post_status() {
+ /**
+ * Subscription post statuses.
+ */
+ register_post_status(
+ 'subscr_pending',
+ [
+ 'label' => _x( 'Pending', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Pending (%s) ', 'Pending (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_cancelled',
+ [
+ 'label' => _x( 'Cancelled', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Cancelled (%s) ', 'Cancelled (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_expired',
+ [
+ 'label' => _x( 'Expired', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Expired (%s) ', 'Expired (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_failed',
+ [
+ 'label' => _x( 'Failed', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Failed (%s) ', 'Failed (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_on_hold',
+ [
+ 'label' => _x( 'On Hold', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'On Hold (%s) ', 'On Hold (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_active',
+ [
+ 'label' => _x( 'Active', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Active (%s) ', 'Active (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+
+ register_post_status(
+ 'subscr_completed',
+ [
+ 'label' => _x( 'Completed', 'Subscription status', 'pronamic_ideal' ),
+ 'public' => false,
+ 'exclude_from_search' => false,
+ 'show_in_admin_all_list' => true,
+ 'show_in_admin_status_list' => true,
+ /* translators: %s: count value */
+ 'label_count' => _n_noop( 'Completed (%s) ', 'Completed (%s) ', 'pronamic_ideal' ),
+ ]
+ );
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionStatus.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionStatus.php
new file mode 100644
index 0000000..9282358
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionStatus.php
@@ -0,0 +1,72 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Core
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+/**
+ * Title: WordPress pay subscription statuses constants
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @author Reüel van der Steege
+ * @version 2.2.4
+ * @since 2.2.4
+ */
+class SubscriptionStatus {
+ /**
+ * Status indicator for active
+ *
+ * @var string
+ */
+ const ACTIVE = 'Active';
+
+ /**
+ * Status indicator for cancelled
+ *
+ * @var string
+ */
+ const CANCELLED = 'Cancelled';
+
+ /**
+ * Status indicator for completed
+ *
+ * @var string
+ */
+ const COMPLETED = 'Completed';
+
+ /**
+ * Status indicator for expired
+ *
+ * @var string
+ */
+ const EXPIRED = 'Expired';
+
+ /**
+ * Status indicator for failure
+ *
+ * @var string
+ */
+ const FAILURE = 'Failure';
+
+ /**
+ * Status indicator for on hold
+ *
+ * @var string
+ */
+ const ON_HOLD = 'On Hold';
+
+ /**
+ * Status indicator for open
+ *
+ * @var string
+ */
+ const OPEN = 'Open';
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsCompletionController.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsCompletionController.php
new file mode 100644
index 0000000..7515619
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsCompletionController.php
@@ -0,0 +1,278 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use WP_CLI;
+use WP_Post;
+use WP_Query;
+
+/**
+ * Subscriptions completion controller
+ */
+class SubscriptionsCompletionController {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ \add_action( 'init', [ $this, 'maybe_schedule_actions' ] );
+
+ \add_action( 'pronamic_pay_schedule_subscriptions_completion', [ $this, 'schedule_all' ] );
+
+ \add_action( 'pronamic_pay_schedule_paged_subscriptions_completion', [ $this, 'schedule_paged' ] );
+
+ \add_action( 'pronamic_pay_complete_subscription', [ $this, 'action_complete_subscription' ] );
+ }
+
+ /**
+ * Maybe schedule actions.
+ *
+ * @link https://actionscheduler.org/
+ * @return void
+ */
+ public function maybe_schedule_actions() {
+ if ( false === \as_next_scheduled_action( 'pronamic_pay_schedule_subscriptions_completion', [], 'pronamic-pay' ) ) {
+ \as_schedule_cron_action( \time(), '0 * * * *', 'pronamic_pay_schedule_subscriptions_completion', [], 'pronamic-pay' );
+ }
+ }
+
+ /**
+ * Schedule all.
+ *
+ * @return void
+ */
+ public function schedule_all() {
+ if ( $this->is_processing_disabled() ) {
+ return;
+ }
+
+ $query = $this->get_subscriptions_wp_query_that_require_completion();
+
+ if ( 0 === $query->max_num_pages ) {
+ return;
+ }
+
+ $pages = \range( $query->max_num_pages, 1 );
+
+ foreach ( $pages as $page ) {
+ $this->schedule_page( $page );
+ }
+ }
+
+ /**
+ * Schedule page.
+ *
+ * @param int $page Page.
+ * @return int
+ */
+ private function schedule_page( $page ) {
+ return \as_enqueue_async_action(
+ 'pronamic_pay_schedule_paged_subscriptions_completion',
+ [
+ 'page' => $page,
+ ],
+ 'pronamic-pay'
+ );
+ }
+
+ /**
+ * Schedule paged.
+ *
+ * @param int $page Page.
+ * @return void
+ */
+ public function schedule_paged( $page ) {
+ $query = $this->get_subscriptions_wp_query_that_require_completion(
+ [
+ 'paged' => $page,
+ ]
+ );
+
+ $posts = \array_filter(
+ $query->posts,
+ function ( $post ) {
+ return ( $post instanceof WP_Post );
+ }
+ );
+
+ $subscriptions = [];
+
+ foreach ( $posts as $post ) {
+ $subscription = \get_pronamic_subscription( $post->ID );
+
+ if ( null !== $subscription ) {
+ $subscriptions[] = $subscription;
+ }
+ }
+
+ foreach ( $subscriptions as $subscription ) {
+ $this->schedule_subscription_completion( $subscription );
+ }
+ }
+
+
+ /**
+ * Test if the subscription meets the notification requirements.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return bool True if meets requirements, false otherwise.
+ */
+ private function meets_completion_requirements( Subscription $subscription ) {
+ /**
+ * If a subscription does not have a end date, it makes no sense to complete.
+ */
+ $end_date = $subscription->get_end_date();
+
+ if ( null === $end_date ) {
+ return false;
+ }
+
+ /**
+ * If the end date is in the future, it makes no sense to complete.
+ */
+ $date = new \DateTimeImmutable();
+
+ if ( $end_date > $date ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Schedule subscription completion.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return int|null
+ */
+ private function schedule_subscription_completion( Subscription $subscription ) {
+ if ( ! $this->meets_completion_requirements( $subscription ) ) {
+ return null;
+ }
+
+ $action_id = $subscription->get_meta( 'completion_action_id' );
+
+ if ( ! empty( $action_id ) ) {
+ return $action_id;
+ }
+
+ $actions_args = [
+ 'subscription_id' => $subscription->get_id(),
+ ];
+
+ if ( false !== \as_next_scheduled_action( 'pronamic_pay_complete_subscription', $actions_args, 'pronamic-pay' ) ) {
+ return null;
+ }
+
+ $action_id = \as_enqueue_async_action(
+ 'pronamic_pay_complete_subscription',
+ $actions_args,
+ 'pronamic-pay'
+ );
+
+ $subscription->set_meta( 'completion_action_id', $action_id );
+
+ $subscription->save();
+
+ return $action_id;
+ }
+
+ /**
+ * Action complete subscription.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @return void
+ * @throws \Exception Throws exception when unable to load subscription.
+ */
+ public function action_complete_subscription( $subscription_id ) {
+ // Check subscription.
+ $subscription = \get_pronamic_subscription( (int) $subscription_id );
+
+ if ( null === $subscription ) {
+ throw new \Exception(
+ \sprintf(
+ 'Unable to load subscription from post ID: %s.',
+ \esc_html( (string) $subscription_id )
+ )
+ );
+ }
+
+ $this->complete_subscription( $subscription );
+
+ $subscription->set_meta( 'completion_action_id', null );
+
+ $subscription->save();
+ }
+
+ /**
+ * Complete subscription.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ */
+ public function complete_subscription( Subscription $subscription ) {
+ if ( ! $this->meets_completion_requirements( $subscription ) ) {
+ return;
+ }
+
+ $subscription->status = SubscriptionStatus::COMPLETED;
+ }
+
+ /**
+ * Get WordPress query for subscriptions that require a notification.
+ *
+ * @param array $args Arguments.
+ * @return WP_Query
+ */
+ private function get_subscriptions_wp_query_that_require_completion( $args = [] ) {
+ $date = new \DateTimeImmutable( 'now', new \DateTimeZone( 'GMT' ) );
+
+ $query_args = [
+ 'post_type' => 'pronamic_pay_subscr',
+ /**
+ * Posts per page is set to 100, higher could result in performance issues.
+ *
+ * @link https://github.com/WordPress/WordPress-Coding-Standards/wiki/Customizable-sniff-properties#wp-postsperpage-post-limit
+ */
+ 'posts_per_page' => 100,
+ 'post_status' => 'subscr_active',
+ 'meta_query' => [
+ [
+ 'key' => '_pronamic_subscription_end_date',
+ 'compare' => '<=',
+ 'value' => $date->format( 'Y-m-d H:i:s' ),
+ 'type' => 'DATETIME',
+ ],
+ ],
+ 'order' => 'DESC',
+ 'orderby' => 'ID',
+ ];
+
+ if ( \array_key_exists( 'paged', $args ) ) {
+ $query_args['paged'] = $args['paged'];
+ $query_args['no_found_rows'] = true;
+ }
+
+ $query = new WP_Query( $query_args );
+
+ return $query;
+ }
+
+ /**
+ * Is subscriptions processing disabled.
+ *
+ * @return bool True if processing recurring payment is disabled, false otherwise.
+ */
+ private function is_processing_disabled() {
+ return (bool) \get_option( 'pronamic_pay_subscriptions_processing_disabled', false );
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsDataStoreCPT.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsDataStoreCPT.php
new file mode 100644
index 0000000..fec18ff
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsDataStoreCPT.php
@@ -0,0 +1,719 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\DateTime\DateTimeZone;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Customer;
+use Pronamic\WordPress\Pay\MoneyJsonTransformer;
+use Pronamic\WordPress\Pay\Payments\LegacyPaymentsDataStoreCPT;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+
+/**
+ * Title: Subscriptions data store CPT
+ *
+ * @link https://woocommerce.com/2017/04/woocommerce-3-0-release/
+ * @link https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.0.1
+ */
+class SubscriptionsDataStoreCPT extends LegacyPaymentsDataStoreCPT {
+ /**
+ * Subscriptions.
+ *
+ * @var array
+ */
+ private $subscriptions;
+
+ /**
+ * Status map.
+ *
+ * @var array
+ */
+ private $status_map;
+
+ /**
+ * Construct subscriptions data store CPT object.
+ */
+ public function __construct() {
+ $this->meta_key_prefix = '_pronamic_subscription_';
+
+ $this->register_meta();
+
+ $this->subscriptions = [];
+
+ $this->status_map = [
+ SubscriptionStatus::CANCELLED => 'subscr_cancelled',
+ SubscriptionStatus::EXPIRED => 'subscr_expired',
+ SubscriptionStatus::FAILURE => 'subscr_failed',
+ SubscriptionStatus::ACTIVE => 'subscr_active',
+ SubscriptionStatus::ON_HOLD => 'subscr_on_hold',
+ SubscriptionStatus::OPEN => 'subscr_pending',
+ SubscriptionStatus::COMPLETED => 'subscr_completed',
+ // Map payment status `Success` for backwards compatibility.
+ PaymentStatus::SUCCESS => 'subscr_active',
+ ];
+ }
+
+ /**
+ * Preserves the initial JSON post_content passed to save into the post.
+ *
+ * This is needed to prevent KSES and other {@see 'content_save_pre'} filters
+ * from corrupting JSON data.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/160
+ * @link https://developer.wordpress.org/reference/hooks/wp_insert_post_data/
+ * @param array $data An array of slashed and processed post data.
+ * @param array $postarr An array of sanitized (and slashed) but otherwise unmodified post data.
+ * @param array $unsanitized_postarr An array of slashed yet *unsanitized* and unprocessed post data as originally passed to wp_insert_post().
+ * @return array Filtered post data.
+ */
+ public function preserve_post_content( $data, $postarr, $unsanitized_postarr ) {
+ if ( ! \array_key_exists( 'post_type', $data ) ) {
+ return $data;
+ }
+
+ if ( 'pronamic_pay_subscr' !== $data['post_type'] ) {
+ return $data;
+ }
+
+ if ( ! \array_key_exists( 'post_content', $unsanitized_postarr ) ) {
+ return $data;
+ }
+
+ $data['post_content'] = $unsanitized_postarr['post_content'];
+
+ return $data;
+ }
+
+ /**
+ * Get subscription by ID.
+ *
+ * @param int $id Payment ID.
+ * @return Subscription|null
+ */
+ public function get_subscription( $id ) {
+ if ( \array_key_exists( $id, $this->subscriptions ) ) {
+ return $this->subscriptions[ $id ];
+ }
+
+ if ( empty( $id ) ) {
+ return null;
+ }
+
+ $id = (int) $id;
+
+ $post_type = \get_post_type( $id );
+
+ if ( 'pronamic_pay_subscr' !== $post_type ) {
+ return null;
+ }
+
+ $subscription = new Subscription();
+
+ $subscription->set_id( $id );
+
+ $this->subscriptions[ $id ] = $subscription;
+
+ $this->read( $subscription );
+
+ return $this->subscriptions[ $id ];
+ }
+
+ /**
+ * Get post status from meta status.
+ *
+ * @param string|null $meta_status Meta status.
+ * @return string|null
+ */
+ private function get_post_status_from_meta_status( $meta_status ) {
+ if ( null === $meta_status ) {
+ return null;
+ }
+
+ if ( isset( $this->status_map[ $meta_status ] ) ) {
+ return $this->status_map[ $meta_status ];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get post data.
+ *
+ * @param Subscription $subscription Payment.
+ * @param array $data Post data.
+ * @return array
+ * @throws \Exception Throws an exception if an error occurs while encoding the payment to JSON.
+ */
+ private function get_post_data( Subscription $subscription, $data ) {
+ $json_string = \wp_json_encode( $subscription->get_json() );
+
+ if ( false === $json_string ) {
+ throw new \Exception( 'Error occurred while encoding the subscription to JSON.' );
+ }
+
+ $data['post_content'] = \wp_slash( $json_string );
+ $data['post_mime_type'] = 'application/json';
+
+ $status = $this->get_post_status_from_meta_status( $subscription->get_status() );
+
+ if ( null !== $status ) {
+ $data['post_status'] = $status;
+ }
+
+ return $data;
+ }
+
+ /**
+ * Create subscription.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L47-L76
+ *
+ * @param Subscription $subscription Create the specified subscription in this data store.
+ * @return bool
+ * @throws \Exception Throws exception when create fails.
+ */
+ public function create( $subscription ) {
+ /**
+ * Pre-create subscription.
+ *
+ * @param Subscription $subscription Subscription.
+ */
+ \do_action( 'pronamic_pay_pre_create_subscription', $subscription );
+
+ $customer = $subscription->get_customer();
+
+ $customer_user_id = null === $customer ? 0 : $customer->get_user_id();
+
+ $result = \wp_insert_post(
+ $this->get_post_data(
+ $subscription,
+ [
+ 'post_type' => 'pronamic_pay_subscr',
+ 'post_date_gmt' => $this->get_mysql_utc_date( $subscription->date ),
+ 'post_title' => \sprintf(
+ 'Subscription %s',
+ $subscription->get_key()
+ ),
+ 'post_author' => null === $customer_user_id ? 0 : $customer_user_id,
+ ]
+ ),
+ true
+ );
+
+ if ( \is_wp_error( $result ) ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not create subscription: "%s".',
+ \esc_html( $result->get_error_message() )
+ )
+ );
+ }
+
+ $subscription->set_id( $result );
+ $subscription->post = \get_post( $result );
+
+ $this->subscriptions[ $result ] = $subscription;
+
+ /**
+ * New subscription created.
+ *
+ * @param Subscription $subscription Subscription.
+ */
+ do_action( 'pronamic_pay_new_subscription', $subscription );
+
+ return true;
+ }
+
+ /**
+ * Update subscription.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L113-L154
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ *
+ * @param Subscription $subscription The subscription to update in this data store.
+ * @return bool
+ * @throws \Exception Throws exception when update fails.
+ */
+ public function update( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return false;
+ }
+
+ $result = \wp_update_post(
+ $this->get_post_data(
+ $subscription,
+ [
+ 'ID' => $id,
+ ]
+ ),
+ true
+ );
+
+ if ( \is_wp_error( $result ) ) {
+ throw new \Exception(
+ \sprintf(
+ 'Could not update subscription: "%s".',
+ \esc_html( $result->get_error_message() )
+ )
+ );
+ }
+
+ $subscription->post = \get_post( $result );
+
+ $this->subscriptions[ $result ] = $subscription;
+
+ return true;
+ }
+
+ /**
+ * Save subscription.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L113-L154
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ * @param Subscription $subscription The subscription to save in this data store.
+ * @return boolean True if saved, false otherwise.
+ */
+ public function save( $subscription ) {
+ $id = $subscription->get_id();
+
+ \add_filter( 'wp_insert_post_data', [ $this, 'preserve_post_content' ], 5, 3 );
+
+ $result = empty( $id ) ? $this->create( $subscription ) : $this->update( $subscription );
+
+ \remove_filter( 'wp_insert_post_data', [ $this, 'preserve_post_content' ], 5 );
+
+ $this->update_post_meta( $subscription );
+
+ return $result;
+ }
+
+ /**
+ * Read subscription.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/abstract-wc-order-data-store-cpt.php#L78-L111
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L81-L136
+ * @link https://developer.wordpress.org/reference/functions/get_post_field/
+ *
+ * @param Subscription $subscription The subscription to read the additional data for.
+ *
+ * @return void
+ * @throws \Exception Throws exception on invalid post date.
+ */
+ public function read( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $subscription->post = get_post( $id );
+ $subscription->title = get_the_title( $id );
+ $subscription->date = new DateTime( get_post_field( 'post_date_gmt', $id, 'raw' ), new DateTimeZone( 'UTC' ) );
+
+ $content = get_post_field( 'post_content', $id, 'raw' );
+
+ $json = json_decode( $content );
+
+ if ( is_object( $json ) ) {
+ Subscription::from_json( $json, $subscription );
+ }
+
+ // Set user ID from `post_author` field if not set from subscription JSON.
+ $customer = $subscription->get_customer();
+
+ if ( null === $customer ) {
+ $customer = new Customer();
+
+ $subscription->set_customer( $customer );
+ }
+
+ if ( null === $customer->get_user_id() ) {
+ $post_author = intval( get_post_field( 'post_author', $id, 'raw' ) );
+
+ if ( ! empty( $post_author ) ) {
+ $customer->set_user_id( $post_author );
+ }
+ }
+
+ $this->read_post_meta( $subscription );
+
+ // Phases.
+ if ( is_object( $json ) && ! property_exists( $json, 'phases' ) ) {
+ // Amount.
+ $amount = new Money(
+ (string) $this->get_meta( $id, 'amount' ),
+ (string) $this->get_meta_string( $id, 'currency' )
+ );
+
+ if ( \property_exists( $json, 'total_amount' ) ) {
+ $amount = MoneyJsonTransformer::from_json( $json->total_amount );
+ }
+
+ // Phase.
+ $start_date = $this->get_meta_date( $id, 'start_date' );
+
+ if ( null === $start_date ) {
+ $start_date = clone $subscription->get_date();
+ }
+
+ $interval_spec = 'P' . $this->get_meta_int( $id, 'interval' ) . $this->get_meta_string( $id, 'interval_period' );
+
+ $phase = $subscription->new_phase(
+ $start_date,
+ $interval_spec,
+ $amount
+ );
+
+ $phase->set_total_periods( $this->get_meta_int( $id, 'frequency' ) );
+ }
+ }
+
+ /**
+ * Get meta status label.
+ *
+ * @param string|null $meta_status The subscription meta status to get the status label for.
+ * @return string|false
+ */
+ public function get_meta_status_label( $meta_status ) {
+ $post_status = $this->get_post_status_from_meta_status( $meta_status );
+
+ if ( empty( $post_status ) ) {
+ return false;
+ }
+
+ $status_object = get_post_status_object( $post_status );
+
+ if ( isset( $status_object, $status_object->label ) ) {
+ return $status_object->label;
+ }
+
+ return false;
+ }
+
+ /**
+ * Register meta.
+ *
+ * @return void
+ */
+ private function register_meta() {
+ $this->register_meta_key(
+ 'config_id',
+ [
+ 'label' => __( 'Config ID', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'source',
+ [
+ 'label' => __( 'Source', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'source_id',
+ [
+ 'label' => __( 'Source ID', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'currency',
+ [
+ 'label' => __( 'Currency', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'amount',
+ [
+ 'label' => __( 'Amount', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'frequency',
+ [
+ 'label' => __( 'Frequency', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'interval',
+ [
+ 'label' => __( 'Interval', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'interval_period',
+ [
+ 'label' => __( 'Interval Period', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'transaction_id',
+ [
+ 'label' => __( 'Transaction ID', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'status',
+ [
+ 'label' => __( 'Status', 'pronamic_ideal' ),
+ ]
+ );
+
+ $this->register_meta_key(
+ 'description',
+ [
+ 'label' => __( 'Description', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'email',
+ [
+ 'label' => __( 'Email', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'anonymize',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'customer_name',
+ [
+ 'label' => __( 'Customer Name', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+
+ $this->register_meta_key(
+ 'payment_method',
+ [
+ 'label' => __( 'Payment Method', 'pronamic_ideal' ),
+ 'privacy_export' => true,
+ 'privacy_erasure' => 'erase',
+ ]
+ );
+ }
+
+ /**
+ * Read post meta.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/abstracts/abstract-wc-data.php#L462-L507
+ *
+ * @param Subscription $subscription The subscription to read the post meta for.
+ * @return void
+ */
+ protected function read_post_meta( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $subscription->transaction_id = $this->get_meta_string( $id, 'transaction_id' );
+ $subscription->status = $this->get_meta_string( $id, 'status' );
+
+ // Payment method.
+ $payment_method = $subscription->get_payment_method();
+
+ if ( empty( $payment_method ) ) {
+ $subscription->set_payment_method( $this->get_meta_string( $id, 'payment_method' ) );
+ }
+
+ // Set next date.
+ $next_date = $this->get_meta_date( $id, 'next_payment' );
+
+ $subscription->set_next_payment_date( $next_date );
+
+ // Legacy.
+ parent::read_post_meta( $subscription );
+
+ // Read subscription data from first payment.
+ $config_id = $subscription->get_config_id();
+
+ $payment_method = $subscription->get_payment_method();
+
+ if ( null === $config_id || null === $payment_method ) {
+ $first_payment = $this->get_first_payment( $subscription );
+
+ if ( is_object( $first_payment ) ) {
+ // Gateway.
+ if ( empty( $config_id ) ) {
+ $subscription->set_config_id( $first_payment->get_config_id() );
+ }
+
+ // Payment method.
+ if ( empty( $payment_method ) ) {
+ $subscription->set_payment_method( $first_payment->get_payment_method() );
+ }
+ }
+ }
+ }
+
+ /**
+ * Get first payment for subscription.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return \Pronamic\WordPress\Pay\Payments\Payment|null
+ */
+ private function get_first_payment( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return null;
+ }
+
+ $payments = get_pronamic_payments_by_meta(
+ '_pronamic_payment_subscription_id',
+ $id,
+ [
+ 'posts_per_page' => 1,
+ 'orderby' => 'post_date',
+ 'order' => 'ASC',
+ ]
+ );
+
+ $payment = \reset( $payments );
+
+ if ( false !== $payment ) {
+ return $payment;
+ }
+
+ return null;
+ }
+
+ /**
+ * Update payment post meta.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.2.6/includes/data-stores/class-wc-order-data-store-cpt.php#L154-L257
+ * @param Subscription $subscription The subscription to update the post meta for.
+ * @return void
+ */
+ private function update_post_meta( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $customer = $subscription->get_customer();
+
+ $this->update_meta( $id, 'config_id', $subscription->config_id );
+ $this->update_meta( $id, 'source', $subscription->source );
+ $this->update_meta( $id, 'source_id', $subscription->source_id );
+ $this->update_meta( $id, 'email', ( null === $customer ? null : $customer->get_email() ) );
+ $this->update_meta( $id, 'end_date', $subscription->get_end_date() );
+ $this->update_meta( $id, 'next_payment', $subscription->get_next_payment_date() );
+ $this->update_meta( $id, 'next_payment_delivery_date', $subscription->get_next_payment_delivery_date() );
+ $this->update_meta( $id, 'version', $subscription->get_version() );
+
+ // Maybe delete next payment date post meta.
+ if ( null === $subscription->get_next_payment_date() ) {
+ \delete_post_meta( $id, $this->meta_key_prefix . 'next_payment' );
+ \delete_post_meta( $id, $this->meta_key_prefix . 'next_payment_delivery_date' );
+ }
+
+ if ( null === $subscription->get_end_date() ) {
+ \delete_post_meta( $id, $this->meta_key_prefix . 'end_date' );
+ }
+
+ $this->update_meta_status( $subscription );
+ }
+
+ /**
+ * Update meta status.
+ *
+ * @param Subscription $subscription The subscription to update the status for.
+ * @return void
+ */
+ public function update_meta_status( $subscription ) {
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ return;
+ }
+
+ $previous_status = $this->get_meta( $id, 'status' );
+
+ $this->update_meta( $id, 'status', $subscription->status );
+
+ if ( $previous_status !== $subscription->status ) {
+ if ( empty( $previous_status ) ) {
+ $previous_status = null;
+ }
+
+ $can_redirect = false;
+
+ $source = $subscription->source;
+
+ $updated_status = $subscription->status;
+
+ $old_status = empty( $previous_status ) ? 'unknown' : strtolower( $previous_status );
+ $old_status = \str_replace( ' ', '_', $old_status );
+
+ $new_status = empty( $updated_status ) ? 'unknown' : strtolower( $updated_status );
+ $new_status = \str_replace( ' ', '_', $new_status );
+
+ /**
+ * Subscription status updated for plugin integration source from old to new status.
+ *
+ * [`{$source}`](https://github.com/pronamic/wp-pronamic-pay/wiki#sources)
+ * [`{$old_status}`](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status)
+ * [`{$new_status}`](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status)
+ *
+ * @param Subscription $subscription Subscription.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the subscription update.
+ * @param null|string $previous_status Previous [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ * @param null|string $updated_status Updated [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ */
+ do_action( 'pronamic_subscription_status_update_' . $source . '_' . $old_status . '_to_' . $new_status, $subscription, $can_redirect, $previous_status, $updated_status );
+
+ /**
+ * Subscription status updated for plugin integration source.
+ *
+ * [`{$source}`](https://github.com/pronamic/wp-pronamic-pay/wiki#sources)
+ *
+ * @param Subscription $subscription Subscription.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the subscription update.
+ * @param null|string $previous_status Previous [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ * @param null|string $updated_status Updated [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ */
+ do_action( 'pronamic_subscription_status_update_' . $source, $subscription, $can_redirect, $previous_status, $updated_status );
+
+ /**
+ * Subscription status updated.
+ *
+ * @param Subscription $subscription Subscription.
+ * @param bool $can_redirect Flag to indicate if redirect is allowed after the subscription update.
+ * @param null|string $previous_status Previous [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ * @param null|string $updated_status Updated [subscription status](https://github.com/pronamic/wp-pronamic-pay/wiki#subscription-status).
+ */
+ do_action( 'pronamic_subscription_status_update', $subscription, $can_redirect, $previous_status, $updated_status );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsFollowUpPaymentsController.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsFollowUpPaymentsController.php
new file mode 100644
index 0000000..50a26de
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsFollowUpPaymentsController.php
@@ -0,0 +1,436 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\Pay\Plugin;
+use WP_CLI;
+use WP_Post;
+use WP_Query;
+
+/**
+ * Subscriptions follow-up payments controller
+ */
+class SubscriptionsFollowUpPaymentsController {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ \add_action( 'init', [ $this, 'maybe_schedule_actions' ] );
+
+ \add_action( 'pronamic_pay_schedule_follow_up_payments', [ $this, 'schedule_all' ] );
+
+ \add_action( 'pronamic_pay_schedule_subscriptions_follow_up_payment', [ $this, 'schedule_paged' ] );
+
+ \add_action( 'pronamic_pay_create_subscription_follow_up_payment', [ $this, 'action_create_subscription_follow_up_payment' ] );
+
+ $this->cli();
+ }
+
+ /**
+ * CLI.
+ *
+ * @link https://github.com/woocommerce/woocommerce/blob/3.3.1/includes/class-woocommerce.php#L365-L369
+ * @link https://github.com/woocommerce/woocommerce/blob/3.3.1/includes/class-wc-cli.php
+ * @link https://make.wordpress.org/cli/handbook/commands-cookbook/
+ * @return void
+ */
+ private function cli() {
+ if ( ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {
+ return;
+ }
+
+ WP_CLI::add_command(
+ 'pay subscription list',
+ function () {
+ WP_CLI::debug( 'Query subscriptions that require follow-up payment.' );
+
+ $query = $this->get_subscriptions_wp_query_that_require_follow_up_payment();
+
+ WP_CLI::debug( \sprintf( 'Query executed: `found_posts` = %s, `max_num_pages`: %s.', $query->found_posts, $query->max_num_pages ) );
+
+ WP_CLI\Utils\format_items(
+ 'table',
+ $query->posts,
+ [
+ 'ID',
+ 'post_title',
+ ]
+ );
+ }
+ );
+
+ WP_CLI::add_command(
+ 'pay subscription schedule',
+ function ( $args, $assoc_args ) {
+ if ( $this->is_processing_disabled() ) {
+ WP_CLI::error( 'Subscriptions processing is disabled.' );
+ }
+
+ /**
+ * Schedule all subscriptions pages.
+ */
+ $all = \WP_CLI\Utils\get_flag_value( $assoc_args, 'all', false );
+
+ if ( $all ) {
+ WP_CLI::line( 'Schedule all subscriptions pages follow-up payments…' );
+
+ $this->schedule_all();
+ }
+
+ /**
+ * Schedule one subscriptions page.
+ */
+ $page = (int) \WP_CLI\Utils\get_flag_value( $assoc_args, 'page' );
+
+ if ( $page > 0 ) {
+ WP_CLI::line( \sprintf( 'Schedule subscriptions page %s follow-up payments…', $page ) );
+
+ $action_id = $this->schedule_page( $page );
+
+ WP_CLI::line( \sprintf( 'Action scheduled: %s', (string) $action_id ) );
+ }
+
+ /**
+ * Schedule specific subscriptions.
+ */
+ foreach ( $args as $id ) {
+ $subscription = \get_pronamic_subscription( $id );
+
+ if ( null === $subscription ) {
+ WP_CLI::error( \sprintf( 'Could not find a subscription with ID: %s', $id ) );
+ }
+
+ WP_CLI::line( \sprintf( 'Schedule subscription %s follow-up payment…', $id ) );
+
+ $action_id = $this->schedule_subscription_follow_up_payment( $subscription );
+
+ if ( null === $action_id ) {
+ WP_CLI::error( 'Could not schedule action.' );
+ }
+
+ WP_CLI::line( \sprintf( 'Action scheduled: %s', (string) $action_id ) );
+ }
+ }
+ );
+ }
+
+ /**
+ * Maybe schedule actions.
+ *
+ * @link https://actionscheduler.org/
+ * @return void
+ */
+ public function maybe_schedule_actions() {
+ if ( false === \as_next_scheduled_action( 'pronamic_pay_schedule_follow_up_payments', [], 'pronamic-pay' ) ) {
+ \as_schedule_cron_action( \time(), '0 * * * *', 'pronamic_pay_schedule_follow_up_payments', [], 'pronamic-pay' );
+ }
+ }
+
+ /**
+ * Schedule all.
+ *
+ * @return void
+ */
+ public function schedule_all() {
+ if ( $this->is_processing_disabled() ) {
+ return;
+ }
+
+ $query = $this->get_subscriptions_wp_query_that_require_follow_up_payment();
+
+ if ( 0 === $query->max_num_pages ) {
+ return;
+ }
+
+ $pages = \range( $query->max_num_pages, 1 );
+
+ foreach ( $pages as $page ) {
+ $this->schedule_page( $page );
+ }
+ }
+
+ /**
+ * Schedule page.
+ *
+ * @param int $page Page.
+ * @return int
+ */
+ private function schedule_page( $page ) {
+ return \as_enqueue_async_action(
+ 'pronamic_pay_schedule_subscriptions_follow_up_payment',
+ [
+ 'page' => $page,
+ ],
+ 'pronamic-pay'
+ );
+ }
+
+ /**
+ * Schedule subscriptions follow-up payment.
+ *
+ * @param int $page Page.
+ * @return void
+ */
+ public function schedule_paged( $page ) {
+ $query = $this->get_subscriptions_wp_query_that_require_follow_up_payment(
+ [
+ 'paged' => $page,
+ ]
+ );
+
+ $posts = \array_filter(
+ $query->posts,
+ function ( $post ) {
+ return ( $post instanceof WP_Post );
+ }
+ );
+
+ $subscriptions = [];
+
+ foreach ( $posts as $post ) {
+ $subscription = \get_pronamic_subscription( $post->ID );
+
+ if ( null !== $subscription ) {
+ $subscriptions[] = $subscription;
+ }
+ }
+
+ foreach ( $subscriptions as $subscription ) {
+ $this->schedule_subscription_follow_up_payment( $subscription );
+ }
+ }
+
+ /**
+ * Test if the subscription meets the follow-up requirements.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return bool True if meets requirements, false otherwise.
+ */
+ private function meets_follow_up_payment_requirements( Subscription $subscription ) {
+ if ( 'woocommerce' === $subscription->get_source() ) {
+ return false;
+ }
+
+ $next_payment_date = $subscription->get_next_payment_date();
+
+ if ( null === $next_payment_date ) {
+ return false;
+ }
+
+ $next_payment_delivery_date = $subscription->get_next_payment_delivery_date();
+
+ if ( null === $next_payment_delivery_date ) {
+ return false;
+ }
+
+ $query_start_date = $this->get_follow_up_payment_query_start_date();
+
+ if ( $next_payment_date < $query_start_date && $next_payment_delivery_date < $query_start_date ) {
+ return false;
+ }
+
+ $query_end_date = $this->get_follow_up_payment_query_end_date();
+
+ if ( $next_payment_date > $query_end_date && $next_payment_delivery_date > $query_end_date ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Schedule subscription follow-up payment.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return int|null
+ */
+ private function schedule_subscription_follow_up_payment( Subscription $subscription ) {
+ if ( ! $this->meets_follow_up_payment_requirements( $subscription ) ) {
+ return null;
+ }
+
+ $action_id = $subscription->get_meta( 'create_follow_up_payment_action_id' );
+
+ if ( ! empty( $action_id ) ) {
+ return $action_id;
+ }
+
+ $actions_args = [
+ 'subscription_id' => $subscription->get_id(),
+ ];
+
+ if ( false !== \as_next_scheduled_action( 'pronamic_pay_create_subscription_follow_up_payment', $actions_args, 'pronamic-pay' ) ) {
+ return null;
+ }
+
+ $action_id = \as_enqueue_async_action(
+ 'pronamic_pay_create_subscription_follow_up_payment',
+ $actions_args,
+ 'pronamic-pay'
+ );
+
+ $subscription->set_meta( 'create_follow_up_payment_action_id', $action_id );
+
+ $subscription->save();
+
+ return $action_id;
+ }
+
+
+ /**
+ * Action create subscription follow-up payment.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @return void
+ * @throws \Exception Throws exception when unable to load subscription.
+ */
+ public function action_create_subscription_follow_up_payment( $subscription_id ) {
+ // Check subscription.
+ $subscription = \get_pronamic_subscription( (int) $subscription_id );
+
+ if ( null === $subscription ) {
+ throw new \Exception(
+ \sprintf(
+ 'Unable to load subscription from post ID: %s.',
+ \esc_html( (string) $subscription_id )
+ )
+ );
+ }
+
+ $subscription->set_meta( 'create_follow_up_payment_action_id', null );
+
+ $this->create_subscription_follow_up_payment( $subscription );
+
+ $subscription->save();
+ }
+
+ /**
+ * Create subscription follow-up payment.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ * @throws \Exception Throws exception when gateway not found.
+ */
+ public function create_subscription_follow_up_payment( Subscription $subscription ) {
+ if ( ! $this->meets_follow_up_payment_requirements( $subscription ) ) {
+ return;
+ }
+
+ // Next period.
+ $next_period = $subscription->get_next_period();
+
+ if ( null === $next_period ) {
+ return;
+ }
+
+ // New payment.
+ $payment = $next_period->new_payment();
+
+ $payment->set_lines( $subscription->get_lines() );
+
+ $payment->set_meta( 'mollie_sequence_type', 'recurring' );
+
+ // Start payment.
+ $payment = Plugin::start_payment( $payment );
+
+ // Update payment.
+ Plugin::update_payment( $payment, false );
+ }
+
+ /**
+ * Get query start date for subscriptions that require a follow-up payment.
+ *
+ * @return \DateTimeImmutable
+ * @throws \Exception Throws exception in case of error.
+ */
+ private function get_follow_up_payment_query_start_date() {
+ return new \DateTimeImmutable( '-1 day', new \DateTimeZone( 'GMT' ) );
+ }
+
+ /**
+ * Get query end date for subscriptions that require a follow-up payment.
+ *
+ * @return \DateTimeImmutable
+ * @throws \Exception Throws exception in case of error.
+ */
+ private function get_follow_up_payment_query_end_date() {
+ return new \DateTimeImmutable( 'now', new \DateTimeZone( 'GMT' ) );
+ }
+
+ /**
+ * Get WordPress query for subscriptions that require a follow-up payment.
+ *
+ * @param array $args Arguments.
+ * @return WP_Query
+ */
+ private function get_subscriptions_wp_query_that_require_follow_up_payment( $args = [] ) {
+ $start_date = $this->get_follow_up_payment_query_start_date();
+ $end_date = $this->get_follow_up_payment_query_end_date();
+
+ $query_args = [
+ 'post_type' => 'pronamic_pay_subscr',
+ /**
+ * Posts per page is set to 100, higher could result in performance issues.
+ *
+ * @link https://github.com/WordPress/WordPress-Coding-Standards/wiki/Customizable-sniff-properties#wp-postsperpage-post-limit
+ */
+ 'posts_per_page' => 100,
+ 'post_status' => [
+ 'subscr_active',
+ ],
+ 'meta_query' => [
+ [
+ 'relation' => 'OR',
+ [
+ 'key' => '_pronamic_subscription_next_payment',
+ 'compare' => 'BETWEEN',
+ 'value' => [
+ $start_date->format( 'Y-m-d H:i:s' ),
+ $end_date->format( 'Y-m-d H:i:s' ),
+ ],
+ 'type' => 'DATETIME',
+ ],
+ [
+ 'key' => '_pronamic_subscription_next_payment_delivery_date',
+ 'compare' => 'BETWEEN',
+ 'value' => [
+ $start_date->format( 'Y-m-d H:i:s' ),
+ $end_date->format( 'Y-m-d H:i:s' ),
+ ],
+ 'type' => 'DATETIME',
+ ],
+ ],
+ ],
+ 'order' => 'DESC',
+ 'orderby' => 'ID',
+ ];
+
+ if ( \array_key_exists( 'paged', $args ) ) {
+ $query_args['paged'] = $args['paged'];
+ $query_args['no_found_rows'] = true;
+ }
+
+ $query = new WP_Query( $query_args );
+
+ return $query;
+ }
+
+ /**
+ * Is subscriptions processing disabled.
+ *
+ * @return bool True if processing recurring payment is disabled, false otherwise.
+ */
+ private function is_processing_disabled() {
+ return (bool) \get_option( 'pronamic_pay_subscriptions_processing_disabled', false );
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsModule.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsModule.php
new file mode 100644
index 0000000..d67fa59
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsModule.php
@@ -0,0 +1,816 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Core\Util;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * Title: Subscriptions module
+ * Description:
+ * Copyright: 2005-2023 Pronamic
+ * Company: Pronamic
+ *
+ * @link https://woocommerce.com/2017/04/woocommerce-3-0-release/
+ * @link https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.0.1
+ */
+class SubscriptionsModule {
+ /**
+ * Plugin.
+ *
+ * @var Plugin $plugin
+ */
+ public $plugin;
+
+ /**
+ * Privacy.
+ *
+ * @var SubscriptionsPrivacy
+ */
+ public $privacy;
+
+ /**
+ * Construct and initialize a subscriptions module object.
+ *
+ * @param Plugin $plugin The plugin.
+ */
+ public function __construct( Plugin $plugin ) {
+ $this->plugin = $plugin;
+
+ // Subscriptions privacy exporters and erasers.
+ $this->privacy = new SubscriptionsPrivacy();
+
+ // Actions.
+ \add_action( 'wp_loaded', [ $this, 'maybe_handle_subscription_action' ] );
+
+ \add_action( 'init', [ $this, 'maybe_schedule_subscription_events' ] );
+
+ // Exclude subscription notes.
+ \add_filter( 'comments_clauses', [ $this, 'exclude_subscription_comment_notes' ], 10, 2 );
+
+ \add_action( 'pronamic_pay_pre_create_subscription', [ SubscriptionHelper::class, 'complement_subscription' ], 10, 1 );
+ \add_action( 'pronamic_pay_pre_create_payment', [ $this, 'complement_subscription_by_payment' ], 10, 1 );
+
+ // Payment source filters.
+ \add_filter( 'pronamic_payment_source_text_subscription_payment_method_change', [ $this, 'source_text_subscription_payment_method_change' ] );
+ \add_filter( 'pronamic_payment_source_description_subscription_payment_method_change', [ $this, 'source_description_subscription_payment_method_change' ] );
+
+ // Listen to payment status changes so we can update related subscriptions.
+ \add_action( 'pronamic_payment_status_update', [ $this, 'payment_status_update' ] );
+
+ // Listen to subscription status changes so we can log these in a note.
+ \add_action( 'pronamic_subscription_status_update', [ $this, 'log_subscription_status_update' ], 10, 4 );
+
+ // REST API.
+ \add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
+
+ // Follow-up payments.
+ $follow_up_payments_controller = new SubscriptionsFollowUpPaymentsController();
+
+ $follow_up_payments_controller->setup();
+
+ // Notifications.
+ $notifications_controller = new SubscriptionsNotificationsController();
+
+ $notifications_controller->setup();
+
+ // Completion.
+ $completion_controller = new SubscriptionsCompletionController();
+
+ $completion_controller->setup();
+ }
+
+ /**
+ * Comments clauses.
+ *
+ * @param array $clauses The database query clauses.
+ * @param \WP_Comment_Query $query The WordPress comment query object.
+ * @return array
+ */
+ public function exclude_subscription_comment_notes( $clauses, $query ) {
+ $type = $query->query_vars['type'];
+
+ // Ignore subscription notes comments if it's not specifically requested.
+ if ( 'subscription_note' !== $type ) {
+ $clauses['where'] .= " AND comment_type != 'subscription_note'";
+ }
+
+ return $clauses;
+ }
+
+ /**
+ * Complement subscription by payment.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public function complement_subscription_by_payment( $payment ) {
+ foreach ( $payment->get_subscriptions() as $subscription ) {
+ if ( ! $subscription->is_first_payment( $payment ) ) {
+ continue;
+ }
+
+ // Complement subscription.
+ SubscriptionHelper::complement_subscription_by_payment( $subscription, $payment );
+ }
+ }
+
+ /**
+ * Payment status update.
+ *
+ * @param Payment $payment The status updated payment.
+ * @return void
+ */
+ public function payment_status_update( $payment ) {
+ foreach ( $payment->get_subscriptions() as $subscription ) {
+ // Status.
+ $status_before = $subscription->get_status();
+ $status_update = $status_before;
+
+ switch ( $payment->get_status() ) {
+ case PaymentStatus::OPEN:
+ // @todo
+ break;
+ case PaymentStatus::SUCCESS:
+ $status_update = SubscriptionStatus::ACTIVE;
+
+ break;
+ case PaymentStatus::FAILURE:
+ /**
+ * Subscription status for failed payment.
+ *
+ * @todo Determine update status based on reason of failed payment. Use `failure` for now as that is usually the desired status.
+ * @link https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/guidance-reason-codes-sepa-direct-debit-r-transactions
+ * @link https://github.com/pronamic/wp-pronamic-ideal/commit/48449417eac49eb6a93480e3b523a396c7db9b3d#diff-6712c698c6b38adfa7190a4be983a093
+ */
+ $status_update = SubscriptionStatus::ON_HOLD;
+
+ break;
+ case PaymentStatus::CANCELLED:
+ case PaymentStatus::EXPIRED:
+ // Set subscription status to 'On Hold' only if the subscription is not already active when processing the first payment.
+ if ( $subscription->is_first_payment( $payment ) && SubscriptionStatus::ACTIVE === $subscription->get_status() ) {
+ $status_update = SubscriptionStatus::ON_HOLD;
+ }
+
+ break;
+ }
+
+ /*
+ * The status of canceled or completed subscriptions will not be changed automatically,
+ * unless the cancelled subscription is manually being renewed.
+ */
+ $is_renewal = false;
+
+ if ( true === $payment->get_meta( 'manual_subscription_renewal' ) && SubscriptionStatus::CANCELLED === $status_before && SubscriptionStatus::ACTIVE === $status_update ) {
+ $is_renewal = true;
+ }
+
+ if ( $is_renewal || ! in_array( $status_before, [ SubscriptionStatus::CANCELLED, SubscriptionStatus::COMPLETED, SubscriptionStatus::ON_HOLD ], true ) ) {
+ $subscription->set_status( $status_update );
+
+ // Update.
+ if ( $status_before !== $status_update ) {
+ $subscription->save();
+ }
+ }
+ }
+ }
+
+ /**
+ * Get subscription status update note.
+ *
+ * @param string|null $old_status Old meta status.
+ * @param string $new_status New meta status.
+ * @return string
+ */
+ private function get_subscription_status_update_note( $old_status, $new_status ) {
+ $old_label = $this->plugin->subscriptions_data_store->get_meta_status_label( $old_status );
+ $new_label = $this->plugin->subscriptions_data_store->get_meta_status_label( $new_status );
+
+ if ( null === $old_status ) {
+ return sprintf(
+ /* translators: 1: new status */
+ __( 'Subscription created with status "%1$s".', 'pronamic_ideal' ),
+ esc_html( empty( $new_label ) ? $new_status : $new_label )
+ );
+ }
+
+ return sprintf(
+ /* translators: 1: old status, 2: new status */
+ __( 'Subscription status changed from "%1$s" to "%2$s".', 'pronamic_ideal' ),
+ esc_html( empty( $old_label ) ? $old_status : $old_label ),
+ esc_html( empty( $new_label ) ? $new_status : $new_label )
+ );
+ }
+
+ /**
+ * Subscription status update.
+ *
+ * @param Subscription $subscription The status updated subscription.
+ * @param bool $can_redirect Whether or not redirects should be performed.
+ * @param string|null $old_status Old meta status.
+ * @param string $new_status New meta status.
+ *
+ * @return void
+ */
+ public function log_subscription_status_update( $subscription, $can_redirect, $old_status, $new_status ) {
+ $note = $this->get_subscription_status_update_note( $old_status, $new_status );
+
+ try {
+ $subscription->add_note( $note );
+ } catch ( \Exception $e ) {
+ return;
+ }
+ }
+
+ /**
+ * Handle subscription actions.
+ *
+ * Extensions like Gravity Forms can send action links in for example
+ * email notifications so users can cancel or renew their subscription.
+ *
+ * @return void
+ */
+ public function maybe_handle_subscription_action() {
+ // phpcs:disable WordPress.Security.NonceVerification.Recommended
+ if ( ! isset( $_GET['subscription'] ) || ! isset( $_GET['action'] ) || ! isset( $_GET['key'] ) ) {
+ return;
+ }
+
+ Util::no_cache();
+
+ $subscription_id = filter_input( INPUT_GET, 'subscription', \FILTER_SANITIZE_NUMBER_INT );
+
+ $subscription = get_pronamic_subscription( $subscription_id );
+
+ // Check if subscription and key are valid.
+ if ( ! $subscription || $_GET['key'] !== $subscription->get_key() ) {
+ wp_safe_redirect( home_url() );
+
+ exit;
+ }
+
+ // Handle action.
+ switch ( $_GET['action'] ) {
+ // phpcs:enable WordPress.Security.NonceVerification.Recommended
+ case 'cancel':
+ $this->handle_subscription_cancel( $subscription );
+
+ break;
+ case 'renew':
+ $this->handle_subscription_renew( $subscription );
+
+ break;
+ case 'mandate':
+ $this->handle_subscription_mandate( $subscription );
+
+ exit;
+ }
+ }
+
+ /**
+ * Handle cancel subscription action request.
+ *
+ * @param Subscription $subscription Subscription to cancel.
+ * @return void
+ */
+ private function handle_subscription_cancel( Subscription $subscription ) {
+ $this->maybe_cancel_subscription( $subscription );
+
+ require __DIR__ . '/../../views/subscription-cancel.php';
+
+ exit;
+ }
+
+ /**
+ * Maybe cancel subscription.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ */
+ private function maybe_cancel_subscription( Subscription $subscription ) {
+ if ( SubscriptionStatus::CANCELLED === $subscription->get_status() ) {
+ return;
+ }
+
+ if ( ! \array_key_exists( 'pronamic_pay_cancel_subscription_nonce', $_POST ) ) {
+ return;
+ }
+
+ $nonce = \sanitize_key( $_POST['pronamic_pay_cancel_subscription_nonce'] );
+
+ if ( ! wp_verify_nonce( $nonce, 'pronamic_pay_cancel_subscription_' . $subscription->get_id() ) ) {
+ return;
+ }
+
+ $subscription->set_status( SubscriptionStatus::CANCELLED );
+
+ $subscription->save();
+
+ $url = \home_url();
+
+ $page_id = \pronamic_pay_get_page_id( 'subscription_canceled' );
+
+ if ( $page_id > 0 ) {
+ $page_url = \get_permalink( $page_id );
+
+ if ( false !== $page_url ) {
+ $url = $page_url;
+ }
+ }
+
+ \wp_safe_redirect( $url );
+
+ exit;
+ }
+
+ /**
+ * Check if subscription should be renewed.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return bool
+ */
+ private function should_renew( Subscription $subscription ) {
+ if ( ! \array_key_exists( 'pronamic_pay_renew_subscription_nonce', $_POST ) ) {
+ return false;
+ }
+
+ $nonce = \sanitize_key( $_POST['pronamic_pay_renew_subscription_nonce'] );
+
+ if ( ! wp_verify_nonce( $nonce, 'pronamic_pay_renew_subscription_' . $subscription->get_id() ) ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle renew subscription action request.
+ *
+ * @param Subscription $subscription Subscription to renew.
+ * @return void
+ * @throws \Exception Throws exception if unable to redirect (empty payment action URL).
+ */
+ private function handle_subscription_renew( Subscription $subscription ) {
+ // Check gateway.
+ $gateway = $subscription->get_gateway();
+
+ if ( null === $gateway ) {
+ require __DIR__ . '/../../views/subscription-renew-failed.php';
+
+ exit;
+ }
+
+ // Check current phase.
+ $current_phase = $subscription->get_current_phase();
+
+ if ( null === $current_phase ) {
+ require __DIR__ . '/../../views/subscription-renew-failed.php';
+
+ exit;
+ }
+
+ if ( $this->should_renew( $subscription ) ) {
+ try {
+ // Create payment.
+ $payment = $subscription->new_payment();
+
+ $payment->order_id = $subscription->get_order_id();
+
+ /**
+ * We set the payment method to `null` so that users get the
+ * chance to choose a payment method themselves if possible.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay-mollie/issues/23
+ * @link https://github.com/pronamic/wp-pay-core/pull/99
+ */
+ $payment->set_payment_method( null );
+
+ $payment->set_lines( $subscription->get_lines() );
+ $payment->set_total_amount( $current_phase->get_amount() );
+
+ // Maybe cancel current expired phase and add new phase.
+ if ( SubscriptionStatus::CANCELLED === $subscription->get_status() && 'gravityformsideal' === $subscription->get_source() ) {
+ $now = new DateTimeImmutable();
+
+ if ( $current_phase->get_next_date() < $now ) {
+ // Cancel current phase.
+ $current_phase->set_canceled_at( $now );
+
+ // Add new phase, starting now.
+ $new_phase = new SubscriptionPhase( $subscription, $now, $current_phase->get_interval(), $current_phase->get_amount() );
+
+ $subscription->add_phase( $new_phase );
+ }
+ }
+
+ // Set payment period.
+ $renewal_period = $subscription->get_renewal_period();
+
+ if ( null !== $renewal_period ) {
+ $payment->set_total_amount( $renewal_period->get_amount() );
+
+ $payment->add_period( $renewal_period );
+ }
+
+ // Start payment.
+ $payment = Plugin::start_payment( $payment );
+ } catch ( \Exception $e ) {
+ require __DIR__ . '/../../views/subscription-renew-failed.php';
+
+ exit;
+ }
+
+ // Redirect.
+ try {
+ $gateway->redirect( $payment );
+ } catch ( \Exception $e ) {
+ Plugin::render_exception( $e );
+
+ exit;
+ }
+
+ return;
+ }
+
+ require __DIR__ . '/../../views/subscription-renew.php';
+
+ exit;
+ }
+
+ /**
+ * Handle subscription mandate update action request.
+ *
+ * @param Subscription $subscription Subscription to update mandate for.
+ * @return void
+ * @throws \Exception Throws exception if unable to redirect (empty payment action URL).
+ */
+ private function handle_subscription_mandate( Subscription $subscription ) {
+ $gateway = $subscription->get_gateway();
+
+ if ( null === $gateway ) {
+ require __DIR__ . '/../../views/subscription-mandate-failed.php';
+
+ exit;
+ }
+
+ $nonce = array_key_exists( 'pronamic_pay_nonce', $_POST ) ? \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_nonce'] ) ) : '';
+
+ if ( \wp_verify_nonce( $nonce, 'pronamic_pay_update_subscription_mandate' ) ) {
+ $mandate_id = null;
+
+ if ( \array_key_exists( 'pronamic_pay_subscription_mandate', $_POST ) ) {
+ $mandate_id = \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_subscription_mandate'] ) );
+ }
+
+ if ( ! empty( $mandate_id ) ) {
+ try {
+ if ( ! \is_callable( [ $gateway, 'update_subscription_mandate' ] ) ) {
+ throw new \Exception( __( 'Gateway does not support subscription mandate updates.', 'pronamic_ideal' ) );
+ }
+
+ $gateway->update_subscription_mandate( $subscription, $mandate_id );
+
+ require __DIR__ . '/../../views/subscription-mandate-updated.php';
+
+ exit;
+ } catch ( \Exception $e ) {
+ require __DIR__ . '/../../views/subscription-mandate-failed.php';
+
+ exit;
+ }
+ }
+
+ // Start new first payment.
+ try {
+ $payment = $subscription->new_payment();
+
+ // Set source.
+ $payment->set_source( 'subscription_payment_method_change' );
+ $payment->set_source_id( null );
+
+ // Set payment method.
+ if ( array_key_exists( 'pronamic_pay_subscription_payment_method', $_POST ) ) {
+ $payment_method = \sanitize_text_field( \wp_unslash( $_POST['pronamic_pay_subscription_payment_method'] ) );
+
+ if ( ! empty( $payment_method ) ) {
+ $payment->set_payment_method( $payment_method );
+ }
+ }
+
+ /*
+ * Use payment method minimum amount for verification payment.
+ *
+ * @link https://help.mollie.com/hc/en-us/articles/115000667365-What-are-the-minimum-and-maximum-amounts-per-payment-method-
+ */
+ switch ( $payment->get_payment_method() ) {
+ case PaymentMethods::DIRECT_DEBIT_BANCONTACT:
+ $amount = 0.02;
+
+ break;
+ case PaymentMethods::DIRECT_DEBIT_SOFORT:
+ $amount = 0.10;
+
+ break;
+ case PaymentMethods::APPLE_PAY:
+ case PaymentMethods::CREDIT_CARD:
+ case PaymentMethods::PAYPAL:
+ $amount = 0.00;
+
+ break;
+ default:
+ $amount = 0.01;
+ }
+
+ $total_amount = new Money(
+ $amount,
+ $payment->get_total_amount()->get_currency()
+ );
+
+ $payment->set_total_amount( $total_amount );
+
+ // Make sure to only start payments for supported gateways.
+ $gateway = $payment->get_gateway();
+
+ if ( null === $gateway ) {
+ require __DIR__ . '/../../views/subscription-mandate-failed.php';
+
+ exit;
+ }
+
+ // Start payment.
+ $payment = Plugin::start_payment( $payment );
+ } catch ( \Exception $e ) {
+ require __DIR__ . '/../../views/subscription-mandate-failed.php';
+
+ exit;
+ }
+
+ $gateway->redirect( $payment );
+
+ return;
+ }
+
+ \wp_register_script(
+ 'pronamic-pay-slick-carousel-script',
+ plugins_url( 'assets/slick-carousel/slick.min.js', dirname( __DIR__ ) ),
+ [
+ 'jquery',
+ ],
+ '1.8.1',
+ false
+ );
+
+ \wp_register_script(
+ 'pronamic-pay-subscription-mandate',
+ plugins_url( 'js/dist/subscription-mandate.min.js', dirname( __DIR__ ) ),
+ [
+ 'jquery',
+ 'pronamic-pay-slick-carousel-script',
+ ],
+ $this->plugin->get_version(),
+ false
+ );
+
+ \wp_register_style(
+ 'pronamic-pay-slick-carousel-style',
+ plugins_url( 'assets/slick-carousel/slick.min.css', dirname( __DIR__ ) ),
+ [],
+ '1.8.1'
+ );
+
+ // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion -- No version for Google Fonts.
+ \wp_register_style(
+ 'pronamic-pay-google-font-roboto-mono',
+ 'https://fonts.googleapis.com/css2?family=Roboto+Mono&display=swap',
+ [],
+ // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- No version for Google Fonts.
+ null
+ );
+
+ \wp_register_style(
+ 'pronamic-pay-subscription-mandate',
+ plugins_url( 'css/card-slider.css', dirname( __DIR__ ) ),
+ [
+ 'pronamic-pay-redirect',
+ 'pronamic-pay-slick-carousel-style',
+ 'pronamic-pay-google-font-roboto-mono',
+ ],
+ $this->plugin->get_version()
+ );
+
+ require __DIR__ . '/../../views/subscription-mandate.php';
+
+ exit;
+ }
+
+ /**
+ * Can payment be retried.
+ *
+ * @param Payment $payment Payment to retry.
+ * @return bool
+ */
+ public function can_retry_payment( Payment $payment ) {
+ // Check status.
+ if ( PaymentStatus::FAILURE !== $payment->get_status() ) {
+ return false;
+ }
+
+ // Check periods.
+ $periods = $payment->get_periods();
+
+ if ( null === $periods ) {
+ return false;
+ }
+
+ // Check for pending and successful child payments.
+ $payments = \get_pronamic_payments_by_meta( '', '', [ 'post_parent' => $payment->get_id() ] );
+
+ foreach ( $payments as $child_payment ) {
+ if ( \in_array( $child_payment->get_status(), [ PaymentStatus::OPEN, PaymentStatus::SUCCESS ], true ) ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Maybe schedule subscription payments.
+ *
+ * @return void
+ */
+ public function maybe_schedule_subscription_events() {
+ // Unschedule legacy WordPress Cron hook.
+ \wp_clear_scheduled_hook( 'pronamic_pay_update_subscription_payments' );
+ \wp_clear_scheduled_hook( 'pronamic_pay_complete_subscriptions' );
+ }
+
+ /**
+ * Is subscriptions processing disabled.
+ *
+ * @return bool True if processing recurring payment is disabled, false otherwise.
+ */
+ public function is_processing_disabled() {
+ return (bool) \get_option( 'pronamic_pay_subscriptions_processing_disabled', false );
+ }
+
+ /**
+ * REST API init.
+ *
+ * @link https://developer.wordpress.org/rest-api/extending-the-rest-api/adding-custom-endpoints/
+ * @link https://developer.wordpress.org/reference/hooks/rest_api_init/
+ *
+ * @return void
+ */
+ public function rest_api_init() {
+ \register_rest_route(
+ 'pronamic-pay/v1',
+ '/subscriptions/(?P\d+)',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'rest_api_subscription' ],
+ 'permission_callback' => function () {
+ return \current_user_can( 'edit_payments' );
+ },
+ 'args' => [
+ 'subscription_id' => [
+ 'description' => __( 'Subscription ID.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ ],
+ ]
+ );
+
+ \register_rest_route(
+ 'pronamic-pay/v1',
+ '/subscriptions/(?P\d+)/phases/(?P\d+)',
+ [
+ 'methods' => 'GET',
+ 'callback' => [ $this, 'rest_api_subscription_phase' ],
+ 'permission_callback' => function () {
+ return \current_user_can( 'edit_payments' );
+ },
+ 'args' => [
+ 'subscription_id' => [
+ 'description' => __( 'Subscription ID.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ 'sequence_number' => [
+ 'description' => __( 'Subscription phase sequence number.', 'pronamic_ideal' ),
+ 'type' => 'integer',
+ ],
+ ],
+ ]
+ );
+ }
+
+ /**
+ * REST API subscription.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return object
+ */
+ public function rest_api_subscription( \WP_REST_Request $request ) {
+ $subscription_id = $request->get_param( 'subscription_id' );
+
+ $subscription = \get_pronamic_subscription( $subscription_id );
+
+ if ( null === $subscription ) {
+ return new \WP_Error(
+ 'pronamic-pay-subscription-not-found',
+ \sprintf(
+ /* translators: %s: Subscription ID */
+ \__( 'Could not find subscription with ID `%s`.', 'pronamic_ideal' ),
+ $subscription_id
+ ),
+ $subscription_id
+ );
+ }
+
+ return $subscription;
+ }
+
+ /**
+ * REST API subscription phase.
+ *
+ * @param \WP_REST_Request $request Request.
+ * @return object
+ */
+ public function rest_api_subscription_phase( \WP_REST_Request $request ) {
+ $subscription_id = $request->get_param( 'subscription_id' );
+
+ $subscription = \get_pronamic_subscription( $subscription_id );
+
+ if ( null === $subscription ) {
+ return new \WP_Error(
+ 'pronamic-pay-subscription-not-found',
+ \sprintf(
+ /* translators: %s: Subscription ID */
+ \__( 'Could not find subscription with ID `%s`.', 'pronamic_ideal' ),
+ $subscription_id
+ ),
+ $subscription_id
+ );
+ }
+
+ $sequence_number = $request->get_param( 'sequence_number' );
+
+ $phase = $subscription->get_phase_by_sequence_number( $sequence_number );
+
+ if ( null === $phase ) {
+ return new \WP_Error(
+ 'pronamic-pay-subscription-phase-not-found',
+ \sprintf(
+ /* translators: %s: Subscription ID */
+ \__( 'Could not find subscription phase with sequence number `%s`.', 'pronamic_ideal' ),
+ $sequence_number
+ ),
+ $sequence_number
+ );
+ }
+
+ return $phase;
+ }
+
+ /**
+ * Source text filter.
+ *
+ * @param string $text The source text to filter.
+ * @return string
+ */
+ public function source_text_subscription_payment_method_change( $text ) {
+ $text = \__( 'Subscription payment method change', 'pronamic_ideal' );
+
+ return $text;
+ }
+
+ /**
+ * Source description filter.
+ *
+ * @param string $text The source text to filter.
+ * @return string
+ */
+ public function source_description_subscription_payment_method_change( $text ) {
+ $text = \__( 'subscription payment method change', 'pronamic_ideal' );
+
+ return $text;
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsNotificationsController.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsNotificationsController.php
new file mode 100644
index 0000000..d21c45d
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsNotificationsController.php
@@ -0,0 +1,315 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use WP_Post;
+use WP_Query;
+
+/**
+ * Subscriptions Notifications Controller
+ */
+class SubscriptionsNotificationsController {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ \add_action( 'init', [ $this, 'maybe_schedule_actions' ] );
+
+ \add_action( 'pronamic_pay_schedule_subscriptions_notification', [ $this, 'schedule_all' ] );
+
+ \add_action( 'pronamic_pay_schedule_paged_subscriptions_notification', [ $this, 'schedule_paged' ] );
+
+ \add_action( 'pronamic_pay_send_subscription_renewal_notification', [ $this, 'action_send_subscription_renewal_notification' ] );
+ }
+
+ /**
+ * Maybe schedule actions.
+ *
+ * @link https://actionscheduler.org/
+ * @return void
+ */
+ public function maybe_schedule_actions() {
+ if ( false === \as_next_scheduled_action( 'pronamic_pay_schedule_subscriptions_notification', [], 'pronamic-pay' ) ) {
+ \as_schedule_cron_action( \time(), '0 0 * * *', 'pronamic_pay_schedule_subscriptions_notification', [], 'pronamic-pay' );
+ }
+ }
+
+ /**
+ * Schedule all.
+ *
+ * @return void
+ */
+ public function schedule_all() {
+ if ( $this->is_processing_disabled() ) {
+ return;
+ }
+
+ $query = $this->get_subscriptions_wp_query_that_require_notification();
+
+ if ( 0 === $query->max_num_pages ) {
+ return;
+ }
+
+ $pages = \range( $query->max_num_pages, 1 );
+
+ foreach ( $pages as $page ) {
+ $this->schedule_page( $page );
+ }
+ }
+
+ /**
+ * Schedule page.
+ *
+ * @param int $page Page.
+ * @return int
+ */
+ private function schedule_page( $page ) {
+ return \as_enqueue_async_action(
+ 'pronamic_pay_schedule_paged_subscriptions_notification',
+ [
+ 'page' => $page,
+ ],
+ 'pronamic-pay'
+ );
+ }
+
+ /**
+ * Schedule paged.
+ *
+ * @param int $page Page.
+ * @return void
+ */
+ public function schedule_paged( $page ) {
+ $query = $this->get_subscriptions_wp_query_that_require_notification(
+ [
+ 'paged' => $page,
+ ]
+ );
+
+ $posts = \array_filter(
+ $query->posts,
+ function ( $post ) {
+ return ( $post instanceof WP_Post );
+ }
+ );
+
+ $subscriptions = [];
+
+ foreach ( $posts as $post ) {
+ $subscription = \get_pronamic_subscription( $post->ID );
+
+ if ( null !== $subscription ) {
+ $subscriptions[] = $subscription;
+ }
+ }
+
+ foreach ( $subscriptions as $subscription ) {
+ $this->schedule_subscription_notification( $subscription );
+ }
+ }
+
+ /**
+ * Test if the subscription meets the notification requirements.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return bool True if meets requirements, false otherwise.
+ */
+ private function meets_notification_requirements( Subscription $subscription ) {
+ /**
+ * If a subscription does not have a next payment date, it makes no sense to
+ * send a notification.
+ */
+ $next_payment_date = $subscription->get_next_payment_date();
+
+ if ( null === $next_payment_date ) {
+ return false;
+ }
+
+ /**
+ * If the current date is greater than the next payment date, it no longer makes
+ * sense to send a notification.
+ */
+ $date = new \DateTimeImmutable();
+
+ if ( $date > $next_payment_date ) {
+ return false;
+ }
+
+ /**
+ * If a notification has already been sent in the past week, it no longer makes
+ * sense to send a notification.
+ */
+ $notification_date_string = $subscription->get_meta( 'notification_date_1_week' );
+
+ if ( $notification_date_string ) {
+ $notification_date = new DateTime( $notification_date_string );
+
+ $threshold_date = new DateTime( 'midnight -1 week' );
+
+ if ( $notification_date > $threshold_date ) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Schedule subscription follow-up payment.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return int|null
+ */
+ private function schedule_subscription_notification( Subscription $subscription ) {
+ if ( ! $this->meets_notification_requirements( $subscription ) ) {
+ return null;
+ }
+
+ $action_id = $subscription->get_meta( 'send_subscription_renewal_notification_action_id' );
+
+ if ( ! empty( $action_id ) ) {
+ return $action_id;
+ }
+
+ $actions_args = [
+ 'subscription_id' => $subscription->get_id(),
+ ];
+
+ if ( false !== \as_next_scheduled_action( 'pronamic_pay_send_subscription_renewal_notification', $actions_args, 'pronamic-pay' ) ) {
+ return null;
+ }
+
+ $action_id = \as_enqueue_async_action(
+ 'pronamic_pay_send_subscription_renewal_notification',
+ $actions_args,
+ 'pronamic-pay'
+ );
+
+ $subscription->set_meta( 'send_subscription_renewal_notification_action_id', $action_id );
+
+ $subscription->save();
+
+ return $action_id;
+ }
+
+ /**
+ * Action send subscription renewal notification.
+ *
+ * @param int $subscription_id Subscription ID.
+ * @return void
+ * @throws \Exception Throws exception when unable to load subscription.
+ */
+ public function action_send_subscription_renewal_notification( $subscription_id ) {
+ // Check subscription.
+ $subscription = \get_pronamic_subscription( (int) $subscription_id );
+
+ if ( null === $subscription ) {
+ throw new \Exception(
+ \sprintf(
+ 'Unable to load subscription from post ID: %s.',
+ \esc_html( (string) $subscription_id )
+ )
+ );
+ }
+
+ $this->send_subscription_renewal_notification( $subscription );
+
+ $subscription->set_meta( 'send_subscription_renewal_notification_action_id', null );
+
+ $subscription->save();
+ }
+
+ /**
+ * Send subscription renewal notification.
+ *
+ * @param Subscription $subscription Subscription.
+ * @return void
+ * @throws \Exception Throws exception when gateway not found.
+ */
+ public function send_subscription_renewal_notification( Subscription $subscription ) {
+ if ( ! $this->meets_notification_requirements( $subscription ) ) {
+ return;
+ }
+
+ $source = $subscription->get_source();
+
+ /**
+ * Send renewal notice for source.
+ *
+ * [`{$source}`](https://github.com/pronamic/wp-pronamic-pay/wiki#sources)
+ *
+ * @param Subscription $subscription Subscription.
+ */
+ \do_action( 'pronamic_subscription_renewal_notice_' . $source, $subscription );
+
+ $subscription->set_meta( 'notification_date_1_week', \gmdate( DATE_ATOM ) );
+ }
+
+ /**
+ * Get WordPress query for subscriptions that require a notification.
+ *
+ * @param array $args Arguments.
+ * @return WP_Query
+ */
+ private function get_subscriptions_wp_query_that_require_notification( $args = [] ) {
+ $start_date = new \DateTimeImmutable( 'midnight +1 week', new \DateTimeZone( 'GMT' ) );
+ $end_date = new \DateTimeImmutable( 'tomorrow +1 week', new \DateTimeZone( 'GMT' ) );
+
+ $query_args = [
+ 'post_type' => 'pronamic_pay_subscr',
+ /**
+ * Posts per page is set to 100, higher could result in performance issues.
+ *
+ * @link https://github.com/WordPress/WordPress-Coding-Standards/wiki/Customizable-sniff-properties#wp-postsperpage-post-limit
+ */
+ 'posts_per_page' => 100,
+ 'post_status' => [
+ 'subscr_active',
+ ],
+ 'meta_query' => [
+ [
+ [
+ 'key' => '_pronamic_subscription_next_payment',
+ 'compare' => 'BETWEEN',
+ 'value' => [
+ $start_date->format( 'Y-m-d H:i:s' ),
+ $end_date->format( 'Y-m-d H:i:s' ),
+ ],
+ 'type' => 'DATETIME',
+ ],
+ ],
+ ],
+ 'order' => 'DESC',
+ 'orderby' => 'ID',
+ ];
+
+ if ( \array_key_exists( 'paged', $args ) ) {
+ $query_args['paged'] = $args['paged'];
+ $query_args['no_found_rows'] = true;
+ }
+
+ $query = new WP_Query( $query_args );
+
+ return $query;
+ }
+
+ /**
+ * Is subscriptions processing disabled.
+ *
+ * @return bool True if processing recurring payment is disabled, false otherwise.
+ */
+ private function is_processing_disabled() {
+ return (bool) \get_option( 'pronamic_pay_subscriptions_processing_disabled', false );
+ }
+}
diff --git a/packages/wp-pay/core/src/Subscriptions/SubscriptionsPrivacy.php b/packages/wp-pay/core/src/Subscriptions/SubscriptionsPrivacy.php
new file mode 100644
index 0000000..69edc81
--- /dev/null
+++ b/packages/wp-pay/core/src/Subscriptions/SubscriptionsPrivacy.php
@@ -0,0 +1,242 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Subscriptions
+ */
+
+namespace Pronamic\WordPress\Pay\Subscriptions;
+
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+
+/**
+ * Subscriptions Privacy class.
+ *
+ * @author Reüel van der Steege
+ * @version 2.2.6
+ * @since 2.0.2
+ */
+class SubscriptionsPrivacy {
+ /**
+ * Subscriptions privacy constructor.
+ */
+ public function __construct() {
+ // Register exporters.
+ add_action( 'pronamic_pay_privacy_register_exporters', [ $this, 'register_exporters' ] );
+
+ // Register erasers.
+ add_action( 'pronamic_pay_privacy_register_erasers', [ $this, 'register_erasers' ] );
+ }
+
+ /**
+ * Register privacy exporters.
+ *
+ * @param \Pronamic\WordPress\Pay\PrivacyManager $privacy_manager Privacy manager.
+ *
+ * @return void
+ */
+ public function register_exporters( $privacy_manager ) {
+ // Subscriptions export.
+ $privacy_manager->add_exporter(
+ 'subscriptions',
+ __( 'Subscriptions', 'pronamic_ideal' ),
+ [ $this, 'subscriptions_export' ]
+ );
+ }
+
+ /**
+ * Register privacy erasers.
+ *
+ * @param \Pronamic\WordPress\Pay\PrivacyManager $privacy_manager Privacy manager.
+ *
+ * @return void
+ */
+ public function register_erasers( $privacy_manager ) {
+ // Subscriptions anonymizer.
+ $privacy_manager->add_eraser(
+ 'subscriptions',
+ __( 'Subscriptions', 'pronamic_ideal' ),
+ [ $this, 'subscriptions_anonymizer' ]
+ );
+ }
+
+ /**
+ * Subscriptions exporter.
+ *
+ * @param string $email_address Email address.
+ * @return array
+ */
+ public function subscriptions_export( $email_address ) {
+ // Subscriptions data store.
+ $data_store = pronamic_pay_plugin()->subscriptions_data_store;
+
+ // Privacy manager.
+ $privacy_manager = pronamic_pay_plugin()->privacy_manager;
+
+ // Get subscriptions.
+ // @todo use paging.
+ $subscriptions = get_pronamic_subscriptions_by_meta(
+ $data_store->meta_key_prefix . 'email',
+ $email_address
+ );
+
+ // Get registered meta keys for export.
+ $meta_keys = wp_list_filter(
+ $data_store->get_registered_meta(),
+ [
+ 'privacy_export' => true,
+ ]
+ );
+
+ $items = [];
+
+ // Loop subscriptions.
+ foreach ( $subscriptions as $subscription ) {
+ $export_data = [];
+
+ $id = $subscription->get_id();
+
+ if ( empty( $id ) ) {
+ continue;
+ }
+
+ $subscription_meta = get_post_meta( $id );
+
+ // Get subscription meta.
+ foreach ( $meta_keys as $meta_key => $meta_options ) {
+ $meta_key = $data_store->meta_key_prefix . $meta_key;
+
+ if ( ! array_key_exists( $meta_key, $subscription_meta ) ) {
+ continue;
+ }
+
+ // Add export value.
+ $export_data[] = $privacy_manager->export_meta( $meta_key, $meta_options, $subscription_meta );
+ }
+
+ // Add item to export data.
+ if ( ! empty( $export_data ) ) {
+ $items[] = [
+ 'group_id' => 'pronamic-pay-subscriptions',
+ 'group_label' => __( 'Subscriptions', 'pronamic_ideal' ),
+ 'item_id' => 'pronamic-pay-subscription-' . $id,
+ 'data' => $export_data,
+ ];
+ }
+ }
+
+ $done = true;
+
+ // Return export data.
+ return [
+ 'data' => $items,
+ 'done' => $done,
+ ];
+ }
+
+ /**
+ * Subscriptions anonymizer.
+ *
+ * @param string $email_address Email address.
+ * @return array
+ */
+ public function subscriptions_anonymizer( $email_address ) {
+ // Subscriptions data store.
+ $data_store = pronamic_pay_plugin()->subscriptions_data_store;
+
+ // Privacy manager.
+ $privacy_manager = pronamic_pay_plugin()->privacy_manager;
+
+ // Return values.
+ $items_removed = false;
+ $items_retained = false;
+ $messages = [];
+ $done = false;
+
+ // Get subscriptions.
+ // @todo use paging.
+ $subscriptions = get_pronamic_subscriptions_by_meta(
+ $data_store->meta_key_prefix . 'email',
+ $email_address
+ );
+
+ // Get registered meta keys for erasure.
+ $meta_keys = wp_list_filter(
+ $data_store->get_registered_meta(),
+ [
+ 'privacy_erasure' => null,
+ ],
+ 'NOT'
+ );
+
+ // Loop subscriptions.
+ foreach ( $subscriptions as $subscription ) {
+ $subscription_id = $subscription->get_id();
+
+ if ( empty( $subscription_id ) ) {
+ continue;
+ }
+
+ $subscription_meta = get_post_meta( $subscription_id );
+
+ $subscription_status = null;
+
+ if ( isset( $subscription_meta[ $data_store->meta_key_prefix . 'status' ] ) ) {
+ $subscription_status = $subscription_meta[ $data_store->meta_key_prefix . 'status' ];
+ }
+
+ // Subscription note and erasure return message.
+ $note = __( 'Subscription anonymized for personal data erasure request.', 'pronamic_ideal' );
+ /* translators: %s = subscription id */
+ $message = __( 'Subscription ID %s anonymized.', 'pronamic_ideal' );
+
+ // Anonymize completed and cancelled subscriptions.
+ if ( isset( $subscription_status ) && in_array( $subscription_status, [ SubscriptionStatus::COMPLETED, SubscriptionStatus::CANCELLED ], true ) ) {
+ // Erase subscription meta.
+ foreach ( $meta_keys as $meta_key => $meta_options ) {
+ $meta_key = $data_store->meta_key_prefix . $meta_key;
+
+ if ( ! array_key_exists( $meta_key, $subscription_meta ) ) {
+ continue;
+ }
+
+ $action = ( isset( $meta_options['privacy_erasure'] ) ? $meta_options['privacy_erasure'] : null );
+
+ $privacy_manager->erase_meta( $subscription_id, $meta_key, $action );
+ }
+
+ $items_removed = true;
+ } else {
+ $note = __( 'Subscription not anonymized for personal data erasure request because of active status.', 'pronamic_ideal' );
+
+ /* translators: %s: Subscription ID */
+ $message = __( 'Subscription ID %s not anonymized because of active status.', 'pronamic_ideal' );
+
+ $items_retained = true;
+ }
+
+ // Add erasure return message.
+ $messages[] = sprintf( $message, $subscription_id );
+
+ // Add subscription note.
+ try {
+ $subscription->add_note( $note );
+ } catch ( \Exception $e ) {
+ continue;
+ }
+ }
+
+ $done = true;
+
+ // Return results.
+ return [
+ 'items_removed' => $items_removed,
+ 'items_retained' => $items_retained,
+ 'messages' => $messages,
+ 'done' => $done,
+ ];
+ }
+}
diff --git a/packages/wp-pay/core/src/TrackingModule.php b/packages/wp-pay/core/src/TrackingModule.php
new file mode 100644
index 0000000..19f229a
--- /dev/null
+++ b/packages/wp-pay/core/src/TrackingModule.php
@@ -0,0 +1,107 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * Tracking module
+ *
+ * @author Reüel van der Steege
+ * @since 2.2.6
+ * @version 2.2.6
+ */
+class TrackingModule {
+ /**
+ * URL parameters.
+ *
+ * @var null|array
+ */
+ private $parameters;
+
+ /**
+ * Get tracking URL.
+ *
+ * @param string $url URL to add tracking parameters to.
+ * @return string
+ */
+ public function get_tracking_url( $url ) {
+ if ( null === $this->parameters ) {
+ $this->build_parameters();
+ }
+
+ return \add_query_arg( $this->parameters, $url );
+ }
+
+ /**
+ * Build URL parameters.
+ *
+ * @return void
+ */
+ private function build_parameters() {
+ // General parameters.
+ $params = [
+ 'locale' => \get_locale(),
+ 'php' => \str_replace( PHP_EXTRA_VERSION, '', \strval( \phpversion() ) ),
+ ];
+
+ // Add extensions parameters.
+ $plugins = \get_plugins();
+
+ $extensions = \array_merge(
+ [
+ 'pronamic-ideal',
+ 'contact-form-7',
+ 'wpforms',
+ ],
+ $this->get_supported_extensions()
+ );
+
+ foreach ( $plugins as $slug => $plugin ) {
+ foreach ( $extensions as $extension ) {
+ if ( false === \stristr( $slug, $extension ) ) {
+ continue;
+ }
+
+ // Add plugin to URL parameters.
+ $slug = dirname( $slug );
+
+ $params[ $slug ] = $plugin['Version'];
+ }
+ }
+
+ // Set parameters.
+ $this->parameters = $params;
+ }
+
+ /**
+ * Get supported extensions.
+ *
+ * @return array
+ */
+ public function get_supported_extensions() {
+ $extensions = [];
+
+ $extensions_json_path = \dirname( \pronamic_pay_plugin()->get_file() ) . '/other/extensions.json';
+
+ if ( \is_readable( $extensions_json_path ) ) {
+ $data = \file_get_contents( $extensions_json_path, true );
+
+ if ( false !== $data ) {
+ $data = \json_decode( $data );
+
+ if ( null !== $data ) {
+ $extensions = \wp_list_pluck( $data, 'slug' );
+ }
+ }
+ }
+
+ return $extensions;
+ }
+}
diff --git a/packages/wp-pay/core/src/Upgrades/Upgrade.php b/packages/wp-pay/core/src/Upgrades/Upgrade.php
new file mode 100644
index 0000000..6aeb04b
--- /dev/null
+++ b/packages/wp-pay/core/src/Upgrades/Upgrade.php
@@ -0,0 +1,62 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Upgrades
+ */
+
+namespace Pronamic\WordPress\Pay\Upgrades;
+
+/**
+ * Upgrade
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ */
+abstract class Upgrade {
+ /**
+ * Version.
+ *
+ * @var string
+ */
+ private $version;
+
+ /**
+ * Construct upgrade object.
+ *
+ * @param string $version Version.
+ */
+ public function __construct( $version ) {
+ $this->set_version( $version );
+ }
+
+ /**
+ * Get version.
+ *
+ * @return string
+ */
+ public function get_version() {
+ return $this->version;
+ }
+
+ /**
+ * Set version.
+ *
+ * @param string $version Version.
+ * @return void
+ */
+ public function set_version( $version ) {
+ $this->version = $version;
+ }
+
+ /**
+ * Execute.
+ *
+ * @return void
+ */
+ abstract public function execute();
+}
diff --git a/packages/wp-pay/core/src/Upgrades/Upgrades.php b/packages/wp-pay/core/src/Upgrades/Upgrades.php
new file mode 100644
index 0000000..417e10a
--- /dev/null
+++ b/packages/wp-pay/core/src/Upgrades/Upgrades.php
@@ -0,0 +1,95 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay\Upgrades
+ */
+
+namespace Pronamic\WordPress\Pay\Upgrades;
+
+use ArrayIterator;
+use Countable;
+use IteratorAggregate;
+use Traversable;
+
+/**
+ * Upgrades
+ *
+ * @author Remco Tolsma
+ * @version 2.2.6
+ * @since 2.2.6
+ * @implements \IteratorAggregate
+ */
+class Upgrades implements Countable, IteratorAggregate {
+ /**
+ * Upgrades.
+ *
+ * @var array
+ */
+ private $upgrades;
+
+ /**
+ * Executable.
+ *
+ * @var boolean
+ */
+ private $executable;
+
+ /**
+ * Construct.
+ */
+ public function __construct() {
+ $this->upgrades = [];
+ $this->executable = true;
+ }
+
+ /**
+ * Are executable.
+ *
+ * @return boolean True if upgrade are executable, false otherwise.
+ */
+ public function are_executable() {
+ return $this->executable;
+ }
+
+ /**
+ * Set the upgrades as executable or not.
+ *
+ * @param boolean $executable True if upgrades are executable, false otherwise.
+ * @return void
+ */
+ public function set_executable( $executable ) {
+ $this->executable = $executable;
+ }
+
+ /**
+ * Add upgrades.
+ *
+ * @param Upgrade $upgrade The upgrade to add.
+ * @return void
+ */
+ public function add( Upgrade $upgrade ) {
+ $this->upgrades[] = $upgrade;
+ }
+
+ /**
+ * Get iterator.
+ *
+ * @return Traversable
+ */
+ public function getIterator(): Traversable {
+ return new ArrayIterator( $this->upgrades );
+ }
+
+ /**
+ * Count upgrades.
+ *
+ * @return int
+ */
+ public function count(): int {
+ return count( $this->upgrades );
+ }
+}
diff --git a/packages/wp-pay/core/src/Util.php b/packages/wp-pay/core/src/Util.php
new file mode 100644
index 0000000..4fe2125
--- /dev/null
+++ b/packages/wp-pay/core/src/Util.php
@@ -0,0 +1,303 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use DateInterval;
+use Pronamic\WordPress\Pay\Core\Util as Core_Util;
+use Pronamic\WordPress\Money\Money;
+use SimpleXMLElement;
+use WP_Error;
+
+/**
+ * WordPress utility class
+ *
+ * @author Remco Tolsma
+ * @version 2.5.0
+ * @since 2.0.1
+ */
+class Util {
+ /**
+ * Format date interval.
+ *
+ * @param DateInterval $date_interval Date interval.
+ *
+ * @return string
+ */
+ public static function format_date_interval( DateInterval $date_interval ) {
+ // Periods.
+ $periods = [];
+
+ foreach ( [ 'y', 'm', 'd', 'h', 'i', 's' ] as $period ) {
+ $value = $date_interval->$period;
+
+ // Check value.
+ if ( 0 === $value ) {
+ continue;
+ }
+
+ // Format.
+ $format = '';
+
+ switch ( $period ) {
+ case 'y':
+ /* translators: %s: number of years */
+ $format = _n( '%s year', '%s years', $value, 'pronamic_ideal' );
+
+ break;
+ case 'm':
+ /* translators: %s: number of months */
+ $format = _n( '%s month', '%s months', $value, 'pronamic_ideal' );
+
+ break;
+ case 'd':
+ /* translators: %s: number of days */
+ $format = _n( '%s day', '%s days', $value, 'pronamic_ideal' );
+
+ break;
+ case 'h':
+ /* translators: %s: number of hours */
+ $format = _n( '%s hour', '%s hours', $value, 'pronamic_ideal' );
+
+ break;
+ case 'i':
+ /* translators: %s: number of minutes */
+ $format = _n( '%s minute', '%s minutes', $value, 'pronamic_ideal' );
+
+ break;
+ case 's':
+ /* translators: %s: number of seconds */
+ $format = _n( '%s second', '%s seconds', $value, 'pronamic_ideal' );
+
+ break;
+ }
+
+ // Add period.
+ $periods[] = \sprintf( $format, $value );
+ }
+
+ // Multiple periods.
+ if ( count( $periods ) > 1 ) {
+ $last_period = \array_pop( $periods );
+
+ $formatted = \implode( ', ', $periods );
+
+ return sprintf(
+ /* translators: 1: formatted periods, 2: last formatted period */
+ __( '%1$s and %2$s', 'pronamic_ideal' ),
+ $formatted,
+ $last_period
+ );
+ }
+
+ // Single period.
+ $formatted = \implode( ', ', $periods );
+
+ return $formatted;
+ }
+
+ /**
+ * Format recurrences date interval.
+ *
+ * @param DateInterval $date_interval Date interval.
+ *
+ * @return string
+ */
+ public static function format_recurrences( DateInterval $date_interval ) {
+ $formatted_interval = self::format_date_interval( $date_interval );
+
+ // Check empty date interval.
+ if ( empty( $formatted_interval ) ) {
+ return '—';
+ }
+
+ return sprintf(
+ /* translators: %s: formatted date interval periods */
+ __( 'Every %s', 'pronamic_ideal' ),
+ $formatted_interval
+ );
+ }
+
+ /**
+ * Format interval.
+ *
+ * @param int $interval The interval number.
+ * @param string $period The period indicator.
+ *
+ * @return string|null
+ */
+ public static function format_interval( $interval, $period ) {
+ switch ( $period ) {
+ case 'D':
+ case 'day':
+ case 'days':
+ /* translators: %s: interval */
+ return sprintf( _n( 'Every %s day', 'Every %s days', $interval, 'pronamic_ideal' ), $interval );
+ case 'W':
+ case 'week':
+ case 'weeks':
+ /* translators: %s: interval */
+ return sprintf( _n( 'Every %s week', 'Every %s weeks', $interval, 'pronamic_ideal' ), $interval );
+ case 'M':
+ case 'month':
+ case 'months':
+ /* translators: %s: interval */
+ return sprintf( _n( 'Every %s month', 'Every %s months', $interval, 'pronamic_ideal' ), $interval );
+ case 'Y':
+ case 'year':
+ case 'years':
+ /* translators: %s: interval */
+ return sprintf( _n( 'Every %s year', 'Every %s years', $interval, 'pronamic_ideal' ), $interval );
+ }
+
+ return null;
+ }
+
+ /**
+ * Convert single interval period character to full name.
+ *
+ * @param string $interval_period string Short interval period (D, W, M or Y).
+ *
+ * @return string
+ */
+ public static function to_interval_name( $interval_period ) {
+ switch ( $interval_period ) {
+ case 'D':
+ return 'days';
+ case 'W':
+ return 'weeks';
+ case 'M':
+ return 'months';
+ case 'Y':
+ return 'years';
+ }
+
+ return $interval_period;
+ }
+
+ /**
+ * Format frequency.
+ *
+ * @param int $frequency The number of times.
+ *
+ * @return string
+ */
+ public static function format_frequency( $frequency ) {
+ if ( empty( $frequency ) ) {
+ return _x( 'Unlimited', 'Recurring payment', 'pronamic_ideal' );
+ }
+
+ /* translators: %s: frequency */
+ return sprintf( _n( '%s period', '%s periods', $frequency, 'pronamic_ideal' ), $frequency );
+ }
+
+ /**
+ * Flattens a multi-dimensional array into a single level array that uses "square bracket" notation to indicate depth.
+ *
+ * @link https://github.com/pronamic/wp-pay-core/issues/73
+ * @param iterable $data Data.
+ * @param string $name Parent.
+ * @param array $result Result.
+ * @return array
+ */
+ public static function array_square_bracket( $data, $name = '', $result = [] ) {
+ foreach ( $data as $key => $item ) {
+ if ( '' !== $name ) {
+ $key = $name . '[' . $key . ']';
+ }
+
+ if ( is_array( $item ) ) {
+ $result = self::array_square_bracket( $item, $key, $result );
+ } else {
+ $result[ $key ] = $item;
+ }
+ }
+
+ return $result;
+ }
+ /**
+ * Get hidden inputs HTML for data.
+ *
+ * @param array $data Array with name and value pairs to convert to hidden HTML input elements.
+ *
+ * @return string
+ */
+ public static function html_hidden_fields( $data ) {
+ $html = '';
+
+ $data = self::array_square_bracket( $data );
+
+ foreach ( $data as $name => $value ) {
+ $html .= sprintf( ' ', esc_attr( $name ), esc_attr( $value ) );
+ }
+
+ return $html;
+ }
+
+ /**
+ * Array to HTML attributes.
+ *
+ * @param array $attributes The key and value pairs to convert to HTML attributes.
+ *
+ * @return string
+ */
+ public static function array_to_html_attributes( array $attributes ) {
+ $html = '';
+
+ foreach ( $attributes as $key => $value ) {
+ // Check boolean attribute.
+ if ( \is_bool( $value ) ) {
+ if ( $value ) {
+ $html .= sprintf( '%s ', $key );
+ }
+
+ continue;
+ }
+
+ $html .= sprintf( '%s="%s" ', $key, esc_attr( $value ) );
+ }
+
+ $html = trim( $html );
+
+ return $html;
+ }
+
+ /**
+ * Select options grouped.
+ *
+ * @param array $groups The grouped select options.
+ * @param string $selected_value The selected value.
+ *
+ * @return string
+ */
+ public static function select_options_grouped( $groups, $selected_value = null ) {
+ $html = '';
+
+ if ( is_array( $groups ) ) {
+ foreach ( $groups as $group ) {
+ $optgroup = isset( $group['name'] ) && ! empty( $group['name'] );
+
+ if ( $optgroup ) {
+ $html .= '';
+ }
+
+ foreach ( $group['options'] as $value => $label ) {
+ $html .= '' . $label . ' ';
+ }
+
+ if ( $optgroup ) {
+ $html .= ' ';
+ }
+ }
+ }
+
+ return $html;
+ }
+}
diff --git a/packages/wp-pay/core/src/VatNumbers/VatNumber.php b/packages/wp-pay/core/src/VatNumbers/VatNumber.php
new file mode 100644
index 0000000..d3eaf60
--- /dev/null
+++ b/packages/wp-pay/core/src/VatNumbers/VatNumber.php
@@ -0,0 +1,211 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\VatNumbers;
+
+/**
+ * VAT Number
+ *
+ * @link https://en.wikipedia.org/wiki/VAT_identification_number
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 1.4.0
+ */
+class VatNumber {
+ /**
+ * Value.
+ *
+ * @var string
+ */
+ private $value;
+
+ /**
+ * Validity.
+ *
+ * @var VatNumberValidity|null
+ */
+ private $validity;
+
+ /**
+ * Construct VAT number object.
+ *
+ * @param string $value VAT identification number.
+ */
+ public function __construct( $value ) {
+ $this->value = $value;
+ }
+
+ /**
+ * Get value.
+ *
+ * @return string
+ */
+ public function get_value() {
+ return $this->value;
+ }
+
+ /**
+ * Get 2 digit prefix.
+ *
+ * The full identifier starts with an ISO 3166-1 alpha-2 (2 letters) country code (except for Greece, which uses the ISO 639-1 language code EL for the Greek language, instead of its ISO 3166-1 alpha-2 country code GR).
+ *
+ * @link https://en.wikipedia.org/wiki/VAT_identification_number
+ * @return string
+ */
+ public function get_2_digit_prefix() {
+ $value = self::normalize( $this->value );
+
+ $prefix = \substr( $value, 0, 2 );
+
+ return $prefix;
+ }
+
+ /**
+ * Get normalized value.
+ *
+ * @return string
+ */
+ public function normalized() {
+ return self::normalize( $this->value );
+ }
+
+ /**
+ * Get the number without the 2 digit prefix.
+ *
+ * @link https://en.wikipedia.org/wiki/VAT_identification_number
+ * @return string
+ */
+ public function normalized_without_prefix() {
+ $value = self::normalize( $this->value );
+
+ return \substr( $value, 2 );
+ }
+
+ /**
+ * Get validity.
+ *
+ * @return VatNumberValidity|null
+ */
+ public function get_validity() {
+ return $this->validity;
+ }
+
+ /**
+ * Set validity
+ *
+ * @param VatNumberValidity|null $validity Validity.
+ * @return void
+ */
+ public function set_validity( VatNumberValidity $validity = null ) {
+ $this->validity = $validity;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return string|object
+ */
+ public function get_json() {
+ if ( null === $this->validity ) {
+ return $this->value;
+ }
+
+ $data = [
+ 'value' => $this->value,
+ 'validity' => $this->validity->get_json(),
+ ];
+
+ return (object) $data;
+ }
+
+ /**
+ * Create VAT number from JSON.
+ *
+ * @param mixed $json JSON.
+ * @return VatNumber
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( \is_string( $json ) ) {
+ return new self( $json );
+ }
+
+ if ( ! \is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be either a string or object.' );
+ }
+
+ if ( ! \property_exists( $json, 'value' ) ) {
+ throw new \InvalidArgumentException( 'JSON object must contain value property.' );
+ }
+
+ $vat_number = new self( $json->value );
+
+ if ( property_exists( $json, 'validity' ) ) {
+ $validity = VatNumberValidity::from_json( $json->validity );
+
+ $vat_number->set_validity( $validity );
+ }
+
+ return $vat_number;
+ }
+
+ /**
+ * Create VAT number from string.
+ *
+ * @param string $value VAT number string.
+ * @return VatNumber
+ */
+ public static function from_string( $value ) {
+ return new self( $value );
+ }
+
+ /**
+ * Create VAT number from prefix and number.
+ *
+ * @param string $prefix Prefix (country code).
+ * @param string $value VAT number.
+ * @return VatNumber
+ */
+ public static function from_prefix_and_number( $prefix, $value ) {
+ return new self( $prefix . $value );
+ }
+
+ /**
+ * Create string representation of VAT number.
+ *
+ * @return string
+ */
+ public function __toString() {
+ return $this->value;
+ }
+
+ /**
+ * Normalize VAT number.
+ *
+ * @link https://gitlab.com/pronamic-plugins/edd-vat/-/blob/1.0.0/includes/class-check-vat-eu.php#L39-47
+ * @param string $value VAT identification number.
+ * @return string
+ */
+ public static function normalize( $value ) {
+ /**
+ * Replace white spaces and dots.
+ */
+ $value = \str_replace(
+ [
+ ' ',
+ '.',
+ ],
+ '',
+ $value
+ );
+
+ return $value;
+ }
+}
diff --git a/packages/wp-pay/core/src/VatNumbers/VatNumberValidationService.php b/packages/wp-pay/core/src/VatNumbers/VatNumberValidationService.php
new file mode 100644
index 0000000..1aae044
--- /dev/null
+++ b/packages/wp-pay/core/src/VatNumbers/VatNumberValidationService.php
@@ -0,0 +1,28 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\VatNumbers;
+
+/**
+ * VAT number validation service
+ *
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 2.1.0
+ */
+class VatNumberValidationService {
+ /**
+ * VIES.
+ *
+ * @link https://ec.europa.eu/taxation_customs/vies/?locale=en
+ * @var string
+ */
+ const VIES = 'vies';
+}
diff --git a/packages/wp-pay/core/src/VatNumbers/VatNumberValidity.php b/packages/wp-pay/core/src/VatNumbers/VatNumberValidity.php
new file mode 100644
index 0000000..5c6b3dc
--- /dev/null
+++ b/packages/wp-pay/core/src/VatNumbers/VatNumberValidity.php
@@ -0,0 +1,242 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\VatNumbers;
+
+/**
+ * VAT Number validity
+ *
+ * @link https://ec.europa.eu/taxation_customs/vies/?locale=en
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 1.4.0
+ */
+class VatNumberValidity {
+ /**
+ * VAT Number.
+ *
+ * @var VatNumber
+ */
+ private $vat_number;
+
+ /**
+ * Request date.
+ *
+ * @var \DateTimeInterface
+ */
+ private $request_date;
+
+ /**
+ * Valid flag.
+ *
+ * @var bool
+ */
+ private $valid;
+
+ /**
+ * Name.
+ *
+ * @var string|null
+ */
+ private $name;
+
+ /**
+ * Address.
+ *
+ * @var string|null
+ */
+ private $address;
+
+ /**
+ * Validation service indicator.
+ *
+ * @var string|null
+ */
+ private $service;
+
+ /**
+ * Construct VAT number object.
+ *
+ * @param VatNumber $vat_number VAT identification number.
+ * @param \DateTimeInterface $request_date Request date.
+ * @param bool $valid True if valid, false otherwise.
+ */
+ public function __construct( VatNumber $vat_number, \DateTimeInterface $request_date, $valid ) {
+ $this->vat_number = $vat_number;
+ $this->request_date = $request_date;
+ $this->valid = $valid;
+ }
+
+ /**
+ * Get VAT number.
+ *
+ * @return VatNumber
+ */
+ public function get_vat_number() {
+ return $this->vat_number;
+ }
+
+ /**
+ * Get request date.
+ *
+ * @return \DateTimeInterface
+ */
+ public function get_request_date() {
+ return $this->request_date;
+ }
+
+ /**
+ * Is valid.
+ *
+ * @return bool True if valid, false otherwise.
+ */
+ public function is_valid() {
+ return $this->valid;
+ }
+
+ /**
+ * Set valid.
+ *
+ * @param bool $valid Valid.
+ * @return void
+ */
+ public function set_valid( $valid ) {
+ $this->valid = $valid;
+ }
+
+ /**
+ * Get name.
+ *
+ * @return string|null
+ */
+ public function get_name() {
+ return $this->name;
+ }
+
+ /**
+ * Set name.
+ *
+ * @param string|null $name Name.
+ * @return void
+ */
+ public function set_name( $name ) {
+ $this->name = $name;
+ }
+
+ /**
+ * Get address.
+ *
+ * @return string|null
+ */
+ public function get_address() {
+ return $this->address;
+ }
+
+ /**
+ * Set address.
+ *
+ * @param string|null $address Address.
+ * @return void
+ */
+ public function set_address( $address ) {
+ $this->address = $address;
+ }
+
+ /**
+ * Get service.
+ *
+ * @return string|null
+ */
+ public function get_service() {
+ return $this->service;
+ }
+
+ /**
+ * Set service.
+ *
+ * @param string|null $service Service.
+ * @return void
+ */
+ public function set_service( $service ) {
+ $this->service = $service;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object|null
+ */
+ public function get_json() {
+ $data = [
+ 'vat_number' => $this->vat_number->get_value(),
+ 'request_date' => $this->request_date->format( 'Y-m-d' ),
+ 'valid' => $this->valid,
+ ];
+
+ if ( null !== $this->name ) {
+ $data['name'] = $this->name;
+ }
+
+ if ( null !== $this->address ) {
+ $data['address'] = $this->address;
+ }
+
+ if ( null !== $this->service ) {
+ $data['service'] = $this->service;
+ }
+
+ return (object) $data;
+ }
+
+ /**
+ * Create from object.
+ *
+ * @param mixed $json JSON.
+ * @return VatNumberValidity
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException( 'JSON value must be an object.' );
+ }
+
+ if ( ! property_exists( $json, 'vat_number' ) ) {
+ throw new \InvalidArgumentException( 'JSON object does not contain `vat_number` property.' );
+ }
+
+ if ( ! property_exists( $json, 'request_date' ) ) {
+ throw new \InvalidArgumentException( 'JSON object does not contain `request_date` property.' );
+ }
+
+ if ( ! property_exists( $json, 'valid' ) ) {
+ throw new \InvalidArgumentException( 'JSON object does not contain `valid` property.' );
+ }
+
+ $validity = new self(
+ VatNumber::from_json( $json->vat_number ),
+ new \DateTimeImmutable( $json->request_date ),
+ \boolval( $json->valid )
+ );
+
+ if ( property_exists( $json, 'name' ) ) {
+ $validity->set_name( $json->name );
+ }
+
+ if ( property_exists( $json, 'address' ) ) {
+ $validity->set_address( $json->address );
+ }
+
+ if ( property_exists( $json, 'service' ) ) {
+ $validity->set_service( $json->service );
+ }
+
+ return $validity;
+ }
+}
diff --git a/packages/wp-pay/core/src/VatNumbers/VatNumberViesValidator.php b/packages/wp-pay/core/src/VatNumbers/VatNumberViesValidator.php
new file mode 100644
index 0000000..20c9f83
--- /dev/null
+++ b/packages/wp-pay/core/src/VatNumbers/VatNumberViesValidator.php
@@ -0,0 +1,60 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\VatNumbers;
+
+/**
+ * VAT Number VIES validator
+ *
+ * @author Remco Tolsma
+ * @version 2.4.0
+ * @since 1.4.0
+ */
+class VatNumberViesValidator {
+ /**
+ * API URL
+ */
+ const API_URL = 'http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
+
+ /**
+ * Validate VAT number.
+ *
+ * @param VatNumber $vat_number VAT number.
+ * @return VatNumberValidity
+ * @throws \Exception SOAP error.
+ */
+ public static function validate( VatNumber $vat_number ) {
+ // Client.
+ $client = new \SoapClient( self::API_URL );
+
+ // Parameters.
+ $parameters = [
+ 'countryCode' => $vat_number->get_2_digit_prefix(),
+ 'vatNumber' => $vat_number->normalized_without_prefix(),
+ ];
+
+ // Response.
+ $response = $client->checkVat( $parameters );
+
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- VIES response object.
+ $vat_number = VatNumber::from_prefix_and_number( $response->countryCode, $response->vatNumber );
+
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- VIES response object.
+ $request_date = new \DateTime( $response->requestDate );
+
+ $validity = new VatNumberValidity( $vat_number, $request_date, $response->valid );
+
+ $validity->set_name( $response->name );
+ $validity->set_address( $response->address );
+ $validity->set_service( VatNumberValidationService::VIES );
+
+ return $validity;
+ }
+}
diff --git a/packages/wp-pay/core/src/VatRates.php b/packages/wp-pay/core/src/VatRates.php
new file mode 100644
index 0000000..c71b794
--- /dev/null
+++ b/packages/wp-pay/core/src/VatRates.php
@@ -0,0 +1,50 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+/**
+ * VAT rates
+ *
+ * @link https://ec.europa.eu/taxation_customs/sites/taxation/files/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf
+ * @link https://github.com/apilayer/euvatrates.com
+ * @author Remco Tolsma
+ * @version 2.1.0
+ * @since 2.1.0
+ */
+class VatRates {
+ /**
+ * Standard rate.
+ *
+ * @var string
+ */
+ const STANDARD = 'standard';
+
+ /**
+ * Reduced rate.
+ *
+ * @var string
+ */
+ const REDUCED = 'reduced';
+
+ /**
+ * Super reduced rate.
+ *
+ * @var string
+ */
+ const SUPER_REDUCED = 'super_reduced';
+
+ /**
+ * Parking rate.
+ *
+ * @var string
+ */
+ const PARKING = 'parking';
+}
diff --git a/packages/wp-pay/core/src/Webhooks/WebhookLogger.php b/packages/wp-pay/core/src/Webhooks/WebhookLogger.php
new file mode 100644
index 0000000..4adc705
--- /dev/null
+++ b/packages/wp-pay/core/src/Webhooks/WebhookLogger.php
@@ -0,0 +1,91 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Webhooks;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Payments\Payment;
+use Pronamic\WordPress\Pay\Plugin;
+
+/**
+ * Webhook logger class
+ *
+ * @author Reüel van der Steege
+ * @version 2.1.6
+ * @since 2.1.6
+ */
+class WebhookLogger {
+ /**
+ * Setup.
+ *
+ * @return void
+ */
+ public function setup() {
+ add_action( 'pronamic_pay_webhook_log_payment', [ $this, 'log_payment' ] );
+ }
+
+ /**
+ * Log payment.
+ *
+ * @param Payment $payment Payment.
+ *
+ * @return void
+ *
+ * @throws \Exception Throws an Exception on request date error.
+ */
+ public function log_payment( Payment $payment ) {
+ $request_info = new WebhookRequestInfo(
+ new DateTime(),
+ \get_self_link()
+ );
+
+ $request_info->set_payment( $payment );
+
+ $this->log_request( $request_info );
+ }
+
+ /**
+ * Log request.
+ *
+ * @param WebhookRequestInfo $request_info Request info.
+ *
+ * @return void
+ */
+ public function log_request( WebhookRequestInfo $request_info ) {
+ // Payment.
+ $payment = $request_info->get_payment();
+
+ if ( null === $payment ) {
+ return;
+ }
+
+ // Config ID.
+ $config_id = $payment->get_config_id();
+
+ if ( null === $config_id ) {
+ return;
+ }
+
+ // Gateway.
+ if ( null === $payment->get_gateway() ) {
+ return;
+ }
+
+ // Update webhook log.
+ $json = wp_json_encode( $request_info );
+
+ if ( $json ) {
+ update_post_meta( $config_id, '_pronamic_gateway_webhook_log', wp_slash( $json ) );
+
+ // Delete outdated webhook URLs transient.
+ delete_transient( 'pronamic_outdated_webhook_urls' );
+ }
+ }
+}
diff --git a/packages/wp-pay/core/src/Webhooks/WebhookRequestInfo.php b/packages/wp-pay/core/src/Webhooks/WebhookRequestInfo.php
new file mode 100644
index 0000000..86b1ccc
--- /dev/null
+++ b/packages/wp-pay/core/src/Webhooks/WebhookRequestInfo.php
@@ -0,0 +1,181 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay\Webhooks;
+
+use JsonSerializable;
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Payments\Payment;
+
+/**
+ * Webhook request info class
+ *
+ * @author Reüel van der Steege
+ * @version 2.2.6
+ * @since 2.1.6
+ */
+class WebhookRequestInfo implements JsonSerializable {
+ /**
+ * Date.
+ *
+ * @var DateTime
+ */
+ private $request_date;
+
+ /**
+ * Request URL.
+ *
+ * @var string
+ */
+ private $request_url;
+
+ /**
+ * Payment.
+ *
+ * @var Payment|null
+ */
+ private $payment;
+
+ /**
+ * Construct webhook request info object.
+ *
+ * @param DateTime $request_date Request date.
+ * @param string $request_url Request URL.
+ */
+ public function __construct( DateTime $request_date, $request_url ) {
+ $this->request_date = $request_date;
+ $this->request_url = $request_url;
+ }
+
+ /**
+ * Get request date.
+ *
+ * @return DateTime
+ */
+ public function get_request_date() {
+ return $this->request_date;
+ }
+
+ /**
+ * Get request URL.
+ *
+ * @return string
+ */
+ public function get_request_url() {
+ return $this->request_url;
+ }
+
+ /**
+ * Get payment.
+ *
+ * @return Payment|null
+ */
+ public function get_payment() {
+ return $this->payment;
+ }
+
+ /**
+ * Set payment.
+ *
+ * @param Payment $payment Payment.
+ * @return void
+ */
+ public function set_payment( Payment $payment ) {
+ $this->payment = $payment;
+ }
+
+ /**
+ * Get JSON.
+ *
+ * @return object
+ */
+ public function get_json() {
+ $properties = [
+ 'request_date' => $this->request_date->format( DATE_ATOM ),
+ 'request_url' => $this->request_url,
+ ];
+
+ if ( null !== $this->payment ) {
+ $properties['payment_id'] = $this->payment->get_id();
+ }
+
+ $object = (object) $properties;
+
+ return $object;
+ }
+
+ /**
+ * JSON serialize.
+ *
+ * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+ * @return mixed
+ */
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize() {
+ return $this->get_json();
+ }
+
+ /**
+ * Create webhook request info from object.
+ *
+ * @param mixed $json JSON.
+ *
+ * @return WebhookRequestInfo
+ *
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON is not an object.
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON does not contain `request_date` property.
+ * @throws \InvalidArgumentException Throws invalid argument exception when JSON does not contain `request_url` property.
+ */
+ public static function from_json( $json ) {
+ if ( ! is_object( $json ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'JSON value must be an object (%s).',
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+ \esc_html( \var_export( $json, true ) )
+ )
+ );
+ }
+
+ if ( ! isset( $json->request_date ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'JSON must contain `request_date` property (%s).',
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+ \esc_html( \var_export( $json, true ) )
+ )
+ );
+ }
+
+ if ( ! isset( $json->request_url ) ) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'JSON must contain `request_url` property (%s).',
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
+ \esc_html( \var_export( $json, true ) )
+ )
+ );
+ }
+
+ $request_date = new DateTime( $json->request_date );
+
+ $webhook_request_info = new WebhookRequestInfo( $request_date, $json->request_url );
+
+ if ( isset( $json->payment_id ) ) {
+ $payment = get_pronamic_payment( $json->payment_id );
+
+ if ( $payment ) {
+ $webhook_request_info->set_payment( $payment );
+ }
+ }
+
+ return $webhook_request_info;
+ }
+}
diff --git a/packages/wp-pay/core/views/exception.php b/packages/wp-pay/core/views/exception.php
new file mode 100644
index 0000000..f98a8ab
--- /dev/null
+++ b/packages/wp-pay/core/views/exception.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Exception $exception Exception.
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+ getMessage() ); ?>
+
+ getCode() ) : ?>
+
+
+ getCode() ); ?>
+
+
+
+ is_debug_mode() && current_user_can( 'manage_options' ) ) : ?>
+
+
+
+ ';
+ echo esc_html( $exception->getTraceAsString() );
+ echo '';
+
+ ?>
+
+
+
+
+
+
+getPrevious();
+
+if ( null !== $exception ) {
+ require __FILE__;
+}
diff --git a/packages/wp-pay/core/views/form.php b/packages/wp-pay/core/views/form.php
new file mode 100644
index 0000000..05d32e6
--- /dev/null
+++ b/packages/wp-pay/core/views/form.php
@@ -0,0 +1,68 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Payments\Payment $payment Payment.
+ * @var \Pronamic\WordPress\Pay\Core\Gateway $this Gateway.
+ */
+
+use Pronamic\WordPress\Html\Element;
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$action_url = $payment->get_action_url();
+
+if ( empty( $action_url ) ) {
+ esc_html_e( 'It is currently not possible to pay, please contact us for more information (error: no action URL found).', 'pronamic_ideal' );
+
+ return;
+}
+
+?>
+
+ get_output_fields( $payment );
+
+ $data = Util::array_square_bracket( $data );
+
+ foreach ( $data as $name => $value ) {
+ printf(
+ ' ',
+ esc_attr( $name ),
+ esc_attr( $value )
+ );
+ }
+
+ ?>
+
+
+
+
+ 'text/javascript',
+ ]
+ );
+
+ $element->children[] = 'document.pronamic_ideal_form.submit();';
+
+ $element->output();
+}
diff --git a/packages/wp-pay/core/views/meta-box-gateway-config.php b/packages/wp-pay/core/views/meta-box-gateway-config.php
new file mode 100644
index 0000000..25531ad
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-gateway-config.php
@@ -0,0 +1,132 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Plugin $plugin Plugin.
+ * @var \WP_Post $post Post.
+ */
+
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$integrations = iterator_to_array( $plugin->gateway_integrations );
+
+usort(
+ $integrations,
+ function ( $integration_a, $integration_b ) {
+ return strcasecmp( (string) $integration_a->get_name(), (string) $integration_b->get_name() );
+ }
+);
+
+// Sections.
+$config_id = $post->ID;
+
+$gateway_id = get_post_meta( $config_id, '_pronamic_gateway_id', true );
+
+// Select gateway if we already know which one to use, because there is only a single gateway registered.
+if ( empty( $gateway_id ) && 1 === count( $integrations ) ) {
+ $integration = reset( $integrations );
+
+ if ( false !== $integration ) {
+ $gateway_id = $integration->get_id();
+ }
+}
+
+?>
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-gateway-payment-methods.php b/packages/wp-pay/core/views/meta-box-gateway-payment-methods.php
new file mode 100644
index 0000000..92d96b1
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-gateway-payment-methods.php
@@ -0,0 +1,95 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var array $columns Columns.
+ * @var array $payment_methods Payment methods.
+ * @var bool $supports_methods_request Supports methods request.
+ */
+
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$show_recurring_column = false;
+
+foreach ( $payment_methods as $payment_method ) {
+ if ( $payment_method->supports( 'recurring' ) ) {
+ $show_recurring_column = true;
+
+ break;
+ }
+}
+
+?>
+
diff --git a/packages/wp-pay/core/views/meta-box-gateway-settings.php b/packages/wp-pay/core/views/meta-box-gateway-settings.php
new file mode 100644
index 0000000..a47b2b3
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-gateway-settings.php
@@ -0,0 +1,425 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Plugin $plugin Plugin.
+ * @var string $gateway_id Gateway ID.
+ * @var int $config_id Configuration ID.
+ * @var \Pronamic\WordPress\Pay\Core\Gateway $gateway Gateway.
+ */
+
+use Pronamic\WordPress\Html\Element;
+use Pronamic\WordPress\Pay\Admin\AdminGatewayPostType;
+use Pronamic\WordPress\Pay\Util;
+use Pronamic\WordPress\Pay\Webhooks\WebhookRequestInfo;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$integration = $plugin->gateway_integrations->get_integration( $gateway_id );
+
+if ( null === $integration ) {
+ return;
+}
+
+$fields = $integration->get_settings_fields();
+
+$sections = [
+ 'general' => (object) [
+ 'title' => __( 'General', 'pronamic_ideal' ),
+ 'fields' => [],
+ ],
+ 'advanced' => (object) [
+ 'title' => __( 'Advanced', 'pronamic_ideal' ),
+ 'fields' => [],
+ ],
+ 'feedback' => (object) [
+ 'title' => __( 'Feedback', 'pronamic_ideal' ),
+ 'fields' => [],
+ ],
+ 'payment_methods' => (object) [
+ 'title' => __( 'Payment Methods', 'pronamic_ideal' ),
+ 'fields' => [
+ [
+ 'section' => 'payment_methods',
+ 'title' => __( 'Supported Payment Methods', 'pronamic_ideal' ),
+ 'type' => 'custom',
+ 'callback' => function () use ( $gateway, $gateway_id ) {
+ AdminGatewayPostType::settings_payment_methods( $gateway, $gateway_id );
+ },
+ ],
+ ],
+ ],
+];
+
+// Feedback.
+if ( $integration->supports( 'webhook' ) ) {
+ $fields[] = [
+ 'section' => 'feedback',
+ 'title' => __( 'Webhook Status', 'pronamic_ideal' ),
+ 'type' => 'custom',
+ 'callback' => function () use ( $gateway, $gateway_id, $config_id ) {
+ AdminGatewayPostType::settings_webhook_log( $gateway, $gateway_id, $config_id );
+ },
+ ];
+}
+
+// Check if webhook configuration is needed.
+if ( $integration->supports( 'webhook' ) && ! $integration->supports( 'webhook_no_config' ) ) {
+ $webhook_config_needed = true;
+
+ $log = get_post_meta( $config_id, '_pronamic_gateway_webhook_log', true );
+
+ if ( ! empty( $log ) ) {
+ $log = json_decode( $log );
+
+ $request_info = WebhookRequestInfo::from_json( $log );
+
+ // Validate log request URL against current home URL.
+ if ( str_starts_with( $request_info->get_request_url(), home_url( '/' ) ) ) {
+ $webhook_config_needed = false;
+ }
+ }
+
+ if ( $webhook_config_needed ) {
+ $sections['feedback']->title = sprintf(
+ '⚠️ %s',
+ $sections['feedback']->title
+ );
+
+ $fields[] = [
+ 'section' => 'general',
+ 'title' => __( 'Transaction feedback', 'pronamic_ideal' ),
+ 'type' => 'custom',
+ 'callback' => function () {
+ printf(
+ '⚠️ %s',
+ esc_html__(
+ 'Processing gateway transaction feedback in the background requires additional configuration.',
+ 'pronamic_ideal'
+ )
+ );
+ },
+ ];
+ }
+}
+
+// Sections.
+foreach ( $fields as $field_id => $field ) {
+ $section = 'general';
+
+ if ( array_key_exists( 'section', $field ) ) {
+ $section = $field['section'];
+ }
+
+ if ( ! array_key_exists( $section, $sections ) ) {
+ $section = 'general';
+ }
+
+ $sections[ $section ]->fields[] = $field;
+}
+
+$sections = array_filter(
+ $sections,
+ function ( $section ) {
+ return ! empty( $section->fields );
+ }
+);
+
+?>
+
+
+
+
+
+
+ title ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-gateway-test.php b/packages/wp-pay/core/views/meta-box-gateway-test.php
new file mode 100644
index 0000000..00224a3
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-gateway-test.php
@@ -0,0 +1,345 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \WP_Post $post WordPress post.
+ */
+
+use Pronamic\WordPress\Money\Currencies;
+use Pronamic\WordPress\Money\Currency;
+use Pronamic\WordPress\Pay\Plugin;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$gateway = Plugin::get_gateway( $post->ID );
+
+if ( null === $gateway ) {
+ printf(
+ '%s ',
+ esc_html( __( 'Please save the entered account details of your payment provider, to make a test payment.', 'pronamic_ideal' ) )
+ );
+
+ return;
+}
+
+wp_nonce_field( 'test_pay_gateway', 'pronamic_pay_test_nonce' );
+
+$currency_default = Currency::get_instance( 'EUR' );
+
+$payment_methods = $gateway->get_payment_methods(
+ [
+ 'status' => [
+ '',
+ 'active',
+ ],
+ ]
+);
+
+?>
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-gateway-webhook-log.php b/packages/wp-pay/core/views/meta-box-gateway-webhook-log.php
new file mode 100644
index 0000000..bc063e6
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-gateway-webhook-log.php
@@ -0,0 +1,87 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var string $gateway_id Gateway ID.
+ * @var int $config_id Configuration ID.
+ * @var \Pronamic\WordPress\Pay\Core\Gateway $gateway Gateway.
+ */
+
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Webhooks\WebhookRequestInfo;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$integration = pronamic_pay_plugin()->gateway_integrations->get_integration( $gateway_id );
+
+if ( ! $integration || ! $integration->supports( 'webhook_log' ) ) {
+ esc_html_e( 'This gateway does not support webhook logging.', 'pronamic_ideal' );
+
+ return;
+}
+
+$webhook_log_json_string = get_post_meta( $config_id, '_pronamic_gateway_webhook_log', true );
+
+$config_gateway_id = get_post_meta( $config_id, '_pronamic_gateway_id', true );
+
+if ( empty( $webhook_log_json_string ) || $config_gateway_id !== $gateway_id ) {
+ esc_html_e( 'No webhook request processed yet.', 'pronamic_ideal' );
+
+ return;
+}
+
+$object = json_decode( $webhook_log_json_string );
+
+try {
+ $webhook_log_request_info = WebhookRequestInfo::from_json( $object );
+} catch ( InvalidArgumentException $e ) {
+ printf(
+ /* translators: %s: Exception message. */
+ esc_html__( 'The following error occurred when reading the webhook request information: "%s".', 'pronamic_ideal' ),
+ esc_html( $e->getMessage() )
+ );
+
+ return;
+}
+
+$payment = $webhook_log_request_info->get_payment();
+
+$payment_id = ( null === $payment ) ? null : $payment->get_id();
+
+if ( null !== $payment_id ) {
+ echo wp_kses(
+ sprintf(
+ /* translators: 1: formatted date, 2: payment edit url, 3: payment id */
+ __(
+ 'Last webhook request processed on %1$s for payment #%3$s .',
+ 'pronamic_ideal'
+ ),
+ $webhook_log_request_info->get_request_date()->format_i18n( _x( 'l j F Y \a\t H:i', 'full datetime format', 'pronamic_ideal' ) ),
+ esc_url( (string) get_edit_post_link( $payment_id ) ),
+ (string) $payment_id
+ ),
+ [
+ 'a' => [
+ 'href' => true,
+ 'title' => true,
+ ],
+ ]
+ );
+} else {
+ echo esc_html(
+ sprintf(
+ /* translators: %s: formatted date */
+ __(
+ 'Last webhook request processed on %s.',
+ 'pronamic_ideal'
+ ),
+ $webhook_log_request_info->get_request_date()->format_i18n( _x( 'l j F Y \a\t H:i', 'full datetime format', 'pronamic_ideal' ) )
+ )
+ );
+}
diff --git a/packages/wp-pay/core/views/meta-box-notes.php b/packages/wp-pay/core/views/meta-box-notes.php
new file mode 100644
index 0000000..be644c1
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-notes.php
@@ -0,0 +1,70 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\DateTime\DateTimeZone;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $notes ) ) {
+ return;
+}
+
+if ( ! is_array( $notes ) ) {
+ return;
+}
+
+if ( empty( $notes ) ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ comment_date_gmt, new DateTimeZone( 'UTC' ) );
+
+ echo esc_html( $date->format_i18n() );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-payment-info.php b/packages/wp-pay/core/views/meta-box-payment-info.php
new file mode 100644
index 0000000..9250548
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-payment-info.php
@@ -0,0 +1,966 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Plugin $plugin Plugin.
+ * @var \Pronamic\WordPress\Pay\Payments\Payment $payment Payment.
+ */
+
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Gender;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\VatNumbers\VatNumberValidationService;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
+ date->format_i18n() ); ?>
+
+
+
+ get_failure_reason();
+
+ if ( PaymentStatus::FAILURE === $payment->get_status() && null !== $failure_reason ) :
+
+ ?>
+
+
+
+
+
+
+ get_status_label();
+
+ echo \esc_html( ( null === $status_label ) ? '—' : $status_label );
+
+ printf(
+ ' — %s',
+ esc_html( $failure_reason )
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_id() ); ?>
+
+
+
+
+
+
+
+ get_order_id() ); ?>
+
+
+
+
+
+
+
+ get_description() ); ?>
+
+
+
+
+
+
+
+ get_total_amount()->format_i18n() );
+
+ ?>
+
+
+
+ get_refunded_amount();
+
+ if ( $amount_refunded->get_value() > 0 ) :
+
+ ?>
+
+
+
+
+
+
+ format_i18n() ); ?>
+
+
+
+
+
+ get_charged_back_amount();
+
+ if ( null !== $charged_back_amount && $charged_back_amount->get_value() > 0 ) :
+
+ ?>
+
+
+
+
+
+
+ format_i18n() ); ?>
+
+
+
+
+
+
+
+
+
+
+ get_id() );
+
+ ?>
+
+
+
+ get_meta( 'purchase_id' );
+
+ if ( $purchase_id ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ config_id ) : ?>
+
+
+
+
+
+
+ config_id ), '', '', $payment->config_id ); ?>
+
+
+
+
+
+
+
+
+
+
+ get_payment_method();
+
+ // Name.
+ $name = PaymentMethods::get_name( $payment_method );
+ $name = ( null === $name ) ? $payment_method : $name;
+
+ $gateway = Plugin::get_gateway( (int) $payment->get_config_id() );
+
+ if ( null !== $gateway && null !== $payment_method ) {
+ $method = $gateway->get_payment_method( $payment_method );
+
+ if ( null !== $method ) {
+ $name = $method->get_name();
+ }
+ }
+
+ // Icon.
+ $icon_url = PaymentMethods::get_icon_url( $payment_method );
+
+ if ( null !== $icon_url ) {
+ \printf(
+ ' ',
+ \esc_url( $icon_url ),
+ \esc_attr( (string) $name )
+ );
+ }
+
+ // Name.
+ echo esc_html( (string) $name );
+
+ // Issuer.
+ $issuer = $payment->get_meta( 'issuer' );
+
+ if ( $issuer ) {
+ echo esc_html( sprintf( ' (`%s`)', $issuer ) );
+ }
+
+ ?>
+
+
+
+ get_consumer_bank_details();
+
+ if ( null !== $consumer_bank_details ) :
+
+ $consumer_name = $consumer_bank_details->get_name();
+
+ if ( null !== $consumer_name ) :
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_city();
+
+ if ( null !== $account_holder_city ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_country();
+
+ if ( null !== $account_holder_country ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_account_number();
+
+ if ( null !== $account_number ) :
+
+ if ( PaymentMethods::CREDIT_CARD === $payment->get_payment_method() && 4 === strlen( $account_number ) ) {
+ $account_number = sprintf( '●●●● ●●●● ●●●● %d', $account_number );
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_iban();
+
+ if ( null !== $iban ) :
+ ?>
+
+
+
+ %s',
+ esc_attr( _x( 'International Bank Account Number', 'IBAN abbreviation title', 'pronamic_ideal' ) ),
+ esc_html__( 'IBAN', 'pronamic_ideal' )
+ );
+
+ ?>
+
+
+
+
+
+
+ get_bic();
+
+ if ( null !== $bic ) :
+ ?>
+
+
+
+ %s',
+ esc_attr( _x( 'Bank Identifier Code', 'BIC abbreviation title', 'pronamic_ideal' ) ),
+ esc_html__( 'BIC', 'pronamic_ideal' )
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+ get_bank_transfer_recipient_details();
+
+ ?>
+
+
+
+
+
+
+
+
+ [],
+ 'br' => [],
+ ]
+ );
+
+ ?>
+
+
+
+
+
+ get_customer();
+
+ if ( null !== $customer ) :
+
+ $text = \strval( $customer->get_name() );
+
+ if ( empty( $text ) ) :
+
+ $text = $customer->get_email();
+
+ endif;
+
+ if ( ! empty( $text ) ) :
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_company_name();
+
+ if ( null !== $company_name ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_vat_number();
+
+ if ( null !== $vat_number ) :
+ $vat_number_validity = $vat_number->get_validity();
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_birth_date();
+
+ if ( null !== $birth_date ) :
+ ?>
+
+
+
+
+
+
+ format_i18n( __( 'D j M Y', 'pronamic_ideal' ) ) )
+
+ ?>
+
+
+
+
+
+ get_gender();
+
+ if ( null !== $gender ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ get_user_id();
+
+ if ( null !== $user_id ) :
+ ?>
+
+
+
+
+
+
+ display_name;
+
+ if ( ! empty( $display_name ) ) {
+ $user_text .= sprintf( ' - %s', $display_name );
+ }
+
+ printf(
+ '%s ',
+ esc_url( get_edit_user_link( $user_id ) ),
+ esc_html( $user_text )
+ );
+
+ ?>
+
+
+
+
+
+ get_ip_address();
+
+ if ( null !== $ip_address ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_billing_address() ) : ?>
+
+
+
+
+
+
+ get_billing_address();
+
+ echo wp_kses(
+ nl2br( (string) $address ),
+ [
+ 'br' => [],
+ ]
+ );
+
+ ?>
+
+
+
+
+
+ get_shipping_address() ) : ?>
+
+
+
+
+
+
+ get_shipping_address();
+
+ echo wp_kses(
+ nl2br( (string) $address ),
+ [
+ 'br' => [],
+ ]
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ get_source_text(),
+ [
+ 'a' => [
+ 'href' => true,
+ ],
+ 'br' => [],
+ ]
+ );
+
+ ?>
+
+
+
+ get_source() ) : ?>
+
+
+
+
+
+
+ get_meta( 'membership_user_id' ) ); ?>
+
+
+
+
+
+
+
+ get_meta( 'membership_subscription_id' ) ); ?>
+
+
+
+
+
+ get_meta( 'ogone_alias' );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ is_debug_mode() ) : ?>
+
+ get_version() ) : ?>
+
+
+
+
+
+
+ get_version() ); ?>
+
+
+
+
+
+ get_mode() ) : ?>
+
+
+
+
+
+
+ get_mode() ) {
+ case 'live':
+ esc_html_e( 'Live', 'pronamic_ideal' );
+
+ break;
+ case 'test':
+ esc_html_e( 'Test', 'pronamic_ideal' );
+
+ break;
+ default:
+ echo esc_html( $payment->get_mode() );
+
+ break;
+ }
+
+ ?>
+
+
+
+
+
+
+
+ get_customer();
+
+ if ( null !== $customer ) :
+
+ $user_agent = $customer->get_user_agent();
+
+ if ( null !== $user_agent ) :
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_action_url();
+
+ if ( null !== $url ) {
+ printf(
+ '%s ',
+ esc_attr( $url ),
+ esc_html( $url )
+ );
+ }
+
+ ?>
+
+
+
+
+
+
+
+ get_pay_redirect_url();
+
+ printf(
+ '%s ',
+ esc_attr( $url ),
+ esc_html( $url )
+ );
+
+ ?>
+
+
+
+
+
+
+
+ get_return_url();
+
+ printf(
+ '%s ',
+ esc_attr( $url ),
+ esc_html( $url )
+ );
+
+ ?>
+
+
+
+
+
+
+
+ get_return_redirect_url();
+
+ printf(
+ '%s ',
+ esc_attr( $url ),
+ esc_html( $url )
+ );
+
+ ?>
+
+
+
+ is_debug_mode() ) : ?>
+
+
+
+
+
+
+ get_id() );
+
+ $rest_api_nonce_url = wp_nonce_url( $rest_api_url, 'wp_rest' );
+
+ printf(
+ '%s ',
+ esc_url( $rest_api_nonce_url ),
+ esc_html( $rest_api_url )
+ );
+
+ ?>
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-payment-lines.php b/packages/wp-pay/core/views/meta-box-payment-lines.php
new file mode 100644
index 0000000..748e5c3
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-payment-lines.php
@@ -0,0 +1,415 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Money\TaxedMoney;
+use Pronamic\WordPress\Number\Number;
+use Pronamic\WordPress\Pay\Payments\PaymentLine;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( empty( $lines ) ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ %s',
+ \esc_attr__( 'Unit price with discount including tax.', 'pronamic_ideal' ),
+ \esc_html__( 'Unit Price', 'pronamic_ideal' )
+ );
+
+ ?>
+
+
+
+ %s',
+ \esc_attr__( 'Total discount.', 'pronamic_ideal' ),
+ \esc_html__( 'Discount', 'pronamic_ideal' )
+ );
+
+ ?>
+
+
+ %s',
+ \esc_attr__( 'Total amount with discount including tax.', 'pronamic_ideal' ),
+ \esc_html__( 'Total Amount', 'pronamic_ideal' )
+ );
+
+ ?>
+
+
+
+
+
+ get_amount()->get_currency();
+
+ $quantity_total = new Number( 0 );
+ $tax_amount_total = new Money( 0, $currency );
+ $refunded_quantity_total = new Number( 0 );
+ $refunded_amount_total = new Money( 0, $currency );
+ $refunded_tax_total = new Money( 0, $currency );
+
+ foreach ( $lines as $line ) {
+ $quantity = $line->get_quantity();
+
+ if ( null !== $quantity ) {
+ $quantity_total = $quantity_total->add( Number::from_int( $quantity ) );
+ }
+
+ $total_amount = $line->get_total_amount();
+
+ if ( $total_amount instanceof TaxedMoney ) {
+ $tax_amount = $total_amount->get_tax_amount();
+
+ if ( null !== $tax_amount ) {
+ $tax_amount_total = $tax_amount_total->add( $tax_amount );
+ }
+ }
+ }
+
+ if ( isset( $payment ) ) {
+ foreach ( $payment->refunds as $refund ) {
+ foreach ( $refund->lines as $refund_line ) {
+ $refunded_quantity_total = $refunded_quantity_total->add( $refund_line->get_quantity() );
+
+ $line_total = $refund_line->get_total_amount();
+
+ $refunded_amount_total = $refunded_amount_total->add( $refund_line->get_total_amount() );
+
+ if ( $line_total instanceof TaxedMoney ) {
+ $tax_amount = $line_total->get_tax_amount();
+
+ if ( null !== $tax_amount ) {
+ $refunded_tax_total = $refunded_tax_total->add( $tax_amount );
+ }
+ }
+ }
+ }
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+ format_i18n() );
+
+ if ( ! $refunded_quantity_total->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_quantity_total->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+ get_discount_amount();
+
+ return ( null === $discount_amount ) ? null : $discount_amount->get_value();
+ },
+ $lines->get_array()
+ );
+
+ $discount_amount = new Money( \array_sum( $values ), $lines->get_amount()->get_currency() );
+
+ echo \esc_html( $discount_amount->format_i18n() );
+
+ ?>
+
+
+ get_amount()->format_i18n() );
+
+ if ( ! $refunded_amount_total->get_number()->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_amount_total->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+ format_i18n() );
+
+ if ( ! $refunded_tax_total->get_number()->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_tax_total->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+ refunds as $refund ) {
+ foreach ( $refund->lines as $refund_line ) {
+ if ( $refund_line->get_payment_line() === $line ) {
+ $refunded_quantity = $refunded_quantity->add( $refund_line->get_quantity() );
+
+ $line_total = $refund_line->get_total_amount();
+
+ $refunded_amount = $refunded_amount->add( $line_total );
+
+ if ( $line_total instanceof TaxedMoney ) {
+ $tax_amount = $line_total->get_tax_amount();
+
+ if ( null !== $tax_amount ) {
+ $refunded_tax = $refunded_tax->add( $tax_amount );
+ }
+ }
+ }
+ }
+ }
+ }
+
+ ?>
+ get_id() ); ?>
+ get_sku() ); ?>
+
+ get_image_url();
+
+ if ( ! empty( $image_url ) ) {
+ \printf(
+ ' ',
+ \esc_url( $image_url )
+ );
+ }
+
+ ?>
+
+
+ get_product_url();
+
+ $description = $line->get_description();
+
+ if ( ! empty( $product_url ) ) {
+ // Product URL with or without description.
+ $line_title = $line->get_name();
+
+ $classes = [];
+
+ if ( ! empty( $description ) ) {
+ $line_title = $line->get_description();
+ $classes[] = 'pronamic-pay-tip';
+ }
+
+ \printf(
+ '%4$s ',
+ \esc_attr( \implode( ' ', $classes ) ),
+ \esc_url( $line->get_product_url() ),
+ \esc_attr( $line_title ),
+ \esc_html( $line->get_name() )
+ );
+ } elseif ( ! empty( $description ) ) {
+ // Description without product URL.
+ \printf(
+ '%2$s ',
+ \esc_attr( $line->get_description() ),
+ \esc_html( $line->get_name() )
+ );
+ } else {
+ // No description and no product URL.
+ echo \esc_html( $line->get_name() );
+ }
+
+ ?>
+
+
+ get_unit_price();
+
+ if ( null !== $unit_price ) {
+ $tips = [
+ \__( 'No tax information.', 'pronamic_ideal' ),
+ ];
+
+ if ( $unit_price instanceof TaxedMoney ) {
+ $tips = [
+ \sprintf(
+ /* translators: %s: price excluding tax */
+ \__( 'Exclusive tax: %s', 'pronamic_ideal' ),
+ $unit_price->get_excluding_tax()
+ ),
+ \sprintf(
+ /* translators: %s: price including tax */
+ \__( 'Inclusive tax: %s', 'pronamic_ideal' ),
+ $unit_price->get_including_tax()
+ ),
+ ];
+ }
+
+ \printf(
+ '%s ',
+ \esc_attr( \implode( ' ', $tips ) ),
+ \esc_html( $unit_price->format_i18n() )
+ );
+
+ }
+
+ ?>
+
+
+ get_quantity() );
+
+ if ( ! $refunded_quantity->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_quantity->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+ get_discount_amount();
+
+ if ( null !== $discount_amount ) {
+ echo \esc_html( $discount_amount );
+ }
+
+ ?>
+
+
+ get_total_amount();
+
+ $tips = [
+ \__( 'No tax information.', 'pronamic_ideal' ),
+ ];
+
+ if ( $line_total instanceof TaxedMoney ) {
+ $tips = [
+ \sprintf(
+ /* translators: %s: price excluding tax */
+ \__( 'Exclusive tax: %s', 'pronamic_ideal' ),
+ $line->get_total_amount()->get_excluding_tax()
+ ),
+ \sprintf(
+ /* translators: %s: price including tax */
+ \__( 'Inclusive tax: %s', 'pronamic_ideal' ),
+ $line->get_total_amount()->get_including_tax()
+ ),
+ ];
+ }
+
+ \printf(
+ '%s ',
+ \esc_attr( \implode( ' ', $tips ) ),
+ \esc_html( $line_total->format_i18n() )
+ );
+
+ if ( ! $refunded_amount->get_number()->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_amount->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+ get_tax_amount();
+ $tax_percentage = $line_total->get_tax_percentage();
+
+ if ( null !== $tax_amount ) {
+ $tip = '';
+
+ if ( null !== $tax_percentage ) {
+ $number = Number::from_mixed( $tax_percentage );
+
+ $tip = $number->format_i18n() . '%';
+ }
+
+ \printf(
+ '%s ',
+ \esc_attr( $tip ),
+ \esc_html( $tax_amount->format_i18n() )
+ );
+ }
+ }
+
+ if ( ! $refunded_tax->get_number()->is_zero() ) {
+ \printf( '%s ', \esc_html( $refunded_tax->negative()->format_i18n() ) );
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-payment-refunds.php b/packages/wp-pay/core/views/meta-box-payment-refunds.php
new file mode 100644
index 0000000..93c8882
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-payment-refunds.php
@@ -0,0 +1,77 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+namespace Pronamic\WordPress\Pay;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( empty( $payment->refunds ) ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ refunds as $refund ) : ?>
+
+
+ created_at->format_i18n() ); ?>
+ get_amount()->format_i18n() ); ?>
+ psp_id ); ?>
+ get_description() ); ?>
+
+ created_by->ID > 0 ) {
+ $name = $refund->created_by->display_name;
+ }
+
+ echo \esc_html( $name );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-payment-subscription.php b/packages/wp-pay/core/views/meta-box-payment-subscription.php
new file mode 100644
index 0000000..50011eb
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-payment-subscription.php
@@ -0,0 +1,144 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Payments\Payment $payment Payment.
+ */
+
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$subscriptions = $payment->get_subscriptions();
+
+if ( empty( $subscriptions ) ) : ?>
+
+
+
+
+
+
+
+
+
+ get_id();
+
+ $phase = $subscription->get_display_phase();
+
+ ?>
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-payment-update.php b/packages/wp-pay/core/views/meta-box-payment-update.php
new file mode 100644
index 0000000..81ca096
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-payment-update.php
@@ -0,0 +1,147 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $post ) ) {
+ return;
+}
+
+$states = [
+ PaymentStatus::OPEN => _x( 'Pending', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::ON_HOLD => _x( 'On Hold', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::SUCCESS => _x( 'Completed', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::CANCELLED => _x( 'Cancelled', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::REFUNDED => _x( 'Refunded', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::FAILURE => _x( 'Failed', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::EXPIRED => _x( 'Expired', 'Payment status', 'pronamic_ideal' ),
+ PaymentStatus::AUTHORIZED => _x( 'Authorized', 'Payment status', 'pronamic_ideal' ),
+];
+
+$payment = \get_pronamic_payment( \get_the_ID() );
+
+if ( null === $payment ) {
+ return;
+}
+
+ksort( $states );
+
+// WordPress by default doesn't allow `post_author` values of `0`, that's why we use a dash (`-`).
+// @link https://github.com/WordPress/WordPress/blob/4.9.5/wp-admin/includes/post.php#L56-L64.
+$post_author = get_post_field( 'post_author' );
+$post_author = empty( $post_author ) ? '-' : $post_author;
+
+?>
+
+
+
+
+
+
+
+
+
+ get_status_label();
+
+ $status_label = ( null === $status_label ) ? '—' : $status_label;
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ $label ) {
+ printf(
+ '%s ',
+ esc_attr( $payment_status ),
+ selected( $payment_status, $payment->get_status(), false ),
+ esc_html( $label )
+ );
+ }
+
+ ?>
+
+
+
+
+
+
+
+ get_gateway();
+
+ /**
+ * Check status button.
+ */
+ if ( null !== $gateway && $gateway->supports( 'payment_status_request' ) ) {
+ // Only show button if gateway exists and status check is supported.
+ $action_url = wp_nonce_url(
+ add_query_arg(
+ [
+ 'post' => $post->ID,
+ 'action' => 'edit',
+ 'pronamic_pay_check_status' => true,
+ ],
+ admin_url( 'post.php' )
+ ),
+ 'pronamic_payment_check_status_' . $post->ID
+ );
+
+ printf(
+ '
',
+ esc_url( $action_url ),
+ esc_html__( 'Check status', 'pronamic_ideal' )
+ );
+ }
+
+ ?>
+
+
+
+
+
+ ',
+ esc_attr( (string) $payment->get_id() )
+ );
+
+ submit_button(
+ __( 'Update', 'pronamic_ideal' ),
+ 'primary',
+ 'pronamic_payment_update',
+ false
+ );
+
+ ?>
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-subscription-info.php b/packages/wp-pay/core/views/meta-box-subscription-info.php
new file mode 100644
index 0000000..f3cc30e
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-subscription-info.php
@@ -0,0 +1,347 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Plugin $plugin Plugin.
+ * @var \Pronamic\WordPress\Pay\Subscriptions\Subscription $subscription Subscription.
+ */
+
+use Pronamic\WordPress\DateTime\DateTime;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionStatus;
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$subscription_id = $subscription->get_id();
+
+$customer = $subscription->get_customer();
+$user_id = is_null( $customer ) ? null : $customer->get_user_id();
+
+$phase = $subscription->get_display_phase();
+
+?>
+
diff --git a/packages/wp-pay/core/views/meta-box-subscription-payments.php b/packages/wp-pay/core/views/meta-box-subscription-payments.php
new file mode 100644
index 0000000..3561d2f
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-subscription-payments.php
@@ -0,0 +1,284 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Plugin $plugin Plugin.
+ * @var \Pronamic\WordPress\Pay\Subscriptions\Subscription $subscription Subscription.
+ */
+
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionStatus;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$subscription_id = $subscription->get_id();
+
+if ( null === $subscription_id ) {
+ return;
+}
+
+$payments = $subscription->get_payments();
+
+$data = [];
+
+foreach ( $payments as $payment ) {
+ $periods = (array) $payment->get_periods();
+
+ /**
+ * A payment can be for multiple and different subscription periods.
+ * Here we only want to show the periods of this subscription and
+ * therefore we filter out other periods.
+ */
+ $periods = \array_filter(
+ $periods,
+ function ( $period ) use ( $subscription ) {
+ return ( $subscription->get_id() === $period->get_phase()->get_subscription()->get_id() );
+ }
+ );
+
+ if ( 0 === count( $periods ) ) {
+ $key = 'payment-' . $payment->get_id();
+
+ $data[ $key ] = (object) [
+ 'date' => $payment->get_date(),
+ 'payments' => [ $payment ],
+ 'period' => null,
+ ];
+ }
+
+ foreach ( $periods as $period ) {
+ $key = 'period-' . $period->get_start_date()->getTimestamp();
+
+ if ( ! array_key_exists( $key, $data ) ) {
+ $data[ $key ] = (object) [
+ 'date' => $payment->get_date(),
+ 'payments' => [],
+ 'period' => $period,
+ ];
+ }
+
+ $data[ $key ]->payments[] = $payment;
+ }
+}
+
+foreach ( $data as $item ) {
+ usort(
+ $item->payments,
+ function ( $a, $b ) {
+ return $a->get_date() <=> $b->get_date();
+ }
+ );
+
+ $item->first = reset( $item->payments );
+
+ if ( false !== $item->first ) {
+ $item->date = $item->first->get_date();
+ }
+
+ $statuses = array_map(
+ function ( $payment ) {
+ return $payment->get_status();
+ },
+ $item->payments
+ );
+
+ $item->can_retry = ! ( in_array( PaymentStatus::OPEN, $statuses, true ) || in_array( PaymentStatus::SUCCESS, $statuses, true ) );
+}
+
+usort(
+ $data,
+ function ( $a, $b ) {
+ return $b->date <=> $a->date;
+ }
+);
+
+if ( 0 === count( $payments ) ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_next_payment_date();
+
+ $next_payment_delivery_date = $subscription->get_next_payment_delivery_date();
+
+ $next_period = $subscription->get_next_period();
+
+ $gateway = Plugin::get_gateway( (int) $subscription->get_config_id() );
+
+ $allow_next_period_statuses = [ SubscriptionStatus::OPEN, SubscriptionStatus::ACTIVE, SubscriptionStatus::FAILURE ];
+
+ if ( null !== $next_period && \in_array( $subscription->get_status(), $allow_next_period_statuses, true ) && null !== $gateway && $gateway->supports( 'recurring' ) ) :
+
+ ?>
+
+
+
+ human_readable_range() ); ?>
+
+ get_source(), [ 'woocommerce' ], true ) && null !== $next_payment_date ) :
+
+ echo wp_kses_post(
+ sprintf(
+ /* translators: %s: next payment date */
+ __( 'Will be created on %s', 'pronamic_ideal' ),
+ \esc_html( $next_payment_date->format_i18n( __( 'D j M Y', 'pronamic_ideal' ) ) )
+ )
+ );
+
+ elseif ( null !== $next_payment_delivery_date ) :
+
+ $create_next_payment_url = \wp_nonce_url(
+ \add_query_arg(
+ \urlencode_deep(
+ [
+ 'period_payment' => true,
+ 'subscription_id' => $subscription->get_id(),
+ 'sequence_number' => $next_period->get_phase()->get_sequence_number(),
+ 'start_date' => $next_period->get_start_date()->format( DATE_ATOM ),
+ 'end_date' => $next_period->get_end_date()->format( DATE_ATOM ),
+ ]
+ ),
+ \get_edit_post_link( $subscription_id )
+ ),
+ 'pronamic_period_payment_' . $subscription->get_id()
+ );
+
+ echo wp_kses_post(
+ sprintf(
+ /* translators: 1: next payment delivery date, 2: create next period payment anchor */
+ __( 'Will be created on %1$s or %2$s', 'pronamic_ideal' ),
+ \esc_html( $next_payment_delivery_date->format_i18n( __( 'D j M Y', 'pronamic_ideal' ) ) ),
+ \sprintf(
+ '%2$s ',
+ \esc_url( $create_next_payment_url ),
+ \esc_html( \__( 'create now', 'pronamic_ideal' ) )
+ )
+ )
+ );
+
+ endif;
+
+ ?>
+
+ get_amount()->format_i18n() ); ?>
+ —
+
+
+ payments as $payment ) :
+ $payment_id = $payment->get_id();
+ $payments_post_type = get_post_type( $payment_id );
+
+ ?>
+
+
+
+
+
+
+ period ) :
+ $prefix = ( $payment === $item->first ? '' : '⌙ ' );
+
+ echo esc_html( $prefix . $item->period->human_readable_range() );
+
+ endif;
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ period && $payment === $item->first && $item->can_retry && $plugin->subscriptions_module->can_retry_payment( $payment ) ) : ?>
+
+
+
+
+ true,
+ 'subscription_id' => $subscription->get_id(),
+ 'sequence_number' => $item->period->get_phase()->get_sequence_number(),
+ 'start_date' => $item->period->get_start_date()->format( DATE_ATOM ),
+ 'end_date' => $item->period->get_end_date()->format( DATE_ATOM ),
+ ]
+ ),
+ \get_edit_post_link( $subscription_id )
+ ),
+ 'pronamic_period_payment_' . $subscription->get_id()
+ );
+
+ \printf(
+ '%s
',
+ \esc_url( $action_url ),
+ \esc_html(
+ sprintf(
+ /* translators: %d: payment ID */
+ __( 'Retry payment #%d', 'pronamic_ideal' ),
+ $payment_id
+ )
+ )
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-subscription-phases.php b/packages/wp-pay/core/views/meta-box-subscription-phases.php
new file mode 100644
index 0000000..3cfa011
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-subscription-phases.php
@@ -0,0 +1,165 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Pay\Util;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPhase;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
+
+ is_trial() ) {
+ $has_trial = true;
+ }
+
+ if ( $phase->is_alignment() || $phase->is_prorated() ) {
+ $has_alignment = true;
+ }
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_amount()->format_i18n() ); ?>
+
+
+ get_total_periods();
+
+ if ( null === $total_periods ) {
+ // Unlimited.
+ echo esc_html( strval( Util::format_recurrences( $phase->get_interval() ) ) );
+ }
+
+ if ( 1 === $total_periods ) {
+ // No recurrence.
+ echo '—';
+ }
+
+ if ( $total_periods > 1 ) {
+ // Fixed number of recurrences.
+ printf(
+ '%s (%s)',
+ esc_html( strval( Util::format_recurrences( $phase->get_interval() ) ) ),
+ esc_html( strval( Util::format_frequency( $total_periods ) ) )
+ );
+ }
+
+ ?>
+
+
+ get_start_date();
+
+ echo esc_html( ( new \Pronamic\WordPress\DateTime\DateTime( '@' . $start_date->getTimestamp() ) )->format_i18n() );
+
+ ?>
+
+
+ get_end_date();
+
+ echo esc_html( null === $end_date ? '∞' : ( new \Pronamic\WordPress\DateTime\DateTime( '@' . $end_date->getTimestamp() ) )->format_i18n() );
+
+ ?>
+
+
+
+
+
+ is_trial() ? __( 'Yes', 'pronamic_ideal' ) : __( 'No', 'pronamic_ideal' ) );
+
+ ?>
+
+
+
+
+
+
+
+ is_alignment() ? __( 'Yes', 'pronamic_ideal' ) : __( 'No', 'pronamic_ideal' ) );
+
+ ?>
+
+
+ is_prorated() ? __( 'Yes', 'pronamic_ideal' ) : __( 'No', 'pronamic_ideal' ) );
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/meta-box-subscription-update.php b/packages/wp-pay/core/views/meta-box-subscription-update.php
new file mode 100644
index 0000000..b1d9069
--- /dev/null
+++ b/packages/wp-pay/core/views/meta-box-subscription-update.php
@@ -0,0 +1,241 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Html\Element;
+use Pronamic\WordPress\Pay\Plugin;
+use Pronamic\WordPress\Pay\Payments\PaymentStatus;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPostType;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionStatus;
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $post ) ) {
+ return;
+}
+
+$subscription = \get_pronamic_subscription( (int) get_the_ID() );
+
+if ( null === $subscription ) {
+ return;
+}
+
+$states = [
+ SubscriptionStatus::OPEN => _x( 'Pending', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::CANCELLED => _x( 'Cancelled', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::EXPIRED => _x( 'Expired', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::FAILURE => _x( 'Failed', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::ON_HOLD => _x( 'On Hold', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::ACTIVE => _x( 'Active', 'Subscription status', 'pronamic_ideal' ),
+ SubscriptionStatus::COMPLETED => _x( 'Completed', 'Subscription status', 'pronamic_ideal' ),
+ // Map payment status `Success` for backwards compatibility.
+ PaymentStatus::SUCCESS => _x( 'Active', 'Subscription status', 'pronamic_ideal' ),
+];
+
+ksort( $states );
+
+$states_options = [
+ SubscriptionStatus::ACTIVE,
+ SubscriptionStatus::CANCELLED,
+ SubscriptionStatus::ON_HOLD,
+];
+
+// WordPress by default doesn't allow `post_author` values of `0`, that's why we use a dash (`-`).
+// @link https://github.com/WordPress/WordPress/blob/4.9.5/wp-admin/includes/post.php#L56-L64.
+$post_author = get_post_field( 'post_author' );
+$post_author = empty( $post_author ) ? '-' : $post_author;
+
+
+?>
+
+
+
+
+
+
+
+ post_status );
+
+ $status_label = isset( $status_object, $status_object->label ) ? $status_object->label : '—';
+
+ ?>
+
+
+
+ post_status ) : ?>
+
+
+
+
+
+
+
+
+
+ $label ) {
+ if ( ! in_array( $subscription_status, $states_options, true ) && $subscription_status !== $subscription->get_status() ) {
+ continue;
+ }
+
+ printf(
+ '%s ',
+ esc_attr( $subscription_status ),
+ selected( $subscription_status, $subscription->get_status(), false ),
+ esc_html( $label )
+ );
+ }
+
+ ?>
+
+
+
+
+
+
+ get_status(), [ SubscriptionStatus::FAILURE, SubscriptionStatus::ON_HOLD ], true ) ) : ?>
+
+
+
+ %s',
+ \__( 'Recurring payments will not be created until manual reactivation of this subscription.', 'pronamic_ideal' ),
+ \__( 'See subscription and payment notes for details about status changes.', 'pronamic_ideal' )
+ )
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+
+ get_next_payment_date();
+
+ ?>
+
+
+
+
+
+
format_i18n( \__( 'D j M Y', 'pronamic_ideal' ) ) ); ?>
+
+ get_source() ) : ?>
+
+
+
+
+
+
+
+
+
+
+ get_status() && null !== $next_payment_date && $next_payment_date < $today ) :
+ ?>
+
+
+
+
+
+
+
+
+
+ get_source_text(),
+ [
+ 'a' => [
+ 'href' => true,
+ ],
+ ]
+ )
+ );
+
+ ?>
+
+
+
+
+
+
+
+
+ ',
+ esc_attr( (string) $subscription->get_id() )
+ );
+
+ submit_button(
+ __( 'Update', 'pronamic_ideal' ),
+ 'primary',
+ 'pronamic_subscription_update',
+ false
+ );
+
+ ?>
+
+
+
+
diff --git a/packages/wp-pay/core/views/notice-license.php b/packages/wp-pay/core/views/notice-license.php
new file mode 100644
index 0000000..c5a00be
--- /dev/null
+++ b/packages/wp-pay/core/views/notice-license.php
@@ -0,0 +1,51 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $data ) ) {
+ return;
+}
+
+$class = ( 'valid' === $data->license ) ? 'updated' : 'error';
+
+?>
+
+
+ —
+ license ) {
+ echo \esc_html(
+ \sprintf(
+ /* translators: %s: Pronamic Pay */
+ \__( 'Thank you for activating your license and using the %s plugin.', 'pronamic_ideal' ),
+ \__( 'Pronamic Pay', 'pronamic_ideal' )
+ )
+ );
+ } elseif ( 'invalid' === $data->license && \property_exists( $data, 'activations_left' ) && 0 === $data->activations_left ) {
+ echo \wp_kses(
+ __( 'This license does not have any activations left. Maybe you have to deactivate your license on a local/staging server. This can be done on your Pronamic.shop account .', 'pronamic_ideal' ),
+ [
+ 'a' => [
+ 'href' => true,
+ 'target' => true,
+ ],
+ ]
+ );
+ } else {
+ \esc_html_e( 'There was a problem activating your license key, please try again or contact support.', 'pronamic_ideal' );
+ }
+
+ ?>
+
+
diff --git a/packages/wp-pay/core/views/page-dashboard.php b/packages/wp-pay/core/views/page-dashboard.php
new file mode 100644
index 0000000..ac10078
--- /dev/null
+++ b/packages/wp-pay/core/views/page-dashboard.php
@@ -0,0 +1,458 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$container_index = 1;
+
+?>
+
diff --git a/packages/wp-pay/core/views/page-reports.php b/packages/wp-pay/core/views/page-reports.php
new file mode 100644
index 0000000..14e2890
--- /dev/null
+++ b/packages/wp-pay/core/views/page-reports.php
@@ -0,0 +1,72 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Admin\AdminReports $admin_reports Admin reports.
+ */
+
+use Pronamic\WordPress\Money\Money;
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
diff --git a/packages/wp-pay/core/views/page-settings.php b/packages/wp-pay/core/views/page-settings.php
new file mode 100644
index 0000000..3300653
--- /dev/null
+++ b/packages/wp-pay/core/views/page-settings.php
@@ -0,0 +1,53 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+// phpcs:ignore WordPress.Security.NonceVerification.Recommended
+$message_id = array_key_exists( 'message', $_GET ) ? \sanitize_text_field( \wp_unslash( $_GET['message'] ) ) : '';
+
+if ( ! empty( $message_id ) ) {
+ switch ( $message_id ) {
+ case 'pages-generated':
+ printf(
+ '',
+ esc_html__( 'The default payment status pages are created.', 'pronamic_ideal' )
+ );
+
+ break;
+ case 'pages-not-generated':
+ printf(
+ '',
+ esc_html__( 'The default payment status pages could not be created.', 'pronamic_ideal' )
+ );
+
+ break;
+ }
+}
+
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-dashboard.php b/packages/wp-pay/core/views/pointer-dashboard.php
new file mode 100644
index 0000000..5e747dc
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-dashboard.php
@@ -0,0 +1,22 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-forms.php b/packages/wp-pay/core/views/pointer-forms.php
new file mode 100644
index 0000000..38ed902
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-forms.php
@@ -0,0 +1,39 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+ “Gravity Forms” plugin.', 'pronamic_ideal' ),
+ esc_attr( 'https://www.pronamic.nl/go/gravity-forms/' ),
+ esc_attr( '_blank' )
+ ),
+ [
+ 'a' => [
+ 'href' => true,
+ 'target' => true,
+ ],
+ ]
+ );
+
+ ?>
+
diff --git a/packages/wp-pay/core/views/pointer-gateways.php b/packages/wp-pay/core/views/pointer-gateways.php
new file mode 100644
index 0000000..badaab0
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-gateways.php
@@ -0,0 +1,22 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-payments.php b/packages/wp-pay/core/views/pointer-payments.php
new file mode 100644
index 0000000..4e5d5ce
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-payments.php
@@ -0,0 +1,22 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-reports.php b/packages/wp-pay/core/views/pointer-reports.php
new file mode 100644
index 0000000..6e957ea
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-reports.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-settings.php b/packages/wp-pay/core/views/pointer-settings.php
new file mode 100644
index 0000000..cc56f44
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-settings.php
@@ -0,0 +1,21 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/pointer-start.php b/packages/wp-pay/core/views/pointer-start.php
new file mode 100644
index 0000000..a9f9e60
--- /dev/null
+++ b/packages/wp-pay/core/views/pointer-start.php
@@ -0,0 +1,20 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/redirect-via-html.php b/packages/wp-pay/core/views/redirect-via-html.php
new file mode 100644
index 0000000..3bb8973
--- /dev/null
+++ b/packages/wp-pay/core/views/redirect-via-html.php
@@ -0,0 +1,94 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ * @var \Pronamic\WordPress\Pay\Payments\Payment $payment Payment.
+ * @var \Pronamic\WordPress\Pay\Core\Gateway $this Gateway.
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ output_form( $payment ); ?>
+
+
+
+
+
+
+
+
+
+ get_date()->format_i18n() ); ?>
+
+ get_transaction_id(); ?>
+
+
+
+
+
+
+
+
+
+ get_description() ); ?>
+
+
+ get_total_amount()->format_i18n() ); ?>
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-cancel.php b/packages/wp-pay/core/views/subscription-cancel.php
new file mode 100644
index 0000000..41995fb
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-cancel.php
@@ -0,0 +1,82 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionStatus;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $subscription ) ) {
+ return;
+}
+
+$phase = $subscription->get_current_phase();
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_status() ) : ?>
+
+
+
+
+
+
+
+
+ get_status() ) : ?>
+
+
+
+
+
+
+
+
+ get_id(), 'pronamic_pay_cancel_subscription_nonce' ); ?>
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-details.php b/packages/wp-pay/core/views/subscription-details.php
new file mode 100644
index 0000000..7abffbc
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-details.php
@@ -0,0 +1,96 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+use Pronamic\WordPress\Pay\Util;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $subscription ) ) {
+ return;
+}
+
+/*
+ * Subscription details.
+ */
+$details = [
+ [
+ 'term' => __( 'Description', 'pronamic_ideal' ),
+ 'description' => $subscription->get_description(),
+ ],
+];
+
+// Current phase.
+$phase = $subscription->get_current_phase();
+
+$recurrence = '—';
+
+if ( null !== $phase ) {
+ // Amount.
+ $details[] = [
+ 'term' => __( 'Amount', 'pronamic_ideal' ),
+ 'description' => $phase->get_amount()->format_i18n(),
+ ];
+
+ // Recurrence.
+ if ( $phase->is_infinite() ) :
+ // Infinite.
+ $recurrence = Util::format_recurrences( $phase->get_interval() );
+
+ elseif ( 1 !== $phase->get_total_periods() ) :
+ // Fixed number of recurrences.
+ $recurrence = sprintf(
+ '%s (%s)',
+ Util::format_recurrences( $phase->get_interval() ),
+ Util::format_frequency( $phase->get_total_periods() )
+ );
+
+ endif;
+}
+
+// Payment method.
+$payment_method = $subscription->get_payment_method();
+
+if ( ! empty( $payment_method ) ) {
+ $details[] = [
+ 'term' => __( 'Payment method', 'pronamic_ideal' ),
+ 'description' => PaymentMethods::get_name( $payment_method ),
+ ];
+}
+
+// Recurrence.
+$details[] = [
+ 'term' => __( 'Recurrence', 'pronamic_ideal' ),
+ 'description' => $recurrence,
+];
+
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-mandate-failed.php b/packages/wp-pay/core/views/subscription-mandate-failed.php
new file mode 100644
index 0000000..0d63d87
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-mandate-failed.php
@@ -0,0 +1,38 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-mandate-updated.php b/packages/wp-pay/core/views/subscription-mandate-updated.php
new file mode 100644
index 0000000..9c8f1d0
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-mandate-updated.php
@@ -0,0 +1,44 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-mandate.php b/packages/wp-pay/core/views/subscription-mandate.php
new file mode 100644
index 0000000..9305c78
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-mandate.php
@@ -0,0 +1,299 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\Pay\Cards;
+use Pronamic\WordPress\Pay\Core\PaymentMethods;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $subscription ) ) {
+ return;
+}
+
+if ( ! isset( $gateway ) ) {
+ return;
+}
+
+if ( ! class_exists( '\Pronamic\WordPress\Mollie\Client' ) ) {
+ return;
+}
+
+$mollie_customer_id = $subscription->get_meta( 'mollie_customer_id' );
+
+if ( empty( $mollie_customer_id ) ) {
+ include \get_404_template();
+
+ exit;
+}
+
+$api_key = \get_post_meta( $subscription->config_id, '_pronamic_gateway_mollie_api_key', true );
+
+$client = new \Pronamic\WordPress\Mollie\Client( $api_key );
+
+/**
+ * Mandates.
+ *
+ * @link https://docs.mollie.com/reference/v2/mandates-api/list-mandates
+ */
+$mollie_customer_mandates = [];
+
+// phpcs:disable Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+
+try {
+ $response = $client->get_mandates( $mollie_customer_id );
+
+ if (
+ property_exists( $response, '_embedded' )
+ &&
+ property_exists( $response->_embedded, 'mandates' )
+ ) {
+ $mollie_customer_mandates = $response->_embedded->mandates;
+ }
+} catch ( \Exception $exception ) {
+ /**
+ * Nothing to do.
+ *
+ * Retrieval of customer mandates could fail for example when the configuration
+ * has changed and the customer is invalid now. We cannot retrieve mandates, but
+ * it should still be possible to add a new payment method to the subscription.
+ */
+}
+
+// phpcs:enable Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+
+$subscription_mandate_id = $subscription->get_meta( 'mollie_mandate_id' );
+
+// Set current subscription mandate as first item.
+$current_mandate = wp_list_filter( $mollie_customer_mandates, [ 'id' => $subscription_mandate_id ] );
+
+if ( is_array( $current_mandate ) ) {
+ unset( $mollie_customer_mandates[ key( $current_mandate ) ] );
+
+ $mollie_customer_mandates = array_merge( $current_mandate, $mollie_customer_mandates );
+}
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 ) : ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ status ) {
+ continue;
+ }
+
+ $card_name = '';
+ $account_number = null;
+ $account_label = null;
+ $bic_or_brand = null;
+ $logo_url = null;
+
+ switch ( $mandate->method ) {
+ case 'creditcard':
+ $card_name = $mandate->details->cardHolder;
+ $account_number = str_pad( $mandate->details->cardNumber, 16, '*', \STR_PAD_LEFT );
+ $account_label = _x( 'Card Number', 'Card selector', 'pronamic_ideal' );
+
+ $bic_or_brand = $mandate->details->cardLabel;
+
+ break;
+ case 'directdebit':
+ $card_name = $mandate->details->consumerName;
+ $account_number = $mandate->details->consumerAccount;
+ $account_label = _x( 'Account Number', 'Card selector', 'pronamic_ideal' );
+
+ $bic_or_brand = substr( $mandate->details->consumerAccount, 4, 4 );
+
+ break;
+ }
+
+ // Split account number in chunks.
+ if ( null !== $account_number ) {
+ $account_number = \chunk_split( $account_number, 4, ' ' );
+ }
+
+ $card_title = '';
+
+ $classes = [ 'pp-card' ];
+
+ $bg_color = 'purple';
+
+ $card = $cards->get_card( $bic_or_brand );
+
+ // Set card brand specific details.
+ if ( null !== $card ) {
+ $card_title = $card['title'];
+
+ $classes[] = 'brand-' . $card['brand'];
+
+ $logo_url = $cards->get_card_logo_url( $card['brand'] );
+
+ $bg_color = 'transparent';
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_payment_methods(
+ [
+ 'status' => [ '', 'active' ],
+ 'supports' => 'recurring',
+ ]
+ );
+
+ /*
+ * Filter out payment methods with required fields,
+ * as these are not supported for now.
+ *
+ * @link https://github.com/pronamic/wp-pronamic-pay/issues/361
+ */
+ $payment_methods = array_filter(
+ $payment_methods->get_array(),
+ function ( $payment_method ) {
+ $required_fields = array_filter(
+ $payment_method->get_fields(),
+ function ( $field ) {
+ return $field->is_required();
+ }
+ );
+
+ return 0 === count( $required_fields );
+ }
+ );
+
+ foreach ( $payment_methods as $payment_method ) {
+ $payment_method_id = $payment_method->get_id();
+
+ $name = $payment_method->get_name();
+ $name = ( '' === $name ) ? $payment_method_id : $name;
+
+ printf(
+ '%s ',
+ esc_attr( $payment_method_id ),
+ esc_html( $name )
+ );
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-renew-failed.php b/packages/wp-pay/core/views/subscription-renew-failed.php
new file mode 100644
index 0000000..373bb0f
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-renew-failed.php
@@ -0,0 +1,44 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/subscription-renew.php b/packages/wp-pay/core/views/subscription-renew.php
new file mode 100644
index 0000000..583bd77
--- /dev/null
+++ b/packages/wp-pay/core/views/subscription-renew.php
@@ -0,0 +1,107 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+use Pronamic\WordPress\DateTime\DateTimeImmutable;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionPhase;
+use Pronamic\WordPress\Pay\Subscriptions\SubscriptionStatus;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+if ( ! isset( $subscription, $gateway ) ) {
+ return;
+}
+
+$phase = $subscription->get_current_phase();
+
+?>
+
+
+>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_current_phase();
+
+ $now = new DateTimeImmutable();
+
+ if (
+ null !== $phase && $phase->get_next_date() < $now
+ &&
+ SubscriptionStatus::CANCELLED === $subscription->get_status() && 'gravityformsideal' === $subscription->get_source()
+ ) {
+ $phase = new SubscriptionPhase( $subscription, $now, $phase->get_interval(), $phase->get_amount() );
+ }
+
+ $next_period = $phase->get_next_period();
+
+ // Maybe use period from last failed payment.
+ $renewal_period = $subscription->get_renewal_period();
+
+ if ( null !== $renewal_period ) {
+ $next_period = $renewal_period;
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+ human_readable_range( __( 'l j F Y', 'pronamic_ideal' ), _x( 'until', 'period separator', 'pronamic_ideal' ) ) )
+ );
+
+ ?>
+
+
+
+ get_id(), 'pronamic_pay_renew_subscription_nonce' ); ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/wp-pay/core/views/widget-payments-status-list.php b/packages/wp-pay/core/views/widget-payments-status-list.php
new file mode 100644
index 0000000..edba8d7
--- /dev/null
+++ b/packages/wp-pay/core/views/widget-payments-status-list.php
@@ -0,0 +1,70 @@
+
+ * @copyright 2005-2023 Pronamic
+ * @license GPL-3.0-or-later
+ * @package Pronamic\WordPress\Pay
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit;
+}
+
+$counts = \wp_count_posts( 'pronamic_payment' );
+
+$states = [
+ /* translators: %s: posts count value */
+ 'payment_completed' => __( '%s completed', 'pronamic_ideal' ),
+ /* translators: %s: posts count value */
+ 'payment_pending' => __( '%s pending', 'pronamic_ideal' ),
+ /* translators: %s: posts count value */
+ 'payment_cancelled' => __( '%s cancelled', 'pronamic_ideal' ),
+ /* translators: %s: posts count value */
+ 'payment_failed' => __( '%s failed', 'pronamic_ideal' ),
+ /* translators: %s: posts count value */
+ 'payment_expired' => __( '%s expired', 'pronamic_ideal' ),
+];
+
+$url = \add_query_arg(
+ [
+ 'post_type' => 'pronamic_payment',
+ ],
+ \admin_url( 'edit.php' )
+);
+
+?>
+
diff --git a/src/AlipayPaymentMethod.php b/src/AlipayPaymentMethod.php
index 2fb5f37..b683bb7 100644
--- a/src/AlipayPaymentMethod.php
+++ b/src/AlipayPaymentMethod.php
@@ -39,9 +39,8 @@ class AlipayPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new AlipayGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::ALIPAY );
- $this->_default_button_url = plugins_url( 'images/alipay/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new AlipayGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::ALIPAY );
parent::__construct( $pm_instance );
}
diff --git a/src/BancontactPaymentMethod.php b/src/BancontactPaymentMethod.php
index 984f2a2..a5b050c 100644
--- a/src/BancontactPaymentMethod.php
+++ b/src/BancontactPaymentMethod.php
@@ -39,9 +39,8 @@ class BancontactPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new BancontactGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BANCONTACT );
- $this->_default_button_url = plugins_url( 'images/bancontact/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new BancontactGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BANCONTACT );
parent::__construct( $pm_instance );
}
diff --git a/src/BankTransferPaymentMethod.php b/src/BankTransferPaymentMethod.php
index 8ab5063..7201d6d 100644
--- a/src/BankTransferPaymentMethod.php
+++ b/src/BankTransferPaymentMethod.php
@@ -39,9 +39,8 @@ class BankTransferPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new BankTransferGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BANK_TRANSFER );
- $this->_default_button_url = plugins_url( 'images/bank-transfer/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new BankTransferGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BANK_TRANSFER );
parent::__construct( $pm_instance );
}
diff --git a/src/BelfiusPaymentMethod.php b/src/BelfiusPaymentMethod.php
index fdb3017..28a53a5 100644
--- a/src/BelfiusPaymentMethod.php
+++ b/src/BelfiusPaymentMethod.php
@@ -39,9 +39,8 @@ class BelfiusPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new BelfiusGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BELFIUS );
- $this->_default_button_url = plugins_url( 'images/belfius/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new BelfiusGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BELFIUS );
parent::__construct( $pm_instance );
}
diff --git a/src/BitcoinPaymentMethod.php b/src/BitcoinPaymentMethod.php
index eea4fa7..f8f9e26 100644
--- a/src/BitcoinPaymentMethod.php
+++ b/src/BitcoinPaymentMethod.php
@@ -39,9 +39,8 @@ class BitcoinPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new BitcoinGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BITCOIN );
- $this->_default_button_url = plugins_url( 'images/bitcoin/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new BitcoinGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::BITCOIN );
parent::__construct( $pm_instance );
}
diff --git a/src/CreditCardPaymentMethod.php b/src/CreditCardPaymentMethod.php
index 3f3da13..fd353bd 100644
--- a/src/CreditCardPaymentMethod.php
+++ b/src/CreditCardPaymentMethod.php
@@ -39,9 +39,8 @@ class CreditCardPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new CreditCardGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::CREDIT_CARD );
- $this->_default_button_url = plugins_url( 'images/credit-card/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new CreditCardGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::CREDIT_CARD );
parent::__construct( $pm_instance );
}
diff --git a/src/DirectDebitPaymentMethod.php b/src/DirectDebitPaymentMethod.php
index ea232f9..1a5d7b5 100644
--- a/src/DirectDebitPaymentMethod.php
+++ b/src/DirectDebitPaymentMethod.php
@@ -39,9 +39,8 @@ class DirectDebitPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new DirectDebitGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::DIRECT_DEBIT );
- $this->_default_button_url = plugins_url( 'images/direct-debit/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new DirectDebitGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::DIRECT_DEBIT );
parent::__construct( $pm_instance );
}
diff --git a/src/GiropayPaymentMethod.php b/src/GiropayPaymentMethod.php
index 45db8a3..ed4ce4d 100644
--- a/src/GiropayPaymentMethod.php
+++ b/src/GiropayPaymentMethod.php
@@ -39,9 +39,8 @@ class GiropayPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new GiropayGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::GIROPAY );
- $this->_default_button_url = plugins_url( 'images/giropay/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new GiropayGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::GIROPAY );
parent::__construct( $pm_instance );
}
diff --git a/src/IDealPaymentMethod.php b/src/IDealPaymentMethod.php
index 95c5e3e..8af6a42 100644
--- a/src/IDealPaymentMethod.php
+++ b/src/IDealPaymentMethod.php
@@ -39,9 +39,8 @@ class IDealPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new IDealGateway();
- $this->_pretty_name = __( 'iDEAL', 'pronamic_ideal' );
- $this->_default_button_url = plugins_url( 'images/ideal/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new IDealGateway();
+ $this->_pretty_name = __( 'iDEAL', 'pronamic_ideal' );
parent::__construct( $pm_instance );
}
diff --git a/src/IDealQRPaymentMethod.php b/src/IDealQRPaymentMethod.php
index 3e25d28..ce08068 100644
--- a/src/IDealQRPaymentMethod.php
+++ b/src/IDealQRPaymentMethod.php
@@ -39,9 +39,8 @@ class IDealQRPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new IDealQRGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::IDEALQR );
- $this->_default_button_url = plugins_url( 'images/ideal-qr/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new IDealQRGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::IDEALQR );
parent::__construct( $pm_instance );
}
diff --git a/src/KBCPaymentMethod.php b/src/KBCPaymentMethod.php
index 8ac734c..434af7a 100644
--- a/src/KBCPaymentMethod.php
+++ b/src/KBCPaymentMethod.php
@@ -39,9 +39,8 @@ class KBCPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new KBCGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::KBC );
- $this->_default_button_url = plugins_url( 'images/kbc/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new KBCGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::KBC );
parent::__construct( $pm_instance );
}
diff --git a/src/PayPalPaymentMethod.php b/src/PayPalPaymentMethod.php
index 1a1c9d1..278178d 100644
--- a/src/PayPalPaymentMethod.php
+++ b/src/PayPalPaymentMethod.php
@@ -39,9 +39,8 @@ class PayPalPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new PayPalGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::PAYPAL );
- $this->_default_button_url = plugins_url( 'images/paypal/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new PayPalGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::PAYPAL );
parent::__construct( $pm_instance );
}
diff --git a/src/PayconiqPaymentMethod.php b/src/PayconiqPaymentMethod.php
index 69b8d0d..5041286 100644
--- a/src/PayconiqPaymentMethod.php
+++ b/src/PayconiqPaymentMethod.php
@@ -39,9 +39,8 @@ class PayconiqPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new PayconiqPaymentMethod();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::PAYCONIQ );
- $this->_default_button_url = plugins_url( 'images/payconiq/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new PayconiqPaymentMethod();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::PAYCONIQ );
parent::__construct( $pm_instance );
}
diff --git a/src/PaymentMethod.php b/src/PaymentMethod.php
index 6e27cfd..1edd7c3 100644
--- a/src/PaymentMethod.php
+++ b/src/PaymentMethod.php
@@ -49,9 +49,8 @@ class PaymentMethod extends EE_PMT_Base {
*/
public function __construct( $pm_instance = null ) {
if ( null === $this->payment_method ) {
- $this->_gateway = new Gateway();
- $this->_pretty_name = __( 'Pronamic', 'pronamic_ideal' );
- $this->_default_button_url = plugins_url( 'images/credit-card/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new Gateway();
+ $this->_pretty_name = __( 'Pronamic', 'pronamic_ideal' );
}
parent::__construct( $pm_instance );
diff --git a/src/SofortPaymentMethod.php b/src/SofortPaymentMethod.php
index 737ad5d..2f3677d 100644
--- a/src/SofortPaymentMethod.php
+++ b/src/SofortPaymentMethod.php
@@ -39,9 +39,8 @@ class SofortPaymentMethod extends PaymentMethod {
* @param EE_Payment_Method $pm_instance Event Espresso payment method instance.
*/
public function __construct( $pm_instance = null ) {
- $this->_gateway = new SofortGateway();
- $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::SOFORT );
- $this->_default_button_url = plugins_url( 'images/sofort/icon-64x48.png', Plugin::$file );
+ $this->_gateway = new SofortGateway();
+ $this->_pretty_name = PaymentMethods::get_name( PaymentMethods::SOFORT );
parent::__construct( $pm_instance );
}