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.
3270 lines
134 KiB
PHP
3270 lines
134 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use Box\Mod\Invoice\Entity\Invoice;
|
||
use Box\Mod\Invoice\Entity\PayGateway;
|
||
use Box\Mod\Invoice\Entity\PayGatewayCustomer;
|
||
use Box\Mod\Invoice\Entity\PayGatewayProduct;
|
||
use Box\Mod\Invoice\Entity\Transaction;
|
||
use Box\Mod\Invoice\Repository\PayGatewayCustomerRepository;
|
||
use Box\Mod\Invoice\Repository\PayGatewayProductRepository;
|
||
use Box\Mod\Invoice\Repository\TransactionRepository;
|
||
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||
use Doctrine\ORM\EntityManagerInterface;
|
||
use Stripe\StripeClient;
|
||
|
||
use function Tests\Helpers\container;
|
||
use function Tests\Helpers\createEntity;
|
||
use function Tests\Helpers\setEntityId;
|
||
|
||
const TEST_WEBHOOK_SECRET = 'whsec_test_dummy';
|
||
|
||
beforeEach(function (): void {
|
||
$this->adapter = new Payment_Adapter_Stripe([
|
||
'test_mode' => true,
|
||
'test_api_key' => 'sk_test_dummy',
|
||
'test_pub_key' => 'pk_test_dummy',
|
||
'test_webhook_secret' => TEST_WEBHOOK_SECRET,
|
||
'gateway_id' => 1,
|
||
]);
|
||
|
||
// cacheGatewayCustomer()/cacheGatewayProduct() open their own isolated
|
||
// EntityManager rather than using $di['em'] - default that to a no-op
|
||
// stub so tests don't hit a real database; tests that care about the
|
||
// cache write override this.
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', fn (): object => noOpEntityManagerMock());
|
||
});
|
||
|
||
function noOpEntityManagerMock(): Mockery\MockInterface
|
||
{
|
||
$em = Mockery::mock(EntityManagerInterface::class);
|
||
$em->shouldReceive('getReference')->byDefault()->andReturnUsing(
|
||
static fn (string $class, mixed $id): object => createEntity($class, ['id' => $id])
|
||
);
|
||
$em->shouldReceive('persist')->byDefault();
|
||
$em->shouldReceive('flush')->byDefault();
|
||
$em->shouldReceive('isOpen')->byDefault()->andReturn(true);
|
||
|
||
return $em;
|
||
}
|
||
|
||
/**
|
||
* A Doctrine\DBAL\Exception\UniqueConstraintViolationException wrapping a minimal fake driver
|
||
* exception - enough to construct without a real database error.
|
||
*/
|
||
function uniqueConstraintViolationException(): UniqueConstraintViolationException
|
||
{
|
||
$driverException = new class extends Exception implements Doctrine\DBAL\Driver\Exception {
|
||
public function getSQLState(): ?string
|
||
{
|
||
return '23000';
|
||
}
|
||
};
|
||
|
||
return new UniqueConstraintViolationException($driverException, null);
|
||
}
|
||
|
||
test('cancels a Stripe subscription', function (): void {
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('sub_123', [])
|
||
->andReturn((object) ['status' => 'active']);
|
||
$subscriptionsMock->shouldReceive('cancel')
|
||
->once()
|
||
->with('sub_123', []);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$this->adapter->cancelSubscription('sub_123');
|
||
});
|
||
|
||
test('does not cancel an already canceled Stripe subscription again', function (): void {
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('sub_123', [])
|
||
->andReturn((object) ['status' => Stripe\Subscription::STATUS_CANCELED]);
|
||
$subscriptionsMock->shouldReceive('cancel')->never();
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$this->adapter->cancelSubscription('sub_123');
|
||
});
|
||
|
||
test('schedules a Stripe subscription cancellation at period end', function (): void {
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('sub_123', [])
|
||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => false]);
|
||
$subscriptionsMock->shouldReceive('update')
|
||
->once()
|
||
->with('sub_123', ['cancel_at_period_end' => true]);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$this->adapter->cancelSubscriptionAtPeriodEnd('sub_123');
|
||
});
|
||
|
||
test('does not reschedule a Stripe subscription already ending at period end', function (): void {
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('sub_123', [])
|
||
->andReturn((object) ['status' => 'active', 'cancel_at_period_end' => true]);
|
||
$subscriptionsMock->shouldReceive('update')->never();
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$this->adapter->cancelSubscriptionAtPeriodEnd('sub_123');
|
||
});
|
||
|
||
function setPrivateProperty(object $obj, string $property, mixed $value): void
|
||
{
|
||
$reflection = new ReflectionClass($obj);
|
||
$prop = $reflection->getProperty($property);
|
||
$prop->setValue($obj, $value);
|
||
}
|
||
|
||
function expectStripeObjectLock(Mockery\MockInterface $dbalMock, string $objectId, int $gatewayId): void
|
||
{
|
||
$lockName = 'fb:stripe:' . substr(hash('sha256', $gatewayId . ':' . $objectId), 0, 54);
|
||
$dbalMock->shouldReceive('getDatabasePlatform')
|
||
->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class));
|
||
$dbalMock->shouldReceive('fetchOne')
|
||
->once()
|
||
->with('SELECT GET_LOCK(:name, :timeout)', ['name' => $lockName, 'timeout' => 10])
|
||
->andReturn(1);
|
||
$dbalMock->shouldReceive('executeStatement')
|
||
->once()
|
||
->with('SELECT RELEASE_LOCK(:name)', ['name' => $lockName])
|
||
->andReturn(1);
|
||
}
|
||
|
||
function signStripeWebhookPayload(string $payload, string $secret = TEST_WEBHOOK_SECRET): string
|
||
{
|
||
$timestamp = time();
|
||
$signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
|
||
|
||
return "t={$timestamp},v1={$signature}";
|
||
}
|
||
|
||
function invokePrivateMethod(object $obj, string $method, array $args = []): mixed
|
||
{
|
||
$reflection = new ReflectionClass($obj);
|
||
$methodObj = $reflection->getMethod($method);
|
||
|
||
return $methodObj->invokeArgs($obj, $args);
|
||
}
|
||
|
||
function buildTransaction(): Transaction
|
||
{
|
||
return createEntity(Transaction::class);
|
||
}
|
||
|
||
/**
|
||
* @return array{em: Mockery\MockInterface, txRepo: Mockery\MockInterface, subRepo: Mockery\MockInterface, invoiceRepo: Mockery\MockInterface}
|
||
*/
|
||
function buildEntityManagerMocks(): array
|
||
{
|
||
$txRepo = Mockery::mock(TransactionRepository::class);
|
||
$subRepo = Mockery::mock(Box\Mod\Invoice\Repository\SubscriptionRepository::class);
|
||
$invoiceRepo = Mockery::mock(Box\Mod\Invoice\Repository\InvoiceRepository::class);
|
||
|
||
$txRepo->shouldReceive('find')->byDefault()->andReturn(null);
|
||
$txRepo->shouldReceive('findOneBy')->byDefault()->andReturn(null);
|
||
$txRepo->shouldReceive('findActiveByTxnIdAndGatewayId')->byDefault()->andReturn(null);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')->byDefault()->andReturn(null);
|
||
|
||
$subRepo->shouldReceive('find')->byDefault()->andReturn(null);
|
||
$subRepo->shouldReceive('findOneBy')->byDefault()->andReturn(null);
|
||
$subRepo->shouldReceive('findOneBySid')->byDefault()->andReturn(null);
|
||
|
||
$invoiceRepo->shouldReceive('find')->byDefault()->andReturn(null);
|
||
$invoiceRepo->shouldReceive('findByHash')->byDefault()->andReturn(null);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
|
||
$connection->shouldReceive('fetchOne')->byDefault()->andReturn(null);
|
||
$connection->shouldReceive('executeStatement')->byDefault()->andReturn(1);
|
||
|
||
$em = Mockery::mock(EntityManagerInterface::class);
|
||
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
|
||
Transaction::class => $txRepo,
|
||
Box\Mod\Invoice\Entity\Subscription::class => $subRepo,
|
||
Invoice::class => $invoiceRepo,
|
||
default => Mockery::mock(Doctrine\ORM\EntityRepository::class)->shouldIgnoreMissing(),
|
||
});
|
||
$em->shouldReceive('getConnection')->byDefault()->andReturn($connection);
|
||
$em->shouldReceive('flush')->byDefault();
|
||
$em->shouldReceive('persist')->byDefault();
|
||
$em->shouldReceive('remove')->byDefault();
|
||
|
||
return ['em' => $em, 'txRepo' => $txRepo, 'subRepo' => $subRepo, 'invoiceRepo' => $invoiceRepo, 'connection' => $connection];
|
||
}
|
||
|
||
describe('getStripeRecurringParams', function (): void {
|
||
test('carries the period quantity through as interval_count', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'getStripeRecurringParams', ['3M']);
|
||
|
||
expect($result)->toBe(['interval' => 'month', 'interval_count' => 3]);
|
||
});
|
||
|
||
test('maps a yearly period to interval_count 1', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'getStripeRecurringParams', ['1Y']);
|
||
|
||
expect($result)->toBe(['interval' => 'year', 'interval_count' => 1]);
|
||
});
|
||
|
||
test('maps a biennial period to a 2-year interval_count', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'getStripeRecurringParams', ['2Y']);
|
||
|
||
expect($result)->toBe(['interval' => 'year', 'interval_count' => 2]);
|
||
});
|
||
|
||
test('maps a custom day-based period', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'getStripeRecurringParams', ['45D']);
|
||
|
||
expect($result)->toBe(['interval' => 'day', 'interval_count' => 45]);
|
||
});
|
||
|
||
test('rejects a period whose interval_count exceeds Stripe\'s 3-year cap', function (): void {
|
||
// Billing periods allow up to 5Y, but Stripe's own recurring interval_count cap for
|
||
// "year" is 3, so this must fail loudly instead of silently mis-billing.
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'getStripeRecurringParams', ['5Y']))
|
||
->toThrow(Payment_Exception::class);
|
||
});
|
||
});
|
||
|
||
describe('isStripeWebhook', function (): void {
|
||
test('identifies customer.subscription.created webhook', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'customer.subscription.created'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies invoice.paid webhook', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'invoice.paid'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies invoice.payment_succeeded webhook', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'invoice.payment_succeeded'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies invoice_payment.paid webhook (API 2026-06-24+)', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'invoice_payment.paid'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies invoice_payment.failed webhook (API 2026-06-24+)', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'invoice_payment.failed'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies all Stripe webhook events for dispatch', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'payment_intent.created'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
// All JSON payloads with a type field are recognized as webhooks.
|
||
// Unhandled types are deleted in processWebhookEvent to prevent noise.
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies payment_intent.succeeded webhook', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'payment_intent.succeeded'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('identifies setup_intent.succeeded webhook', function (): void {
|
||
$data = ['http_raw_post_data' => json_encode(['type' => 'setup_intent.succeeded'])];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [$data]);
|
||
|
||
expect($result)->toBeTrue();
|
||
});
|
||
|
||
test('returns false for empty raw post data', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'isStripeWebhook', [['http_raw_post_data' => null]]);
|
||
|
||
expect($result)->toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('handleSubscriptionCreated', function (): void {
|
||
test('creates subscription via createOrUpdateSubscription helper', function (): void {
|
||
$tx = buildTransaction();
|
||
$gatewayId = 1;
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_123';
|
||
$stripeSubscription->currency = 'usd';
|
||
$stripeSubscription->metadata = (object) [
|
||
'invoice_id' => '5',
|
||
'client_id' => '10',
|
||
];
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeSubscription];
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
|
||
$invoiceModel->id = 5;
|
||
$invoiceModel->client_id = 10;
|
||
$invoiceModel->currency = 'USD';
|
||
|
||
$capturedSubscriptionData = null;
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_create')
|
||
->once()
|
||
->withArgs(function ($data) use (&$capturedSubscriptionData): bool {
|
||
$capturedSubscriptionData = $data;
|
||
|
||
return true;
|
||
})
|
||
->andReturn(1);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->andReturn(10.00);
|
||
|
||
$subscriptionService = Mockery::mock();
|
||
$subscriptionService->shouldReceive('getSubscriptionPeriod')->andReturn('1M');
|
||
|
||
['em' => $em, 'subRepo' => $subRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$em->shouldReceive('getConnection')->andReturn($connection);
|
||
$invoiceRepo->shouldReceive('find')->with(5)->andReturn($invoiceModel);
|
||
$subRepo->shouldReceive('findOneBy')
|
||
->once()
|
||
->with(['sid' => 'sub_123'])
|
||
->andReturn(null);
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(function ($name, $sub = '') use ($invoiceService, $subscriptionService) {
|
||
if ($name === 'Invoice' && $sub === 'Subscription') {
|
||
return $subscriptionService;
|
||
}
|
||
if ($name === 'Invoice') {
|
||
return $invoiceService;
|
||
}
|
||
|
||
return Mockery::mock();
|
||
});
|
||
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleSubscriptionCreated', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
$gatewayId,
|
||
]);
|
||
|
||
expect($result)->toBeFalse()
|
||
->and($capturedSubscriptionData)->not->toBeNull()
|
||
->and($capturedSubscriptionData['currency'])->toBe('USD')
|
||
->and($capturedSubscriptionData['sid'])->toBe('sub_123')
|
||
->and($tx->getInvoice()?->getId())->toBe(5);
|
||
});
|
||
|
||
test('returns false when metadata is missing', function (): void {
|
||
$tx = buildTransaction();
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_no_meta';
|
||
$stripeSubscription->metadata = new stdClass();
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeSubscription];
|
||
|
||
$di = container();
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleSubscriptionCreated', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
expect($result)->toBeFalse();
|
||
});
|
||
|
||
test('skips creation when metadata is missing', function (): void {
|
||
$tx = buildTransaction();
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_789';
|
||
$stripeSubscription->metadata = (object) [];
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeSubscription];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
||
|
||
$this->adapter->setDi(container());
|
||
|
||
invokePrivateMethod($this->adapter, 'handleSubscriptionCreated', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
// Asserting no exception and no API call is sufficient
|
||
expect(true)->toBeTrue();
|
||
});
|
||
});
|
||
|
||
test('syncs subscription webhook status through the internal service path', function (): void {
|
||
$stripeSubscription = (object) [
|
||
'id' => 'sub_123',
|
||
'status' => 'canceled',
|
||
];
|
||
$event = (object) ['data' => (object) ['object' => $stripeSubscription]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_get')
|
||
->once()
|
||
->with(['sid' => 'sub_123'])
|
||
->andReturn(['id' => 42]);
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_update');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('updateStatusFromGateway')
|
||
->once()
|
||
->with(42, 'canceled');
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleSubscriptionUpdated', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toBeFalse();
|
||
});
|
||
|
||
test('syncs end-of-period cancellation state from Stripe', function (): void {
|
||
$event = (object) ['data' => (object) ['object' => (object) [
|
||
'id' => 'sub_123',
|
||
'status' => 'active',
|
||
'cancel_at_period_end' => true,
|
||
]]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_get')->once()->andReturn(['id' => 42]);
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('updateStatusFromGateway')
|
||
->once()
|
||
->with(42, Box\Mod\Invoice\ServiceSubscription::STATUS_PENDING_CANCELLATION);
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleSubscriptionUpdated', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toBeFalse();
|
||
});
|
||
|
||
test('finalizes local cancellation when Stripe deletes a subscription', function (): void {
|
||
$event = (object) ['data' => (object) ['object' => (object) ['id' => 'sub_123']]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('findIdBySid')->once()->with('sub_123')->andReturn(42);
|
||
$subscriptionService->shouldReceive('finalizeCancellationFromGateway')->once()->with(42);
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleSubscriptionDeleted', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toBeFalse();
|
||
});
|
||
|
||
test('propagates local cancellation failures so Stripe retries the webhook', function (): void {
|
||
$event = (object) ['data' => (object) ['object' => (object) ['id' => 'sub_123']]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('findIdBySid')->once()->with('sub_123')->andReturn(42);
|
||
$subscriptionService->shouldReceive('finalizeCancellationFromGateway')
|
||
->once()
|
||
->with(42)
|
||
->andThrow(new RuntimeException('Service cancellation failed'));
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'handleSubscriptionDeleted', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toThrow(RuntimeException::class, 'Service cancellation failed');
|
||
});
|
||
|
||
test('propagates subscription lookup failures so Stripe retries the webhook', function (): void {
|
||
$event = (object) ['data' => (object) ['object' => (object) ['id' => 'sub_123']]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('findIdBySid')
|
||
->once()
|
||
->with('sub_123')
|
||
->andThrow(new RuntimeException('Database unavailable'));
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'handleSubscriptionDeleted', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toThrow(RuntimeException::class, 'Database unavailable');
|
||
});
|
||
|
||
test('ignores deleted Stripe subscriptions without a local record', function (): void {
|
||
$event = (object) ['data' => (object) ['object' => (object) ['id' => 'sub_missing']]];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('findIdBySid')->once()->with('sub_missing')->andReturn(null);
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleSubscriptionDeleted', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toBeFalse();
|
||
});
|
||
|
||
test('skips subscription update webhooks without a local subscription', function (): void {
|
||
$event = (object) [
|
||
'data' => (object) [
|
||
'object' => (object) [
|
||
'id' => 'sub_missing',
|
||
'status' => 'active',
|
||
],
|
||
],
|
||
];
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_get')
|
||
->once()
|
||
->with(['sid' => 'sub_missing'])
|
||
->andThrow(new Exception('Subscription not found'));
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleSubscriptionUpdated', [
|
||
$apiAdmin,
|
||
buildTransaction(),
|
||
$event,
|
||
]))->toBeFalse();
|
||
});
|
||
|
||
describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||
test('links transaction to invoice and processes without re-claiming the row', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 42;
|
||
$tx->invoice_id = null;
|
||
|
||
$stripeInvoice = new stdClass();
|
||
$stripeInvoice->id = 'in_123';
|
||
$stripeInvoice->subscription = 'sub_abc';
|
||
$stripeInvoice->billing_reason = 'subscription_create';
|
||
$stripeInvoice->amount_paid = 1500;
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeInvoice];
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_abc';
|
||
$stripeSubscription->metadata = (object) [
|
||
'invoice_id' => '99',
|
||
'client_id' => '5',
|
||
];
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->with('sub_abc', [])
|
||
->andReturn($stripeSubscription);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
// The service layer holds the processing claim on entry: the adapter
|
||
// must never re-claim, or the claim fails and the payment is skipped.
|
||
$transactionService = Mockery::mock();
|
||
$transactionService->shouldNotReceive('claimForProcessing');
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('in_123', null, 42)
|
||
->andReturn(null);
|
||
$invoiceRepo->shouldReceive('find')->atLeast()->once()->with(99)->andReturn(createEntity(Invoice::class, ['id' => 99]));
|
||
// flush() happens when the invoice link is persisted.
|
||
$em->shouldReceive('flush')->atLeast()->once();
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_123', 1);
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->andReturn(false);
|
||
$invoiceService->shouldReceive('issueInvoice')->andReturn(true);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')->andReturn(true);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(function ($module, $service = null) use ($transactionService, $invoiceService) {
|
||
if ($service === 'Transaction') {
|
||
return $transactionService;
|
||
}
|
||
if ($module === 'Invoice') {
|
||
return $invoiceService;
|
||
}
|
||
|
||
return Mockery::mock();
|
||
});
|
||
|
||
$this->adapter->setDi($di);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
||
|
||
invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
// Processing proceeds: the invoice link is persisted and funds move.
|
||
expect($tx->getInvoice()?->getId())->toBe(99);
|
||
});
|
||
|
||
test('falls back to treating unpaid original invoice as initial payment', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 50;
|
||
|
||
$stripeInvoice = new stdClass();
|
||
$stripeInvoice->id = 'in_456';
|
||
$stripeInvoice->subscription = 'sub_def';
|
||
// billing_reason is NOT subscription_create - tests the fallback
|
||
$stripeInvoice->billing_reason = 'cycle';
|
||
$stripeInvoice->amount_paid = 2500;
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeInvoice];
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_def';
|
||
$stripeSubscription->metadata = (object) [
|
||
'invoice_id' => '77',
|
||
'client_id' => '8',
|
||
];
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->andReturn($stripeSubscription);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$originalInvoice = createEntity(Invoice::class);
|
||
|
||
$originalInvoice->id = 77;
|
||
$originalInvoice->status = Invoice::STATUS_UNPAID;
|
||
$originalInvoice->currency = 'USD';
|
||
|
||
// Distinguished from $originalInvoice by its currency, so the
|
||
// identity-based matchers below prove the adapter uses this second
|
||
// fixture (from the $isInitialPayment lookup) rather than a stale
|
||
// reference to the first lookup result.
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
|
||
$invoiceModel->id = 77;
|
||
$invoiceModel->status = Invoice::STATUS_UNPAID;
|
||
$invoiceModel->issued = 0;
|
||
$invoiceModel->currency = 'EUR';
|
||
|
||
$transactionService = Mockery::mock();
|
||
$transactionService->shouldNotReceive('claimForProcessing');
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')
|
||
->with($invoiceModel)
|
||
->andReturn(false);
|
||
$invoiceService->shouldReceive('issueInvoice')
|
||
->with($invoiceModel, ['use_credits' => false])
|
||
->andReturn(true);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')
|
||
->with($invoiceModel)
|
||
->andReturn(true);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')
|
||
->with(77)
|
||
->andReturn($originalInvoice, $invoiceModel);
|
||
// Duplicate-event check — no prior transaction processed this Stripe invoice.
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('in_456', null, 50)
|
||
->andReturn(null);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_456', 1);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => match ($service) {
|
||
'Transaction' => $transactionService,
|
||
default => $invoiceService,
|
||
});
|
||
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
// The unpaid original invoice should be issued and paid via the fallback
|
||
expect($tx->getInvoice()?->getId())->toBe(77);
|
||
});
|
||
});
|
||
|
||
describe('handleInvoicePaymentSucceeded renewal after the first invoice was paid', function (): void {
|
||
test('credits a renewal payment and settles the renewal invoice', function (): void {
|
||
// A subscription's metadata always points at the first FOSSBilling invoice, which is paid by the time the
|
||
// first renewal is charged. The renewal payment must still be credited and applied to the renewal invoice.
|
||
$tx = buildTransaction();
|
||
$tx->id = 60;
|
||
|
||
$stripeInvoice = new stdClass();
|
||
$stripeInvoice->id = 'in_renewal_1';
|
||
$stripeInvoice->subscription = 'sub_renew';
|
||
$stripeInvoice->billing_reason = 'subscription_cycle';
|
||
$stripeInvoice->amount_paid = 2500;
|
||
$stripeInvoice->currency = 'usd';
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeInvoice];
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_renew';
|
||
$stripeSubscription->metadata = (object) ['invoice_id' => '77', 'client_id' => '8'];
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')->andReturn($stripeSubscription);
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$originalInvoice = createEntity(Invoice::class);
|
||
$originalInvoice->id = 77;
|
||
$originalInvoice->status = Invoice::STATUS_PAID; // paid at signup
|
||
$originalInvoice->currency = 'USD';
|
||
|
||
$renewalInvoice = createEntity(Invoice::class);
|
||
$renewalInvoice->id = 88;
|
||
$renewalInvoice->status = Invoice::STATUS_UNPAID;
|
||
$renewalInvoice->currency = 'USD';
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('generateRenewalInvoiceForSubscriptionPayment')
|
||
->with('sub_renew', 8)
|
||
->andReturn($renewalInvoice);
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->andReturn(false);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')->andReturn(true);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')->with(77)->andReturn($originalInvoice);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->with('in_renewal_1', null, 60)
|
||
->andReturn(null);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_renewal_1', 1);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$processed = invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [$apiAdmin, $tx, $event, 1]);
|
||
|
||
expect($processed)->toBeTrue();
|
||
expect($tx->getInvoice()?->getId())->toBe(88);
|
||
});
|
||
|
||
test('skips a duplicate initial payment when the original invoice is already paid', function (): void {
|
||
// The redirect flow pays the first invoice immediately, so when the
|
||
// invoice.paid webhook for the initial payment arrives the original
|
||
// invoice is already paid. It must not be credited a second time and
|
||
// must not mint a renewal invoice.
|
||
$tx = buildTransaction();
|
||
$tx->id = 61;
|
||
|
||
$stripeInvoice = new stdClass();
|
||
$stripeInvoice->id = 'in_init_dup';
|
||
$stripeInvoice->subscription = 'sub_dup';
|
||
$stripeInvoice->billing_reason = 'subscription_create';
|
||
$stripeInvoice->amount_paid = 1500;
|
||
$stripeInvoice->currency = 'usd';
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeInvoice];
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_dup';
|
||
$stripeSubscription->metadata = (object) ['invoice_id' => '99', 'client_id' => '5'];
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')->andReturn($stripeSubscription);
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$originalInvoice = createEntity(Invoice::class);
|
||
$originalInvoice->id = 99;
|
||
$originalInvoice->status = Invoice::STATUS_PAID;
|
||
$originalInvoice->currency = 'USD';
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldNotReceive('generateRenewalInvoiceForSubscriptionPayment', 'payInvoiceWithCredits');
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')->with(99)->andReturn($originalInvoice);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->with('in_init_dup', null, 61)
|
||
->andReturn(null);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_init_dup', 1);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$processed = invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [$apiAdmin, $tx, $event, 1]);
|
||
|
||
expect($processed)->toBeFalse();
|
||
});
|
||
|
||
test('skips an inconclusive billing_reason when the original invoice is already paid', function (): void {
|
||
// A missing billing_reason with a paid original is ambiguous: it could
|
||
// be a duplicate initial webhook or a renewal. Skipping avoids
|
||
// double-crediting the initial payment and minting a spurious renewal.
|
||
$tx = buildTransaction();
|
||
$tx->id = 62;
|
||
|
||
$stripeInvoice = new stdClass();
|
||
$stripeInvoice->id = 'in_missing_reason';
|
||
$stripeInvoice->subscription = 'sub_missing_reason';
|
||
$stripeInvoice->amount_paid = 1500;
|
||
$stripeInvoice->currency = 'usd';
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $stripeInvoice];
|
||
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_missing_reason';
|
||
$stripeSubscription->metadata = (object) ['invoice_id' => '99', 'client_id' => '5'];
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')->andReturn($stripeSubscription);
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$originalInvoice = createEntity(Invoice::class);
|
||
$originalInvoice->id = 99;
|
||
$originalInvoice->status = Invoice::STATUS_PAID;
|
||
$originalInvoice->currency = 'USD';
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldNotReceive('generateRenewalInvoiceForSubscriptionPayment', 'payInvoiceWithCredits');
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')->with(99)->andReturn($originalInvoice);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->with('in_missing_reason', null, 62)
|
||
->andReturn(null);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_missing_reason', 1);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$processed = invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [$apiAdmin, $tx, $event, 1]);
|
||
|
||
expect($processed)->toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('resolveStripeInvoice', function (): void {
|
||
test('passes through legacy invoice objects unchanged', function (): void {
|
||
$invoice = new stdClass();
|
||
$invoice->object = 'invoice';
|
||
$invoice->id = 'in_123';
|
||
$invoice->subscription = 'sub_abc';
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'resolveStripeInvoice', [$invoice]);
|
||
|
||
expect($result)->toBe($invoice);
|
||
});
|
||
|
||
test('retrieves full invoice for invoice_payment objects', function (): void {
|
||
$paymentObject = new stdClass();
|
||
$paymentObject->object = 'invoice_payment';
|
||
$paymentObject->id = 'inpay_123';
|
||
$paymentObject->invoice = 'in_456';
|
||
|
||
$fullInvoice = new stdClass();
|
||
$fullInvoice->object = 'invoice';
|
||
$fullInvoice->id = 'in_456';
|
||
$fullInvoice->subscription = 'sub_def';
|
||
|
||
$invoicesMock = Mockery::mock();
|
||
$invoicesMock->shouldReceive('retrieve')
|
||
->with('in_456', [])
|
||
->andReturn($fullInvoice);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->invoices = $invoicesMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'resolveStripeInvoice', [$paymentObject]);
|
||
|
||
expect($result)->toBe($fullInvoice)
|
||
->and($result->subscription)->toBe('sub_def');
|
||
});
|
||
|
||
test('returns null when invoice_payment has no invoice reference', function (): void {
|
||
$paymentObject = new stdClass();
|
||
$paymentObject->object = 'invoice_payment';
|
||
$paymentObject->id = 'inpay_789';
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'resolveStripeInvoice', [$paymentObject]);
|
||
|
||
expect($result)->toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('extractSubscriptionId', function (): void {
|
||
test('reads top-level subscription from legacy API invoices', function (): void {
|
||
$invoice = new stdClass();
|
||
$invoice->subscription = 'sub_legacy_123';
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'extractSubscriptionId', [$invoice]);
|
||
|
||
expect($result)->toBe('sub_legacy_123');
|
||
});
|
||
|
||
test('reads nested subscription from new API (2026-06-24+) invoices', function (): void {
|
||
$invoice = new stdClass();
|
||
$invoice->parent = (object) [
|
||
'type' => 'subscription_details',
|
||
'subscription_details' => (object) [
|
||
'subscription' => 'sub_new_456',
|
||
'metadata' => ['client_id' => '1', 'invoice_id' => '95'],
|
||
],
|
||
];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'extractSubscriptionId', [$invoice]);
|
||
|
||
expect($result)->toBe('sub_new_456');
|
||
});
|
||
|
||
test('reads subscription from line items as fallback', function (): void {
|
||
$invoice = new stdClass();
|
||
$invoice->lines = (object) [
|
||
'data' => [
|
||
(object) [
|
||
'parent' => (object) [
|
||
'type' => 'subscription_item_details',
|
||
'subscription_item_details' => (object) [
|
||
'subscription' => 'sub_line_789',
|
||
],
|
||
],
|
||
],
|
||
],
|
||
];
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'extractSubscriptionId', [$invoice]);
|
||
|
||
expect($result)->toBe('sub_line_789');
|
||
});
|
||
|
||
test('returns null when no subscription reference exists', function (): void {
|
||
$invoice = new stdClass();
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'extractSubscriptionId', [$invoice]);
|
||
|
||
expect($result)->toBeNull();
|
||
});
|
||
|
||
test('returns null for null input', function (): void {
|
||
$result = invokePrivateMethod($this->adapter, 'extractSubscriptionId', [null]);
|
||
|
||
expect($result)->toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('handleInvoicePaymentSucceeded with invoice_payment event (API 2026-06-24+)', function (): void {
|
||
test('processes invoice_payment.paid by retrieving full invoice and subscription', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 101;
|
||
|
||
// This mirrors the actual webhook payload from API 2026-06-24
|
||
$invoicePayment = new stdClass();
|
||
$invoicePayment->object = 'invoice_payment';
|
||
$invoicePayment->id = 'inpay_1TnBdD';
|
||
$invoicePayment->invoice = 'in_1TnBdC';
|
||
$invoicePayment->amount_paid = 7194;
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $invoicePayment];
|
||
|
||
// Full invoice returned by invoices->retrieve (API 2026-06-24 format)
|
||
$fullInvoice = new stdClass();
|
||
$fullInvoice->object = 'invoice';
|
||
$fullInvoice->id = 'in_1TnBdC';
|
||
// New API: subscription is nested under parent.subscription_details
|
||
$fullInvoice->parent = (object) [
|
||
'type' => 'subscription_details',
|
||
'subscription_details' => (object) [
|
||
'subscription' => 'sub_abc',
|
||
'metadata' => ['client_id' => '5', 'invoice_id' => '42'],
|
||
],
|
||
];
|
||
$fullInvoice->billing_reason = 'subscription_create';
|
||
$fullInvoice->amount_paid = 7194;
|
||
|
||
// Subscription returned by subscriptions->retrieve
|
||
$stripeSubscription = new stdClass();
|
||
$stripeSubscription->id = 'sub_abc';
|
||
$stripeSubscription->metadata = (object) [
|
||
'invoice_id' => '42',
|
||
'client_id' => '7',
|
||
];
|
||
|
||
$invoicesMock = Mockery::mock();
|
||
$invoicesMock->shouldReceive('retrieve')
|
||
->with('in_1TnBdC', [])
|
||
->andReturn($fullInvoice);
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('retrieve')
|
||
->with('sub_abc', [])
|
||
->andReturn($stripeSubscription);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->invoices = $invoicesMock;
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
|
||
$invoiceModel->id = 42;
|
||
$invoiceModel->status = Invoice::STATUS_UNPAID;
|
||
$invoiceModel->issued = 0;
|
||
|
||
$transactionService = Mockery::mock();
|
||
$transactionService->shouldNotReceive('claimForProcessing');
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')
|
||
->andReturn(false);
|
||
$invoiceService->shouldReceive('issueInvoice')
|
||
->andReturn(true);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')
|
||
->andReturn(true);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')->with(42)->andReturn($invoiceModel);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('in_1TnBdC', null, 101)
|
||
->andReturn(null);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'in_1TnBdC', 4);
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => match ($service) {
|
||
'Transaction' => $transactionService,
|
||
default => $invoiceService,
|
||
});
|
||
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
4,
|
||
]);
|
||
|
||
expect($tx->getInvoice()?->getId())->toBe(42);
|
||
});
|
||
});
|
||
|
||
describe('handlePaymentIntentSucceededWebhook', function (): void {
|
||
test('skips processing when payment already handled via redirect flow', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 200;
|
||
|
||
$paymentIntent = new stdClass();
|
||
$paymentIntent->id = 'pi_existing';
|
||
$paymentIntent->object = 'payment_intent';
|
||
$paymentIntent->status = 'succeeded';
|
||
$paymentIntent->amount = 1500;
|
||
$paymentIntent->currency = 'usd';
|
||
$paymentIntent->metadata = (object) ['invoice_id' => '10', 'client_id' => '3'];
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $paymentIntent];
|
||
|
||
// Simulate an already-processed transaction from the redirect flow
|
||
$existingTx = buildTransaction();
|
||
$existingTx->id = 199;
|
||
$existingTx->status = Transaction::STATUS_PROCESSED;
|
||
$existingTx->invoice = createEntity(Invoice::class, ['id' => 10]);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'pi_existing', 1);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('pi_existing', 1, 200)
|
||
->andReturn($existingTx);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'handlePaymentIntentSucceededWebhook', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
expect($tx->getInvoice()?->getId())->toBe(10)
|
||
->and($tx->txn_id)->toBe('pi_existing');
|
||
});
|
||
|
||
test('processes one-time payment when not already handled', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 201;
|
||
|
||
$paymentIntent = new stdClass();
|
||
$paymentIntent->id = 'pi_new';
|
||
$paymentIntent->object = 'payment_intent';
|
||
$paymentIntent->status = 'succeeded';
|
||
$paymentIntent->amount = 2999;
|
||
$paymentIntent->currency = 'usd';
|
||
$paymentIntent->metadata = (object) ['invoice_id' => '15', 'client_id' => '7'];
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $paymentIntent];
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
|
||
$invoiceModel->id = 15;
|
||
$invoiceModel->issued = 1;
|
||
$invoiceModel->client_id = 7;
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'pi_new', 1);
|
||
$clientModel = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 7]);
|
||
|
||
$transactionService = Mockery::mock();
|
||
$transactionService->shouldNotReceive('claimForProcessing');
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->andReturn(29.99);
|
||
$invoiceService->shouldReceive('validatePaymentAmount')
|
||
->once()
|
||
->with(Mockery::on(fn (mixed $amount): bool => is_float($amount) && $amount === 29.99), 29.99)
|
||
->andReturn(null);
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->andReturn(false);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')->andReturn(true);
|
||
|
||
$clientService = Mockery::mock();
|
||
$clientService->shouldReceive('addFunds')->once();
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$clientRepo = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
|
||
$clientRepo->shouldReceive('find')->with(7)->andReturn($clientModel);
|
||
$em->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\Client::class)->andReturn($clientRepo);
|
||
$invoiceRepo->shouldReceive('find')
|
||
->with(15)
|
||
->andReturn($invoiceModel, null);
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('pi_new', 1, 201)
|
||
->andReturn(null);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => match (true) {
|
||
$service === 'Transaction' => $transactionService,
|
||
$module === 'client' => $clientService,
|
||
default => $invoiceService,
|
||
});
|
||
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'handlePaymentIntentSucceededWebhook', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
expect($tx->getInvoice()?->getId())->toBe(15)
|
||
->and($tx->status)->toBe(Transaction::STATUS_PROCESSED);
|
||
});
|
||
});
|
||
|
||
describe('processPaymentIntent', function (): void {
|
||
test('validates a redirect payment amount as a float', function (): void {
|
||
$invoice = createEntity(Invoice::class, [
|
||
'id' => 15,
|
||
'client_id' => 7,
|
||
'issued' => true,
|
||
]);
|
||
|
||
$tx = buildTransaction();
|
||
$tx->id = 402;
|
||
$tx->gateway = createEntity(PayGateway::class, ['id' => 4]);
|
||
$tx->invoice = $invoice;
|
||
|
||
$client = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 7]);
|
||
$charge = (object) [
|
||
'id' => 'pi_redirect',
|
||
'status' => 'succeeded',
|
||
'amount' => 2999,
|
||
'currency' => 'usd',
|
||
];
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->with($invoice)->andReturn(29.99);
|
||
$invoiceService->shouldReceive('validatePaymentAmount')
|
||
->once()
|
||
->with(Mockery::on(fn (mixed $amount): bool => is_float($amount) && $amount === 29.99), 29.99)
|
||
->andReturn(null);
|
||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')->once()->with($invoice)->andReturn(false);
|
||
$invoiceService->shouldReceive('payInvoiceWithCredits')->once()->with($invoice)->andReturn(true);
|
||
|
||
$transactionService = Mockery::mock();
|
||
$transactionService->shouldNotReceive('claimForProcessing');
|
||
|
||
$clientService = Mockery::mock();
|
||
$clientService->shouldReceive('addFunds')->once();
|
||
|
||
['em' => $em, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$invoiceRepo->shouldReceive('find')->with(15)->andReturn($invoice);
|
||
$clientRepo = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
|
||
$clientRepo->shouldReceive('find')->once()->with(7)->andReturn($client);
|
||
$em->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\Client::class)->andReturn($clientRepo);
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => match (true) {
|
||
$service === 'Transaction' => $transactionService,
|
||
$module === 'client' => $clientService,
|
||
default => $invoiceService,
|
||
});
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'processPaymentIntentUnderLock', [$tx, $invoice, $charge]);
|
||
|
||
expect($tx->status)->toBe(Transaction::STATUS_PROCESSED);
|
||
});
|
||
|
||
test('deletes the redirect transaction when the webhook already recorded the PaymentIntent', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 401;
|
||
$tx->gateway = createEntity(PayGateway::class, ['id' => 4]);
|
||
|
||
$existingTx = buildTransaction();
|
||
|
||
$paymentIntent = Stripe\PaymentIntent::constructFrom([
|
||
'id' => 'pi_webhook_first',
|
||
'status' => 'succeeded',
|
||
'amount' => 2500,
|
||
'currency' => 'usd',
|
||
'metadata' => ['gateway_id' => '4'],
|
||
]);
|
||
|
||
$paymentIntentsMock = Mockery::mock();
|
||
$paymentIntentsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('pi_webhook_first', [])
|
||
->andReturn($paymentIntent);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->paymentIntents = $paymentIntentsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'pi_webhook_first', 4);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findActiveByTxnIdAndGatewayId')
|
||
->once()
|
||
->with('pi_webhook_first', 4, 401)
|
||
->andReturn($existingTx);
|
||
$em->shouldReceive('remove')->once()->with($tx);
|
||
$em->shouldReceive('flush')->once();
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'processPaymentIntent', [
|
||
$tx,
|
||
null,
|
||
['get' => ['payment_intent' => 'pi_webhook_first']],
|
||
]);
|
||
|
||
expect($tx->txn_id)->toBe('pi_webhook_first');
|
||
});
|
||
|
||
test('rejects redirect PaymentIntents that are not bound to the invoice', function (array $paymentIntent): void {
|
||
$tx = buildTransaction();
|
||
$tx->setGateway(createEntity(PayGateway::class, ['id' => 4]));
|
||
|
||
$invoice = createEntity(Invoice::class, [
|
||
'id' => 15,
|
||
'currency' => 'USD',
|
||
]);
|
||
|
||
$paymentIntentsMock = Mockery::mock();
|
||
$paymentIntentsMock->shouldReceive('retrieve')
|
||
->once()
|
||
->with('pi_unbound', [])
|
||
->andReturn(Stripe\PaymentIntent::constructFrom($paymentIntent));
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->paymentIntents = $paymentIntentsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'processPaymentIntent', [
|
||
$tx,
|
||
$invoice,
|
||
['get' => ['payment_intent' => 'pi_unbound']],
|
||
]))->toThrow(FOSSBilling\Core\Exception\BaseException::class);
|
||
})->with([
|
||
'wrong invoice' => [[
|
||
'id' => 'pi_unbound',
|
||
'currency' => 'usd',
|
||
'metadata' => ['invoice_id' => '99', 'gateway_id' => '4'],
|
||
]],
|
||
'wrong gateway' => [[
|
||
'id' => 'pi_unbound',
|
||
'currency' => 'usd',
|
||
'metadata' => ['invoice_id' => '15', 'gateway_id' => '8'],
|
||
]],
|
||
'wrong currency' => [[
|
||
'id' => 'pi_unbound',
|
||
'currency' => 'jpy',
|
||
'metadata' => ['invoice_id' => '15', 'gateway_id' => '4'],
|
||
]],
|
||
]);
|
||
});
|
||
|
||
describe('processSetupIntent', function (): void {
|
||
test('rejects a setup intent whose metadata invoice_id does not match the resolved invoice', function (): void {
|
||
// Regression coverage: unlike the webhook flow (which derives the invoice from the setup
|
||
// intent's own metadata), the redirect flow resolves $invoice and $setupIntent
|
||
// independently, from separate query parameters on the redirect URL (see
|
||
// resolveInvoice()). Without this check, a request naming a victim's invoice_id alongside
|
||
// the requester's own completed setup intent would subscribe/charge the requester but
|
||
// credit and mark paid whatever invoice_id was supplied.
|
||
$victimInvoice = createEntity(Invoice::class, ['id' => 90, 'client_id' => 5]);
|
||
|
||
$setupIntent = Stripe\SetupIntent::constructFrom([
|
||
'id' => 'seti_attacker',
|
||
'status' => 'succeeded',
|
||
'payment_method' => 'pm_attacker',
|
||
'customer' => 'cus_attacker',
|
||
'metadata' => ['invoice_id' => '999', 'gateway_id' => '1', 'price_id' => 'price_attacker'],
|
||
]);
|
||
|
||
$setupIntentsMock = Mockery::mock();
|
||
$setupIntentsMock->shouldReceive('retrieve')->once()->with('seti_attacker', [])->andReturn($setupIntent);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->setupIntents = $setupIntentsMock;
|
||
$stripeMock->subscriptions = Mockery::mock(); // no expectations - must not be touched
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$tx = buildTransaction();
|
||
$tx->id = 700;
|
||
$this->adapter->setDi(container());
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
||
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
||
|
||
invokePrivateMethod($this->adapter, 'processSetupIntent', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$victimInvoice,
|
||
['get' => ['setup_intent' => 'seti_attacker']],
|
||
1,
|
||
]);
|
||
|
||
expect($tx->status)->toBe(Transaction::STATUS_ERROR);
|
||
});
|
||
|
||
test('rejects a setup intent whose metadata gateway_id does not match the current gateway', function (): void {
|
||
$invoice = createEntity(Invoice::class, ['id' => 91, 'client_id' => 5]);
|
||
|
||
$setupIntent = Stripe\SetupIntent::constructFrom([
|
||
'id' => 'seti_wrong_gateway',
|
||
'status' => 'succeeded',
|
||
'payment_method' => 'pm_x',
|
||
'customer' => 'cus_x',
|
||
'metadata' => ['invoice_id' => '91', 'gateway_id' => '2', 'price_id' => 'price_x'],
|
||
]);
|
||
|
||
$setupIntentsMock = Mockery::mock();
|
||
$setupIntentsMock->shouldReceive('retrieve')->once()->with('seti_wrong_gateway', [])->andReturn($setupIntent);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->setupIntents = $setupIntentsMock;
|
||
$stripeMock->subscriptions = Mockery::mock();
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$tx = buildTransaction();
|
||
$tx->id = 701;
|
||
$this->adapter->setDi(container());
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_create');
|
||
|
||
invokePrivateMethod($this->adapter, 'processSetupIntent', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$invoice,
|
||
['get' => ['setup_intent' => 'seti_wrong_gateway']],
|
||
1,
|
||
]);
|
||
|
||
expect($tx->status)->toBe(Transaction::STATUS_ERROR);
|
||
});
|
||
|
||
test('proceeds when the setup intent metadata matches the resolved invoice and gateway', function (): void {
|
||
$invoice = createEntity(Invoice::class, ['id' => 92, 'client_id' => 5, 'currency' => 'USD']);
|
||
|
||
$setupIntent = Stripe\SetupIntent::constructFrom([
|
||
'id' => 'seti_matching',
|
||
'status' => 'succeeded',
|
||
'payment_method' => 'pm_match',
|
||
'customer' => 'cus_match',
|
||
'metadata' => ['invoice_id' => '92', 'gateway_id' => '1', 'price_id' => 'price_match'],
|
||
]);
|
||
|
||
$setupIntentsMock = Mockery::mock();
|
||
$setupIntentsMock->shouldReceive('retrieve')->once()->with('seti_matching', [])->andReturn($setupIntent);
|
||
|
||
$subscription = Stripe\Subscription::constructFrom(['id' => 'sub_match', 'latest_invoice' => null]);
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('create')->once()->andReturn($subscription);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->setupIntents = $setupIntentsMock;
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$tx = buildTransaction();
|
||
$tx->id = 702;
|
||
$this->adapter->setDi(container());
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_create')->once()->andReturn(1);
|
||
|
||
invokePrivateMethod($this->adapter, 'processSetupIntent', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$invoice,
|
||
['get' => ['setup_intent' => 'seti_matching']],
|
||
1,
|
||
]);
|
||
|
||
expect($tx->status)->toBe(Transaction::STATUS_PROCESSED)
|
||
->and($tx->s_id)->toBe('sub_match');
|
||
});
|
||
});
|
||
|
||
describe('createStripeSubscription', function (): void {
|
||
test('changes the idempotency key when the resolved price differs for the same invoice', function (): void {
|
||
// Regression coverage for https://github.com/FOSSBilling/FOSSBilling/issues/4228: the
|
||
// same invoice can produce setup intents bound to different prices over time (e.g. a
|
||
// stale setup intent's late webhook arriving after a reload already completed checkout
|
||
// with a different one) - reusing a plain invoice-id idempotency key across that would
|
||
// hit an idempotency_error instead of the expected/handled idempotency_key_in_use race.
|
||
$invoice = createEntity(Invoice::class, ['id' => 93, 'client_id' => 5]);
|
||
$setupIntent = Stripe\SetupIntent::constructFrom(['id' => 'seti_x', 'payment_method' => 'pm_x']);
|
||
|
||
$capturedKeys = [];
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('create')
|
||
->twice()
|
||
->with(Mockery::any(), Mockery::on(function (array $options) use (&$capturedKeys): bool {
|
||
$capturedKeys[] = $options['idempotency_key'] ?? null;
|
||
|
||
return true;
|
||
}))
|
||
->andReturn(Stripe\Subscription::constructFrom(['id' => 'sub_x']));
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
$this->adapter->setDi(container());
|
||
|
||
invokePrivateMethod($this->adapter, 'createStripeSubscription', ['cus_a', 'price_a', $setupIntent, $invoice]);
|
||
invokePrivateMethod($this->adapter, 'createStripeSubscription', ['cus_a', 'price_b', $setupIntent, $invoice]);
|
||
|
||
expect($capturedKeys)->toHaveCount(2)
|
||
->and($capturedKeys[0])->not->toBe($capturedKeys[1]);
|
||
});
|
||
});
|
||
|
||
test('releases the Stripe object lock when processing fails', function (): void {
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'pi_failure', 2);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'withStripeObjectLock', [
|
||
'pi_failure',
|
||
2,
|
||
fn () => throw new RuntimeException('Processing failed'),
|
||
]))->toThrow(RuntimeException::class, 'Processing failed');
|
||
});
|
||
|
||
test('logs Stripe object lock timeouts with lock context', function (): void {
|
||
$lockName = 'fb:stripe:' . substr(hash('sha256', '2:pi_timeout'), 0, 54);
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$dbalMock->shouldReceive('getDatabasePlatform')
|
||
->andReturn(Mockery::mock(Doctrine\DBAL\Platforms\MySQLPlatform::class));
|
||
$dbalMock->shouldReceive('fetchOne')
|
||
->once()
|
||
->with('SELECT GET_LOCK(:name, :timeout)', ['name' => $lockName, 'timeout' => 10])
|
||
->andReturn(0);
|
||
|
||
$logger = new Tests\Helpers\TestLogger();
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['logger'] = $logger;
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(fn (): mixed => invokePrivateMethod($this->adapter, 'withStripeObjectLock', [
|
||
'pi_timeout',
|
||
2,
|
||
fn (): null => null,
|
||
]))->toThrow(FOSSBilling\Core\Exception\BaseException::class, 'Timed out waiting to process this Stripe payment')
|
||
->and($logger->calls)->toHaveCount(1)
|
||
->and($logger->calls[0]['method'])->toBe('warning')
|
||
->and($logger->calls[0]['params'][0])->toBe('Timed out after {duration_ms} ms waiting for Stripe object lock {lock_name}')
|
||
->and($logger->calls[0]['params'][1]['lock_name'])->toBe($lockName);
|
||
});
|
||
|
||
describe('_generateSubscriptionForm', function (): void {
|
||
test('serializes concurrent form generation per invoice and reuses the setup intent via idempotency key', function (): void {
|
||
// Related hardening for https://github.com/FOSSBilling/FOSSBilling/issues/4228:
|
||
// this is the sole place the Stripe customer/product/price are first
|
||
// resolved, but the checkout page can be loaded more than once for
|
||
// the same invoice (reload, duplicate tab, back/forward). Locking on
|
||
// the invoice ID avoids repeated loads redundantly repeating the
|
||
// same cache lookups and Stripe calls, and the idempotency key on
|
||
// setupIntents->create() makes a repeated load reuse the very same
|
||
// setup intent instead of creating another one.
|
||
$invoiceModel = createEntity(Invoice::class, [
|
||
'id' => 40,
|
||
'currency' => 'USD',
|
||
'buyer_email' => 'lock@example.com',
|
||
'buyer_first_name' => 'Lock',
|
||
'buyer_last_name' => 'Test',
|
||
]);
|
||
|
||
$customer = Stripe\Customer::constructFrom(['id' => 'cus_lock']);
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->once()->andReturn(
|
||
Stripe\SearchResult::constructFrom(['data' => [$customer]])
|
||
);
|
||
|
||
$product = Stripe\Product::constructFrom(['id' => 'prod_lock']);
|
||
$productsMock = Mockery::mock();
|
||
$productsMock->shouldReceive('search')->once()->andReturn(
|
||
Stripe\SearchResult::constructFrom(['data' => [$product]])
|
||
);
|
||
|
||
$price = Stripe\Price::constructFrom(['id' => 'price_lock', 'unit_amount' => null]);
|
||
$pricesMock = Mockery::mock();
|
||
$pricesMock->shouldReceive('all')->once()->andReturn(
|
||
Stripe\Collection::constructFrom(['data' => []])
|
||
);
|
||
$pricesMock->shouldReceive('create')->once()->andReturn($price);
|
||
|
||
$setupIntent = Stripe\SetupIntent::constructFrom(['id' => 'seti_lock', 'client_secret' => 'seti_lock_secret']);
|
||
$setupIntentsMock = Mockery::mock();
|
||
$capturedParams = null;
|
||
$setupIntentsMock->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(function (array $params) use (&$capturedParams): bool {
|
||
$capturedParams = $params;
|
||
|
||
return true;
|
||
}),
|
||
Mockery::on(function (array $options) use (&$capturedParams): bool {
|
||
// The key is hashed from the actual request params (like _generateForm()
|
||
// does for one-time payments), not just the invoice ID, so it changes if a
|
||
// repeated load resolves a different customer/price for this invoice.
|
||
$expectedKey = sprintf(
|
||
'setup_invoice_40_gateway_1_%s',
|
||
hash('sha256', json_encode($capturedParams, JSON_THROW_ON_ERROR))
|
||
);
|
||
|
||
return ($options['idempotency_key'] ?? null) === $expectedKey;
|
||
})
|
||
)
|
||
->andReturn($setupIntent);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
$stripeMock->products = $productsMock;
|
||
$stripeMock->prices = $pricesMock;
|
||
$stripeMock->setupIntents = $setupIntentsMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
// getOrCreateCustomer() skips its own cache lookup here since this
|
||
// invoice has no client_id (see its docblock) - that path is
|
||
// covered by its own dedicated tests below.
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 'gen_subscription_form_invoice_40', 1);
|
||
$dbalMock->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$this->adapter->setDi($di);
|
||
|
||
$html = invokePrivateMethod($this->adapter, '_generateSubscriptionForm', [$invoiceModel]);
|
||
|
||
expect($html)->toContain('seti_lock_secret');
|
||
});
|
||
|
||
test('changes the idempotency key when the resolved customer/price differ for the same invoice', function (): void {
|
||
// Regression coverage for https://github.com/FOSSBilling/FOSSBilling/issues/4228: reusing
|
||
// a plain invoice-id idempotency key across two different resolved customer/price pairs
|
||
// (e.g. an admin edits the invoice's items/amount between two checkout page loads) is
|
||
// exactly the parameter-mismatch bug this file exists to avoid, so the key must be hashed
|
||
// from the actual request params too - matching _generateForm()'s existing pattern.
|
||
$resolveKeyFor = function (string $externalCustomerId, string $externalPriceId): ?string {
|
||
$adapter = new Payment_Adapter_Stripe([
|
||
'test_mode' => true,
|
||
'test_api_key' => 'sk_test_dummy',
|
||
'test_pub_key' => 'pk_test_dummy',
|
||
'test_webhook_secret' => TEST_WEBHOOK_SECRET,
|
||
'gateway_id' => 1,
|
||
]);
|
||
|
||
$invoiceModel = createEntity(Invoice::class, [
|
||
'id' => 41,
|
||
'currency' => 'USD',
|
||
'buyer_email' => 'reprice@example.com',
|
||
'buyer_first_name' => 'Re',
|
||
'buyer_last_name' => 'Price',
|
||
'client_id' => 20,
|
||
]);
|
||
|
||
$customerRepo = Mockery::mock(PayGatewayCustomerRepository::class);
|
||
$customerRepo->shouldReceive('findOneByGatewayAndClient')->andReturn(
|
||
createEntity(PayGatewayCustomer::class, ['external_customer_id' => $externalCustomerId])
|
||
);
|
||
$productRepo = Mockery::mock(PayGatewayProductRepository::class);
|
||
$productRepo->shouldReceive('findOneByGatewayAndCacheKey')->andReturn(
|
||
createEntity(PayGatewayProduct::class, ['external_price_id' => $externalPriceId])
|
||
);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayCustomer::class)->andReturn($customerRepo);
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayProduct::class)->andReturn($productRepo);
|
||
$adapter->setDi($di);
|
||
|
||
$capturedKey = null;
|
||
$setupIntentsMock = Mockery::mock();
|
||
$setupIntentsMock->shouldReceive('create')
|
||
->once()
|
||
->with(Mockery::any(), Mockery::on(function (array $options) use (&$capturedKey): bool {
|
||
$capturedKey = $options['idempotency_key'] ?? null;
|
||
|
||
return true;
|
||
}))
|
||
->andReturn(Stripe\SetupIntent::constructFrom(['id' => 'seti_x', 'client_secret' => 'secret_x']));
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->setupIntents = $setupIntentsMock;
|
||
setPrivateProperty($adapter, 'stripe', $stripeMock);
|
||
|
||
invokePrivateMethod($adapter, 'generateSubscriptionFormUnderLock', [$invoiceModel]);
|
||
|
||
return $capturedKey;
|
||
};
|
||
|
||
$firstKey = $resolveKeyFor('cus_a', 'price_a');
|
||
$secondKey = $resolveKeyFor('cus_a', 'price_b');
|
||
|
||
expect($firstKey)->not->toBeNull()
|
||
->and($secondKey)->not->toBeNull()
|
||
->and($firstKey)->not->toBe($secondKey);
|
||
});
|
||
});
|
||
|
||
describe('getOrCreateCustomer', function (): void {
|
||
test('returns the cached customer ID without touching Stripe', function (): void {
|
||
$invoiceModel = createEntity(Invoice::class, ['id' => 50, 'client_id' => 9]);
|
||
|
||
$cached = createEntity(PayGatewayCustomer::class, ['external_customer_id' => 'cus_cached']);
|
||
$customerRepo = Mockery::mock(PayGatewayCustomerRepository::class);
|
||
$customerRepo->shouldReceive('findOneByGatewayAndClient')->once()->with(1, 9)->andReturn($cached);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = Mockery::mock(); // no expectations - must not be called
|
||
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayCustomer::class)->andReturn($customerRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreateCustomer', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('cus_cached');
|
||
});
|
||
|
||
test('resolves and caches a new customer on a cache miss', function (): void {
|
||
$invoiceModel = createEntity(Invoice::class, [
|
||
'id' => 51,
|
||
'client_id' => 10,
|
||
'buyer_email' => 'newcustomer@example.com',
|
||
'buyer_first_name' => 'New',
|
||
'buyer_last_name' => 'Customer',
|
||
]);
|
||
|
||
$customerRepo = Mockery::mock(PayGatewayCustomerRepository::class);
|
||
$customerRepo->shouldReceive('findOneByGatewayAndClient')->once()->with(1, 10)->andReturn(null);
|
||
|
||
$customer = Stripe\Customer::constructFrom(['id' => 'cus_fresh']);
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->once()->andReturn(Stripe\SearchResult::constructFrom(['data' => []]));
|
||
$customersMock->shouldReceive('create')->once()->andReturn($customer);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$isolatedEm = noOpEntityManagerMock();
|
||
$isolatedEm->shouldReceive('persist')->once()->with(Mockery::type(PayGatewayCustomer::class));
|
||
$isolatedEm->shouldReceive('flush')->once();
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', fn (): object => $isolatedEm);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayCustomer::class)->andReturn($customerRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreateCustomer', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('cus_fresh');
|
||
});
|
||
|
||
test('returns the winning row instead of its own resolved ID when a concurrent request caches first', function (): void {
|
||
// Two requests for the same (gateway, client) can both miss the cache read in
|
||
// getOrCreateCustomer() and each resolve their own Stripe customer before either persists.
|
||
// The unique constraint (see PayGatewayCustomerRepositoryTest) stops both rows from
|
||
// existing; cacheGatewayCustomer() must then re-read and return the winner's ID rather
|
||
// than the one this request resolved, so every caller converges on one customer.
|
||
$invoiceModel = createEntity(Invoice::class, [
|
||
'id' => 52,
|
||
'client_id' => 11,
|
||
'buyer_email' => 'raced@example.com',
|
||
'buyer_first_name' => 'Raced',
|
||
'buyer_last_name' => 'Customer',
|
||
]);
|
||
|
||
$customerRepo = Mockery::mock(PayGatewayCustomerRepository::class);
|
||
$customerRepo->shouldReceive('findOneByGatewayAndClient')->once()->with(1, 11)->andReturn(null);
|
||
|
||
$customer = Stripe\Customer::constructFrom(['id' => 'cus_this_request']);
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->once()->andReturn(Stripe\SearchResult::constructFrom(['data' => []]));
|
||
$customersMock->shouldReceive('create')->once()->andReturn($customer);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$winningRow = createEntity(PayGatewayCustomer::class, ['external_customer_id' => 'cus_other_request_won']);
|
||
$isolatedEmRepo = Mockery::mock(PayGatewayCustomerRepository::class);
|
||
$isolatedEmRepo->shouldReceive('findOneByGatewayAndClient')->once()->with(1, 11)->andReturn($winningRow);
|
||
$isolatedEm = noOpEntityManagerMock();
|
||
$isolatedEm->shouldReceive('flush')->once()->andThrow(uniqueConstraintViolationException());
|
||
$isolatedEm->shouldReceive('getRepository')->with(PayGatewayCustomer::class)->andReturn($isolatedEmRepo);
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', fn (): object => $isolatedEm);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayCustomer::class)->andReturn($customerRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreateCustomer', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('cus_other_request_won');
|
||
});
|
||
|
||
test('resolves without caching when the invoice has no client', function (): void {
|
||
$invoiceModel = createEntity(Invoice::class, [
|
||
'id' => 53,
|
||
'buyer_email' => 'guest@example.com',
|
||
'buyer_first_name' => 'Guest',
|
||
'buyer_last_name' => 'Buyer',
|
||
]);
|
||
|
||
$customer = Stripe\Customer::constructFrom(['id' => 'cus_guest']);
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->once()->andReturn(Stripe\SearchResult::constructFrom(['data' => []]));
|
||
$customersMock->shouldReceive('create')->once()->andReturn($customer);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$di = container();
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreateCustomer', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('cus_guest');
|
||
});
|
||
});
|
||
|
||
describe('getOrCreatePriceId', function (): void {
|
||
test('returns the cached price ID without touching Stripe', function (): void {
|
||
$invoiceModel = createEntity(Invoice::class, ['id' => 60, 'currency' => 'USD']);
|
||
|
||
$cacheKey = hash('sha256', implode('|', ['Test Product', 'usd', 0, 'month', 1]));
|
||
$cached = createEntity(PayGatewayProduct::class, ['external_price_id' => 'price_cached']);
|
||
$productRepo = Mockery::mock(PayGatewayProductRepository::class);
|
||
$productRepo->shouldReceive('findOneByGatewayAndCacheKey')->once()->with(1, $cacheKey)->andReturn($cached);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->products = Mockery::mock(); // no expectations - must not be called
|
||
$stripeMock->prices = Mockery::mock(); // no expectations - must not be called
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayProduct::class)->andReturn($productRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreatePriceId', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('price_cached');
|
||
});
|
||
|
||
test('resolves and caches a new product/price on a cache miss', function (): void {
|
||
$invoiceModel = createEntity(Invoice::class, ['id' => 61, 'currency' => 'USD']);
|
||
|
||
$cacheKey = hash('sha256', implode('|', ['Test Product', 'usd', 0, 'month', 1]));
|
||
$productRepo = Mockery::mock(PayGatewayProductRepository::class);
|
||
$productRepo->shouldReceive('findOneByGatewayAndCacheKey')->once()->with(1, $cacheKey)->andReturn(null);
|
||
|
||
$product = Stripe\Product::constructFrom(['id' => 'prod_fresh']);
|
||
$productsMock = Mockery::mock();
|
||
$productsMock->shouldReceive('search')->once()->andReturn(Stripe\SearchResult::constructFrom(['data' => []]));
|
||
$productsMock->shouldReceive('create')->once()->andReturn($product);
|
||
|
||
$price = Stripe\Price::constructFrom(['id' => 'price_fresh', 'unit_amount' => null]);
|
||
$pricesMock = Mockery::mock();
|
||
$pricesMock->shouldReceive('all')->once()->andReturn(Stripe\Collection::constructFrom(['data' => []]));
|
||
$pricesMock->shouldReceive('create')->once()->andReturn($price);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->products = $productsMock;
|
||
$stripeMock->prices = $pricesMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$isolatedEm = noOpEntityManagerMock();
|
||
$isolatedEm->shouldReceive('persist')->once()->with(Mockery::type(PayGatewayProduct::class));
|
||
$isolatedEm->shouldReceive('flush')->once();
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', fn (): object => $isolatedEm);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayProduct::class)->andReturn($productRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreatePriceId', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('price_fresh');
|
||
});
|
||
|
||
test('returns the winning row instead of its own resolved ID when a concurrent request caches first', function (): void {
|
||
// Mirrors getOrCreateCustomer's equivalent case: the unique constraint stops two
|
||
// product/price rows existing for the same (gateway, cache key), so the loser here must
|
||
// re-read and return the winner's price ID rather than the one it just resolved.
|
||
$invoiceModel = createEntity(Invoice::class, ['id' => 62, 'currency' => 'USD']);
|
||
|
||
$cacheKey = hash('sha256', implode('|', ['Test Product', 'usd', 0, 'month', 1]));
|
||
$productRepo = Mockery::mock(PayGatewayProductRepository::class);
|
||
$productRepo->shouldReceive('findOneByGatewayAndCacheKey')->once()->with(1, $cacheKey)->andReturn(null);
|
||
|
||
$product = Stripe\Product::constructFrom(['id' => 'prod_this_request']);
|
||
$productsMock = Mockery::mock();
|
||
$productsMock->shouldReceive('search')->once()->andReturn(Stripe\SearchResult::constructFrom(['data' => []]));
|
||
$productsMock->shouldReceive('create')->once()->andReturn($product);
|
||
|
||
$price = Stripe\Price::constructFrom(['id' => 'price_this_request', 'unit_amount' => null]);
|
||
$pricesMock = Mockery::mock();
|
||
$pricesMock->shouldReceive('all')->once()->andReturn(Stripe\Collection::constructFrom(['data' => []]));
|
||
$pricesMock->shouldReceive('create')->once()->andReturn($price);
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->products = $productsMock;
|
||
$stripeMock->prices = $pricesMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$winningRow = createEntity(PayGatewayProduct::class, ['external_price_id' => 'price_other_request_won']);
|
||
$isolatedEmRepo = Mockery::mock(PayGatewayProductRepository::class);
|
||
$isolatedEmRepo->shouldReceive('findOneByGatewayAndCacheKey')->once()->with(1, $cacheKey)->andReturn($winningRow);
|
||
$isolatedEm = noOpEntityManagerMock();
|
||
$isolatedEm->shouldReceive('flush')->once()->andThrow(uniqueConstraintViolationException());
|
||
$isolatedEm->shouldReceive('getRepository')->with(PayGatewayProduct::class)->andReturn($isolatedEmRepo);
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', fn (): object => $isolatedEm);
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->andReturn([['title' => 'Test Product']]);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||
$di['em']->shouldReceive('getRepository')->with(PayGatewayProduct::class)->andReturn($productRepo);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'getOrCreatePriceId', [$invoiceModel]);
|
||
|
||
expect($result)->toBe('price_other_request_won');
|
||
});
|
||
});
|
||
|
||
describe('newIsolatedEntityManager', function (): void {
|
||
test('memoizes the isolated EntityManager while it stays open', function (): void {
|
||
// Resolving a customer and a product/price on the same checkout - the common case for a
|
||
// client's first invoice - must not pay for a fresh EntityManagerFactory::create() twice.
|
||
$callCount = 0;
|
||
$em = noOpEntityManagerMock();
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', function () use (&$callCount, $em): object {
|
||
++$callCount;
|
||
|
||
return $em;
|
||
});
|
||
|
||
$first = invokePrivateMethod($this->adapter, 'newIsolatedEntityManager');
|
||
$second = invokePrivateMethod($this->adapter, 'newIsolatedEntityManager');
|
||
|
||
expect($first)->toBe($em)
|
||
->and($second)->toBe($em)
|
||
->and($callCount)->toBe(1);
|
||
});
|
||
|
||
test('rebuilds once the memoized EntityManager reports itself closed', function (): void {
|
||
// Mirrors Doctrine's own behavior: a flush() that hits a unique constraint violation
|
||
// leaves the EntityManager closed, regardless of what the caller does (see
|
||
// cacheGatewayCustomer()'s docblock) - so the stale instance must not be reused.
|
||
$closedEm = noOpEntityManagerMock();
|
||
$closedEm->shouldReceive('isOpen')->andReturn(false);
|
||
|
||
$freshEm = noOpEntityManagerMock();
|
||
|
||
$queue = [$closedEm, $freshEm];
|
||
setPrivateProperty($this->adapter, 'entityManagerFactory', function () use (&$queue): object {
|
||
return array_shift($queue);
|
||
});
|
||
|
||
$first = invokePrivateMethod($this->adapter, 'newIsolatedEntityManager');
|
||
$second = invokePrivateMethod($this->adapter, 'newIsolatedEntityManager');
|
||
|
||
expect($first)->toBe($closedEm)
|
||
->and($second)->toBe($freshEm);
|
||
});
|
||
});
|
||
|
||
describe('handleSetupIntentSucceededWebhook', function (): void {
|
||
test('skips processing when setup already handled via redirect flow', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 300;
|
||
|
||
$setupIntent = new stdClass();
|
||
$setupIntent->id = 'seti_existing';
|
||
$setupIntent->object = 'setup_intent';
|
||
$setupIntent->status = 'succeeded';
|
||
$setupIntent->payment_method = 'pm_123';
|
||
$setupIntent->metadata = (object) ['invoice_id' => '20'];
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $setupIntent];
|
||
|
||
$existingTx = buildTransaction();
|
||
$existingTx->id = 299;
|
||
$existingTx->status = Transaction::STATUS_PROCESSED;
|
||
$existingTx->invoice = createEntity(Invoice::class, ['id' => 20]);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||
->once()
|
||
->with('seti_existing')
|
||
->andReturn($existingTx);
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
invokePrivateMethod($this->adapter, 'handleSetupIntentSucceededWebhook', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
expect($tx->getInvoice()?->getId())->toBe(20);
|
||
});
|
||
|
||
test('creates subscription when not already handled', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 301;
|
||
|
||
// Mirrors what _generateSubscriptionForm() actually creates: a setup
|
||
// intent with the customer already attached and the resolved price
|
||
// ID stashed in metadata.
|
||
$setupIntent = Stripe\SetupIntent::constructFrom([
|
||
'id' => 'seti_new',
|
||
'object' => 'setup_intent',
|
||
'status' => 'succeeded',
|
||
'payment_method' => 'pm_456',
|
||
'customer' => 'cus_existing',
|
||
'metadata' => ['invoice_id' => '25', 'price_id' => 'price_existing'],
|
||
]);
|
||
|
||
$event = new stdClass();
|
||
$event->data = (object) ['object' => $setupIntent];
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
|
||
$invoiceModel->id = 25;
|
||
$invoiceModel->currency = 'USD';
|
||
$invoiceModel->buyer_email = 'test@example.com';
|
||
$invoiceModel->buyer_first_name = 'Test';
|
||
$invoiceModel->buyer_last_name = 'User';
|
||
|
||
$subscription = Stripe\Subscription::constructFrom(['id' => 'sub_new_123']);
|
||
|
||
$subscriptionsMock = Mockery::mock();
|
||
$subscriptionsMock->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $params): bool => $params['customer'] === 'cus_existing'
|
||
&& $params['items'] === [['price' => 'price_existing']]),
|
||
Mockery::any()
|
||
)
|
||
->andReturn($subscription);
|
||
|
||
// The customer and price are already known from the setup intent, so
|
||
// resolving them must not fall back to Stripe's Search API - doing so
|
||
// is what let the redirect and webhook flows resolve different
|
||
// customers/products for the same invoice and made Stripe reject the
|
||
// second createStripeSubscription() call as an idempotency-key
|
||
// mismatch (the bug this test guards against).
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->never();
|
||
$customersMock->shouldReceive('create')->never();
|
||
|
||
$productsMock = Mockery::mock();
|
||
$productsMock->shouldReceive('search')->never();
|
||
$productsMock->shouldReceive('create')->never();
|
||
|
||
$pricesMock = Mockery::mock();
|
||
$pricesMock->shouldReceive('all')->never();
|
||
$pricesMock->shouldReceive('create')->never();
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
$stripeMock->products = $productsMock;
|
||
$stripeMock->prices = $pricesMock;
|
||
$stripeMock->subscriptions = $subscriptionsMock;
|
||
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
$di = container();
|
||
$invoiceRepo = $di['em']->getRepository(Invoice::class);
|
||
$invoiceRepo->shouldReceive('find')
|
||
->with(25)
|
||
->andReturn($invoiceModel);
|
||
$this->adapter->setDi($di);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldReceive('invoice_subscription_create')->once()->andReturn(1);
|
||
|
||
invokePrivateMethod($this->adapter, 'handleSetupIntentSucceededWebhook', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$event,
|
||
1,
|
||
]);
|
||
|
||
expect($tx->getInvoice()?->getId())->toBe(25)
|
||
->and($tx->s_id)->toBe('sub_new_123');
|
||
});
|
||
|
||
test('redirect and webhook flows resolve identical customer/price for the same setup intent', function (): void {
|
||
// Regression test for https://github.com/FOSSBilling/FOSSBilling/issues/4228:
|
||
// both flows independently re-derived the Stripe customer (via the
|
||
// eventually-consistent Search API) and product/price, so a race
|
||
// between them could create duplicate Stripe customers/products and
|
||
// then submit different parameters under the same subscription
|
||
// idempotency key, which Stripe rejects with an idempotency_error.
|
||
// Both flows must now resolve the customer/price straight from the
|
||
// setup intent, so calling the resolvers twice (simulating the
|
||
// webhook and redirect flows racing on the same setup intent) must
|
||
// always agree, without ever touching Stripe's Search API.
|
||
$setupIntent = Stripe\SetupIntent::constructFrom([
|
||
'id' => 'seti_race',
|
||
'object' => 'setup_intent',
|
||
'status' => 'succeeded',
|
||
'payment_method' => 'pm_789',
|
||
'customer' => 'cus_race',
|
||
'metadata' => ['invoice_id' => '30', 'price_id' => 'price_race'],
|
||
]);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
$invoiceModel->id = 30;
|
||
|
||
$customersMock = Mockery::mock();
|
||
$customersMock->shouldReceive('search')->never();
|
||
$customersMock->shouldReceive('create')->never();
|
||
|
||
$productsMock = Mockery::mock();
|
||
$productsMock->shouldReceive('search')->never();
|
||
$productsMock->shouldReceive('create')->never();
|
||
|
||
$pricesMock = Mockery::mock();
|
||
$pricesMock->shouldReceive('all')->never();
|
||
$pricesMock->shouldReceive('create')->never();
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->customers = $customersMock;
|
||
$stripeMock->products = $productsMock;
|
||
$stripeMock->prices = $pricesMock;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripeMock);
|
||
|
||
// Call each resolver twice, as the webhook and redirect flows would.
|
||
$customerIdFromWebhook = invokePrivateMethod($this->adapter, 'resolveSubscriptionCustomerId', [$setupIntent, $invoiceModel]);
|
||
$customerIdFromRedirect = invokePrivateMethod($this->adapter, 'resolveSubscriptionCustomerId', [$setupIntent, $invoiceModel]);
|
||
$priceIdFromWebhook = invokePrivateMethod($this->adapter, 'resolveSubscriptionPriceId', [$setupIntent, $invoiceModel]);
|
||
$priceIdFromRedirect = invokePrivateMethod($this->adapter, 'resolveSubscriptionPriceId', [$setupIntent, $invoiceModel]);
|
||
|
||
expect($customerIdFromWebhook)->toBe('cus_race')
|
||
->and($customerIdFromWebhook)->toBe($customerIdFromRedirect)
|
||
->and($priceIdFromWebhook)->toBe('price_race')
|
||
->and($priceIdFromWebhook)->toBe($priceIdFromRedirect);
|
||
});
|
||
});
|
||
|
||
describe('processWebhookEvent signature verification', function (): void {
|
||
test('rejects webhook events missing the Stripe-Signature header', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 502;
|
||
|
||
$rawBody = json_encode(['type' => 'payment_intent.succeeded', 'id' => 'evt_unsigned']);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => [],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($this->adapter, 'processWebhookEvent', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$data,
|
||
1,
|
||
]);
|
||
})->throws(FOSSBilling\Core\Exception\BaseException::class, 'Missing Stripe-Signature header');
|
||
|
||
test('rejects webhook events with an invalid signature', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 503;
|
||
|
||
$rawBody = json_encode(['type' => 'payment_intent.succeeded', 'id' => 'evt_bad_sig']);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => ['HTTP_STRIPE_SIGNATURE' => signStripeWebhookPayload($rawBody, 'whsec_wrong_secret')],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($this->adapter, 'processWebhookEvent', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$data,
|
||
1,
|
||
]);
|
||
})->throws(FOSSBilling\Core\Exception\BaseException::class, 'Invalid Stripe webhook signature');
|
||
|
||
test('rejects webhook events when no webhook secret is configured', function (): void {
|
||
$adapter = new Payment_Adapter_Stripe([
|
||
'test_mode' => true,
|
||
'test_api_key' => 'sk_test_dummy',
|
||
'test_pub_key' => 'pk_test_dummy',
|
||
]);
|
||
|
||
$tx = buildTransaction();
|
||
$tx->id = 504;
|
||
|
||
$rawBody = json_encode(['type' => 'payment_intent.succeeded', 'id' => 'evt_no_secret']);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => ['HTTP_STRIPE_SIGNATURE' => signStripeWebhookPayload($rawBody)],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($adapter, 'processWebhookEvent', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$data,
|
||
1,
|
||
]);
|
||
})->throws(FOSSBilling\Core\Exception\BaseException::class, 'Stripe webhook signing secret is not configured');
|
||
});
|
||
|
||
describe('processWebhookEvent noise filtering', function (): void {
|
||
test('deletes transaction for unhandled event types', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 500;
|
||
|
||
$rawBody = json_encode(['type' => 'charge.succeeded', 'id' => 'evt_noise_1']);
|
||
|
||
['em' => $em] = buildEntityManagerMocks();
|
||
$em->shouldReceive('remove')->once()->with($tx);
|
||
$em->shouldReceive('flush')->once();
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => ['HTTP_STRIPE_SIGNATURE' => signStripeWebhookPayload($rawBody)],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($this->adapter, 'processWebhookEvent', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$data,
|
||
1,
|
||
]);
|
||
});
|
||
|
||
test('deletes transaction for subscription lifecycle events', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 501;
|
||
|
||
$rawBody = json_encode([
|
||
'type' => 'customer.subscription.deleted',
|
||
'id' => 'evt_life_1',
|
||
'data' => ['object' => [
|
||
'id' => 'sub_nonexistent',
|
||
'metadata' => ['gateway_id' => '1'],
|
||
]],
|
||
]);
|
||
|
||
$apiAdmin = Mockery::mock();
|
||
$apiAdmin->shouldNotReceive('invoice_subscription_get');
|
||
|
||
$subscriptionService = Mockery::mock(Box\Mod\Invoice\ServiceSubscription::class);
|
||
$subscriptionService->shouldReceive('findIdBySid')->once()->with('sub_nonexistent')->andReturn(null);
|
||
|
||
['em' => $em] = buildEntityManagerMocks();
|
||
$em->shouldReceive('remove')->once()->with($tx);
|
||
$em->shouldReceive('flush')->once();
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$di['mod_service'] = $di->protect(fn () => $subscriptionService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => ['HTTP_STRIPE_SIGNATURE' => signStripeWebhookPayload($rawBody)],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($this->adapter, 'processWebhookEvent', [
|
||
$apiAdmin,
|
||
$tx,
|
||
$data,
|
||
1,
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe('Stripe webhook gateway ownership', function (): void {
|
||
test('tags one-time payments and uses the selected gateway callback', function (): void {
|
||
$adapter = new Payment_Adapter_Stripe([
|
||
'test_mode' => true,
|
||
'test_api_key' => 'sk_test_dummy',
|
||
'test_pub_key' => 'pk_test_dummy',
|
||
'test_webhook_secret' => TEST_WEBHOOK_SECRET,
|
||
'gateway_id' => 3,
|
||
'notify_url' => 'https://billing.example/ipn.php?gateway_id=3&invoice_id=15',
|
||
]);
|
||
|
||
$paymentIntentsMock = Mockery::mock();
|
||
$paymentIntentsMock->shouldReceive('create')
|
||
->once()
|
||
->withArgs(fn (array $params, array $options): bool => $params['metadata']['gateway_id'] === '3'
|
||
&& $params['metadata']['invoice_id'] === '15'
|
||
&& $params['currency'] === 'usd'
|
||
&& $options['idempotency_key'] === sprintf(
|
||
'one_time_invoice_15_gateway_3_%s',
|
||
hash('sha256', json_encode($params, JSON_THROW_ON_ERROR))
|
||
))
|
||
->andReturn(Stripe\PaymentIntent::constructFrom([
|
||
'id' => 'pi_gateway_3',
|
||
'client_secret' => 'pi_gateway_3_secret',
|
||
]));
|
||
|
||
$stripeMock = Mockery::mock(StripeClient::class);
|
||
$stripeMock->paymentIntents = $paymentIntentsMock;
|
||
setPrivateProperty($adapter, 'stripe', $stripeMock);
|
||
|
||
$invoice = createEntity(Invoice::class);
|
||
$invoice->id = 15;
|
||
$invoice->client_id = 7;
|
||
$invoice->currency = 'USD';
|
||
$invoice->buyer_email = 'client@example.com';
|
||
$invoice->buyer_first_name = 'Test';
|
||
$invoice->buyer_last_name = 'Client';
|
||
$invoice->hash = 'invoice-hash';
|
||
$invoice->nr = 15;
|
||
$invoice->serie = 'INV';
|
||
|
||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$connection->shouldReceive('fetchAllAssociative')->once()->andReturn([['title' => 'Hosting']]);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(15.00);
|
||
|
||
$di = container();
|
||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||
$di['mod_service'] = $di->protect(fn () => $invoiceService);
|
||
$adapter->setDi($di);
|
||
|
||
$form = invokePrivateMethod($adapter, '_generateForm', [$invoice]);
|
||
|
||
expect($form)->toContain('https://billing.example/ipn.php?gateway_id=3&invoice_id=15');
|
||
});
|
||
|
||
test('ignores an event created by another FOSSBilling Stripe gateway', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 550;
|
||
|
||
$rawBody = json_encode([
|
||
'type' => 'payment_intent.succeeded',
|
||
'id' => 'evt_wrong_gateway',
|
||
'data' => ['object' => [
|
||
'id' => 'pi_gateway_3',
|
||
'status' => 'succeeded',
|
||
'amount' => 1500,
|
||
'currency' => 'usd',
|
||
'metadata' => [
|
||
'invoice_id' => '10',
|
||
'client_id' => '3',
|
||
'gateway_id' => '3',
|
||
],
|
||
]],
|
||
]);
|
||
|
||
['em' => $em] = buildEntityManagerMocks();
|
||
$em->shouldReceive('remove')->once()->with($tx);
|
||
$em->shouldReceive('flush')->once();
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
$data = [
|
||
'http_raw_post_data' => $rawBody,
|
||
'server' => ['HTTP_STRIPE_SIGNATURE' => signStripeWebhookPayload($rawBody)],
|
||
'get' => [],
|
||
'post' => [],
|
||
];
|
||
|
||
invokePrivateMethod($this->adapter, 'processWebhookEvent', [
|
||
Mockery::mock(),
|
||
$tx,
|
||
$data,
|
||
10,
|
||
]);
|
||
});
|
||
|
||
test('reads copied subscription metadata from recurring invoices', function (): void {
|
||
$event = (object) [
|
||
'type' => 'invoice.paid',
|
||
'data' => (object) ['object' => (object) [
|
||
'parent' => (object) [
|
||
'subscription_details' => (object) [
|
||
'metadata' => (object) ['gateway_id' => '3'],
|
||
],
|
||
],
|
||
]],
|
||
];
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 3]))->toBeTrue()
|
||
->and(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 10]))->toBeFalse();
|
||
});
|
||
|
||
test('resolves gateway ownership from the invoice for legacy Stripe objects', function (): void {
|
||
$event = (object) [
|
||
'type' => 'payment_intent.succeeded',
|
||
'data' => (object) ['object' => (object) [
|
||
'metadata' => (object) ['invoice_id' => '10'],
|
||
]],
|
||
];
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$dbalMock->shouldReceive('fetchOne')
|
||
->twice()
|
||
->with('SELECT gateway_id FROM invoice WHERE id = :id', ['id' => 10])
|
||
->andReturn('3');
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 3]))->toBeTrue()
|
||
->and(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 10]))->toBeFalse();
|
||
});
|
||
|
||
test('resolves gateway ownership from a legacy local subscription', function (): void {
|
||
$event = (object) [
|
||
'type' => 'customer.subscription.updated',
|
||
'data' => (object) ['object' => (object) [
|
||
'id' => 'sub_legacy',
|
||
]],
|
||
];
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
$dbalMock->shouldReceive('fetchOne')
|
||
->twice()
|
||
->with('SELECT pay_gateway_id FROM subscription WHERE sid = :sid', ['sid' => 'sub_legacy'])
|
||
->andReturn('3');
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 3]))->toBeTrue()
|
||
->and(invokePrivateMethod($this->adapter, 'eventBelongsToGateway', [$event, 10]))->toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('applyOneTimePayment already-paid guard', function (): void {
|
||
test('skips processing when invoice is already paid', function (): void {
|
||
$tx = buildTransaction();
|
||
$tx->id = 600;
|
||
$tx->amount = '50.00';
|
||
|
||
$invoice = createEntity(Invoice::class);
|
||
$invoice->id = 42;
|
||
$invoice->status = Invoice::STATUS_PAID;
|
||
|
||
$addFundsCalled = false;
|
||
|
||
$clientService = Mockery::mock();
|
||
$clientService->shouldReceive('addFunds')
|
||
->andReturnUsing(function () use (&$addFundsCalled): void {
|
||
$addFundsCalled = true;
|
||
});
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$transactionService = Mockery::mock();
|
||
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(function ($name, $sub = null) use ($clientService, $invoiceService, $transactionService) {
|
||
if ($name === 'client') {
|
||
return $clientService;
|
||
}
|
||
if ($name === 'Invoice' && $sub === 'Transaction') {
|
||
return $transactionService;
|
||
}
|
||
if ($name === 'Invoice') {
|
||
return $invoiceService;
|
||
}
|
||
|
||
return Mockery::mock();
|
||
});
|
||
$this->adapter->setDi($di);
|
||
|
||
$charge = new stdClass();
|
||
$charge->id = 'pi_test123';
|
||
|
||
invokePrivateMethod($this->adapter, 'applyOneTimePayment', [$tx, $invoice, $charge]);
|
||
|
||
expect($addFundsCalled)->toBeFalse();
|
||
});
|
||
});
|
||
|
||
test('checkout buyer details are encoded as JS string literals', function (): void {
|
||
// HTML entities are never decoded inside <script>, so HTML-escaping would
|
||
// corrupt the data sent to Stripe (issue #4305).
|
||
$encoded = invokePrivateMethod($this->adapter, 'encodeJsString', ["O'Brien & <Sons>"]);
|
||
|
||
expect($encoded)->toBe('"O\u0027Brien \u0026 \u003CSons\u003E"')
|
||
->and(json_decode($encoded))->toBe("O'Brien & <Sons>")
|
||
->and($encoded)->not->toContain('</script>');
|
||
});
|
||
|
||
describe('processTransaction payload handling', function (): void {
|
||
test('rejects an empty payload with a clear error instead of stalling', function (): void {
|
||
$tx = createEntity(Transaction::class, ['id' => 7]);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('find')->with(7)->andReturn($tx);
|
||
|
||
$di = container();
|
||
$di['em'] = $em;
|
||
$this->adapter->setDi($di);
|
||
|
||
$apiAdmin = (new ReflectionClass(FOSSBilling\Core\Api\Proxy::class))->newInstanceWithoutConstructor();
|
||
|
||
try {
|
||
$this->adapter->processTransaction($apiAdmin, 7, [], 1);
|
||
expect(false)->toBeTrue('empty Stripe payloads must throw');
|
||
} catch (Payment_Exception $e) {
|
||
expect($e->getMessage())->toBe('Stripe payment data is missing.')
|
||
->and($e->getCode())->toBe(7020);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe('getAmountInMinorUnits follows Stripe currency rules', function (): void {
|
||
// Stripe lists "BIF CLP DJF GNF JPY KMF KRW MGA PYG RWF UGX VND VUV XAF XOF XPF" as zero-decimal, but its
|
||
// special-cases row requires UGX charges ×100, like ISK (https://docs.stripe.com/currencies). Symfony Intl
|
||
// reports COP, HUF, IDR, ISK, PKR, UGX and others with no decimals, but Stripe expects them ×100.
|
||
test('sends the invoice total in the unit Stripe expects', function (string $currency, int $expected): void {
|
||
$invoice = createEntity(Invoice::class);
|
||
$invoice->id = 5;
|
||
$invoice->currency = $currency;
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->with($invoice)->andReturn(1000.0);
|
||
$di = container();
|
||
$di['mod_service'] = $di->protect(fn ($module) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect($this->adapter->getAmountInMinorUnits($invoice))->toBe($expected);
|
||
})->with([
|
||
'USD' => ['USD', 100000],
|
||
'JPY (zero-decimal)' => ['JPY', 1000],
|
||
'KWD (three-decimal)' => ['KWD', 1000000],
|
||
'BHD (three-decimal)' => ['BHD', 1000000],
|
||
'COP' => ['COP', 100000],
|
||
'HUF' => ['HUF', 100000],
|
||
'IDR' => ['IDR', 100000],
|
||
'ISK (two-decimal representation)' => ['ISK', 100000],
|
||
'PKR' => ['PKR', 100000],
|
||
'UGX (two-decimal representation)' => ['UGX', 100000],
|
||
'AFN' => ['AFN', 100000],
|
||
'YER' => ['YER', 100000],
|
||
'TWD (two-decimal for charges)' => ['TWD', 100000],
|
||
'unknown code falls back to two decimals' => ['XXX', 100000],
|
||
'lowercase USD' => ['usd', 100000],
|
||
'lowercase JPY' => ['jpy', 1000],
|
||
]);
|
||
|
||
test('reads Stripe amounts back in the same unit', function (string $currency, int $minorUnits, float $expected): void {
|
||
expect(invokePrivateMethod($this->adapter, 'getAmountFromMinorUnits', [$minorUnits, $currency]))->toBe($expected);
|
||
})->with([
|
||
'USD' => ['USD', 100000, 1000.0],
|
||
'JPY (zero-decimal)' => ['JPY', 1000, 1000.0],
|
||
'KWD (three-decimal)' => ['KWD', 1000000, 1000.0],
|
||
'HUF' => ['HUF', 100000, 1000.0],
|
||
'UGX (two-decimal representation)' => ['UGX', 100000, 1000.0],
|
||
'unknown code falls back to two decimals' => ['XXX', 100000, 1000.0],
|
||
'lowercase currency' => ['usd', 100000, 1000.0],
|
||
]);
|
||
});
|
||
|
||
describe('Stripe refunds', function (): void {
|
||
test('advertises refund support', function (): void {
|
||
expect(Payment_Adapter_Stripe::supportsRefunds())->toBeTrue();
|
||
});
|
||
|
||
test('refunds a PaymentIntent with converted minor units', function (): void {
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => $p['payment_intent'] === 'pi_123' && $p['amount'] === 1050 && ($p['metadata']['invoice_id'] ?? null) === '5'),
|
||
Mockery::on(fn (array $o): bool => str_starts_with((string) ($o['idempotency_key'] ?? ''), 'refund_'))
|
||
)
|
||
->andReturn((object) ['id' => 're_123']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('pi_123', 10.50, 'USD', ['invoice_id' => '5']))->toBe('re_123');
|
||
});
|
||
|
||
test('refunds a charge directly and respects zero-decimal currencies', function (): void {
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => ($p['charge'] ?? null) === 'ch_123' && $p['amount'] === 1000),
|
||
Mockery::any())
|
||
->andReturn((object) ['id' => 're_456']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('ch_123', 1000, 'JPY'))->toBe('re_456');
|
||
});
|
||
|
||
test('resolves a Stripe invoice to its payment intent', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_123', ['expand' => ['payments']])->andReturn((object) ['payment_intent' => 'pi_inv', 'charge' => null]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(Mockery::on(fn (array $p): bool => ($p['payment_intent'] ?? null) === 'pi_inv' && $p['amount'] === 2000), Mockery::any())
|
||
->andReturn((object) ['id' => 're_789']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('in_123', 20.00, 'USD'))->toBe('re_789');
|
||
});
|
||
|
||
test('rejects transaction ids that cannot be refunded', function (): void {
|
||
expect(fn () => $this->adapter->refundPayment('seti_123', 5.00, 'USD'))->toThrow(Payment_Exception::class);
|
||
expect(fn () => $this->adapter->refundPayment('pi_123', 0, 'USD'))->toThrow(Payment_Exception::class);
|
||
});
|
||
|
||
test('wraps Stripe API failures', function (): void {
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')->once()->andThrow(new Stripe\Exception\InvalidRequestException('Already refunded', 400));
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect(fn () => $this->adapter->refundPayment('pi_123', 5.00, 'USD'))->toThrow(Payment_Exception::class, 'Stripe refund failed');
|
||
});
|
||
|
||
test('refund webhooks are acknowledged without keeping a transaction', function (): void {
|
||
expect(Payment_Adapter_Stripe::HANDLED_EVENT_TYPES)->toContain('charge.refunded')
|
||
->and(Payment_Adapter_Stripe::HANDLED_EVENT_TYPES)->toContain('refund.created');
|
||
|
||
$tx = buildTransaction();
|
||
$event = (object) ['id' => 'evt_1', 'type' => 'refund.created', 'data' => (object) ['object' => (object) ['id' => 're_1']]];
|
||
expect(invokePrivateMethod($this->adapter, 'handleRefundWebhook', [$tx, $event]))->toBeFalse()
|
||
->and($tx->getTxnId())->toBe('re_1');
|
||
});
|
||
});
|
||
|
||
describe('listRefundsForTransaction', function (): void {
|
||
test('maps actionable refunds and skips failed ones', function (): void {
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('all')
|
||
->once()
|
||
->with(['payment_intent' => 'pi_123', 'limit' => 100])
|
||
->andReturn((object) ['data' => [
|
||
(object) ['id' => 're_1', 'amount' => 1050, 'currency' => 'usd', 'status' => 'succeeded', 'metadata' => (object) ['invoice_id' => '5', 'lines' => '12:1']],
|
||
(object) ['id' => 're_2', 'amount' => 500, 'currency' => 'usd', 'status' => 'failed', 'metadata' => (object) []],
|
||
(object) ['id' => 're_3', 'amount' => 200, 'currency' => 'usd', 'status' => 'pending', 'metadata' => (object) []],
|
||
]]);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->listRefundsForTransaction('pi_123', 'USD'))->toBe([
|
||
['id' => 're_1', 'amount' => 10.5, 'invoice_id' => '5', 'lines' => '12:1'],
|
||
['id' => 're_3', 'amount' => 2.0, 'invoice_id' => null, 'lines' => null],
|
||
]);
|
||
});
|
||
|
||
test('resolves a Stripe invoice before listing', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_123', ['expand' => ['payments']])->andReturn((object) ['payment_intent' => 'pi_inv', 'charge' => null]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('all')->once()->with(['payment_intent' => 'pi_inv', 'limit' => 100])->andReturn((object) ['data' => []]);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->listRefundsForTransaction('in_123', 'USD'))->toBe([]);
|
||
});
|
||
});
|
||
|
||
describe('handleRefundCreatedWebhook', function (): void {
|
||
test('ignores refunds that originated in FOSSBilling', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_1',
|
||
'amount' => 2000,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_1',
|
||
'payment_intent' => 'pi_1',
|
||
'metadata' => (object) ['gateway_id' => '1', 'invoice_id' => '5'],
|
||
];
|
||
$event = (object) ['id' => 'evt_1', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
$this->adapter->setDi(container());
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]);
|
||
|
||
expect($result)->toBeFalse();
|
||
});
|
||
|
||
test('records a full dashboard refund as a credit note', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_9',
|
||
'amount' => 2000,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_9',
|
||
'payment_intent' => 'pi_9',
|
||
'metadata' => (object) [],
|
||
];
|
||
$event = (object) ['id' => 'evt_9', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
$payGateway = createEntity(PayGateway::class);
|
||
setEntityId($payGateway, 1);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
$invoiceModel->id = 5;
|
||
$invoiceModel->currency = 'USD';
|
||
$invoiceModel->setStatus(Invoice::STATUS_PAID);
|
||
$invoiceModel->setGateway($payGateway);
|
||
|
||
$paymentTx = buildTransaction();
|
||
$paymentTx->setTxnId('pi_9');
|
||
$paymentTx->setGateway($payGateway);
|
||
$paymentTx->setInvoice($invoiceModel);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findOneByTxnIdAndGatewayId')->once()->with('pi_9', 1)->andReturn($paymentTx);
|
||
$invoiceRepo->shouldReceive('find')->once()->with(5)->andReturn($invoiceModel);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->with($invoiceModel)->andReturn(20.0);
|
||
$invoiceService->shouldReceive('refundInvoice')
|
||
->once()
|
||
->with($invoiceModel, 'Recorded from Stripe dashboard refund re_9', null, ['gateway_refund' => false, 'gateway_returned' => true])
|
||
->andReturn(7);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 're_9', 1);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]);
|
||
|
||
expect($result)->toBeFalse();
|
||
});
|
||
|
||
test('leaves partial dashboard refunds for the admin', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_9',
|
||
'amount' => 500,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_9',
|
||
'payment_intent' => 'pi_9',
|
||
'metadata' => (object) [],
|
||
];
|
||
$event = (object) ['id' => 'evt_9', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
$payGateway = createEntity(PayGateway::class);
|
||
setEntityId($payGateway, 1);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
$invoiceModel->id = 5;
|
||
$invoiceModel->currency = 'USD';
|
||
$invoiceModel->setStatus(Invoice::STATUS_PAID);
|
||
$invoiceModel->setGateway($payGateway);
|
||
|
||
$paymentTx = buildTransaction();
|
||
$paymentTx->setTxnId('pi_9');
|
||
$paymentTx->setGateway($payGateway);
|
||
$paymentTx->setInvoice($invoiceModel);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findOneByTxnIdAndGatewayId')->once()->with('pi_9', 1)->andReturn($paymentTx);
|
||
$invoiceRepo->shouldReceive('find')->once()->with(5)->andReturn($invoiceModel);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(20.0);
|
||
$invoiceService->shouldNotReceive('refundInvoice');
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 're_9', 1);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]);
|
||
|
||
expect($result)->toBeFalse();
|
||
});
|
||
});
|
||
|
||
describe('handleRefundCreatedWebhook races', function (): void {
|
||
test('ignores the webhook when a concurrent refund settled the invoice first', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_9',
|
||
'amount' => 2000,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_9',
|
||
'payment_intent' => 'pi_9',
|
||
'metadata' => (object) [],
|
||
];
|
||
$event = (object) ['id' => 'evt_9', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
$payGateway = createEntity(PayGateway::class);
|
||
setEntityId($payGateway, 1);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
$invoiceModel->id = 5;
|
||
$invoiceModel->currency = 'USD';
|
||
$invoiceModel->setStatus(Invoice::STATUS_PAID);
|
||
$invoiceModel->setGateway($payGateway);
|
||
|
||
$settledInvoice = createEntity(Invoice::class);
|
||
$settledInvoice->id = 5;
|
||
$settledInvoice->currency = 'USD';
|
||
$settledInvoice->setStatus(Invoice::STATUS_REFUNDED);
|
||
$settledInvoice->setGateway($payGateway);
|
||
|
||
$paymentTx = buildTransaction();
|
||
$paymentTx->setTxnId('pi_9');
|
||
$paymentTx->setGateway($payGateway);
|
||
$paymentTx->setInvoice($invoiceModel);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findOneByTxnIdAndGatewayId')->once()->with('pi_9', 1)->andReturn($paymentTx);
|
||
$invoiceRepo->shouldReceive('find')->twice()->with(5)->andReturn($invoiceModel, $settledInvoice);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(20.0);
|
||
$invoiceService->shouldReceive('refundInvoice')
|
||
->once()
|
||
->andThrow(new FOSSBilling\Core\Exception\InformationException('Only paid invoices can be refunded'));
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 're_9', 1);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
$result = invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]);
|
||
|
||
expect($result)->toBeFalse()->and($tx->getStatus())->toBe(Transaction::STATUS_RECEIVED);
|
||
});
|
||
});
|
||
|
||
describe('resolveRefundTargetForStripeInvoice', function (): void {
|
||
test('resolves through the payments list when legacy fields are absent', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_456', ['expand' => ['payments']])->andReturn((object) [
|
||
'payment_intent' => null,
|
||
'charge' => null,
|
||
'payments' => (object) ['data' => [(object) ['status' => 'paid', 'payment' => (object) ['type' => 'payment_intent', 'payment_intent' => 'pi_from_list']]]],
|
||
]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => ($p['payment_intent'] ?? null) === 'pi_from_list'),
|
||
Mockery::any())
|
||
->andReturn((object) ['id' => 're_1']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('in_456', 20.00, 'USD'))->toBe('re_1');
|
||
});
|
||
|
||
test('resolves an expanded payment object through its id', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_789', ['expand' => ['payments']])->andReturn((object) [
|
||
'payments' => (object) ['data' => [(object) ['payment' => (object) ['id' => 'ch_expanded', 'object' => 'charge']]]],
|
||
]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => ($p['charge'] ?? null) === 'ch_expanded'),
|
||
Mockery::any())
|
||
->andReturn((object) ['id' => 're_2']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('in_789', 20.00, 'USD'))->toBe('re_2');
|
||
});
|
||
|
||
test('resolves the wrapped payment reference and skips unpaid entries', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_321', ['expand' => ['payments']])->andReturn((object) [
|
||
'payment_intent' => null,
|
||
'charge' => null,
|
||
'payments' => (object) ['data' => [
|
||
(object) ['status' => 'open', 'payment' => (object) ['type' => 'payment_intent', 'payment_intent' => 'pi_open']],
|
||
(object) ['status' => 'canceled', 'payment' => (object) ['type' => 'charge', 'charge' => 'ch_dead']],
|
||
(object) ['status' => 'paid', 'payment' => (object) ['type' => 'payment_intent', 'payment_intent' => 'pi_wrap']],
|
||
]],
|
||
]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => ($p['payment_intent'] ?? null) === 'pi_wrap'),
|
||
Mockery::any())
|
||
->andReturn((object) ['id' => 're_3']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('in_321', 20.00, 'USD'))->toBe('re_3');
|
||
});
|
||
|
||
test('resolves an expanded object nested in the payment wrapper', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_654', ['expand' => ['payments']])->andReturn((object) [
|
||
'payment_intent' => null,
|
||
'charge' => null,
|
||
'payments' => (object) ['data' => [
|
||
(object) ['status' => 'paid', 'payment' => (object) ['type' => 'payment_intent', 'payment_intent' => (object) ['id' => 'pi_nested', 'object' => 'payment_intent']]],
|
||
]],
|
||
]);
|
||
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')
|
||
->once()
|
||
->with(
|
||
Mockery::on(fn (array $p): bool => ($p['payment_intent'] ?? null) === 'pi_nested'),
|
||
Mockery::any())
|
||
->andReturn((object) ['id' => 're_4']);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect($this->adapter->refundPayment('in_654', 20.00, 'USD'))->toBe('re_4');
|
||
});
|
||
|
||
test('throws when only unpaid invoice payments exist', function (): void {
|
||
$invoices = Mockery::mock();
|
||
$invoices->shouldReceive('retrieve')->once()->with('in_987', ['expand' => ['payments']])->andReturn((object) [
|
||
'payment_intent' => null,
|
||
'charge' => null,
|
||
'payments' => (object) ['data' => [
|
||
(object) ['status' => 'open', 'payment' => (object) ['type' => 'payment_intent', 'payment_intent' => 'pi_open']],
|
||
]],
|
||
]);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->invoices = $invoices;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
expect(fn (): string => $this->adapter->refundPayment('in_987', 20.00, 'USD'))->toThrow(Payment_Exception::class, 'has no refundable payment');
|
||
});
|
||
});
|
||
|
||
describe('refund idempotency keys', function (): void {
|
||
test('issues distinct keys per refunded line selection', function (): void {
|
||
$capturedKeys = [];
|
||
$refunds = Mockery::mock();
|
||
$refunds->shouldReceive('create')->times(3)->andReturnUsing(function (array $params, array $opts) use (&$capturedKeys): object {
|
||
$capturedKeys[] = $opts['idempotency_key'] ?? null;
|
||
|
||
return (object) ['id' => 're_x'];
|
||
});
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->refunds = $refunds;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
$base = ['invoice_id' => '5', 'gateway_id' => '1'];
|
||
$this->adapter->refundPayment('pi_1', 10.00, 'USD', $base + ['lines' => '12:1']);
|
||
$this->adapter->refundPayment('pi_1', 10.00, 'USD', $base + ['lines' => '13:1']);
|
||
$this->adapter->refundPayment('pi_1', 10.00, 'USD', $base + ['lines' => '12:1']);
|
||
|
||
expect($capturedKeys)->toHaveCount(3)
|
||
->and($capturedKeys[0])->not->toBe($capturedKeys[1])
|
||
->and($capturedKeys[0])->toBe($capturedKeys[2]);
|
||
});
|
||
});
|
||
|
||
describe('handleRefundCreatedWebhook invoice payment fallback', function (): void {
|
||
test('records a subscription dashboard refund resolved through the invoice payment list', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_9',
|
||
'amount' => 2000,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_sub',
|
||
'payment_intent' => 'pi_sub',
|
||
'metadata' => (object) [],
|
||
];
|
||
$event = (object) ['id' => 'evt_9', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
$payGateway = createEntity(PayGateway::class);
|
||
setEntityId($payGateway, 1);
|
||
|
||
$invoiceModel = createEntity(Invoice::class);
|
||
$invoiceModel->id = 5;
|
||
$invoiceModel->currency = 'USD';
|
||
$invoiceModel->setStatus(Invoice::STATUS_PAID);
|
||
$invoiceModel->setGateway($payGateway);
|
||
|
||
$paymentTx = buildTransaction();
|
||
$paymentTx->setTxnId('in_9');
|
||
$paymentTx->setGateway($payGateway);
|
||
$paymentTx->setInvoice($invoiceModel);
|
||
|
||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findOneByTxnIdAndGatewayId')->times(3)->andReturnUsing(
|
||
fn (string $txnId, int $gatewayId): ?object => $txnId === 'in_9' ? $paymentTx : null
|
||
);
|
||
$invoiceRepo->shouldReceive('find')->once()->with(5)->andReturn($invoiceModel);
|
||
|
||
$charges = Mockery::mock();
|
||
$charges->shouldReceive('retrieve')->once()->with('ch_sub', [])->andReturn((object) ['payment_intent' => 'pi_sub']);
|
||
|
||
$invoicePayments = Mockery::mock();
|
||
$invoicePayments->shouldReceive('all')
|
||
->once()
|
||
->with(['payment' => ['type' => 'payment_intent', 'payment_intent' => 'pi_sub'], 'limit' => 1])
|
||
->andReturn((object) ['data' => [(object) ['invoice' => 'in_9']]]);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldReceive('getTotalWithTax')->once()->with($invoiceModel)->andReturn(20.0);
|
||
$invoiceService->shouldReceive('refundInvoice')
|
||
->once()
|
||
->with($invoiceModel, 'Recorded from Stripe dashboard refund re_9', null, ['gateway_refund' => false, 'gateway_returned' => true])
|
||
->andReturn(7);
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 're_9', 1);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->charges = $charges;
|
||
$stripe->invoicePayments = $invoicePayments;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]))->toBeFalse();
|
||
});
|
||
|
||
test('ignores the refund when the invoice payment list is empty', function (): void {
|
||
$tx = buildTransaction();
|
||
$refund = (object) [
|
||
'id' => 're_9',
|
||
'amount' => 2000,
|
||
'currency' => 'usd',
|
||
'charge' => 'ch_sub',
|
||
'payment_intent' => 'pi_unknown',
|
||
'metadata' => (object) [],
|
||
];
|
||
$event = (object) ['id' => 'evt_9', 'type' => 'refund.created', 'data' => (object) ['object' => $refund]];
|
||
|
||
['em' => $em, 'txRepo' => $txRepo] = buildEntityManagerMocks();
|
||
$txRepo->shouldReceive('findOneByTxnIdAndGatewayId')->twice()->andReturn(null);
|
||
|
||
$charges = Mockery::mock();
|
||
$charges->shouldReceive('retrieve')->once()->with('ch_sub', [])->andReturn((object) ['payment_intent' => 'pi_unknown']);
|
||
|
||
$invoicePayments = Mockery::mock();
|
||
$invoicePayments->shouldReceive('all')->once()->andReturn((object) ['data' => []]);
|
||
|
||
$invoiceService = Mockery::mock();
|
||
$invoiceService->shouldNotReceive('refundInvoice');
|
||
|
||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||
expectStripeObjectLock($dbalMock, 're_9', 1);
|
||
|
||
$stripe = Mockery::mock(StripeClient::class);
|
||
$stripe->charges = $charges;
|
||
$stripe->invoicePayments = $invoicePayments;
|
||
setPrivateProperty($this->adapter, 'stripe', $stripe);
|
||
|
||
$di = container();
|
||
$di['dbal'] = $dbalMock;
|
||
$di['em'] = $em;
|
||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||
$di['mod_service'] = $di->protect(fn ($module, $service = null) => $invoiceService);
|
||
$this->adapter->setDi($di);
|
||
|
||
expect(invokePrivateMethod($this->adapter, 'handleRefundCreatedWebhook', [$tx, $event, 1]))->toBeFalse();
|
||
});
|
||
});
|