mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-11-06 12:03:14 +09:00
* refactor(library): reorganize FOSSBilling core library
Group Update* prefix into FOSSBilling\Update\{Updater,Patcher,Finalization,ReadinessCheck}
Group Pagination into FOSSBilling\Pagination\{Service,Options,ViewPaginator}
Group System (Config,Environment,Version,Requirements) into FOSSBilling\System
Consolidate Security (Crypt,PasswordManager,Fingerprint,Session,CheckResult/Enum)
Group Exception into FOSSBilling\Exception\{BaseException,InformationException}
Fix I18n case (i18n.php -> I18n/I18n.php) and move Translate -> I18n/Translator
Group Logging (Logger, Monolog -> Factory)
Group Remote (CentralAlerts,ExtensionManager), move ErrorPage -> Http, InjectionAwareInterface -> Interfaces
Update all call sites (di.php, load.php, modules, tests) with no BC shims
Fix import aliasing and phpstan docblocks, remove unused imports
Tests: 2856 passed, phpstan OK
* refactor(validation): migrate Validate into Validation namespace
Split monolithic FOSSBilling\Validate (232 lines, 7 mixed responsibilities)
into focused validators:
- Validation\DomainValidator (isSldValid, isTldValid with DI cache/http)
- Validation\PasswordValidator (isPasswordStrong, passwordsMatch)
- Validation\EmailValidator (isEmailValid)
- Validation\BirthdayValidator (isBirthdayValid)
- Validation\RequiredValidator (checkRequiredParamsForArray)
- Validation\Validator facade retaining same API for $di['validator'] BC-free
Move FOSSBilling\Validate -> FOSSBilling\Validation\Validator,
add FOSSBilling\Validation\DomainValidator via $di['domainValidator'],
keep $di['validator'] as facade. Remove old Validate.php.
Update all call sites and tests (FOSSBilling\Validate -> FOSSBilling\Validation\Validator),
make Validator/DomainValidator non-final for Mockery.
Tests: 2856 passed, phpstan OK
* refactor(tools): split Tools into Utils/Security/Sanitizer/Validation (WIP)
Extract Tools god class (534 lines) into focused helpers:
- Utils\Str (slug, toCamelCase, fromCamelCase)
- Utils\Arr (safeCount, sortByOneKey, getPairsForTableByIds via arr service)
- Utils\Normalizer (normalizeBoolean, normalizePort)
- Utils\Network (isValidHttpInterface, getDefaultInterface, listHttpInterfaces, getExternalIP via network service)
- Utils\Format (humanReadableBytes)
- Security\Credential (generatePassword, session restore tokens)
- Sanitizer\Sanitizer (sanitizeContent, sanitizePlainText, sanitizeMarkdownContent)
- Validation\PhoneValidator + EmailValidator::validateAndSanitizeEmail
Update di.php: replace tools service with arr/network services,
migrate 23 src files to new helpers (FOSSBilling\Utils etc.),
keep Tools.php for remaining tests (to be removed next).
Tests: partial, 11 errors remain for slug mocks (to be fixed).
* refactor(api): introduce Role enum, tighten Api layer (deslopped)
- Add FOSSBilling\Api\Role enum (guest/client/admin) replacing stringly-typed role handling
- Api\Identity now stores Role, exposes getRole():Role + getType():string (BC), typeFromObject():Role
- Api\AbstractApi: make abstract, typed properties (string $ip, ?Module $module, Client|Admin|Guest|null $identity), typed setters/getters, fix BaseException usage
- Api\Proxy: compose Identity VO instead of duplicating type logic, keep lazy dispatcher from container, remove redundant helpers
- Api\Dispatcher: use Role enum and Identity VO, normalize identity, keep direct $di['update_finalization'] lookup, deslop finalization injection and apiClassSuffix helper
- Update tests/Pest IdentityTest to expect Role enum
- Minor: fix Cron/Staff Api call sites for typed identity
Deslop: removed hasModule/hasIdentity/hasService helpers, duplicate forwarding comment, unnecessary setFinalization/constructor injection and apiClassSuffix helper.
* refactor(cache): introduce Driver enum, use typed driver handling
- Add FOSSBilling\Cache\Driver enum (filesystem/redis/memcached)
- CacheFactory: parse config via Driver::tryFrom, match on enum, use ->value for messages, remove unused Tools import
- System\Api\Admin: validate driver via Driver::tryFrom instead of in_array(SUPPORTED_DRIVERS)
* refactor(doctrine+api): address review findings
- Doctrine: remove unused Tools import in DriverManagerFactory
- Doctrine: add Driver/Charset enums (pdo_mysql, utf8/utf8mb4/latin1) and use tryFrom for validation
- Api: make AbstractApi::setModule(object) lenient for test doubles (anonymous class in DispatcherTest)
- Tests: apiEndpoint() now ensures identity via getIdentity() catch and sets Admin/Client/Guest based on Api class
* Type AbstractApi module as Module
* Update Doctrine factory copyright years
* refactor(enums): relocate Client/Order status enums to domain modules
Move FOSSBilling\Enums\ClientStatusEnum -> Box\Mod\Client\Enum\Status
and FOSSBilling\Enums\ClientOrderStatusEnum -> Box\Mod\Order\Enum\Status,
colocating statuses with their owning entities (Client.php:29,
Order.php:24) and removing the generic FOSSBilling\Enums catch-all.
Drops Enum suffix, fixes ClientOrder vs Order naming mismatch, updates
sole consumer Massmailer\Service and deletes src/library/FOSSBilling/Enums/.
* refactor(exception): clean up BaseException and InformationException
- BaseException: type stackTrace(int $length,bool $protected), fix
camelCase ($Length->$length, $my_entry->$arg, $entry_file/file),
debug_backtrace typing, copyright 2025->2026
- InformationException: remove redundant passthrough __construct, now
minimal marker extends BaseException (retains 100+ user-facing
throw sites and Sentry ignore distinction)
* refactor(http): harden Http factories, fix escaping and copyrights
- CsvResponseFactory: validate table name preg_match /^[a-zA-Z0-9_]+$/ and throw
InformationException, add import, copyright 2025->2026
- ErrorPage: escape message via htmlspecialchars in renderPage, use
escapedMessage for fallback and original toggle, copyright 2025->2026
- ExceptionResponseFactory: split rawMessage, htmlspecialchars only for
API_MODE Json, pass raw to ErrorPage (single escape source)
- RequestFactory: copyright 2025->2026
* Clean up locale detection error handling
* refactor(interfaces): split generic Interfaces into domain colocated contracts
- Delete FOSSBilling\Interfaces catch-all (5 files)
- FOSSBilling\Interfaces\ApiArrayInterface -> FOSSBilling\Api\ArrayInterface
(ArrayInterface, removes Api stutter, 60+ entities)
- FOSSBilling\Interfaces\InjectionAwareInterface -> FOSSBilling\Container\InjectionAwareInterface
(Container, 100+ services)
- FOSSBilling\Interfaces\SecurityCheckInterface -> FOSSBilling\Security\CheckInterface
(Security, removes Security stutter, 4 checks)
- FOSSBilling\Interfaces\TimestampInterface -> FOSSBilling\Doctrine\TimestampInterface
(Doctrine, with TimestampTrait)
- FOSSBilling\Interfaces\WidgetProviderInterface -> Box\Mod\Widgets\ProviderInterface
(Widgets module, provider contract for Branding etc)
Update 172 usages, keep InjectionAware/Timestamp short names, shorten
ApiArray/SecurityCheck/WidgetProvider stutter.
* Type logger cache as array in factory
* Update pagination copyright headers
* Update CentralAlerts copyright year
* Clean up sanitizer class imports
* Refine security helpers and enum namespace
* Update system class copyright headers
* Move Twig AppArea enum to root namespace
* Update copyright year in update classes
* Drop legacy Arr/Str wrappers and DI binding
* refactor(utils): complete Tools split and delete god class
- Delete FOSSBilling\Tools (534 lines) god class, remove load.php require/use
- Migrate 16 call sites: di['tools']->validateAndSanitizeEmail -> EmailValidator::validateAndSanitizeEmail (8 Api), News slug -> Str::slug, generatePassword already via Credential, humanReadableBytes via Utils\Format, etc.
- Remove 20 unused use FOSSBilling\Tools imports (Order/Extension/Currency etc.)
- Harden Arr::getPairsForTableByIds with preg_match table validation and backticks (CsvResponseFactory style)
- Remove Str legacy wrappers (slugLegacy/slugInstance/to_camel_case)
- Update 11 test files to remove Tools mocks (Extension/Product/Profile/Custompages/Staff/Client/Email/Invoice/Support) and Container helper
- phpstan 443 OK (one less file), tests 60/6 pre-existing failures (AbstractApi identity/pagination) remain, Tools InvalidCount 54->0 for new code
* Update RequiredParams copyright year
* Update copyright headers to 2026
* Remove dead validation code
* fix(ci): address Rector, CS-Fixer and test failures after library reorg
- Run Rector fixes: RemoveUselessReturnTag, RemoveReadonlyOnReadonlyClass, ReadOnlyProperty, RecastingRemoval
- Run cs:fix (132 files) for import ordering and formatting
- Fix Pest helper to handle Mockery mocks for Admin/Client identities
- Fix DispatcherTest to use Module mocks for extension
- Update Client tests to remove Tools mocks and use new EmailValidator/Url
- Fix Custompages ServiceTest to use real Str::slug and long titles
- Fix Pagination mocks to use Service class
- Fix i18n, InformationException, Requirements, Config namespaces
- Add url to test container with SYSTEM_URL base
- Fix a few remaining service tests (Profile, Extension, Product, Support)
phpstan 451/451 OK, pest now 11 failures (down from 107)
* fix(tests): make suite green after reorg
- Fix Extension ServiceTest InformationException namespace and Str mock
- Fix Product Api Admin paginated products mock
- Fix Client/Service i18n, Profile i18n, and url container
- Fix Custompages long-title slug handling
- All 2998 tests pass (2 skipped), phpstan 451/451
* style: fix cs
* fix(docker): update Version.php path after move to System namespace
* fix(tests): remove remaining Tools references
- Delete obsolete ToolsTest (Tools class removed)
- Remove di['tools'] mocks from Profile ServiceTest
* feat(update): add patch115 to clean up library reorg orphans
Removes stale PSR-4 paths left on disk after refactor/library-reorganization
(Tools split, moved namespaces, low-case i18n) which persist on updated
installs because the updater extracts on top. Uses executeFileActions +
removeEmptyDirectories with exists guards, idempotent.
Bumps fresh-install last_patch to 115 to match latestPatchLevel().
* fix(cache): align TLS + instance_id merge with reorg namespaces
- Use Normalizer::normalizeBoolean instead of removed Tools
- Use BaseException + driver->value for instance_id/TLS errors
- Update tests to expect BaseException
* fix(tests): use US spelling to satisfy spellcheck
Normalise -> Normalize, normalised -> normalized
* refactor(box): move Box legacy globals to semantic namespaces
- Box_App/AppAdmin/AppClient -> FOSSBilling\Http\App/AppAdmin/AppClient
- Box_Authorization -> FOSSBilling\Security\Authorization
- Box_Event/EventDispatcher/EventManager -> FOSSBilling\Event\Event/Dispatcher/Manager
- Codemod 499 references across src/modules, src/di.php, src/index.php, tests
- Fix namespace imports (ArrayObject, Pimple\Container, ReflectionMethod, \RuntimeException, \InvalidArgumentException, \Box\Mod\*, \Override)
- Add Patcher patch116 to clean up orphaned src/library/Box/* + bump last_patch 115->116
* refactor(update): extract Patcher patches to Patch/* orchestrator
- Create FOSSBilling\Update\Patch\PatchInterface + PatchRegistry (explicit MAP, not Finder)
- Move 89 private patch25..patch116 methods to dedicated classes Patch25..Patch116
- Each Patch::apply(Patcher $patcher) delegates via public helpers ($patcher->executeSql etc.)
- Patcher now shrinks from 3624 to 1049 LOC: getPatches() uses PatchRegistry::MAP, applyCorePatches() loops [new PatchClass(), 'apply'] with call_user_func($patch, $this)
- Make helpers public (executeSql, fetchAll, tableHasColumn etc.) and properties public for patch access
- Update UpdatePatcherTest to invoke Patch*::apply and expect 'apply' method, keep ordering/skip checks
- No behavior change: MySQL guard, SchemaSynchronizer, last_patch 116 intact
* fix(spellcheck): add checkperm to dictionary after Box->Http move
* style: apply Rector StringClassNameToClassConstant + typed const
* style: apply Rector IfToNullCoalescing for changed files
* style: apply remaining Rector IfToNullCoalescing
* refactor(update): drop redundant Patch101
ADD INDEX client_order_unpaid_invoice_id_idx is already declared in
Order.php:19 and covered by SchemaSynchronizer's additive sync (safeAddedIndexes).
On PG/SQLite Patch101 never ran (isMysqlDriver guard), MySQL now gets it
via syncPortableSchema after the patch loop. Keeps last_patch at 116,
adds gap 101 alongside 92/95/98.
* refactor(update): make Patch116 superset of 115+116, keep 115 as no-op gap
* fix(spellcheck): add superset/orphans after Patch115/116 merge
* refactor(update): remove Patch115 file, keep 116 as superset
Patch115 orphan cleanup is now handled by Patch116 superset (39 paths).
115 remains a gap (like 92/95/98/101) — installs at 115 still get cleanup via
116's idempotent deletes.
* refactor(update): make Patch39 superset of 38+39, drop Patch38 file
38 (Ispconfig3/Virtualmin) + 39 (srp) are idempotent file deletions.
39 now deletes all 3 orphans; 38 remains gap like 101/115.
Installs at 37 -> 39 and 38 -> 39 both get cleanup via 39 superset.
* fix(spellcheck): add Ispconfig3 for Patch39 superset
* refactor(update): make Patch33 superset of 30-33, drop Patch30-32 files
30 (guzzlehttp) + 31 (htaccess/config.old) + 32 (phpmailer/Box/*) + 33 (FileCache)
are idempotent file deletions. 33 now deletes all 19 orphans; 30-32 remain gaps
like 38/101/115. Installs at 29 -> 33 and 32 -> 33 both get cleanup via 33 superset.
* fix(spellcheck): add idempotently/gaps/FileCache for Patch33 superset
* refactor(update): move gateway/branding helpers into Patch64
- migrateDefaultBrandingAssetsToPublicDirectory and
migrateGatewayAssetsToPublicDirectory were only used by Patch64.
Move them as private helpers inside Patch64 (param Patcher $patcher)
and remove public versions from Patcher.
- Patcher shrinks by ~66 LOC, stays public only for shared helpers.
* Drop getVersion from update patches
* refactor(update): move downloadable helpers into Patch65
- generateDownloadableStoredFilename, copyLegacyDownloadableFile,
migrateDownloadableProduct/Service/OrderStorageKeys were only used
by Patch65 (and internally). Move them as private helpers inside
Patch65 (private array $downloadableStorageMigrationMap + 5 methods
taking Patcher $patcher) and remove public versions + property from
Patcher. Keeps Patcher public API for shared helpers only.
* refactor(update): move email template helpers into Patch52
- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
used by Patch52 directly (and via Patcher::restoreLegacy... for 57/90).
Move them as private helpers inside Patch52 (taking Patcher $patcher)
so Patch52 is self-contained. Keep public versions in Patcher for
restoreLegacyDefaultEmailTemplates used by Patch57/90 (duplicate for now,
could also be moved if desired).
* refactor(update): move email template helpers into Patch52/57/90
- getDefaultEmailTemplateData and getDefaultEmailTemplatePath were only
used by Patch52 direct and via Patcher::restoreLegacyDefaultEmailTemplates
for Patch57/90. Move them as private helpers into Patch52 (self-contained)
and duplicate into Patch57/90 alongside private
restoreLegacyDefaultEmailTemplates (which also moved from Patcher).
- Remove public versions from Patcher — keeps Patcher public API for shared
helpers only (executeSql, tableHasColumn etc.). Fixes unrelated
Patcher.php diff (\s vs \s) by removing the methods entirely.
* refactor(update): move custom page slug helpers into Patch102
- allocateUniqueCustomPageSlug and fitCustomPageSlug were only used
by Patch102 (via $patcher->). Move them as private helpers inside
Patch102 (taking Patcher $patcher, using $patcher->fetchOne and
$this->fitCustomPageSlug) and remove public versions from Patcher.
Keeps Patcher public API for shared helpers only.
* refactor(update): make Patcher helpers and patch bookkeeping driver-agnostic
- applyConfigPatches: normalize driver aliases (mysql/pgsql/sqlite -> pdo_*) and use per-driver port defaults (5432 for pgsql, none for sqlite) instead of hardcoded 3306; aligns with DriverManagerFactory::DEFAULT_PORTS.
- setPatchLevel: replace MySQL-only ON DUPLICATE KEY UPDATE with portable SELECT then UPDATE/INSERT so last_patch works on pgsql/sqlite.
- Rename isMysqlDriver -> isLegacyPatchDriver (keep isMysqlDriver as deprecated alias); availablePatches/applyCorePatches now use isLegacyPatchDriver; update SchemeSynchronizer and test docblocks.
- Make tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex portable via AbstractSchemaManager + driver-specific fallbacks (information_schema.tables WHERE table_schema='public' for pgsql, sqlite_master for sqlite); MySQL path keeps SHOW COLUMNS/INDEX for BC.
- updateTable: use driver-aware quoting (backticks for mysql, double quotes for pgsql/sqlite) instead of hardcoded backticks.
Verified: phpstan 536/536, cs:fix clean, pest 2990 passed.
* chore: add pg catalog words to spellcheck dictionary
* Remove deprecated compatibility shims
* refactor(doctrine): expand Driver enum and use it throughout
- Driver.php: add PdoPgsql/PdoSqlite cases, tryFromAlias(), defaultPort(), isMysql/isSqlite helpers; was single-case.
- DriverManagerFactory.php: use Driver::tryFromAlias() in normalizeDriver(), Driver::tryFrom()->defaultPort()/isMysql/isSqlite in getDatabaseConfig/buildConnectionParams/applySessionSettings instead of string compares.
- Patcher.php: use Driver enum in applyConfigPatches (alias + defaultPort), isLegacyPatchDriver (isMysql), tableExists/getTableColumns/getColumnLength/getColumnType/tableHasIndex/updateTable (portable via AbstractSchemaManager + enum driver checks).
Ensures single source of truth for supported drivers; phpstan 536/536, pest 2990.
* Unify HTML sanitization across Twig and APIs
Replace the old sanitizer helpers with a shared HtmlSanitizerFactory and reuse context-specific sanitizers for content, markdown, adapter output, and theme settings. This also exposes sanitize_html and sanitize_markdown Twig filters, sanitizes markdown render output and email/signature HTML consistently, and simplifies support/email input cleanup by trimming/null-byte stripping instead of HTML-encoding raw input.
* fix: align PayGateway entities with Doctrine TimestampInterface after merge
* Type HTML sanitizer scheme constants
* style: fix import order and FQCN usage
* refactor(sanitizer): centralize HtmlSanitizerFactory sanitization and remove redundant config
* fix: add commonmark to cspell dictionary
* fix: apply Rector autofixes for library reorganization
Addresses Code Quality failures after merging main:
- remove unnecessary (string)/(bool) recasts (RecastingRemovalRector)
- add strict string casts for null-able args (NullToStrictStringFuncCallArgRector)
Files are limited to the PR's changed PHP set (BASE_SHA...HEAD) so
pre-existing drift elsewhere doesn't block this branch.
* fix: stabilize DomainValidator arrow function type for Rector
Add explicit string type to array_filter callback to prevent
oscillation between NullToStrictString (add cast) and
RecastingRemoval (remove cast) on the tld parameter.
* fix: apply Rector autofixes after merge from main
BinaryOpNullableToInstanceof / WhileNullableToInstanceof
* style: fix CS imports after Rector (Invoice / ServiceLicense FQCN -> imported)
* fix: use FOSSBilling\Event\Event in Antispam isSpam test (Box_Event -> Event reorg)
* Move src/library/FOSSBilling to src/core
First-party namespaced classes now live in src/core, leaving src/library
as the legacy PSR-0 adapter layer (Payment_, Registrar_, Server_).
- composer.json: PSR-4 FOSSBilling\ -> src/core/ (classmap unchanged)
- load.php/install.php: new PATH_CORE constant; pre-autoload requires repointed
- Update/ReadinessCheck: core/ added to the writable-folder checks
- Patch117: removes the obsolete library/FOSSBilling tree on upgraded installs
- Dockerfile: copies src/core in the vendor stages; release rewrites
(Version.php, SentryHelper.php, archive composer remap) repointed
- labeler/CODEOWNERS/cspell/AGENTS.md/test path references updated
- install/sql/content.sql: last_patch seed 116 -> 117
* Rename the FOSSBilling\ namespace to FOSSBilling\Core\
All first-party namespaced classes now live under FOSSBilling\Core\,
matching their src/core location. Clean cut, consistent with the Box\
removal on this branch: released extensions referencing the old names
must update their imports once in the same release.
- namespace/use/FQCN/docblock rewrite across src/ and tests/ (626 files)
- composer.json PSR-4: FOSSBilling\Core\ -> src/core/
- Dockerfile release composer remap updated to match
- SentryHelper: core.class tag for core paths, library.class retained
for the PSR-0 adapter layer
- Doctrine proxy namespaces (prod + test) follow the new prefix
- tests/Unit/FOSSBilling -> tests/Unit/FOSSBilling/Core; template
harness dirname depths adjusted
- ignore Strict/ test-harness findings artifacts
* Refresh the Composer autoloader after restoring the cached vendor tree
The php-build vendor cache is keyed on composer.lock, whose content-hash
does not change for autoload-only composer.json edits. On a cache hit the
install step is skipped entirely, leaving vendor/autoload.php generated
from the old PSR-4 mapping - so the library-to-core reorganization made
Rector fatal with "Trait FOSSBilling\Core\Doctrine\TimestampTrait not
found" while compiling analyzed files. Re-dump the autoloader from the
current composer.json after the cache restore.
* Trim comments on the two hand-written pieces to house style
Patch116 uses two dense lines with a short SHA rather than a commit
title; mirror that for Patch117. Shorten the autoloader-refresh comment
in php-quality-setup to the essentials.
* Fix two stale references from the library-to-core move
- GeoIP\Reader still built its bundled MMDB paths from PATH_LIBRARY
. FOSSBilling - point them at PATH_CORE so the default reader works
now that Patch117 removes the old tree; add existence tests so a
future move can't silently break this again.
- Dispatcher passed Request::getClientIp() (nullable on CLI, e.g. cron)
to the string-typed setIp(), TypeErroring before the first cron task;
coalesce to '' to match AbstractApi's own empty-string default.
* Dockerfile: namespace-wrapped cache sanity check belongs under Core
* Add dasharray to cspell words (SVG stroke-dasharray in branding assets)
Fixes Spellcheck failure on the push CI run.
* Deslop merge resolutions: trim comments, drop dead test stubs, import Patch classes
* Fix stale old-namespace references found in completeness audit
- CONTRIBUTING.md documented src/library/FOSSBilling paths/namespaces
- EntityMigrationCoverageTest read the deleted
library/FOSSBilling/UpdatePatcher.php; scan Patcher.php + Patch
classes instead (proven to detect ported migrations)
* Satisfy Rector 2.8: drop redundant cast, add nullable return type
* Address code review: restore Patch30-32, trim Patch33, drop retired hook call in Patch55
- Patch55 called mod_service('hook')->batchConnect(), removed on main
when the hook runtime was retired; the throw skipped the remaining
spamchecker cleanup. Match main (no hook lines).
- Patch33 had absorbed patches 30-32 cleanups, newly deleting the live
vendor/guzzlehttp (now required via sentry/sentry) for installs past
patch 30. Restore Patch30/31/32 as separate classes matching main and
trim Patch33 to the FileCache.php removal.
* Fix uninitialized Arr helper for group-restricted promos
enrichPromoApiArray built Arr without a container, so any promotion
with client-group restrictions fatally dereferenced null di in
getPairsForTableByIds. Initialize the helper with the service
container (with the file's usual missing-container guard) and add a
regression test.
* Keep client-group lookup portable: drop MySQL backticks
The Tools split introduced backtick quoting around the validated
table name; PostgreSQL rejects it. Main used the validated unquoted
name - restore that and assert the exact SQL in the regression test.
1815 lines
85 KiB
PHP
1815 lines
85 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Copyright 2022-2026 FOSSBilling
|
|
* SPDX-License-Identifier: Apache-2.0.
|
|
*
|
|
* @copyright FOSSBilling (https://www.fossbilling.org)
|
|
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
use function Tests\Helpers\container;
|
|
|
|
function buildPayPalEmailAdapter(string $configuredEmail, string $postbackResponse, ?array &$capturedRequestBody = null, ?string &$capturedRawRequestBody = null): Payment_Adapter_PayPalEmail
|
|
{
|
|
$response = Mockery::mock();
|
|
$response->shouldReceive('getContent')->andReturn($postbackResponse);
|
|
|
|
$httpClient = Mockery::mock();
|
|
$httpClient->shouldReceive('withOptions')->andReturnSelf();
|
|
$httpClient->shouldReceive('request')
|
|
->with('POST', Mockery::any(), Mockery::on(function (array $options) use (&$capturedRequestBody, &$capturedRawRequestBody): bool {
|
|
$capturedRawRequestBody = (string) ($options['body'] ?? '');
|
|
parse_str($capturedRawRequestBody, $capturedRequestBody);
|
|
|
|
return true;
|
|
}))
|
|
->andReturn($response);
|
|
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => $configuredEmail, 'test_mode' => false]);
|
|
$di = container();
|
|
$di['http_client'] = $httpClient;
|
|
$adapter->setDi($di);
|
|
|
|
return $adapter;
|
|
}
|
|
|
|
function callIsIpnValid(Payment_Adapter_PayPalEmail $adapter, string $rawPostBody): bool
|
|
{
|
|
$reflection = new ReflectionClass($adapter);
|
|
|
|
return (bool) $reflection->getMethod('_isIpnValid')->invokeArgs($adapter, [['http_raw_post_data' => $rawPostBody]]);
|
|
}
|
|
|
|
describe('_isIpnValid receiver verification', function (): void {
|
|
test('rejects array fields without sending a verification request', function (string $body): void {
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => 'merchant@example.com', 'test_mode' => false]);
|
|
$httpClient = Mockery::mock();
|
|
$httpClient->shouldNotReceive('withOptions');
|
|
$di = container();
|
|
$di['http_client'] = $httpClient;
|
|
$adapter->setDi($di);
|
|
|
|
set_error_handler(static function (int $severity, string $message): never {
|
|
throw new ErrorException($message, 0, $severity);
|
|
});
|
|
|
|
try {
|
|
expect(callIsIpnValid($adapter, $body))->toBeFalse();
|
|
} finally {
|
|
restore_error_handler();
|
|
}
|
|
})->with([
|
|
'array payee' => 'receiver_email[]=merchant%40example.com',
|
|
'nested field' => 'receiver_email=merchant%40example.com&custom[invoice][id]=7',
|
|
]);
|
|
|
|
test('accepts a payment made to the configured merchant account', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
test('accepts the business field when receiver_email is absent', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'business' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
test('accepts the business field when receiver_email is a different confirmed account email', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'primary@example.com',
|
|
'business' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
test('rejects a payment made to a different account', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'attacker@evil.example',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
test('matches the configured address case-insensitively', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('Merchant@Example.COM', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
test('matches the configured address after trimming surrounding whitespace', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => ' merchant@example.com ',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
});
|
|
|
|
test('rejects a notification with no payee field', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
test('rejects a notification whose postback is not VERIFIED', function (): void {
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'INVALID');
|
|
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
]));
|
|
|
|
expect($result)->toBeFalse();
|
|
});
|
|
|
|
test('sends field values back to PayPal unmodified, backslashes included', function (): void {
|
|
$captured = [];
|
|
$capturedRaw = null;
|
|
$adapter = buildPayPalEmailAdapter('merchant@example.com', 'VERIFIED', $captured, $capturedRaw);
|
|
|
|
$addressStreet = 'C:\\Users\\Test 1\\2 Foo St';
|
|
$result = callIsIpnValid($adapter, http_build_query([
|
|
'receiver_email' => 'merchant@example.com',
|
|
'mc_gross' => '10.00',
|
|
'address_street' => $addressStreet,
|
|
]));
|
|
|
|
expect($result)->toBeTrue();
|
|
expect($captured['address_street'])->toBe($addressStreet);
|
|
// Assert on the raw wire body too, not just its parse_str()-decoded form,
|
|
// so a bug in the percent-encoding itself (not just the decoded value)
|
|
// would still be caught.
|
|
expect($capturedRaw)->toContain('address_street=' . urlencode($addressStreet));
|
|
});
|
|
|
|
test('logs unknown PayPal transactions through the application logger', function (): void {
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => 'merchant@example.com', 'test_mode' => false]);
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['logger'] = $logger;
|
|
$adapter->setDi($di);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')
|
|
->once()
|
|
->with(['id' => 42])
|
|
->andReturn([
|
|
'invoice_id' => 7,
|
|
'type' => 'unknown_event',
|
|
'txn_id' => 'paypal-transaction-42',
|
|
'txn_status' => 'Unknown',
|
|
'amount' => '10.00',
|
|
'currency' => 'USD',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')
|
|
->once()
|
|
->with(['id' => 7])
|
|
->andReturn(['client' => ['id' => 9]]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')
|
|
->once()
|
|
->with(Mockery::on(fn (array $data): bool => $data['id'] === 42 && $data['status'] === 'processed'));
|
|
|
|
$adapter->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'unknown_event',
|
|
'payment_status' => 'Unknown',
|
|
],
|
|
'get' => signedPayPalGet(7),
|
|
], 1);
|
|
|
|
expect($logger->calls)->toContain([
|
|
'method' => 'error',
|
|
'params' => ['Unknown PayPal transaction 42'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
function paypalProcessAdapter(object $di): Payment_Adapter_PayPalEmail
|
|
{
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => 'merchant@example.com', 'test_mode' => false]);
|
|
$adapter->setDi($di);
|
|
|
|
return $adapter;
|
|
}
|
|
|
|
/**
|
|
* Build the query-string portion of a callback URL as issued post-fix:
|
|
* invoice id plus its binding signature for the given gateway.
|
|
*/
|
|
function signedPayPalGet(int $invoiceId, int $gatewayId = 1, array $extra = []): array
|
|
{
|
|
return array_merge(
|
|
['invoice_id' => $invoiceId, 'sig' => FOSSBilling\Core\Security\Credential::signCallbackParams($gatewayId, $invoiceId)],
|
|
$extra
|
|
);
|
|
}
|
|
|
|
function paypalEmMocks(?object $invoiceModel = null, ?object $existingSubscription = null, ?callable $findActiveByTxn = null): Mockery\MockInterface
|
|
{
|
|
$invoiceRepo = Mockery::mock(Box\Mod\Invoice\Repository\InvoiceRepository::class);
|
|
$invoiceRepo->shouldReceive('find')->byDefault()->andReturn($invoiceModel);
|
|
$subRepo = Mockery::mock(Box\Mod\Invoice\Repository\SubscriptionRepository::class);
|
|
$subRepo->shouldReceive('findOneBy')->byDefault()->andReturn($existingSubscription);
|
|
$transactionRepo = Mockery::mock(Box\Mod\Invoice\Repository\TransactionRepository::class);
|
|
$transactionRepo->shouldReceive('findActiveByTxnIdAndGatewayId')->byDefault()->andReturnUsing(
|
|
$findActiveByTxn ?? static fn (): ?object => null
|
|
);
|
|
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
|
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
|
$connection->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
|
|
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
|
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
|
|
Box\Mod\Invoice\Entity\Invoice::class => $invoiceRepo,
|
|
Box\Mod\Invoice\Entity\Subscription::class => $subRepo,
|
|
Box\Mod\Invoice\Entity\Transaction::class => $transactionRepo,
|
|
default => Mockery::mock(Doctrine\ORM\EntityRepository::class)->shouldIgnoreMissing(),
|
|
});
|
|
$em->shouldReceive('getConnection')->byDefault()->andReturn($connection);
|
|
|
|
return $em;
|
|
}
|
|
|
|
describe('PayPal subscription IPN handling', function (): void {
|
|
test('subscr_signup stores the subscription against the order invoice', function (): void {
|
|
$updates = [];
|
|
$created = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_create')->once()->withArgs(function (array $data) use (&$created): bool {
|
|
$created[] = $data;
|
|
|
|
return true;
|
|
})->andReturn(7);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks();
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
|
$subscriptionService->shouldNotReceive('getSubscriptionPeriod');
|
|
$di['mod_service'] = $di->protect(static fn (): object => $subscriptionService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_signup',
|
|
'subscr_id' => 'I-ABC123',
|
|
'mc_currency' => 'USD',
|
|
'period3' => '1 Y',
|
|
'amount3' => '120.00',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($created)->toHaveCount(1)
|
|
->and($created[0]['sid'])->toBe('I-ABC123')
|
|
->and($created[0]['period'])->toBe('1Y')
|
|
->and($created[0]['rel_id'])->toBe(16);
|
|
|
|
$transactionUpdates = array_values(array_filter($updates, fn (array $u): bool => ($u['s_id'] ?? null) === 'I-ABC123'));
|
|
expect($transactionUpdates)->toHaveCount(1)
|
|
->and($transactionUpdates[0]['s_period'])->toBe('1Y');
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
|
|
$logged = array_column(array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'info'), 'params');
|
|
expect($logged)->not->toBeEmpty();
|
|
});
|
|
|
|
test('repeated subscr_signup does not create a duplicate subscription', function (): void {
|
|
$updates = [];
|
|
$existing = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => 'subscr_signup', 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => 'USD',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks(null, $existing);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_signup',
|
|
'subscr_id' => 'I-ABC123',
|
|
'mc_currency' => 'USD',
|
|
'period3' => '1 Y',
|
|
'amount3' => '120.00',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('initial subscr_payment pays the original invoice without generating a renewal', function (): void {
|
|
$updates = [];
|
|
$funds = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'subscr_payment', 'txn_id' => 'TXN-1', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once()->withArgs(function (array $data) use (&$funds): bool {
|
|
$funds[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->with(120.00, 120.00)->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
$invoiceService->shouldNotReceive('generateRenewalInvoiceForSubscriptionPayment');
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$em = paypalEmMocks($invoiceModel);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-1',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'subscr_id' => 'I-ABC123',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($funds)->toHaveCount(1)
|
|
->and((float) $funds[0]['amount'])->toBe(120.00);
|
|
$reassigned = array_filter($updates, fn (array $u): bool => isset($u['invoice_id']) && (int) $u['invoice_id'] !== 16);
|
|
expect($reassigned)->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('renewal subscr_payment generates a renewal invoice once the original is paid', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'subscr_payment', 'txn_id' => 'TXN-2', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 99]);
|
|
|
|
$paidInvoice = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$paidInvoice->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$paidInvoice->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_PAID);
|
|
$renewal = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$renewal->shouldReceive('getId')->byDefault()->andReturn(99);
|
|
$renewal->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('generateRenewalInvoiceForSubscriptionPayment')->once()->with('I-ABC123', 9)->andReturn($renewal);
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
|
|
$em = paypalEmMocks($paidInvoice);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-2',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'subscr_id' => 'I-ABC123',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$reassigned = array_values(array_filter($updates, fn (array $u): bool => isset($u['invoice_id']) && (int) $u['invoice_id'] === 99));
|
|
expect($reassigned)->toHaveCount(1);
|
|
});
|
|
|
|
test('non-completed payments keep their error instead of being marked processed', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
$apiAdmin->shouldNotReceive('invoice_pay_with_credits');
|
|
|
|
$em = paypalEmMocks();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Pending'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$received = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'received'));
|
|
expect($received)->toHaveCount(1)
|
|
->and($received[0]['error'])->toContain('Pending');
|
|
$processed = array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed');
|
|
expect($processed)->toBeEmpty();
|
|
});
|
|
|
|
test('subscr_signup without a subscription id throws instead of silently succeeding', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
|
|
|
$em = paypalEmMocks();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn (): mixed => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_signup', 'mc_currency' => 'USD'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class);
|
|
});
|
|
|
|
test('cancellation for an unknown subscription logs a warning instead of throwing', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks();
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_cancel', 'subscr_id' => 'I-UNKNOWN'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$warnings = array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning');
|
|
expect($warnings)->not->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('cancellation updates the stored subscription', function (): void {
|
|
$updates = [];
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getId')->byDefault()->andReturn(7);
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
|
$apiAdmin->shouldReceive('invoice_subscription_update')->once()->with(['id' => 7, 'status' => 'canceled'])->andReturn(true);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks(null, $stored);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_cancel', 'subscr_id' => 'I-ABC123'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('completed payment missing transaction details throws before claiming', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
|
|
$em = paypalEmMocks();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn (): mixed => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class, 'PayPal payment is missing transaction details');
|
|
});
|
|
|
|
test('completed subscription payment missing the subscription id throws before claiming', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
|
|
$em = paypalEmMocks();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn (): mixed => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-1',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class, 'PayPal subscription payment is missing the subscription ID');
|
|
});
|
|
|
|
test('processes a completed payment without re-claiming the row', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'web_accept', 'txn_id' => 'TXN-1', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
|
|
$em = paypalEmMocks($invoiceModel);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-1',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('recurring_payment from the newer flow pays the original invoice', function (): void {
|
|
$updates = [];
|
|
$funds = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'recurring_payment', 'txn_id' => 'TXN-R1', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once()->withArgs(function (array $data) use (&$funds): bool {
|
|
$funds[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->with(120.00, 120.00)->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
$invoiceService->shouldNotReceive('generateRenewalInvoiceForSubscriptionPayment');
|
|
|
|
$em = paypalEmMocks($invoiceModel);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'recurring_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-R1',
|
|
'recurring_payment_id' => 'I-PROFILE1',
|
|
'amount' => '120.00',
|
|
'amount_currency' => 'usd',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($funds)->toHaveCount(1)
|
|
->and((float) $funds[0]['amount'])->toBe(120.00);
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('recurring_payment rejects a currency that does not match the invoice', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn () => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'recurring_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-R1',
|
|
'recurring_payment_id' => 'I-PROFILE1',
|
|
'amount' => '120.00',
|
|
'amount_currency' => 'MXN',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class, 'PayPal payment currency MXN does not match invoice currency USD');
|
|
});
|
|
|
|
test('recurring_payment_profile_created stores the subscription', function (): void {
|
|
$created = [];
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_create')->once()->withArgs(function (array $data) use (&$created): bool {
|
|
$created[] = $data;
|
|
|
|
return true;
|
|
})->andReturn(8);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
|
$subscriptionService->shouldReceive('getSubscriptionPeriod')->once()->with($invoiceModel)->andReturn('1M');
|
|
|
|
$em = paypalEmMocks($invoiceModel);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $subscriptionService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'recurring_payment_profile_created',
|
|
'recurring_payment_id' => 'I-PROFILE2',
|
|
'amount' => '120.00',
|
|
'amount_currency' => 'USD',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($created)->toHaveCount(1)
|
|
->and($created[0]['sid'])->toBe('I-PROFILE2')
|
|
->and($created[0]['currency'])->toBe('USD')
|
|
->and($created[0]['amount'])->toBe('120.00')
|
|
->and($created[0]['period'])->toBe('1M');
|
|
|
|
$linked = array_values(array_filter($updates, fn (array $u): bool => ($u['s_id'] ?? null) === 'I-PROFILE2'));
|
|
expect($linked)->toHaveCount(1)
|
|
->and($linked[0]['s_period'])->toBe('1M');
|
|
});
|
|
|
|
test('recurring_payment_profile_created rejects a currency that does not match the invoice', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn () => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'recurring_payment_profile_created',
|
|
'recurring_payment_id' => 'I-PROFILE2',
|
|
'amount' => '120.00',
|
|
'amount_currency' => 'MXN',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class, 'PayPal payment currency MXN does not match invoice currency USD');
|
|
});
|
|
|
|
test('signup without a period stores null when the invoice is not subscribable', function (): void {
|
|
$created = [];
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_create')->once()->withArgs(function (array $data) use (&$created): bool {
|
|
$created[] = $data;
|
|
|
|
return true;
|
|
})->andReturn(8);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
|
$subscriptionService->shouldReceive('getSubscriptionPeriod')->once()->with($invoiceModel)->andReturnNull();
|
|
|
|
$em = paypalEmMocks($invoiceModel);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $subscriptionService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_signup',
|
|
'subscr_id' => 'I-ABC999',
|
|
'mc_currency' => 'USD',
|
|
'amount3' => '120.00',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($created)->toHaveCount(1)
|
|
->and(array_key_exists('period', $created[0]))->toBeTrue()
|
|
->and($created[0]['period'])->toBeNull();
|
|
|
|
$linked = array_values(array_filter($updates, fn (array $u): bool => ($u['s_id'] ?? null) === 'I-ABC999'));
|
|
expect($linked)->toHaveCount(1)
|
|
->and(array_key_exists('s_period', $linked[0]))->toBeTrue()
|
|
->and($linked[0]['s_period'])->toBeNull();
|
|
});
|
|
|
|
test('subscr_modify updates the stored subscription terms', function (): void {
|
|
$updates = [];
|
|
$subscriptionUpdates = [];
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getId')->byDefault()->andReturn(7);
|
|
$stored->shouldReceive('getPeriod')->byDefault()->andReturn('1Y');
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_update')->once()->withArgs(function (array $data) use (&$subscriptionUpdates): bool {
|
|
$subscriptionUpdates[] = $data;
|
|
|
|
return true;
|
|
})->andReturn(true);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks(null, $stored);
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_modify',
|
|
'subscr_id' => 'I-ABC123',
|
|
'amount3' => '150.00',
|
|
'period3' => '1 Y',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($subscriptionUpdates)->toHaveCount(1)
|
|
->and($subscriptionUpdates[0]['id'])->toBe(7)
|
|
->and($subscriptionUpdates[0]['amount'])->toBe('150.00')
|
|
->and($subscriptionUpdates[0]['period'])->toBe('1Y');
|
|
$linked = array_values(array_filter($updates, fn (array $u): bool => ($u['s_id'] ?? null) === 'I-ABC123'));
|
|
expect($linked)->toHaveCount(1);
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('subscr_modify for an unknown subscription logs a warning instead of throwing', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks();
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_modify', 'subscr_id' => 'I-UNKNOWN'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$warnings = array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning');
|
|
expect($warnings)->not->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('recurring_payment_profile_cancel cancels the stored subscription', function (): void {
|
|
$updates = [];
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getId')->byDefault()->andReturn(7);
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
|
$apiAdmin->shouldReceive('invoice_subscription_update')->once()->with(['id' => 7, 'status' => 'canceled'])->andReturn(true);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks(null, $stored);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'recurring_payment_profile_cancel', 'recurring_payment_id' => 'I-PROFILE1'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('recurring_payment_failed leaves the subscription active', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$em = paypalEmMocks(null, $stored);
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'recurring_payment_failed', 'recurring_payment_id' => 'I-PROFILE1'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$linked = array_values(array_filter($updates, fn (array $u): bool => ($u['s_id'] ?? null) === 'I-PROFILE1'));
|
|
expect($linked)->toHaveCount(1);
|
|
$warnings = array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning');
|
|
expect($warnings)->not->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('subscr_failed for an unknown subscription logs a warning instead of throwing', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks();
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_failed', 'subscr_id' => 'I-UNKNOWN'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$warnings = array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning');
|
|
expect($warnings)->not->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('recurring_payment_skipped is acknowledged without touching the subscription', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks();
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = $logger;
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'recurring_payment_skipped', 'recurring_payment_id' => 'I-PROFILE1'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$warnings = array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning');
|
|
expect($warnings)->not->toBeEmpty();
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('subscription payment for a different invoice throws before claiming', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received',
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getRelType')->byDefault()->andReturn('invoice');
|
|
$stored->shouldReceive('getRelId')->byDefault()->andReturn(99);
|
|
$em = paypalEmMocks(null, $stored);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
expect(fn (): mixed => paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-1',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'subscr_id' => 'I-OTHER',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2))->toThrow(Payment_Exception::class, 'is not linked to invoice 16');
|
|
});
|
|
|
|
test('subscription payment matching the stored invoice proceeds', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'subscr_payment', 'txn_id' => 'TXN-1', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getRelType')->byDefault()->andReturn('invoice');
|
|
$stored->shouldReceive('getRelId')->byDefault()->andReturn(16);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
$invoiceService->shouldNotReceive('generateRenewalInvoiceForSubscriptionPayment');
|
|
|
|
$em = paypalEmMocks($invoiceModel, $stored);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_payment',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-1',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'subscr_id' => 'I-ABC123',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('subscr_signup without a currency fails cleanly instead of warning', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_create')->never();
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
|
|
$em = paypalEmMocks();
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
|
$subscriptionService->shouldNotReceive('getSubscriptionPeriod');
|
|
$di['mod_service'] = $di->protect(static fn (): object => $subscriptionService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'subscr_signup',
|
|
'subscr_id' => 'I-ABC123',
|
|
'period3' => '1 Y',
|
|
'amount3' => '120.00',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal payment is missing currency details');
|
|
|
|
test('isIpnDuplicate tolerates IPNs missing optional keys', function (): void {
|
|
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
|
$connection->shouldReceive('quoteSingleIdentifier')->with('transaction')->andReturn('"transaction"');
|
|
$connection->shouldReceive('fetchAllAssociative')->once()->andReturn([]);
|
|
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
|
$em->shouldReceive('getConnection')->andReturn($connection);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => 'merchant@example.com', 'test_mode' => false]);
|
|
$adapter->setDi($di);
|
|
|
|
expect($adapter->isIpnDuplicate(['txn_id' => 'ABC']))->toBeFalse();
|
|
});
|
|
});
|
|
|
|
describe('PayPal callback invoice binding', function (): void {
|
|
test('rejects a callback whose invoice id was swapped after signing', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => null, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_update', 'invoice_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
// Signature was issued for invoice 16; the callback URL now names 99.
|
|
$sig = FOSSBilling\Core\Security\Credential::signCallbackParams(2, 16);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed'],
|
|
'get' => ['invoice_id' => 99, 'sig' => $sig],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('rejects a callback with no signature', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => null, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_update', 'invoice_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed'],
|
|
'get' => ['invoice_id' => 16],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('rejects a signature issued for a different gateway', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => null, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_update', 'invoice_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed'],
|
|
'get' => ['invoice_id' => 16, 'sig' => FOSSBilling\Core\Security\Credential::signCallbackParams(7, 16)],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('binds an unbound transaction to the verified invoice', function (): void {
|
|
$updates = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => null, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'unknown_event', 'payment_status' => 'Unknown'],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
$bound = array_values(array_filter($updates, fn (array $u): bool => isset($u['invoice_id'])));
|
|
expect($bound)->toHaveCount(1)
|
|
->and((int) $bound[0]['invoice_id'])->toBe(16);
|
|
});
|
|
|
|
test('accepts an unsigned renewal for a stored subscription on the same invoice', function (): void {
|
|
$updates = [];
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getId')->byDefault()->andReturn(5);
|
|
$stored->shouldReceive('getRelType')->byDefault()->andReturn('invoice');
|
|
$stored->shouldReceive('getRelId')->byDefault()->andReturn(16);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_subscription_update')->once()->with(['id' => 5, 'status' => 'canceled']);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->withArgs(function (array $data) use (&$updates): bool {
|
|
$updates[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks(null, $stored);
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
// Legacy recurring profile: callback URL predates signing, so no sig.
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'recurring_payment_profile_cancel', 'subscr_id' => 'I-LEGACY'],
|
|
'get' => ['invoice_id' => 16],
|
|
], 2);
|
|
|
|
$processed = array_values(array_filter($updates, fn (array $u): bool => ($u['status'] ?? null) === 'processed'));
|
|
expect($processed)->toHaveCount(1);
|
|
});
|
|
|
|
test('rejects an unsigned callback naming a different invoice for a stored subscription', function (): void {
|
|
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
|
$stored->shouldReceive('getRelType')->byDefault()->andReturn('invoice');
|
|
$stored->shouldReceive('getRelId')->byDefault()->andReturn(16);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 99, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_get', 'invoice_subscription_update');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks(null, $stored);
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'recurring_payment_profile_cancel', 'subscr_id' => 'I-LEGACY'],
|
|
'get' => ['invoice_id' => 99],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('rejects an unsigned callback for an unknown subscription', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'subscr_cancel', 'subscr_id' => 'I-UNKNOWN'],
|
|
'get' => ['invoice_id' => 16],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('still requires an invoice when none is supplied', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => null, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed'],
|
|
'get' => [],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal transaction is not associated with an invoice');
|
|
|
|
test('accepts a completed payment whose echoed item_number is stale and logs a warning', function (): void {
|
|
$funds = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'web_accept', 'txn_id' => 'TXN-9', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once()->withArgs(function (array $data) use (&$funds): bool {
|
|
$funds[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getNr')->byDefault()->andReturn('00042');
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->with(120.00, 120.00)->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks($invoiceModel);
|
|
$logger = new Tests\Helpers\TestLogger();
|
|
$di['logger'] = $logger;
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-9',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'item_number' => '99999',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($funds)->toHaveCount(1);
|
|
$warnings = array_values(array_filter($logger->calls, fn (array $c): bool => $c['method'] === 'warning'));
|
|
expect($warnings)->toHaveCount(1)
|
|
->and($warnings[0]['params'][0])->toContain('PayPal item_number 99999 does not match invoice 16');
|
|
});
|
|
|
|
test('rejects a completed payment whose echoed invoice reference names a different invoice', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'web_accept', 'txn_id' => 'TXN-9', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getNr')->byDefault()->andReturn('00042');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks($invoiceModel);
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-9',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'item_number' => '00042',
|
|
'invoice' => '99999',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal invoice reference does not match invoice 16');
|
|
|
|
test('accepts a completed payment whose echoed references match the invoice', function (): void {
|
|
$funds = [];
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'web_accept', 'txn_id' => 'TXN-9', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once()->withArgs(function (array $data) use (&$funds): bool {
|
|
$funds[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getNr')->byDefault()->andReturn('00042');
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->with(120.00, 120.00)->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks($invoiceModel);
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-9',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
'item_number' => '00042',
|
|
],
|
|
'get' => signedPayPalGet(16, 2),
|
|
], 2);
|
|
|
|
expect($funds)->toHaveCount(1);
|
|
});
|
|
|
|
test('getInvoiceId resolves a signed callback and rejects a tampered one', function (): void {
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
$adapter = new Payment_Adapter_PayPalEmail(['email' => 'merchant@example.com', 'test_mode' => false, 'gateway_id' => 2]);
|
|
$adapter->setDi($di);
|
|
|
|
expect($adapter->getInvoiceId(['get' => signedPayPalGet(16, 2)]))->toBe(16);
|
|
expect($adapter->getInvoiceId(['get' => []]))->toBeNull();
|
|
expect(fn (): mixed => $adapter->getInvoiceId(['get' => ['invoice_id' => 99, 'sig' => FOSSBilling\Core\Security\Credential::signCallbackParams(2, 16)]]))
|
|
->toThrow(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
});
|
|
|
|
test('accepts an unsigned refund continuing a pre-upgrade payment', function (): void {
|
|
$refunded = [];
|
|
$earlierInvoice = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$earlierInvoice->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$earlier = Mockery::mock(Box\Mod\Invoice\Entity\Transaction::class);
|
|
$earlier->shouldReceive('getInvoice')->byDefault()->andReturn($earlierInvoice);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 16, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
$apiAdmin->shouldReceive('invoice_refund')->once()->withArgs(function (array $data) use (&$refunded): bool {
|
|
$refunded[] = $data;
|
|
|
|
return true;
|
|
});
|
|
|
|
$em = paypalEmMocks(null, null, static fn (string $txnId): ?object => $txnId === 'TXN-ORIG' ? $earlier : null);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
// Refund IPN for a pre-upgrade payment: new txn id, unsigned URL,
|
|
// but parent_txn_id names the original recorded payment.
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Refunded',
|
|
'txn_id' => 'REFUND-9',
|
|
'parent_txn_id' => 'TXN-ORIG',
|
|
'mc_gross' => '-120.00',
|
|
'mc_currency' => 'USD',
|
|
],
|
|
'get' => ['invoice_id' => 16],
|
|
], 2);
|
|
|
|
expect($refunded)->toHaveCount(1)
|
|
->and($refunded[0]['id'])->toBe(16);
|
|
});
|
|
|
|
test('accepts an unsigned delayed completion with a recorded pending payment', function (): void {
|
|
$funds = [];
|
|
$earlierInvoice = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$earlierInvoice->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$earlier = Mockery::mock(Box\Mod\Invoice\Entity\Transaction::class);
|
|
$earlier->shouldReceive('getInvoice')->byDefault()->andReturn($earlierInvoice);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->twice()->with(['id' => 42])->andReturn(
|
|
['invoice_id' => 16, 'type' => null, 'txn_id' => null, 'txn_status' => null, 'amount' => null, 'currency' => null, 'status' => 'received'],
|
|
['invoice_id' => 16, 'type' => 'web_accept', 'txn_id' => 'TXN-E', 'txn_status' => 'Completed', 'amount' => '120.00', 'currency' => 'USD', 'status' => 'processing']
|
|
);
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_claim_for_processing');
|
|
$apiAdmin->shouldReceive('invoice_get')->once()->with(['id' => 16])->andReturn([
|
|
'id' => 16, 'currency' => 'USD', 'client' => ['id' => 9],
|
|
]);
|
|
$apiAdmin->shouldReceive('invoice_transaction_update')->byDefault()->andReturnTrue();
|
|
$apiAdmin->shouldReceive('client_balance_add_funds')->once()->withArgs(function (array $data) use (&$funds): bool {
|
|
$funds[] = $data;
|
|
|
|
return true;
|
|
});
|
|
$apiAdmin->shouldReceive('invoice_pay_with_credits')->once()->with(['id' => 16]);
|
|
|
|
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
|
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
|
|
|
$invoiceService = Mockery::mock();
|
|
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
|
$invoiceService->shouldReceive('validatePaymentAmount')->once()->with(120.00, 120.00)->andReturnNull();
|
|
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->andReturn(false);
|
|
|
|
$em = paypalEmMocks($invoiceModel, null, static fn (): ?object => $earlier);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
$di['mod_service'] = $di->protect(static fn (): object => $invoiceService);
|
|
|
|
// eCheck-style delayed completion: unsigned pre-upgrade URL, but the
|
|
// same PayPal transaction was already recorded for this invoice.
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => [
|
|
'txn_type' => 'web_accept',
|
|
'payment_status' => 'Completed',
|
|
'txn_id' => 'TXN-E',
|
|
'mc_gross' => '120.00',
|
|
'mc_currency' => 'USD',
|
|
],
|
|
'get' => ['invoice_id' => 16],
|
|
], 2);
|
|
|
|
expect($funds)->toHaveCount(1);
|
|
});
|
|
|
|
test('rejects an unsigned callback whose earlier payment belongs to another invoice', function (): void {
|
|
$earlierInvoice = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
|
$earlierInvoice->shouldReceive('getId')->byDefault()->andReturn(16);
|
|
$earlier = Mockery::mock(Box\Mod\Invoice\Entity\Transaction::class);
|
|
$earlier->shouldReceive('getInvoice')->byDefault()->andReturn($earlierInvoice);
|
|
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldReceive('invoice_transaction_get')->once()->with(['id' => 42])->andReturn([
|
|
'invoice_id' => 99, 'type' => null, 'txn_id' => null,
|
|
'txn_status' => null, 'amount' => null, 'currency' => null,
|
|
]);
|
|
$apiAdmin->shouldNotReceive('invoice_get');
|
|
|
|
$em = paypalEmMocks(null, null, static fn (): ?object => $earlier);
|
|
$di = container();
|
|
$di['em'] = $em;
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [
|
|
'post' => ['txn_type' => 'web_accept', 'payment_status' => 'Completed', 'txn_id' => 'TXN-X'],
|
|
'get' => ['invoice_id' => 99],
|
|
], 2);
|
|
})->throws(Payment_Exception::class, 'PayPal callback signature is invalid');
|
|
|
|
test('payment forms sign the callback URL for the paying invoice', function (): void {
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
$di['logger'] = new Tests\Helpers\TestLogger();
|
|
|
|
$adapter = new Payment_Adapter_PayPalEmail([
|
|
'email' => 'merchant@example.com',
|
|
'test_mode' => false,
|
|
'gateway_id' => 2,
|
|
'thankyou_url' => 'https://example.com/thank-you',
|
|
'cancel_url' => 'https://example.com/cancel',
|
|
'notify_url' => 'https://example.com/ipn.php?gateway_id=2&invoice_id=16',
|
|
]);
|
|
$adapter->setDi($di);
|
|
|
|
$invoice = [
|
|
'id' => 16,
|
|
'nr' => '00042',
|
|
'serie' => 'FB',
|
|
'currency' => 'USD',
|
|
'subtotal' => '100.00',
|
|
'tax' => '20.00',
|
|
'lines' => [['title' => 'Test product']],
|
|
];
|
|
|
|
$fields = $adapter->getOneTimePaymentFields($invoice);
|
|
parse_str((string) parse_url($fields['notify_url'], PHP_URL_QUERY), $query);
|
|
|
|
expect((int) ($query['invoice_id'] ?? 0))->toBe(16)
|
|
->and(FOSSBilling\Core\Security\Credential::verifyCallbackSignature(2, 16, $query['sig'] ?? null))->toBeTrue();
|
|
});
|
|
});
|
|
|
|
describe('processTransaction payload handling', function (): void {
|
|
test('rejects a missing post payload with a clear error', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
|
|
try {
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, [], 2);
|
|
expect(false)->toBeTrue('missing PayPal payloads must throw');
|
|
} catch (Payment_Exception $e) {
|
|
expect($e->getMessage())->toBe('PayPal payment data is missing.')
|
|
->and($e->getCode())->toBe(7021);
|
|
}
|
|
});
|
|
|
|
test('rejects a non-array post payload with a clear error', function (): void {
|
|
$apiAdmin = Mockery::mock();
|
|
$apiAdmin->shouldNotReceive('invoice_transaction_get');
|
|
|
|
$di = container();
|
|
$di['em'] = paypalEmMocks();
|
|
|
|
try {
|
|
paypalProcessAdapter($di)->processTransaction($apiAdmin, 42, ['post' => 'corrupt'], 2);
|
|
expect(false)->toBeTrue('corrupt PayPal payloads must throw');
|
|
} catch (Payment_Exception $e) {
|
|
expect($e->getMessage())->toBe('PayPal payment data is missing.')
|
|
->and($e->getCode())->toBe(7021);
|
|
}
|
|
});
|
|
});
|