mirror of
https://github.com/FOSSBilling/FOSSBilling.git
synced 2026-10-23 03:37:01 +09:00
Invoice immutability, issuing model, and audit journal (#4423)
* Add invoice cancel and immutability flow Introduce admin invoice cancel/void support with new before/after events, UI actions, and API/service handling for approved unpaid invoices. Consolidate legacy invoice edit/delete toggles into a single `invoice_immutability` setting, tighten issued-invoice edit rules, update expired unpaid invoice cleanup to void issued invoices instead of deleting them. * Rename invoice approval flow to issuing * Refine invoice draft and void workflows * Add invoice journal history tracking Add an invoice event journal to capture lifecycle changes with trimmed snapshots, expose it through the admin API, and replace the invoice billing-parties tab with a history view and JSON download. This also adds upgrade patches and a backfill so existing installs get a baseline journal entry for invoices that predate the feature, plus tests and schema snapshot updates for the new entity. * Fix client page rendering and JSON encoding Move client related-list queries to the tab scope so shared footer blocks can access them under Twig strict_variables, and add a regression test for the manage page render path. Also harden JSON output in invoice/update patch flows by throwing on journal encoding failures and substituting invalid UTF-8 in legacy invoice snapshots so one bad row does not block updates. * Harden invoice journal follow-ups: consumed-keys changes, activity log lines, translatable labels, E2E coverage * Fix datepicker clear button pushing calendar icon over the field value * Use DBAL 4.5 introspectTableByUnquotedName in invoice patch helpers * Harden invoice issuing and payment recording Remove the orphaned tax_setup_eu endpoint left behind by #1826, whitelist draft statuses, guard first issuance to unpaid drafts, snapshot auto-issued funds invoices, and record the paid journal after the admin paid_at override * Harden invoice money paths Manual refunds no longer log, journal, or dispatch as performed: the skip is recorded distinctly. payInvoiceWithCredits returns the real outcome and the batch variant counts covered/skipped/failed with per-invoice warnings. Processed transactions freeze their history fields, refuse deletion, and clean up orphaned balance rows on unprocessed deletes; transaction linkage refuses canceled/refunded/replaced invoices. processInvoice rejects paid invoices, and the expiry cron rechecks deletability under the row lock. rmByClient is documented as intentional erasure and pinned by test. * Remove dead legacy IPN pipeline, serialize transaction processing The legacy process() pipeline (_parseIpnAndApprove, _debit/_refund/_subscribe/ _unsubscribe, amount/currency validation, debitTransaction) had no production callers: live processing runs through gateway adapters via preProcess. Delete ~350 lines of unreachable payment code plus its tests and the now-unused findOneProcessedByTxnId finder, and wire the atomic claimForProcessing into preProcess, createAndProcess, and processAndCatchErrors so concurrent deliveries converge on a single processor. * Expand invoice coverage and finish consistency sweep Number-series fallback paths, non-default currency payment and missing-rate refusal, tax-rate effect on totals, batch activation isolation and batch delete fail-fast, client pay/PDF controller auth matrix, expiry-cron disabled and isolation paths, zero-total and already-paid email skips, and the reissue replacement email variable. Billing-channel debug lines and the prepare instant-issue warning now carry structured context. * Pin no-gap guarantee for rolled-back number reservations * Document journal retention, log base-currency rewrites on issued invoices * Record offline refund documents in manual refund mode Manual mode no longer returns null while the UI reports success: refunding a paid invoice now generates a credit-note-series document marked as an offline refund (note plus journal flag), so money moved outside FOSSBilling leaves a paper trail. Unknown refund-logic values fail loudly instead of silently skipping. * Complete GDPR erasure: anonymize transactions, cascade support cleanup, refuse deletion with active services Detached transactions are anonymized in place on client erasure (IP, raw IPN payloads, notes scrubbed; financial record kept). Support erasure now removes notes, messages, and edit history instead of orphaning them. Client deletion is refused while provisioned service rows exist, forcing explicit cancellation first. Also quotes the reserved table name portably: invoice deletion and the transaction counter/pickup were syntax errors on SQLite. * Add live client-erasure walkthrough test * Bypass rate limiter in development, fix antispam E2E state pollution * Remove stale Invoice draft comments * Fix spellcheck findings: rename UPDATEABLE_FIELDS, reword coinages * Tolerate missing service tables in client-deletion gate * Exercise promo add-remove on drafts in E2E * Apply Rector suggestions in invoice tests * Address PR review: single-claim transactions, frozen seller snapshot, issuance guard * Trim repeated no-reclaim comments in Stripe adapter * Run schema sync before journal backfill on upgrade * Address second review round: claim quoting, patch recovery, bounded journal healing * Serialize journal baseline writes with a per-invoice claim * Surface instant-issue failures instead of returning a silent draft
This commit is contained in:
1 parent
78b1a23d84
commit
e8f375db3b
78 files changed
+6391
-1324
No files matched your search
@@ -247,6 +247,13 @@ return [
|
||||
'rate_limiter' => [
|
||||
'enabled' => true,
|
||||
|
||||
/*
|
||||
* Development environments (APP_ENV=dev) bypass rate limiting so local
|
||||
* development and E2E runs are never throttled. Set this to true to
|
||||
* test throttling behavior on a development box.
|
||||
*/
|
||||
// 'enforce_in_development' => true,
|
||||
|
||||
/*
|
||||
* Any IP address within this list will not be put through the rate-limiter system.
|
||||
* This is useful if you have an application with a static IP address that needs to make frequent API requests to FOSSBilling.
|
||||
|
||||
@@ -304,7 +304,7 @@ LOCK TABLES `setting` WRITE;
|
||||
|
||||
INSERT INTO `setting` (`id`, `param`, `value`, `public`, `category`, `hash`, `created_at`, `updated_at`)
|
||||
VALUES
|
||||
(1,'last_patch','120',0,NULL,NULL,NOW(),NOW()),
|
||||
(1,'last_patch','124',0,NULL,NULL,NOW(),NOW()),
|
||||
(2,'company_name','Company Name',0,NULL,NULL,NOW(),NOW()),
|
||||
(3,'company_email','support@yourcompany.com',0,NULL,NULL,NOW(),NOW()),
|
||||
(4,'company_signature','FOSSBilling.org - Client Management, Invoicing and Support Software',0,NULL,NULL,NOW(),NOW()),
|
||||
@@ -319,7 +319,7 @@ VALUES
|
||||
(13,'company_note','This is a placeholder for your About Us page. It will be available in /about-us.\nReplace this with information about your company, such as:\n- Your company history and mission\n- Services offered\n- Values and vision\n- Contact or support information\n- Any other details you want your clients to know about your business',0,NULL,NULL,NOW(),NOW()),
|
||||
(14,'invoice_series','FOSS',0,NULL,NULL,NOW(),NOW()),
|
||||
(15,'invoice_due_days','5',0,NULL,NULL,NOW(),NOW()),
|
||||
(16,'invoice_auto_approval','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(16,'invoice_auto_issue','1',0,NULL,NULL,NOW(),NOW()),
|
||||
(17,'invoice_issue_days_before_expire','14',0,NULL,NULL,NOW(),NOW()),
|
||||
(18,'theme','default/client',0,NULL,NULL,NOW(),NOW()),
|
||||
(19,'issue_invoice_days_before_expire','7',0,NULL,NULL,NOW(),NOW()),
|
||||
|
||||
@@ -12,6 +12,7 @@ declare(strict_types=1);
|
||||
namespace FOSSBilling\Security;
|
||||
|
||||
use FOSSBilling\Config;
|
||||
use FOSSBilling\Environment;
|
||||
use FOSSBilling\InjectionAwareInterface;
|
||||
use Pimple\Container;
|
||||
use Psr\Cache\CacheItemPoolInterface;
|
||||
@@ -44,6 +45,10 @@ class RateLimiter implements InjectionAwareInterface
|
||||
{
|
||||
return [
|
||||
'enabled' => true,
|
||||
// Development environments (APP_ENV=dev: DDEV, local checkouts) bypass rate
|
||||
// limiting so local development and E2E runs are never throttled by shared
|
||||
// budgets. Set enforce_in_development to true to test throttling locally.
|
||||
'enforce_in_development' => false,
|
||||
'whitelist_ips' => [],
|
||||
'policies' => [
|
||||
'api_guest' => ['policy' => 'token_bucket', 'limit' => 100, 'interval' => '60 seconds'],
|
||||
@@ -86,7 +91,7 @@ class RateLimiter implements InjectionAwareInterface
|
||||
$config = $this->getConfig();
|
||||
$policy = $config['policies'][$policyName] ?? null;
|
||||
|
||||
if (($config['enabled'] ?? true) === false) {
|
||||
if (!$this->isEnabled()) {
|
||||
return new RateLimitResult($policyName, false, null, null, null, RateLimitResult::REASON_DISABLED);
|
||||
}
|
||||
|
||||
@@ -142,7 +147,18 @@ class RateLimiter implements InjectionAwareInterface
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return ($this->getConfig()['enabled'] ?? true) !== false;
|
||||
$config = $this->getConfig();
|
||||
if (($config['enabled'] ?? true) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Development bypass (see getDefaultConfig()): a dev box is never throttled unless
|
||||
// the operator explicitly opts back in. Production and test environments are unaffected.
|
||||
if (Environment::isDevelopment() && ($config['enforce_in_development'] ?? false) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listIpCounters(?string $ip = null): array
|
||||
|
||||
@@ -211,12 +211,21 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
// MySQL-only patch can run there.
|
||||
$this->seedInvoiceNoteSettings();
|
||||
|
||||
// Portable invoice settings/rename steps, shared with the drift healer.
|
||||
$this->applyPortableInvoiceMigrations();
|
||||
|
||||
// Additive structural sync runs on every platform, MySQL/MariaDB included: it picks up any
|
||||
// column/table/index that's on entity metadata but not yet applied, without needing a
|
||||
// hand-written patch for it - the only mechanism at all on PostgreSQL/SQLite, and on
|
||||
// MySQL/MariaDB a catch-all for anything the patches above didn't (or, going forward, for
|
||||
// structural changes that land on metadata without a patch being written at all).
|
||||
$this->syncPortableSchema();
|
||||
|
||||
// Baseline the invoice journal on every driver, after the sync above: on non-MySQL
|
||||
// installs the invoice_event table only comes into existence there, and backfilling
|
||||
// first would find no table and leave existing invoices without baseline entries.
|
||||
// Deliberately outside the drift healer: a large backlog must not stall page loads.
|
||||
$this->backfillInvoiceJournal();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +248,10 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
* code-only deploy (e.g. `git pull` to a commit that adds an entity column without bumping
|
||||
* Version::VERSION) still gets its schema updated instead of crashing on the next query.
|
||||
*
|
||||
* Runs the portable invoice migrations before the additive sync: renames
|
||||
* cannot be expressed by the sync, so without this a renamed column would
|
||||
* be synced as an empty duplicate (or left crashing).
|
||||
*
|
||||
* The gate is EntityManagerFactory::entityDefinitionsHash(), a content hash every node running
|
||||
* the same code computes identically - the last-synced value is kept in a plain setting row
|
||||
* (SCHEMA_METADATA_HASH_PARAM), so a match costs file reads plus a single SELECT and runs
|
||||
@@ -264,12 +277,25 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
// same-version path it never runs on doesn't leave them missing either.
|
||||
$this->seedInvoiceNoteSettings();
|
||||
|
||||
// Rename steps must precede the additive sync below, which cannot
|
||||
// express them (see applyPortableInvoiceMigrations()).
|
||||
$this->applyPortableInvoiceMigrations();
|
||||
|
||||
if ($this->syncPortableSchema() === null) {
|
||||
$this->recordFailedSyncAttempt($currentHash);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Heal journal gaps left by drift-created tables or lost writes, bounded to
|
||||
// one page so a large backlog converges over runs instead of stalling this one.
|
||||
// Failure here must not fail the sync itself, which already succeeded.
|
||||
try {
|
||||
$this->healInvoiceJournal();
|
||||
} catch (\Throwable $e) {
|
||||
$this->logUpdate('error', 'Invoice journal healing failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logUpdate('error', 'Ambient schema sync failed: ' . $e->getMessage());
|
||||
@@ -412,6 +438,336 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Portable, idempotent invoice settings/rename steps shared by
|
||||
* applyCorePatches() and ensureSchemaInSync(). The additive schema sync
|
||||
* cannot express the approved-to-issued rename, so the drift-healing path
|
||||
* runs these first - otherwise a code-only deploy would sync an empty
|
||||
* issued column alongside the data-bearing approved one (or crash).
|
||||
*/
|
||||
private function applyPortableInvoiceMigrations(): void
|
||||
{
|
||||
$this->migrateInvoiceImmutabilitySetting();
|
||||
|
||||
// Paid invoices keep their issued number, so the retired paid-only
|
||||
// series has no reader left. Remove it unconditionally.
|
||||
$this->executeSql('DELETE FROM setting WHERE param = :param', ['param' => 'invoice_series_paid']);
|
||||
|
||||
// The auto-approval toggle was renamed to match the issue terminology.
|
||||
$this->migrateInvoiceAutoIssueSetting();
|
||||
|
||||
// The invoice approval flag was renamed to issued, matching the new
|
||||
// terminology. Runs before the structural sync so the sync sees
|
||||
// the renamed column instead of adding it alongside the legacy one.
|
||||
$this->renameInvoiceApprovedColumn();
|
||||
|
||||
// The column rename leaves the legacy composite index name behind, and
|
||||
// the structural sync only ever adds - so without this the old name
|
||||
// lingers as a duplicate forever.
|
||||
$this->renameInvoiceStatusIndex();
|
||||
|
||||
// The dead buyer_phone_cc column is gone from entity metadata, and the
|
||||
// structural sync only ever adds - so without this the column lingers
|
||||
// on existing installs forever.
|
||||
$this->dropInvoiceBuyerPhoneCcColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds the retired per-action invoice toggles (invoice_allow_edit_unpaid,
|
||||
* invoice_allow_delete_approved) into the single invoice_immutability
|
||||
* setting, then removes the legacy rows. Either legacy opt-in maps to
|
||||
* 'relaxed', preserving behavior; otherwise the install converges to
|
||||
* 'strict'. Plain portable SQL and idempotent like seedInvoiceNoteSettings(),
|
||||
* so it runs unconditionally from applyCorePatches() on every platform.
|
||||
*/
|
||||
private function migrateInvoiceImmutabilitySetting(): void
|
||||
{
|
||||
$fetch = fn (string $param): mixed => $this->fetchOne('SELECT value FROM setting WHERE param = :param', [
|
||||
'param' => $param,
|
||||
]);
|
||||
|
||||
$migrated = $fetch('invoice_immutability');
|
||||
if ($migrated === false) {
|
||||
$edit = $fetch('invoice_allow_edit_unpaid');
|
||||
$delete = $fetch('invoice_allow_delete_approved');
|
||||
if ($edit !== false || $delete !== false) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->executeSql(
|
||||
'INSERT INTO setting (param, value, public, created_at, updated_at) VALUES (:param, :value, 0, :created_at, :updated_at)',
|
||||
[
|
||||
'param' => 'invoice_immutability',
|
||||
'value' => ($edit === '1' || $delete === '1') ? 'relaxed' : 'strict',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$this->executeSql('DELETE FROM setting WHERE param = :param', ['param' => 'invoice_allow_edit_unpaid']);
|
||||
$this->executeSql('DELETE FROM setting WHERE param = :param', ['param' => 'invoice_allow_delete_approved']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Carries the retired invoice_auto_approval toggle over to its renamed
|
||||
* invoice_auto_issue successor, then drops the legacy row. Like
|
||||
* migrateInvoiceImmutabilitySetting(), plain portable SQL and idempotent,
|
||||
* so it runs unconditionally from applyCorePatches() on every platform.
|
||||
*/
|
||||
private function migrateInvoiceAutoIssueSetting(): void
|
||||
{
|
||||
$autoApproval = $this->fetchOne('SELECT value FROM setting WHERE param = :param', [
|
||||
'param' => 'invoice_auto_approval',
|
||||
]);
|
||||
if ($autoApproval === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$autoIssue = $this->fetchOne('SELECT value FROM setting WHERE param = :param', [
|
||||
'param' => 'invoice_auto_issue',
|
||||
]);
|
||||
if ($autoIssue === false) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->executeSql(
|
||||
'INSERT INTO setting (param, value, public, created_at, updated_at) VALUES (:param, :value, 0, :created_at, :updated_at)',
|
||||
[
|
||||
'param' => 'invoice_auto_issue',
|
||||
'value' => $autoApproval,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->executeSql('DELETE FROM setting WHERE param = :param', ['param' => 'invoice_auto_approval']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames invoice.approved to invoice.issued, preserving values. Portable
|
||||
* across drivers via the DBAL schema manager for the existence check;
|
||||
* MySQL/MariaDB uses CHANGE (works on versions without RENAME COLUMN
|
||||
* support), everything else uses RENAME COLUMN. Idempotent: a fresh
|
||||
* install already has issued, and a migrated one no longer has approved.
|
||||
*/
|
||||
private function renameInvoiceApprovedColumn(): void
|
||||
{
|
||||
if (!$this->di instanceof \Pimple\Container || !$this->di->offsetExists('dbal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = $this->di['dbal']->createSchemaManager()->introspectTableByUnquotedName('invoice');
|
||||
if (!$table->hasColumn('approved') || $table->hasColumn('issued')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isMysqlDriver()) {
|
||||
$this->executeSql('ALTER TABLE `invoice` CHANGE `approved` `issued` TINYINT(1) NOT NULL DEFAULT 0');
|
||||
} else {
|
||||
$this->executeSql('ALTER TABLE invoice RENAME COLUMN approved TO issued');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the legacy invoice_status_approved_due_at_idx composite index for
|
||||
* its issued-column successor. Portable across drivers via the DBAL schema
|
||||
* manager for the existence checks; runs unconditionally from
|
||||
* applyCorePatches() on every platform like the rest of this block.
|
||||
*/
|
||||
private function renameInvoiceStatusIndex(): void
|
||||
{
|
||||
if (!$this->di instanceof \Pimple\Container || !$this->di->offsetExists('dbal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$old = 'invoice_status_approved_due_at_idx';
|
||||
$new = 'invoice_status_issued_due_at_idx';
|
||||
|
||||
$schemaManager = $this->di['dbal']->createSchemaManager();
|
||||
if ($schemaManager->introspectTableByUnquotedName('invoice')->hasIndex($old)) {
|
||||
$this->executeSql($this->isMysqlDriver()
|
||||
? "DROP INDEX `$old` ON `invoice`"
|
||||
: "DROP INDEX $old");
|
||||
}
|
||||
|
||||
if (!$schemaManager->introspectTableByUnquotedName('invoice')->hasIndex($new)) {
|
||||
$this->executeSql('CREATE INDEX ' . $new . ' ON invoice (status, issued, due_at)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the dead invoice.buyer_phone_cc column, which the entity no
|
||||
* longer maps. Portable across drivers via the DBAL schema manager for
|
||||
* the existence check; every supported driver accepts DROP COLUMN.
|
||||
* Idempotent: fresh installs never have it, migrated ones no longer do.
|
||||
*/
|
||||
private function dropInvoiceBuyerPhoneCcColumn(): void
|
||||
{
|
||||
if (!$this->di instanceof \Pimple\Container || !$this->di->offsetExists('dbal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->di['dbal']->createSchemaManager()->introspectTableByUnquotedName('invoice')->hasColumn('buyer_phone_cc')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->executeSql($this->isMysqlDriver()
|
||||
? 'ALTER TABLE `invoice` DROP COLUMN `buyer_phone_cc`'
|
||||
: 'ALTER TABLE invoice DROP COLUMN buyer_phone_cc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one baseline journal entry per invoice that has none, typed by
|
||||
* its actual current state (drafts as created, the rest by status). No
|
||||
* history is fabricated: installs upgrading to the journal get a truthful
|
||||
* starting point, and every later transition appends live entries.
|
||||
* Portable across drivers; idempotent: invoices that already have
|
||||
* journal rows are skipped. Runs from applyCorePatches() only, never from the drift
|
||||
* healer, so a large backlog can't stall page loads.
|
||||
*/
|
||||
private function backfillInvoiceJournal(int $maxPages = 0): void
|
||||
{
|
||||
if (!$this->di instanceof \Pimple\Container || !$this->di->offsetExists('dbal')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dbal = $this->di['dbal'];
|
||||
if (!$dbal->createSchemaManager()->tablesExist(['invoice_event'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$padding = $dbal->fetchOne("SELECT value FROM setting WHERE param = 'invoice_number_padding'");
|
||||
$padding = is_numeric($padding) && (int) $padding > 0 ? (int) $padding : 5;
|
||||
|
||||
// Keyset pages, not one unbounded read: on the first upgrade no invoice has a
|
||||
// journal row, so an unpaged SELECT would load every invoice (including its
|
||||
// notes/text blobs) into memory at once. Only the snapshot columns are read.
|
||||
// Idempotent: written rows drop out of later pages via the LEFT JOIN.
|
||||
// $maxPages bounds periodic healing (cron, drift sync): 0 means no limit.
|
||||
$lastId = 0;
|
||||
$pages = 0;
|
||||
do {
|
||||
$rows = $dbal->fetchAllAssociative(
|
||||
'SELECT i.id, i.nr, i.serie, i.status, i.issued, i.client_id,
|
||||
i.buyer_first_name, i.buyer_last_name, i.buyer_company, i.buyer_company_vat,
|
||||
i.buyer_company_number, i.buyer_address, i.buyer_city, i.buyer_state,
|
||||
i.buyer_country, i.buyer_phone, i.buyer_email, i.buyer_zip,
|
||||
i.seller_company, i.seller_company_vat, i.seller_company_number,
|
||||
i.seller_address, i.seller_phone, i.seller_email,
|
||||
i.paid_at, i.due_at, i.created_at
|
||||
FROM invoice i LEFT JOIN invoice_event e ON e.invoice_id = i.id
|
||||
WHERE e.id IS NULL AND i.id > :last ORDER BY i.id LIMIT 500',
|
||||
['last' => $lastId]
|
||||
);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$lastId = (int) $row['id'];
|
||||
$this->writeBackfillJournalEntry($dbal, $row, $padding);
|
||||
}
|
||||
++$pages;
|
||||
} while ($rows !== [] && ($maxPages === 0 || $pages < $maxPages));
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal invoices missing every journal row (a lost journal write, or a table created
|
||||
* after its invoices by drift healing). Bounded to one page per call so periodic
|
||||
* callers converge without stalling; reruns pick up where this one stopped.
|
||||
*/
|
||||
public function healInvoiceJournal(int $maxPages = 1): void
|
||||
{
|
||||
$this->backfillInvoiceJournal($maxPages);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private function writeBackfillJournalEntry(\Doctrine\DBAL\Connection $dbal, array $row, int $padding): void
|
||||
{
|
||||
$invoiceId = (int) $row['id'];
|
||||
|
||||
// Claim the baseline first: concurrent healers (overlapping cron runs, or drift
|
||||
// healing racing cron) select the same event-less rows. This conditional UPDATE
|
||||
// is atomic on every driver, so exactly one winner per invoice proceeds while
|
||||
// losers - including runs that arrive after a live event landed - skip instead
|
||||
// of writing a duplicate baseline.
|
||||
$claimed = (bool) $dbal->executeStatement(
|
||||
'UPDATE invoice SET updated_at = updated_at WHERE id = :id AND NOT EXISTS (SELECT 1 FROM invoice_event WHERE invoice_id = :id)',
|
||||
['id' => $invoiceId]
|
||||
);
|
||||
if (!$claimed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$nr = is_numeric($row['nr'] ?? null) ? (int) $row['nr'] : $invoiceId;
|
||||
$snapshot = [
|
||||
'serie_nr' => ($row['serie'] ?? '') . sprintf('%0' . $padding . 's', $nr),
|
||||
'status' => $row['status'] ?? null,
|
||||
'issued' => !empty($row['issued']),
|
||||
'subtotal' => null,
|
||||
'tax' => null,
|
||||
'total' => null,
|
||||
'buyer' => [
|
||||
'first_name' => $row['buyer_first_name'] ?? null,
|
||||
'last_name' => $row['buyer_last_name'] ?? null,
|
||||
'company' => $row['buyer_company'] ?? null,
|
||||
'company_vat' => $row['buyer_company_vat'] ?? null,
|
||||
'company_number' => $row['buyer_company_number'] ?? null,
|
||||
'address' => $row['buyer_address'] ?? null,
|
||||
'city' => $row['buyer_city'] ?? null,
|
||||
'state' => $row['buyer_state'] ?? null,
|
||||
'country' => $row['buyer_country'] ?? null,
|
||||
'phone' => $row['buyer_phone'] ?? null,
|
||||
'email' => $row['buyer_email'] ?? null,
|
||||
'zip' => $row['buyer_zip'] ?? null,
|
||||
],
|
||||
'seller' => [
|
||||
'company' => $row['seller_company'] ?? null,
|
||||
'company_vat' => $row['seller_company_vat'] ?? null,
|
||||
'company_number' => $row['seller_company_number'] ?? null,
|
||||
'address' => $row['seller_address'] ?? null,
|
||||
'phone' => $row['seller_phone'] ?? null,
|
||||
'email' => $row['seller_email'] ?? null,
|
||||
],
|
||||
'paid_at' => $this->normalizeBackfillDate($row['paid_at'] ?? null),
|
||||
'due_at' => $this->normalizeBackfillDate($row['due_at'] ?? null),
|
||||
'created_at' => $this->normalizeBackfillDate($row['created_at'] ?? null),
|
||||
];
|
||||
|
||||
$type = 'created';
|
||||
if (!empty($row['issued'])) {
|
||||
$type = match ($row['status'] ?? null) {
|
||||
'paid' => 'paid',
|
||||
'canceled' => 'canceled',
|
||||
'refunded' => 'refunded',
|
||||
default => 'issued',
|
||||
};
|
||||
}
|
||||
|
||||
$dbal->executeStatement(
|
||||
'INSERT INTO invoice_event (invoice_id, type, client_id, snapshot, created_at) VALUES (:invoice_id, :type, :client_id, :snapshot, :created_at)',
|
||||
[
|
||||
'invoice_id' => $invoiceId,
|
||||
'type' => $type,
|
||||
'client_id' => $row['client_id'] !== null ? (int) $row['client_id'] : null,
|
||||
// Substitute rather than throw on legacy bytes that cannot be encoded:
|
||||
// a single bad row must not wedge the whole patch run.
|
||||
'snapshot' => json_encode($snapshot, JSON_INVALID_UTF8_SUBSTITUTE),
|
||||
'created_at' => $snapshot['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private function normalizeBackfillDate(mixed $value): ?string
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d H:i:s');
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the metadata identity the schema was last synced against, in the same plain
|
||||
* setting row ensureSchemaInSync() compares. Portable check-then-write, no
|
||||
@@ -921,6 +1277,10 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
118 => 'patch118',
|
||||
119 => 'patch119',
|
||||
120 => 'patch120',
|
||||
121 => 'patch121',
|
||||
122 => 'patch122',
|
||||
123 => 'patch123',
|
||||
124 => 'patch124',
|
||||
];
|
||||
ksort($patches, SORT_NATURAL);
|
||||
|
||||
@@ -4021,6 +4381,60 @@ class UpdatePatcher implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
private function patch121(): void
|
||||
{
|
||||
// The invoice issue-terminology rename moves invoice.approved to
|
||||
// invoice.issued. Copy values into the new column, then drop the
|
||||
// legacy one; the portable rename covers non-MySQL drivers, and the
|
||||
// index name is swapped by renameInvoiceStatusIndex() below.
|
||||
// The copy runs on every pass until the legacy column is gone, not just on
|
||||
// creation: DDL auto-commits, so a crash between ADD and UPDATE leaves both
|
||||
// columns behind with issued still defaulted, and a create-only guard would
|
||||
// skip the copy forever afterward. Re-copying is idempotent.
|
||||
if ($this->tableHasColumn('invoice', 'approved')) {
|
||||
if (!$this->tableHasColumn('invoice', 'issued')) {
|
||||
$this->executeSql('ALTER TABLE `invoice` ADD COLUMN `issued` TINYINT(1) NOT NULL DEFAULT 0 AFTER `approved`');
|
||||
}
|
||||
$this->executeSql('UPDATE `invoice` SET `issued` = `approved`');
|
||||
$this->executeSql('ALTER TABLE `invoice` DROP COLUMN `approved`');
|
||||
}
|
||||
}
|
||||
|
||||
private function patch122(): void
|
||||
{
|
||||
// invoice.buyer_phone_cc was write-only dead data: nothing ever wrote
|
||||
// it except null-to-null copies, and nothing rendered it. The entity
|
||||
// no longer maps it, so drop the column; the portable drop below
|
||||
// covers non-MySQL drivers. The guard makes reruns a no-op.
|
||||
if ($this->tableHasColumn('invoice', 'buyer_phone_cc')) {
|
||||
$this->executeSql('ALTER TABLE `invoice` DROP COLUMN `buyer_phone_cc`');
|
||||
}
|
||||
}
|
||||
|
||||
private function patch123(): void
|
||||
{
|
||||
// The invoice journal keeps a per-invoice audit trail (see
|
||||
// Service::recordJournalEvent()): one row per lifecycle transition
|
||||
// with a trimmed snapshot. Fresh installs get the table from entity
|
||||
// metadata and the portable sync covers non-MySQL drivers, so this
|
||||
// MySQL-only CREATE is just for existing installs. The guard makes
|
||||
// reruns a no-op.
|
||||
if ($this->tableExists('invoice_event')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->executeSql('CREATE TABLE `invoice_event` (`id` bigint(20) NOT NULL AUTO_INCREMENT, `invoice_id` bigint(20) DEFAULT NULL, `type` varchar(50) NOT NULL DEFAULT \'updated\', `admin_id` bigint(20) DEFAULT NULL, `client_id` bigint(20) DEFAULT NULL, `snapshot` JSON DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `invoice_event_invoice_id_idx` (`invoice_id`))');
|
||||
}
|
||||
|
||||
private function patch124(): void
|
||||
{
|
||||
// Baseline the invoice journal for installs predating it: one entry
|
||||
// per invoice missing from the journal, typed by its current state. The portable
|
||||
// backfill covers non-MySQL drivers; both skip invoices that already
|
||||
// have journal rows, so reruns are no-ops.
|
||||
$this->backfillInvoiceJournal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a database-collision-free slug derived from $base for reconciliation.
|
||||
*
|
||||
|
||||
@@ -256,8 +256,8 @@ class Payment_Adapter_PayPalEmail extends Payment_AdapterAbstract implements FOS
|
||||
$api_admin->client_balance_add_funds($bd);
|
||||
|
||||
if (!empty($tx['invoice_id']) && $invoiceDbModel instanceof Invoice && !$invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
if (!$invoiceDbModel->isApproved()) {
|
||||
$invoiceService->approveInvoice($invoiceDbModel, ['use_credits' => false]);
|
||||
if (!$invoiceDbModel->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoiceDbModel, ['use_credits' => false]);
|
||||
}
|
||||
$api_admin->invoice_pay_with_credits(['id' => $tx['invoice_id']]);
|
||||
} elseif (!empty($tx['invoice_id']) && $invoiceDbModel instanceof Invoice && $invoiceService->isInvoiceTypeDeposit($invoiceDbModel)) {
|
||||
@@ -542,13 +542,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
public function isIpnDuplicate(array $ipn): bool
|
||||
{
|
||||
$sql = 'SELECT id
|
||||
FROM transaction
|
||||
// `transaction` is a reserved word: quote it portably, or the lookup is
|
||||
// a syntax error on SQLite and every IPN looks new.
|
||||
$table = $this->di['em']->getConnection()->quoteSingleIdentifier('transaction');
|
||||
$sql = "SELECT id
|
||||
FROM {$table}
|
||||
WHERE txn_id = :transaction_id
|
||||
AND txn_status = :transaction_status
|
||||
AND type = :transaction_type
|
||||
AND amount = :transaction_amount
|
||||
LIMIT 2';
|
||||
LIMIT 2";
|
||||
|
||||
$bindings = [
|
||||
'transaction_id' => $ipn['txn_id'] ?? null,
|
||||
|
||||
@@ -436,11 +436,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
$transactionService = $this->di['mod_service']('Invoice', 'Transaction');
|
||||
if (!$transactionService->claimForProcessing((int) $tx->getId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No re-claim: the service layer already holds the processing claim, and the
|
||||
// in-memory marker below is what the succeeded branch keys off.
|
||||
$tx->setStatus(Transaction::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
@@ -475,8 +472,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
$clientService->addFunds($client, $bd['amount'], $bd['description'], $bd);
|
||||
|
||||
if ($tx->getInvoice() instanceof Invoice && $invoice instanceof Invoice && !$invoiceService->isInvoiceTypeDeposit($invoice)) {
|
||||
if (!$invoice->isApproved()) {
|
||||
$invoiceService->approveInvoice($invoice, ['use_credits' => false]);
|
||||
if (!$invoice->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoice, ['use_credits' => false]);
|
||||
}
|
||||
$invoiceService->payInvoiceWithCredits($invoice);
|
||||
} elseif ($tx->getInvoice() instanceof Invoice && $invoice instanceof Invoice && $invoiceService->isInvoiceTypeDeposit($invoice)) {
|
||||
@@ -637,8 +634,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
if (!$invoiceService->isInvoiceTypeDeposit($invoice)) {
|
||||
if (!$invoice->isApproved()) {
|
||||
$invoiceService->approveInvoice($invoice, ['use_credits' => false]);
|
||||
if (!$invoice->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoice, ['use_credits' => false]);
|
||||
}
|
||||
$invoiceService->payInvoiceWithCredits($invoice);
|
||||
}
|
||||
@@ -947,11 +944,7 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
'rel_id' => $tx->getId(),
|
||||
];
|
||||
|
||||
$transactionService = $this->di['mod_service']('Invoice', 'Transaction');
|
||||
if (!$transactionService->claimForProcessing((int) $tx->getId())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No re-claim: the service layer already holds the processing claim.
|
||||
$tx->setType(Payment_Transaction::TXTYPE_PAYMENT);
|
||||
$tx->setAmount((string) $bd['amount']);
|
||||
$tx->setCurrency(strtoupper((string) ($stripeInvoice->currency ?? '')));
|
||||
@@ -964,8 +957,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
$invoiceModel = $this->di['em']->getRepository(Invoice::class)->find((int) $invoiceId);
|
||||
|
||||
if ($invoiceModel instanceof Invoice && !$invoiceService->isInvoiceTypeDeposit($invoiceModel)) {
|
||||
if (!$invoiceModel->isApproved()) {
|
||||
$invoiceService->approveInvoice($invoiceModel, ['use_credits' => false]);
|
||||
if (!$invoiceModel->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoiceModel, ['use_credits' => false]);
|
||||
}
|
||||
$invoiceService->payInvoiceWithCredits($invoiceModel);
|
||||
}
|
||||
@@ -1270,11 +1263,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
|
||||
$transactionService = $this->di['mod_service']('Invoice', 'Transaction');
|
||||
if (!$transactionService->claimForProcessing((int) $tx->getId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No re-claim: the service layer already holds the processing claim, and the
|
||||
// in-memory marker below is what the succeeded branch keys off.
|
||||
$tx->setStatus(Transaction::STATUS_PROCESSING);
|
||||
|
||||
$clientService = $this->di['mod_service']('client');
|
||||
@@ -1307,8 +1297,8 @@ class Payment_Adapter_Stripe implements FOSSBilling\InjectionAwareInterface
|
||||
$clientService->addFunds($client, $bd['amount'], $bd['description'], $bd);
|
||||
|
||||
if ($tx->getInvoice() instanceof Invoice && $invoice instanceof Invoice && !$invoiceService->isInvoiceTypeDeposit($invoice)) {
|
||||
if (!$invoice->isApproved()) {
|
||||
$invoiceService->approveInvoice($invoice, ['use_credits' => false]);
|
||||
if (!$invoice->isIssued()) {
|
||||
$invoiceService->issueInvoice($invoice, ['use_credits' => false]);
|
||||
}
|
||||
$invoiceService->payInvoiceWithCredits($invoice);
|
||||
} elseif ($tx->getInvoice() instanceof Invoice && $invoice instanceof Invoice && $invoiceService->isInvoiceTypeDeposit($invoice)) {
|
||||
|
||||
@@ -23,7 +23,9 @@ beforeEach(function () use (&$originalAntispamConfig): void {
|
||||
});
|
||||
|
||||
afterEach(function () use (&$originalAntispamConfig): void {
|
||||
return;
|
||||
if ($originalAntispamConfig === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = ApiClient::request('admin/extension/config_save', array_merge(
|
||||
['ext' => 'mod_antispam'],
|
||||
|
||||
@@ -1246,7 +1246,7 @@ class Service implements InjectionAwareInterface
|
||||
$balanceAmount = $clientBalanceService->getClientBalance($client);
|
||||
$useCredits = $balanceAmount >= $ca['total'];
|
||||
|
||||
$invoiceService->approveInvoice($invoiceModel, ['id' => $invoiceModel->getId(), 'use_credits' => $useCredits]);
|
||||
$invoiceService->issueInvoice($invoiceModel, ['id' => $invoiceModel->getId(), 'use_credits' => $useCredits]);
|
||||
|
||||
$isUnpaid = $invoiceModel instanceof Invoice
|
||||
&& $invoiceModel->getStatus() === Invoice::STATUS_UNPAID;
|
||||
|
||||
@@ -1076,7 +1076,7 @@ test('createFromCart sets the unpaid invoice id on orders when checkout produces
|
||||
$productService->shouldReceive('findProductById')->twice()->with(5)->andReturn($product);
|
||||
$productService->shouldReceive('reserveStockForOrder')->once()->with(Mockery::type(Order::class));
|
||||
|
||||
// Regression test for GH-4246: prepareInvoice()/approveInvoice() must hand back a
|
||||
// Regression test for GH-4246: prepareInvoice()/issueInvoice() must hand back a
|
||||
// Doctrine Invoice entity whose getId() is strictly ?int, matching what
|
||||
// Order::setUnpaidInvoiceId() declares. Before the Invoice module was migrated to
|
||||
// Doctrine, this was a RedBean bean whose ->id was a string, which made every
|
||||
@@ -1086,7 +1086,7 @@ test('createFromCart sets the unpaid invoice id on orders when checkout produces
|
||||
|
||||
$invoiceService = Mockery::mock(Box\Mod\Invoice\Service::class);
|
||||
$invoiceService->shouldReceive('prepareInvoice')->once()->with($client, Mockery::type('array'))->andReturn($invoice);
|
||||
$invoiceService->shouldReceive('approveInvoice')->once()->with($invoice, Mockery::type('array'))->andReturn(true);
|
||||
$invoiceService->shouldReceive('issueInvoice')->once()->with($invoice, Mockery::type('array'))->andReturn(true);
|
||||
|
||||
$clientBalanceService = Mockery::mock(Box\Mod\Client\ServiceBalance::class);
|
||||
$clientBalanceService->shouldReceive('getClientBalance')->once()->with($client)->andReturn(0.0);
|
||||
|
||||
@@ -844,6 +844,8 @@ class Service implements InjectionAwareInterface
|
||||
|
||||
public function remove(Client $model): void
|
||||
{
|
||||
$this->throwIfActiveServicesRemain($model);
|
||||
|
||||
$entityManager = $this->di['em'];
|
||||
$connection = $entityManager->getConnection();
|
||||
|
||||
@@ -890,6 +892,43 @@ class Service implements InjectionAwareInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Client deletion must never silently orphan provisioned services (hosting
|
||||
* accounts, domains, licenses, ...) nor terminate them by surprise: refuse
|
||||
* while any service row exists. Service cancellation alone is not enough -
|
||||
* it terminates remotely but leaves the row in place - so delete the
|
||||
* service or its order first, then delete the client.
|
||||
*
|
||||
* Tables belonging to extensions that were never activated on this install
|
||||
* (e.g. service_apikey on a fresh install) simply don't exist: there is
|
||||
* nothing to orphan in them, so they are skipped rather than queried.
|
||||
*/
|
||||
private function throwIfActiveServicesRemain(Client $model): void
|
||||
{
|
||||
$connection = $this->di['em']->getConnection();
|
||||
$tables = [
|
||||
'service_hosting' => 'hosting',
|
||||
'service_domain' => 'domain',
|
||||
'service_downloadable' => 'downloadable',
|
||||
'service_license' => 'license',
|
||||
'service_custom' => 'custom',
|
||||
'service_apikey' => 'API keys',
|
||||
];
|
||||
$existing = array_flip($connection->createSchemaManager()->listTableNames());
|
||||
$active = [];
|
||||
foreach ($tables as $table => $label) {
|
||||
if (!isset($existing[$table])) {
|
||||
continue;
|
||||
}
|
||||
if ($connection->fetchOne("SELECT 1 FROM {$table} WHERE client_id = :id LIMIT 1", ['id' => $model->getId()])) {
|
||||
$active[] = $label;
|
||||
}
|
||||
}
|
||||
if ($active !== []) {
|
||||
throw new InformationException('Client cannot be deleted while they have service records (:services). Delete their services or orders first, then delete the client.', [':services' => implode(', ', $active)]);
|
||||
}
|
||||
}
|
||||
|
||||
public function authorizeClient($email, $plainTextPassword): ?Client
|
||||
{
|
||||
$client = $this->clientRepository->findOneBy(['email' => $email, 'status' => Client::ACTIVE]);
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Skip E2E tests if environment is not configured
|
||||
if (!getenv('APP_URL') || !getenv('TEST_API_KEY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
use function Tests\Helpers\assertApiResultIsInt;
|
||||
use function Tests\Helpers\assertApiSuccess;
|
||||
|
||||
test('deleting a client erases personal data but keeps anonymized financial records', function (): void {
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
$productId = null;
|
||||
|
||||
try {
|
||||
$productId = erasureCreateProduct();
|
||||
['id' => $clientId, 'token' => $clientToken] = erasureCreateClient();
|
||||
|
||||
$created = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
'invoice_option' => 'issue-invoice',
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
assertApiResultIsInt($created);
|
||||
$orderId = (int) $created->getResult();
|
||||
|
||||
$order = Tests\Helpers\ApiClient::request('admin/order/get', ['id' => $orderId]);
|
||||
assertApiSuccess($order);
|
||||
$invoiceId = (int) $order->getResult()['unpaid_invoice_id'];
|
||||
|
||||
erasureMarkInvoicePaid($invoiceId);
|
||||
|
||||
$txList = Tests\Helpers\ApiClient::request('admin/invoice/transaction_get_list', ['invoice_id' => $invoiceId]);
|
||||
assertApiSuccess($txList);
|
||||
$txRows = $txList->getResult()['list'] ?? [];
|
||||
expect($txRows)->not->toBeEmpty();
|
||||
$txId = (int) $txRows[0]['id'];
|
||||
|
||||
// Plant identifiable data on the transaction, as a gateway IPN would.
|
||||
$marker = uniqid('erasure_');
|
||||
$noted = Tests\Helpers\ApiClient::request('admin/invoice/transaction_update', [
|
||||
'id' => $txId,
|
||||
'note' => 'PII note ' . $marker,
|
||||
]);
|
||||
assertApiSuccess($noted);
|
||||
|
||||
$txBefore = Tests\Helpers\ApiClient::request('admin/invoice/transaction_get', ['id' => $txId]);
|
||||
assertApiSuccess($txBefore);
|
||||
expect($txBefore->getResult()['note'])->toContain($marker);
|
||||
|
||||
// A client ticket with a reply carrying personal content.
|
||||
$helpdesks = Tests\Helpers\ApiClient::request('client/support/helpdesk_get_pairs', [], 'client', $clientToken);
|
||||
assertApiSuccess($helpdesks);
|
||||
$helpdeskIds = array_keys($helpdesks->getResult());
|
||||
expect($helpdeskIds)->not->toBeEmpty();
|
||||
|
||||
$ticket = Tests\Helpers\ApiClient::request('client/support/ticket_create', [
|
||||
'support_helpdesk_id' => $helpdeskIds[0],
|
||||
'subject' => 'Erasure probe ' . $marker,
|
||||
'content' => 'Personal content ' . $marker,
|
||||
], 'client', $clientToken);
|
||||
assertApiSuccess($ticket);
|
||||
assertApiResultIsInt($ticket);
|
||||
$ticketId = (int) $ticket->getResult();
|
||||
|
||||
$reply = Tests\Helpers\ApiClient::request('client/support/ticket_reply', [
|
||||
'id' => $ticketId,
|
||||
'content' => 'Follow-up personal content ' . $marker,
|
||||
], 'client', $clientToken);
|
||||
assertApiSuccess($reply);
|
||||
|
||||
// Erase the client.
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/client/delete', ['id' => $clientId]);
|
||||
assertApiSuccess($deleted);
|
||||
|
||||
// Client, invoice, and ticket are gone.
|
||||
expect(Tests\Helpers\ApiClient::request('admin/client/get', ['id' => $clientId])->wasSuccessful())->toBeFalse();
|
||||
expect(Tests\Helpers\ApiClient::request('admin/invoice/get', ['id' => $invoiceId])->wasSuccessful())->toBeFalse();
|
||||
expect(Tests\Helpers\ApiClient::request('admin/support/ticket_get', ['id' => $ticketId])->wasSuccessful())->toBeFalse();
|
||||
|
||||
// The transaction survives as an anonymized financial record.
|
||||
$txAfter = Tests\Helpers\ApiClient::request('admin/invoice/transaction_get', ['id' => $txId]);
|
||||
assertApiSuccess($txAfter);
|
||||
$txData = $txAfter->getResult();
|
||||
expect($txData['note'])->toBeNull();
|
||||
expect($txData['ip'])->toBeNull();
|
||||
expect($txData['amount'])->not->toBeNull();
|
||||
} finally {
|
||||
erasureDeleteProduct($productId);
|
||||
}
|
||||
});
|
||||
|
||||
function erasureCreateProduct(): int
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/product/prepare', [
|
||||
'title' => 'E2E Erasure Product ' . uniqid(),
|
||||
'type' => 'custom',
|
||||
'product_category_id' => 1,
|
||||
]);
|
||||
assertApiSuccess($result);
|
||||
assertApiResultIsInt($result);
|
||||
$productId = (int) $result->getResult();
|
||||
|
||||
$update = Tests\Helpers\ApiClient::request('admin/product/update', [
|
||||
'id' => $productId,
|
||||
'status' => 'enabled',
|
||||
'pricing' => ['type' => 'once', 'once' => ['price' => 75.0, 'setup' => 0]],
|
||||
]);
|
||||
assertApiSuccess($update);
|
||||
|
||||
return $productId;
|
||||
}
|
||||
|
||||
function erasureCreateClient(): array
|
||||
{
|
||||
$email = 'erasure_client_' . uniqid() . '@example.com';
|
||||
$created = Tests\Helpers\ApiClient::request('admin/client/create', [
|
||||
'email' => $email,
|
||||
'first_name' => 'Test',
|
||||
'password' => 'A1a' . bin2hex(random_bytes(6)),
|
||||
'send_welcome_email' => 0,
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
$clientId = (int) $created->getResult();
|
||||
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
$token = Tests\Helpers\ApiClient::request('admin/profile/api_key_reset', ['id' => $clientId]);
|
||||
assertApiSuccess($token);
|
||||
|
||||
return ['id' => $clientId, 'token' => (string) $token->getResult()];
|
||||
}
|
||||
|
||||
function erasureMarkInvoicePaid(int $invoiceId): void
|
||||
{
|
||||
$gateways = Tests\Helpers\ApiClient::request('admin/invoice/gateway_get_pairs');
|
||||
assertApiSuccess($gateways);
|
||||
$gatewayId = null;
|
||||
foreach ($gateways->getResult() as $id => $title) {
|
||||
if ($title === 'Custom') {
|
||||
$gatewayId = (int) $id;
|
||||
}
|
||||
}
|
||||
if ($gatewayId === null) {
|
||||
throw new RuntimeException('Custom payment gateway not found');
|
||||
}
|
||||
|
||||
$result = Tests\Helpers\ApiClient::request('admin/invoice/mark_as_paid', [
|
||||
'id' => $invoiceId,
|
||||
'gateway_id' => $gatewayId,
|
||||
'transactionId' => 'txn' . uniqid(),
|
||||
]);
|
||||
assertApiSuccess($result);
|
||||
}
|
||||
|
||||
function erasureDeleteProduct(?int $productId): void
|
||||
{
|
||||
if ($productId === null) {
|
||||
return;
|
||||
}
|
||||
Tests\Helpers\ApiClient::request('admin/product/delete', ['id' => $productId]);
|
||||
}
|
||||
@@ -725,11 +725,23 @@ test('remove wraps client cleanup and flush in one transaction', function (): vo
|
||||
$di['mod_service'] = $di->protect(moduleService($services));
|
||||
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$schemaManager = Mockery::mock(Doctrine\DBAL\Schema\AbstractSchemaManager::class);
|
||||
$schemaManager->shouldReceive('listTableNames')->andReturn([
|
||||
'service_hosting',
|
||||
'service_domain',
|
||||
'service_downloadable',
|
||||
'service_license',
|
||||
'service_custom',
|
||||
'service_apikey',
|
||||
]);
|
||||
$connection->shouldReceive('createSchemaManager')->andReturn($schemaManager);
|
||||
$query = Mockery::mock(Doctrine\DBAL\Query\QueryBuilder::class);
|
||||
$query->shouldReceive('delete')->once()->with('extension_meta')->andReturnSelf();
|
||||
$query->shouldReceive('where')->once()->with('client_id = :id')->andReturnSelf();
|
||||
$query->shouldReceive('setParameter')->once()->with('id', 1)->andReturnSelf();
|
||||
$query->shouldReceive('executeStatement')->once()->andReturn(1);
|
||||
// No provisioned services remain: the erasure gate passes on all six tables.
|
||||
$connection->shouldReceive('fetchOne')->times(6)->andReturn(false);
|
||||
$connection->shouldReceive('executeStatement')->once()
|
||||
->with('DELETE FROM activity_client_history WHERE client_id = :id', ['id' => 1])
|
||||
->andReturn(1);
|
||||
@@ -740,7 +752,7 @@ test('remove wraps client cleanup and flush in one transaction', function (): vo
|
||||
|
||||
$em = $di['em'];
|
||||
$em->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\ClientPasswordReset::class)->andReturn($passwordRepository);
|
||||
$em->shouldReceive('getConnection')->once()->andReturn($connection);
|
||||
$em->shouldReceive('getConnection')->twice()->andReturn($connection);
|
||||
$em->shouldReceive('beginTransaction')->once();
|
||||
$em->shouldReceive('remove')->once()->with($reset);
|
||||
$em->shouldReceive('remove')->once()->with($client);
|
||||
@@ -764,9 +776,21 @@ test('remove rolls back and rethrows cleanup failures', function (): void {
|
||||
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('isTransactionActive')->once()->andReturnTrue();
|
||||
$schemaManager = Mockery::mock(Doctrine\DBAL\Schema\AbstractSchemaManager::class);
|
||||
$schemaManager->shouldReceive('listTableNames')->andReturn([
|
||||
'service_hosting',
|
||||
'service_domain',
|
||||
'service_downloadable',
|
||||
'service_license',
|
||||
'service_custom',
|
||||
'service_apikey',
|
||||
]);
|
||||
$connection->shouldReceive('createSchemaManager')->andReturn($schemaManager);
|
||||
// The erasure gate passes: no provisioned services remain.
|
||||
$connection->shouldReceive('fetchOne')->times(6)->andReturn(false);
|
||||
|
||||
$em = $di['em'];
|
||||
$em->shouldReceive('getConnection')->once()->andReturn($connection);
|
||||
$em->shouldReceive('getConnection')->twice()->andReturn($connection);
|
||||
$em->shouldReceive('beginTransaction')->once();
|
||||
$em->shouldReceive('rollback')->once();
|
||||
$em->shouldReceive('commit')->never();
|
||||
@@ -776,6 +800,43 @@ test('remove rolls back and rethrows cleanup failures', function (): void {
|
||||
expect(fn () => $service->remove($client))->toThrow($exception);
|
||||
});
|
||||
|
||||
test('remove refuses clients with provisioned services', function (): void {
|
||||
$service = new Box\Mod\Client\Service();
|
||||
$client = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 1]);
|
||||
|
||||
$orderService = Mockery::mock();
|
||||
$orderService->shouldReceive('rmByClient')->never();
|
||||
|
||||
$di = container();
|
||||
$di['mod_service'] = $di->protect(moduleService(['order' => $orderService]));
|
||||
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$schemaManager = Mockery::mock(Doctrine\DBAL\Schema\AbstractSchemaManager::class);
|
||||
// service_apikey was never activated on this install: the gate skips its
|
||||
// missing table instead of querying it.
|
||||
$schemaManager->shouldReceive('listTableNames')->andReturn([
|
||||
'service_hosting',
|
||||
'service_domain',
|
||||
'service_downloadable',
|
||||
'service_license',
|
||||
'service_custom',
|
||||
]);
|
||||
$connection->shouldReceive('createSchemaManager')->andReturn($schemaManager);
|
||||
// Only hosting remains: the gate names it and stops before any cleanup.
|
||||
$connection->shouldReceive('fetchOne')
|
||||
->times(5)
|
||||
->andReturnUsing(fn (string $sql): string|false => str_contains($sql, 'service_hosting') ? '1' : false);
|
||||
|
||||
$em = $di['em'];
|
||||
$em->shouldReceive('getConnection')->once()->andReturn($connection);
|
||||
$em->shouldReceive('beginTransaction')->never();
|
||||
|
||||
$service->setDi($di);
|
||||
|
||||
expect(fn () => $service->remove($client))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'hosting');
|
||||
});
|
||||
|
||||
test('toApiArray returns array', function (): void {
|
||||
$service = new Box\Mod\Client\Service();
|
||||
$clientGroup = createEntity(Box\Mod\Client\Entity\ClientGroup::class, ['id' => 1, 'title' => 'Group Title']);
|
||||
|
||||
@@ -114,7 +114,7 @@ class Service implements InjectionAwareInterface
|
||||
$sql = 'SELECT status, COUNT(*) as total
|
||||
FROM invoice
|
||||
WHERE client_id = :client_id
|
||||
AND approved = true
|
||||
AND issued = true
|
||||
GROUP BY status';
|
||||
|
||||
$results = $this->di['em']->getConnection()->fetchAllAssociative($sql, $data);
|
||||
|
||||
@@ -92,6 +92,22 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the audit journal of an invoice: one entry per lifecycle
|
||||
* transition with a trimmed snapshot of the invoice at that time.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Invoice ID is missing'])]
|
||||
public function journal($data)
|
||||
{
|
||||
$this->checkPermissions('invoice', 'view');
|
||||
|
||||
$model = $this->_getInvoice($data);
|
||||
|
||||
return $this->getService()->getJournalForInvoice((int) $model->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets invoice status to paid. This method differs from invoice update method
|
||||
* in a way that it sends notification to Events system, so emails are sent.
|
||||
@@ -99,6 +115,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
* @optional bool $execute - execute related tasks on invoice items. Default false.
|
||||
* @optional int $gateway_id - Payment gateway to associate with the invoice
|
||||
* @optional string $transactionId - Custom transaction ID to use when the selected gateway is Custom
|
||||
* @optional string $paid_at - payment date to record instead of now, e.g. "2026-09-01 14:00:00"
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -117,7 +134,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
* Uses clients details, such as currency assigned to client.
|
||||
* If client currency is not defined, sets default currency for client.
|
||||
*
|
||||
* @optional bool $approve - set true to approve invoice after preparation. Defaults to false
|
||||
* @optional bool $issue - set true to issue invoice after preparation. Defaults to false
|
||||
* @optional int $gateway_id - Selected payment gateway id
|
||||
* @optional array $items - list of invoice lines. One line is array of line parameters
|
||||
* @optional string $text_1 - text to be displayed before invoice items table
|
||||
@@ -138,18 +155,18 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve invoice.
|
||||
* Issue invoice.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Invoice ID is missing'])]
|
||||
public function approve($data)
|
||||
public function issue($data)
|
||||
{
|
||||
$this->checkPermissions('invoice', 'manage_invoices');
|
||||
|
||||
$model = $this->_getInvoice($data);
|
||||
|
||||
return $this->getService()->approveInvoice($model, $data);
|
||||
return $this->getService()->issueInvoice($model, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,7 +175,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
* @optional string $note - note for refund
|
||||
* @optional array $items - line id => quantity map for a partial refund; omit for a full refund
|
||||
*
|
||||
* @return bool
|
||||
* @return int $id - newly generated refund document ID
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Invoice ID is missing'])]
|
||||
public function refund($data)
|
||||
@@ -225,7 +242,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an approved unpaid invoice and issue a replacement carrying its
|
||||
* Cancel an issued unpaid invoice and issue a replacement carrying its
|
||||
* lines forward. The replacement takes the next invoice number; the
|
||||
* original number stays with the canceled record.
|
||||
*
|
||||
@@ -254,6 +271,26 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
return $this->getService()->reissueInvoice($model, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel (void) an issued unpaid invoice without issuing a replacement.
|
||||
* The invoice keeps its number with a canceled status so the audit trail
|
||||
* survives. Linked orders keep pointing at it for history; transactions
|
||||
* are detached but kept.
|
||||
*
|
||||
* @optional string $reason - reason recorded in the invoice notes
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Invoice ID is missing'])]
|
||||
public function cancel($data)
|
||||
{
|
||||
$this->checkPermissions('invoice', 'manage_invoices');
|
||||
|
||||
$model = $this->_getInvoice($data);
|
||||
|
||||
return $this->getService()->cancelInvoice($model, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update invoice details.
|
||||
*
|
||||
@@ -262,34 +299,21 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
* @optional string $created_at - Invoice issue date (Y-m-d) or empty to remove
|
||||
* @optional string $serie - Invoice serie
|
||||
* @optional string $nr - Invoice number
|
||||
* @optional string $status - Invoice status: paid|unpaid
|
||||
* @optional string $status - Invoice status for drafts: unpaid|canceled
|
||||
* @optional string $taxrate - Invoice tax rate
|
||||
* @optional string $taxname - Invoice tax name
|
||||
* @optional bool $approved - flag to set invoice as approved. Approved invoices are visible to clients
|
||||
* @optional bool $issued - read-only display of the issue state. Changing it is
|
||||
* refused: issue a draft through the issue action or the
|
||||
* $issue flag below instead.
|
||||
* @optional string $notes - notes
|
||||
* @optional int $gateway_id - selected payment method - gateway id
|
||||
* @optional array $new_item - [title] [price]
|
||||
* @optional string $text_1 - Custom invoice text 1
|
||||
* @optional string $text_2 - Custom invoice text 2
|
||||
* @optional string $seller_company - Seller company name
|
||||
* @optional string $seller_company_vat - Seller company VAT number
|
||||
* @optional string $seller_company_number - Seller company number
|
||||
* @optional string $seller_address - Seller address
|
||||
* @optional string $seller_phone - Seller phone
|
||||
* @optional string $seller_email - Seller email
|
||||
* @optional string $buyer_first_name - Buyer first name
|
||||
* @optional string $buyer_last_name - Buyer last name
|
||||
* @optional string $buyer_company - Buyer company name
|
||||
* @optional string $buyer_company_vat - Buyer company VAT number
|
||||
* @optional string $buyer_company_number - Buyer company number
|
||||
* @optional string $buyer_address - Buyer address
|
||||
* @optional string $buyer_city - Buyer city
|
||||
* @optional string $buyer_state - Buyer state
|
||||
* @optional string $buyer_country - Buyer country
|
||||
* @optional string $buyer_zip - Buyer zip
|
||||
* @optional string $buyer_phone - Buyer phone
|
||||
* @optional string $buyer_email - Buyer email
|
||||
* @optional bool $approve - approve the invoice after saving the supplied changes
|
||||
* @optional bool $issue - issue the invoice after saving the supplied changes
|
||||
*
|
||||
* Buyer and seller details are not accepted here: they are frozen from the
|
||||
* live client and company records when the invoice is issued.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -301,8 +325,8 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
$model = $this->_getInvoice($data);
|
||||
$result = $this->getService()->updateInvoice($model, $data);
|
||||
|
||||
if ($result && !empty($data['approve'])) {
|
||||
return $this->getService()->approveInvoice($model, $data);
|
||||
if ($result && !empty($data['issue'])) {
|
||||
return $this->getService()->issueInvoice($model, $data);
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -359,6 +383,11 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
/**
|
||||
* Delete invoice.
|
||||
*
|
||||
* Unissued unpaid drafts are always deletable. Issued unpaid and
|
||||
* canceled invoices additionally require relaxed invoice immutability.
|
||||
* Paid, refunded, and note/reissue-linked invoices cannot be deleted;
|
||||
* cancel, reissue, or refund them instead.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
#[RequiredParams(['id' => 'Invoice ID is missing'])]
|
||||
@@ -467,7 +496,7 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls due events on unpaid and approved invoices.
|
||||
* Calls due events on unpaid and issued invoices.
|
||||
* Extensions can listen to BeforeInvoiceIsDueEvent and AfterInvoiceIsDueEvent.
|
||||
*
|
||||
* @optional bool $once_per_day - default true. Pass false if you want to execute this action more than once per day
|
||||
@@ -1148,22 +1177,6 @@ class Admin extends \FOSSBilling\Api\AbstractApi
|
||||
return $this->getDi()['pager']->paginateDoctrineQuery($qb, PaginationOptions::fromArray($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically setup the EU VAT tax rules for you for all EU Member States.
|
||||
* This action will delete any existing tax rules and configure the VAT rates
|
||||
* for all EU countries.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function tax_setup_eu($data)
|
||||
{
|
||||
$this->checkPermissions('invoice', 'manage_tax');
|
||||
|
||||
$taxService = $this->getDi()['mod_service']('Invoice', 'Tax');
|
||||
|
||||
return $taxService->setupEUTaxes($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an existing promotion to an order on an unpaid invoice.
|
||||
*
|
||||
|
||||
@@ -29,7 +29,7 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
public function get_list($data)
|
||||
{
|
||||
$data['client_id'] = $this->getIdentity()->getId();
|
||||
$data['approved'] = true;
|
||||
$data['issued'] = true;
|
||||
|
||||
$service = $this->getService();
|
||||
$qb = $service->getInvoiceRepository()->getSearchQueryBuilder($data);
|
||||
@@ -82,7 +82,7 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
}
|
||||
$service = $this->getService();
|
||||
$invoice = $service->generateForOrder($model);
|
||||
$service->approveInvoice($invoice, ['id' => $invoice->getId(), 'use_credits' => true]);
|
||||
$service->issueInvoice($invoice, ['id' => $invoice->getId(), 'use_credits' => true]);
|
||||
$this->getDi()['logger']->info('Generated new renewal invoice #{invoice_id}', ['invoice_id' => $invoice->getId()]);
|
||||
|
||||
return $invoice->getHash();
|
||||
@@ -103,7 +103,7 @@ class Client extends \FOSSBilling\Api\AbstractApi
|
||||
|
||||
$service = $this->getService();
|
||||
$invoice = $service->generateFundsInvoice($this->getIdentity(), $data['amount']);
|
||||
$service->approveInvoice($invoice, ['id' => $invoice->getId()]);
|
||||
$service->issueInvoice($invoice, ['id' => $invoice->getId()]);
|
||||
$this->getDi()['logger']->info('Generated add funds invoice #{invoice_id}', ['invoice_id' => $invoice->getId()]);
|
||||
|
||||
return $invoice->getHash();
|
||||
|
||||
@@ -93,6 +93,7 @@ class Admin implements \FOSSBilling\InjectionAwareInterface
|
||||
$app->get('/invoice/gateways', 'get_gateways', [], static::class);
|
||||
$app->get('/invoice/gateway/:id', 'get_gateway', ['id' => '[0-9]+'], static::class);
|
||||
$app->get('/invoice/manage/:id', 'get_invoice', ['id' => '[0-9]+'], static::class);
|
||||
$app->get('/invoice/manage/:id/journal-download', 'get_journal_download', ['id' => '[0-9]+'], static::class);
|
||||
$app->get('/invoice/transaction/:id', 'get_transaction', ['id' => '[0-9]+'], static::class);
|
||||
$app->get('/invoice/subscription/:id', 'get_subscription', ['id' => '[0-9]+'], static::class);
|
||||
$app->get('/invoice/tax', 'get_taxes', [], static::class);
|
||||
@@ -208,4 +209,29 @@ class Admin implements \FOSSBilling\InjectionAwareInterface
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function get_journal_download(\Box_App $app, $id): Response
|
||||
{
|
||||
$this->di['is_admin_logged'];
|
||||
|
||||
$api = $this->di['api_admin'];
|
||||
$journal = $api->invoice_journal(['id' => $id]);
|
||||
|
||||
$eventId = $app->getRequest()->query->getInt('event', 0);
|
||||
if ($eventId > 0) {
|
||||
$journal = array_values(array_filter(
|
||||
$journal,
|
||||
static fn (array $entry): bool => (int) ($entry['id'] ?? 0) === $eventId
|
||||
));
|
||||
}
|
||||
|
||||
$response = new Response(
|
||||
json_encode($journal, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
|
||||
Response::HTTP_OK,
|
||||
['Content-Type' => 'application/json']
|
||||
);
|
||||
$response->headers->set('Content-Disposition', 'attachment; filename="invoice-' . ((int) $id) . '-journal.json"');
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use FOSSBilling\Interfaces\TimestampInterface;
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Invoice\Repository\InvoiceRepository::class)]
|
||||
#[ORM\Table(name: 'invoice')]
|
||||
#[ORM\Index(name: 'invoice_client_id_idx', columns: ['client_id'])]
|
||||
#[ORM\Index(name: 'invoice_status_approved_due_at_idx', columns: ['status', 'approved', 'due_at'])]
|
||||
#[ORM\Index(name: 'invoice_status_issued_due_at_idx', columns: ['status', 'issued', 'due_at'])]
|
||||
#[ORM\UniqueConstraint(name: 'hash', columns: ['hash'])]
|
||||
#[ORM\Index(name: 'invoice_credit_note_for_idx', columns: ['credit_note_for_invoice_id'])]
|
||||
#[ORM\Index(name: 'invoice_debit_note_for_idx', columns: ['debit_note_for_invoice_id'])]
|
||||
@@ -146,9 +146,6 @@ class Invoice implements TimestampInterface
|
||||
#[ORM\Column(name: 'buyer_phone', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $buyerPhone = null;
|
||||
|
||||
#[ORM\Column(name: 'buyer_phone_cc', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $buyerPhoneCc = null;
|
||||
|
||||
#[ORM\Column(name: 'buyer_email', type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $buyerEmail = null;
|
||||
|
||||
@@ -157,7 +154,7 @@ class Invoice implements TimestampInterface
|
||||
private ?PayGateway $gateway = null;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
|
||||
private bool $approved = false;
|
||||
private bool $issued = false;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $taxname = null;
|
||||
@@ -602,18 +599,6 @@ class Invoice implements TimestampInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuyerPhoneCc(): ?string
|
||||
{
|
||||
return $this->buyerPhoneCc;
|
||||
}
|
||||
|
||||
public function setBuyerPhoneCc(?string $buyerPhoneCc): self
|
||||
{
|
||||
$this->buyerPhoneCc = $buyerPhoneCc;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBuyerEmail(): ?string
|
||||
{
|
||||
return $this->buyerEmail;
|
||||
@@ -638,14 +623,14 @@ class Invoice implements TimestampInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
public function isIssued(): bool
|
||||
{
|
||||
return $this->approved;
|
||||
return $this->issued;
|
||||
}
|
||||
|
||||
public function setApproved(bool $approved): self
|
||||
public function setIssued(bool $issued): self
|
||||
{
|
||||
$this->approved = $approved;
|
||||
$this->issued = $issued;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Invoice\Entity;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use FOSSBilling\Doctrine\CreatedAtTrait;
|
||||
|
||||
#[ORM\Entity(repositoryClass: \Box\Mod\Invoice\Repository\InvoiceEventRepository::class)]
|
||||
#[ORM\Table(name: 'invoice_event')]
|
||||
#[ORM\Index(name: 'invoice_event_invoice_id_idx', columns: ['invoice_id'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class InvoiceEvent
|
||||
{
|
||||
use CreatedAtTrait;
|
||||
|
||||
final public const string TYPE_CREATED = 'created';
|
||||
final public const string TYPE_ISSUED = 'issued';
|
||||
final public const string TYPE_UPDATED = 'updated';
|
||||
final public const string TYPE_PAID = 'paid';
|
||||
final public const string TYPE_REFUNDED = 'refunded';
|
||||
final public const string TYPE_DEBITED = 'debited';
|
||||
final public const string TYPE_CANCELED = 'canceled';
|
||||
final public const string TYPE_REISSUED = 'reissued';
|
||||
final public const string TYPE_ORDER_ATTACHED = 'order_attached';
|
||||
final public const string TYPE_REMINDER = 'reminder';
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: Types::BIGINT)]
|
||||
/** @phpstan-ignore property.unusedType (Doctrine entity ID is auto-generated by the database) */
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(name: 'invoice_id', type: Types::BIGINT, nullable: true)]
|
||||
private ?int $invoiceId = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50)]
|
||||
private string $type = self::TYPE_UPDATED;
|
||||
|
||||
#[ORM\Column(name: 'admin_id', type: Types::BIGINT, nullable: true)]
|
||||
private ?int $adminId = null;
|
||||
|
||||
#[ORM\Column(name: 'client_id', type: Types::BIGINT, nullable: true)]
|
||||
private ?int $clientId = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
private ?array $snapshot = null;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getInvoiceId(): ?int
|
||||
{
|
||||
return $this->invoiceId;
|
||||
}
|
||||
|
||||
public function setInvoiceId(?int $invoiceId): self
|
||||
{
|
||||
$this->invoiceId = $invoiceId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function setType(string $type): self
|
||||
{
|
||||
$this->type = $type;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAdminId(): ?int
|
||||
{
|
||||
return $this->adminId;
|
||||
}
|
||||
|
||||
public function setAdminId(?int $adminId): self
|
||||
{
|
||||
$this->adminId = $adminId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getClientId(): ?int
|
||||
{
|
||||
return $this->clientId;
|
||||
}
|
||||
|
||||
public function setClientId(?int $clientId): self
|
||||
{
|
||||
$this->clientId = $clientId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSnapshot(): ?array
|
||||
{
|
||||
return $this->snapshot;
|
||||
}
|
||||
|
||||
public function setSnapshot(?array $snapshot): self
|
||||
{
|
||||
$this->snapshot = $snapshot;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched after an admin cancels (voids) an issued unpaid invoice without replacement. */
|
||||
final class AfterAdminInvoiceCancelEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched after an admin deletes an unpaid, unapproved invoice. */
|
||||
/** Dispatched after an admin deletes an unpaid, unissued invoice. */
|
||||
final class AfterAdminInvoiceDeleteEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched after an invoice has been approved and credit payment attempted. */
|
||||
final class AfterAdminInvoiceApproveEvent extends Event
|
||||
/** Dispatched after an invoice has been issued and credit payment attempted. */
|
||||
final class AfterAdminInvoiceIssueEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
{
|
||||
@@ -11,7 +11,7 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched for every unpaid, approved invoice overdue or due today. */
|
||||
/** Dispatched for every unpaid, issued invoice overdue or due today. */
|
||||
final class AfterInvoiceIsDueEvent extends Event
|
||||
{
|
||||
/** @param list<int> $reminderIntervals */
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched before an admin cancels (voids) an issued unpaid invoice without replacement. */
|
||||
final class BeforeAdminInvoiceCancelEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched before an admin deletes an unpaid, unapproved invoice. */
|
||||
/** Dispatched before an admin deletes an unpaid, unissued invoice. */
|
||||
final class BeforeAdminInvoiceDeleteEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched before an invoice is approved. */
|
||||
final class BeforeAdminInvoiceApproveEvent extends Event
|
||||
/** Dispatched before an invoice is issued. */
|
||||
final class BeforeAdminInvoiceIssueEvent extends Event
|
||||
{
|
||||
public function __construct(public readonly int $invoiceId)
|
||||
{
|
||||
@@ -11,7 +11,7 @@ namespace Box\Mod\Invoice\Event;
|
||||
|
||||
use FOSSBilling\Events\Event;
|
||||
|
||||
/** Dispatched for every unpaid, approved invoice with a future due date. */
|
||||
/** Dispatched for every unpaid, issued invoice with a future due date. */
|
||||
final class BeforeInvoiceIsDueEvent extends Event
|
||||
{
|
||||
/** @param list<int> $reminderIntervals */
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
namespace Box\Mod\Invoice\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
|
||||
class InvoiceEventRepository extends EntityRepository
|
||||
{
|
||||
/**
|
||||
* @return list<\Box\Mod\Invoice\Entity\InvoiceEvent>
|
||||
*/
|
||||
public function findByInvoiceId(int $invoiceId): array
|
||||
{
|
||||
return $this->findBy(['invoiceId' => $invoiceId], ['id' => 'ASC']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal rows live and die with their invoice: no retention pruning by design, this is the
|
||||
* audit trail. Deletion alongside the invoice covers drafts and client erasure; relaxed-mode
|
||||
* deletions of issued invoices deliberately keep their rows.
|
||||
*/
|
||||
public function deleteByInvoiceId(int $invoiceId): int
|
||||
{
|
||||
return (int) $this->getEntityManager()->getConnection()->delete('invoice_event', ['invoice_id' => $invoiceId]);
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,7 @@ class InvoiceRepository extends EntityRepository
|
||||
* raw-SQL path required.
|
||||
*
|
||||
* @param array $data optional filters: search, id, nr, client_id, client,
|
||||
* status, approved, currency, created_at, date_from,
|
||||
* status, issued, currency, created_at, date_from,
|
||||
* date_to, paid_at, order_id, sort, direction
|
||||
*/
|
||||
public function getSearchQueryBuilder(array $data = []): QueryBuilder
|
||||
@@ -78,9 +78,9 @@ class InvoiceRepository extends EntityRepository
|
||||
$qb->andWhere('(i.id = :id_nr OR i.nr = :id_nr)')->setParameter('id_nr', $idNr);
|
||||
}
|
||||
|
||||
$approved = $data['approved'] ?? null;
|
||||
if ($approved !== null && $approved !== '') {
|
||||
$qb->andWhere('i.approved = :approved')->setParameter('approved', Tools::normalizeBoolean($approved));
|
||||
$issued = $data['issued'] ?? null;
|
||||
if ($issued !== null && $issued !== '') {
|
||||
$qb->andWhere('i.issued = :issued')->setParameter('issued', Tools::normalizeBoolean($issued));
|
||||
}
|
||||
|
||||
$status = $data['status'] ?? null;
|
||||
@@ -219,7 +219,7 @@ class InvoiceRepository extends EntityRepository
|
||||
/**
|
||||
* Must be called within a transaction, held for as long as the state is acted on.
|
||||
*
|
||||
* @return array{status: string, approved: bool}|null
|
||||
* @return array{status: string, issued: bool}|null
|
||||
*/
|
||||
public function lockAndGetState(int $invoiceId): ?array
|
||||
{
|
||||
@@ -230,7 +230,7 @@ class InvoiceRepository extends EntityRepository
|
||||
}
|
||||
|
||||
$row = $connection->fetchAssociative(
|
||||
'SELECT status, approved FROM invoice WHERE id = :id' . RowLock::suffix($connection),
|
||||
'SELECT status, issued FROM invoice WHERE id = :id' . RowLock::suffix($connection),
|
||||
['id' => $invoiceId],
|
||||
);
|
||||
|
||||
@@ -240,7 +240,7 @@ class InvoiceRepository extends EntityRepository
|
||||
|
||||
return [
|
||||
'status' => (string) $row['status'],
|
||||
'approved' => $connection->convertToPHPValue($row['approved'], Types::BOOLEAN) ?? false,
|
||||
'issued' => $connection->convertToPHPValue($row['issued'], Types::BOOLEAN) ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -261,19 +261,19 @@ class InvoiceRepository extends EntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* Approved, unpaid invoices that have not been reminded and were
|
||||
* Issued, unpaid invoices that have not been reminded and were
|
||||
* created before the given cutoff timestamp.
|
||||
*
|
||||
* @return Invoice[]
|
||||
*/
|
||||
public function findUnpaidApprovedNotRemindedBefore(int $cutoffTimestamp): array
|
||||
public function findUnpaidIssuedNotRemindedBefore(int $cutoffTimestamp): array
|
||||
{
|
||||
$cutoff = new \DateTime();
|
||||
$cutoff->setTimestamp($cutoffTimestamp);
|
||||
|
||||
return $this->createQueryBuilder('i')
|
||||
->andWhere('i.status = :status')
|
||||
->andWhere('i.approved = true')
|
||||
->andWhere('i.issued = true')
|
||||
->andWhere('i.remindedAt IS NULL')
|
||||
->andWhere('i.createdAt < :cutoff')
|
||||
->setParameter('status', Invoice::STATUS_UNPAID)
|
||||
|
||||
@@ -36,10 +36,24 @@ class TransactionRepository extends EntityRepository
|
||||
* transaction is a real record of a payment attempt/event and, like
|
||||
* client_order.unpaid_invoice_id, shouldn't be destroyed just because the invoice it
|
||||
* once pointed to was.
|
||||
*
|
||||
* With $anonymize (client-erasure path only), personal data is scrubbed from the
|
||||
* surviving rows - client IP, raw gateway payloads, free-text notes and error output -
|
||||
* while the financial record (amounts, gateway, txn id, status, dates) is kept for the
|
||||
* books. Ordinary invoice deletion leaves the rows untouched: the client still exists
|
||||
* and the admin still needs them intact.
|
||||
*/
|
||||
public function detachFromInvoice(int $invoiceId): int
|
||||
public function detachFromInvoice(int $invoiceId, bool $anonymize = false): int
|
||||
{
|
||||
return (int) $this->getEntityManager()->getConnection()->update('transaction', ['invoice_id' => null], ['invoice_id' => $invoiceId]);
|
||||
$connection = $this->getEntityManager()->getConnection();
|
||||
$data = ['invoice_id' => null];
|
||||
if ($anonymize) {
|
||||
$data += ['ip' => null, 'ipn' => null, 'note' => null, 'error' => null, 'output' => null];
|
||||
}
|
||||
|
||||
// `transaction` is a reserved word: Connection::update() does not quote the table, so
|
||||
// the bare name is a syntax error on SQLite. Quote it portably instead.
|
||||
return (int) $connection->update($connection->quoteSingleIdentifier('transaction'), $data, ['invoice_id' => $invoiceId]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,17 +188,6 @@ class TransactionRepository extends EntityRepository
|
||||
return $transaction instanceof Transaction ? $transaction : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a processed transaction by gateway transaction id.
|
||||
* Mirrors the legacy `findOne('Transaction', 'status = "processed" and txn_id = ?', ...)`.
|
||||
*/
|
||||
public function findOneProcessedByTxnId(string $txnId): ?Transaction
|
||||
{
|
||||
$transaction = $this->findOneBy(['status' => Transaction::STATUS_PROCESSED, 'txnId' => $txnId]);
|
||||
|
||||
return $transaction instanceof Transaction ? $transaction : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an active (received/processing/processed) transaction with the
|
||||
* given gateway transaction id and gateway id, excluding a given id.
|
||||
|
||||
+770
-214
File diff suppressed because it is too large.
Load diff
@@ -60,9 +60,9 @@ class ServiceInvoiceItem implements InjectionAwareInterface
|
||||
if (!$skipEditableCheck) {
|
||||
$editable = $state === null
|
||||
? $invoiceService->isInvoiceEditable($invoice)
|
||||
: $invoiceService->isInvoiceStateEditable($state['status'], $state['approved']);
|
||||
: $invoiceService->isInvoiceStateEditable($state['status'], $state['issued']);
|
||||
if (!$editable) {
|
||||
throw new \FOSSBilling\InformationException('This invoice can no longer be edited. Approved invoices are locked once issued; correct them with a credit note or a replacement invoice.');
|
||||
throw new \FOSSBilling\InformationException('This invoice can no longer be edited. Issued invoices are immutable; correct them with a credit note or a replacement invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,11 +11,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace Box\Mod\Invoice;
|
||||
|
||||
use Box\Mod\Client\Entity\Client;
|
||||
use Box\Mod\Client\Entity\ClientBalance;
|
||||
use Box\Mod\Invoice\Entity\Invoice;
|
||||
use Box\Mod\Invoice\Entity\PayGateway;
|
||||
use Box\Mod\Invoice\Entity\Subscription;
|
||||
use Box\Mod\Invoice\Entity\Transaction;
|
||||
use Box\Mod\Invoice\Event\AfterAdminTransactionCreateEvent;
|
||||
use Box\Mod\Invoice\Event\AfterAdminTransactionProcessEvent;
|
||||
@@ -23,7 +21,6 @@ use Box\Mod\Invoice\Event\AfterAdminTransactionUpdateEvent;
|
||||
use Box\Mod\Invoice\Event\BeforeAdminTransactionCreateEvent;
|
||||
use Box\Mod\Invoice\Event\BeforeAdminTransactionUpdateEvent;
|
||||
use Box\Mod\Invoice\Repository\TransactionRepository;
|
||||
use FOSSBilling\Environment;
|
||||
use FOSSBilling\InjectionAwareInterface;
|
||||
use FOSSBilling\Tools;
|
||||
|
||||
@@ -84,11 +81,19 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
$transactionId = (int) $model->getId();
|
||||
$this->di['event_dispatcher']->dispatch(new BeforeAdminTransactionUpdateEvent($transactionId));
|
||||
|
||||
// A processed transaction records money that already moved. Its
|
||||
// history fields are frozen; only operational annotations (note,
|
||||
// error, error code) may still be edited.
|
||||
if ($model->getStatus() === Transaction::STATUS_PROCESSED) {
|
||||
$this->assertNoProcessedHistoryChange($model, $data);
|
||||
}
|
||||
|
||||
if (!empty($data['invoice_id'])) {
|
||||
$invoice = $this->di['em']->getRepository(Invoice::class)->find((int) $data['invoice_id']);
|
||||
if (!$invoice instanceof Invoice) {
|
||||
throw new \FOSSBilling\InformationException('Invoice not found');
|
||||
}
|
||||
$this->assertInvoiceAcceptsTransactions($invoice);
|
||||
$model->setInvoice($invoice);
|
||||
}
|
||||
$model->setTxnId(isset($data['txn_id']) ? (string) $data['txn_id'] : $model->getTxnId());
|
||||
@@ -131,6 +136,14 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
return $id;
|
||||
}
|
||||
|
||||
// A duplicate IPN delivery may be creating-and-processing the same
|
||||
// logical payment concurrently; the loser returns the id untouched.
|
||||
if (!$this->claimForProcessing((int) $id)) {
|
||||
$this->di['logger']->info('Skipped processing transaction #{id}: already claimed by another worker', ['id' => $id]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
$this->processTransactionWithErrorHandling((int) $id);
|
||||
|
||||
return $id;
|
||||
@@ -153,6 +166,12 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
return;
|
||||
}
|
||||
|
||||
// Webhook retries can arrive while an earlier delivery is still
|
||||
// being processed; the loser leaves the row to its owner.
|
||||
if (!$this->claimForProcessing($id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->processTransaction($id);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -313,6 +332,18 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
public function delete(Transaction $model): bool
|
||||
{
|
||||
$id = $model->getId();
|
||||
// A processed transaction records money that already moved (including
|
||||
// any client-balance credit written when it was debited). Deleting it
|
||||
// would rewrite financial history and orphan those rows.
|
||||
if ($model->getStatus() === Transaction::STATUS_PROCESSED) {
|
||||
throw new \FOSSBilling\InformationException('Processed transactions cannot be deleted because they record money that already moved.');
|
||||
}
|
||||
// Unprocessed transactions move no money, but drop any balance rows
|
||||
// that reference them so no orphans remain.
|
||||
$balances = $this->di['em']->getRepository(ClientBalance::class)->findBy(['type' => 'transaction', 'relId' => (string) $id]);
|
||||
foreach ($balances as $balance) {
|
||||
$this->di['em']->remove($balance);
|
||||
}
|
||||
$this->di['em']->remove($model);
|
||||
$this->di['em']->flush();
|
||||
$this->di['logger']->info('Removed transaction #{id}', ['id' => $id]);
|
||||
@@ -320,6 +351,69 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse changes to a processed transaction's money/history fields.
|
||||
* Only operational annotations may still be edited.
|
||||
*/
|
||||
private function assertNoProcessedHistoryChange(Transaction $model, array $data): void
|
||||
{
|
||||
// Forms round-trip API renderings: '42.5' for a stored DECIMAL '42.50', '' for
|
||||
// nulls. Compare amounts numerically at 2dp and treat '' as null so
|
||||
// re-submitting unchanged values is not flagged as a history change.
|
||||
$norm = static fn ($v): ?string => ($v === null || $v === '') ? null : (string) $v;
|
||||
$differs = static fn (string $key, $current): bool => array_key_exists($key, $data) && $norm($data[$key]) !== $norm($current);
|
||||
$changed = [];
|
||||
if (!empty($data['invoice_id']) && (int) $data['invoice_id'] !== (int) $model->getInvoice()?->getId()) {
|
||||
$changed[] = 'invoice_id';
|
||||
}
|
||||
if (array_key_exists('amount', $data) && $norm($data['amount']) !== null
|
||||
&& number_format((float) $data['amount'], 2, '.', '') !== number_format((float) $model->getAmount(), 2, '.', '')) {
|
||||
$changed[] = 'amount';
|
||||
}
|
||||
if ($differs('currency', $model->getCurrency())) {
|
||||
$changed[] = 'currency';
|
||||
}
|
||||
if (!empty($data['gateway_id']) && (int) $data['gateway_id'] !== (int) $model->getGateway()?->getId()) {
|
||||
$changed[] = 'gateway_id';
|
||||
}
|
||||
if ($differs('type', $model->getType())) {
|
||||
$changed[] = 'type';
|
||||
}
|
||||
if ($differs('txn_id', $model->getTxnId())) {
|
||||
$changed[] = 'txn_id';
|
||||
}
|
||||
if ($differs('txn_status', $model->getTxnStatus())) {
|
||||
$changed[] = 'txn_status';
|
||||
}
|
||||
if ($differs('s_id', $model->getSId())) {
|
||||
$changed[] = 's_id';
|
||||
}
|
||||
if ($differs('s_period', $model->getSPeriod())) {
|
||||
$changed[] = 's_period';
|
||||
}
|
||||
if ($differs('status', $model->getStatus())) {
|
||||
$changed[] = 'status';
|
||||
}
|
||||
if ($changed !== []) {
|
||||
throw new \FOSSBilling\InformationException('Processed transactions cannot change :fields because they record money that already moved.', [':fields' => implode(', ', $changed)]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to link a transaction to an invoice that can no longer accept
|
||||
* payments. Paid invoices are allowed: a payment arriving for an invoice
|
||||
* that was just paid concurrently is credited to the client balance.
|
||||
*/
|
||||
private function assertInvoiceAcceptsTransactions(Invoice $invoice): void
|
||||
{
|
||||
if ($invoice->getStatus() === Invoice::STATUS_CANCELED
|
||||
|| $invoice->getStatus() === Invoice::STATUS_REFUNDED
|
||||
|| $invoice->getReplacedByInvoiceId() !== null
|
||||
) {
|
||||
throw new \FOSSBilling\InformationException('Transactions cannot be linked to a canceled, refunded, or replaced invoice.');
|
||||
}
|
||||
}
|
||||
|
||||
public function toApiArray(Transaction $model, $deep = false, $identity = null): array
|
||||
{
|
||||
$gateway = null;
|
||||
@@ -386,9 +480,11 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
|
||||
public function counter(): array
|
||||
{
|
||||
$sql = 'SELECT status, count(id) as counter
|
||||
FROM transaction
|
||||
GROUP BY status';
|
||||
// `transaction` is a reserved word (bare use is a syntax error on SQLite): quote it.
|
||||
$table = $this->di['em']->getConnection()->quoteSingleIdentifier('transaction');
|
||||
$sql = "SELECT status, count(id) as counter
|
||||
FROM {$table}
|
||||
GROUP BY status";
|
||||
$rows = $this->di['em']->getConnection()->fetchAllAssociative($sql);
|
||||
$data = [];
|
||||
foreach ($rows as $row) {
|
||||
@@ -451,11 +547,13 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
|
||||
public function getReceived()
|
||||
{
|
||||
$sql = 'SELECT m.*
|
||||
FROM transaction as m
|
||||
// `transaction` is a reserved word (bare use is a syntax error on SQLite): quote it.
|
||||
$table = $this->di['em']->getConnection()->quoteSingleIdentifier('transaction');
|
||||
$sql = "SELECT m.*
|
||||
FROM {$table} as m
|
||||
WHERE m.status = :received_status
|
||||
OR (m.status = :processing_status AND (m.updated_at IS NULL OR m.updated_at <= :processing_retry_after))
|
||||
ORDER BY m.id DESC';
|
||||
ORDER BY m.id DESC";
|
||||
|
||||
return $this->di['em']->getConnection()->fetchAllAssociative($sql, [
|
||||
'received_status' => Transaction::STATUS_RECEIVED,
|
||||
@@ -485,8 +583,12 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
*/
|
||||
public function claimForProcessing(int $id): bool
|
||||
{
|
||||
$affectedRows = $this->di['em']->getConnection()->executeStatement(
|
||||
'UPDATE transaction SET status = ?, updated_at = ? WHERE id = ? AND (status IN (?, ?) OR (status = ? AND (updated_at IS NULL OR updated_at <= ?)))',
|
||||
$connection = $this->di['em']->getConnection();
|
||||
// `transaction` is a reserved word: quote it portably, or the claim is a
|
||||
// syntax error on SQLite and no payment can complete there.
|
||||
$table = $connection->quoteSingleIdentifier('transaction');
|
||||
$affectedRows = $connection->executeStatement(
|
||||
"UPDATE {$table} SET status = ?, updated_at = ? WHERE id = ? AND (status IN (?, ?) OR (status = ? AND (updated_at IS NULL OR updated_at <= ?)))",
|
||||
[
|
||||
Transaction::STATUS_PROCESSING,
|
||||
date('Y-m-d H:i:s'),
|
||||
@@ -503,6 +605,15 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
|
||||
public function preProcessTransaction(Transaction $model): bool
|
||||
{
|
||||
// Serialize concurrent processing attempts on the atomic claim: a
|
||||
// double-clicked Process button or overlapping cron runs converge on
|
||||
// a single processor instead of invoking the gateway adapter twice.
|
||||
if (!$this->claimForProcessing((int) $model->getId())) {
|
||||
$this->di['logger']->info('Skipped processing transaction #{id}: already claimed by another worker', ['id' => $model->getId()]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Processing failures throw, so reaching this point means the
|
||||
// transaction was handled successfully regardless of what the
|
||||
// gateway adapter itself returns (some return void).
|
||||
@@ -551,6 +662,12 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
*
|
||||
* @throws \FOSSBilling\Exception
|
||||
*/
|
||||
/**
|
||||
* Dispatch a transaction to its payment adapter. This is the single funnel all adapter
|
||||
* invocations pass through, and every caller (preProcessTransaction, createAndProcess,
|
||||
* processAndCatchErrors) holds the processing claim on entry — adapters must not
|
||||
* re-claim the row, or the claim fails and the payment is silently skipped.
|
||||
*/
|
||||
public function processTransaction($id)
|
||||
{
|
||||
$tx = $this->getTransactionRepository()->find((int) $id);
|
||||
@@ -574,69 +691,6 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
return $adapter->processTransaction($this->di['api_system'], (int) $id, $ipn, (int) $gtw->getId());
|
||||
}
|
||||
|
||||
public function process(Transaction $tx): Transaction
|
||||
{
|
||||
$transaction = $this->getTransactionRepository()->find((int) $tx->getId());
|
||||
if ($transaction === null) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
if ($this->_isProcessed($transaction)) {
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->_parseIpnAndApprove($transaction);
|
||||
|
||||
match ($transaction->getType()) {
|
||||
\Payment_Transaction::TXTYPE_PAYMENT => $this->_debit($transaction),
|
||||
\Payment_Transaction::TXTYPE_REFUND => $this->_refund($transaction),
|
||||
\Payment_Transaction::TXTYPE_SUBSCR_CREATE => $this->_subscribe($transaction),
|
||||
\Payment_Transaction::TXTYPE_SUBSCR_CANCEL => $this->_unsubscribe($transaction),
|
||||
default => throw new \FOSSBilling\Exception('Unknown transaction #:id type: :type', [':id' => $transaction->getId(), ':type' => $transaction->getType()], 632),
|
||||
};
|
||||
} catch (\Exception $e) {
|
||||
$transaction->setStatus(Transaction::STATUS_ERROR);
|
||||
$transaction->setError($e->getMessage());
|
||||
$transaction->setErrorCode((int) $e->getCode());
|
||||
$transaction->setUpdatedAt(new \DateTime());
|
||||
$this->di['em']->flush();
|
||||
|
||||
if (DEBUG) {
|
||||
$this->di['logger']->debug($e->getMessage());
|
||||
}
|
||||
if (Environment::isTesting()) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return $transaction;
|
||||
}
|
||||
|
||||
private function _isProcessed(Transaction $tx): bool
|
||||
{
|
||||
if ($tx->getStatus() === Transaction::STATUS_PROCESSED) {
|
||||
$tx->setError(null);
|
||||
$tx->setErrorCode(null);
|
||||
$tx->setUpdatedAt(new \DateTime());
|
||||
$this->di['em']->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($this->hasProcessedTransaction($tx)) {
|
||||
$tx->setNote(($tx->getNote() ?? '') . 'Transaction was marked as processed. Transaction with same ID is already processed');
|
||||
$tx->setUpdatedAt(new \DateTime());
|
||||
$this->di['em']->flush();
|
||||
|
||||
$this->_markAsProcessed($tx);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively sort array keys to produce a deterministic representation.
|
||||
*/
|
||||
@@ -683,262 +737,4 @@ class ServiceTransaction implements InjectionAwareInterface
|
||||
|
||||
return hash('sha256', (string) $norm);
|
||||
}
|
||||
|
||||
private function hasProcessedTransaction(Transaction $tx)
|
||||
{
|
||||
if (!$tx->getTxnId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$res = $this->getTransactionRepository()->findOneProcessedByTxnId($tx->getTxnId());
|
||||
|
||||
// Return true when a processed transaction with the same txn_id exists.
|
||||
return $res !== null;
|
||||
}
|
||||
|
||||
private function _markAsProcessed(Transaction $tx): void
|
||||
{
|
||||
$tx->setError(null);
|
||||
$tx->setErrorCode(null);
|
||||
$tx->setStatus(Transaction::STATUS_PROCESSED);
|
||||
$tx->setUpdatedAt(new \DateTime());
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
|
||||
private function _parseIpnAndApprove(Transaction &$tx): Transaction
|
||||
{
|
||||
if ($tx->getStatus() === Transaction::STATUS_APPROVED) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
$payGatewayService = $this->di['mod_service']('Invoice', 'PayGateway');
|
||||
|
||||
$ipn = json_decode($tx->getIpn() ?? '', true) ?? [];
|
||||
|
||||
$gtw = $tx->getGateway();
|
||||
if (!$gtw instanceof PayGateway) {
|
||||
throw new \FOSSBilling\Exception('Could not determine transaction origin. Transaction payment gateway is unknown.', null, 701);
|
||||
}
|
||||
|
||||
$adapter = $payGatewayService->getPaymentAdapter($gtw);
|
||||
if (!$tx->getInvoice() && method_exists($adapter, 'getInvoiceId')) {
|
||||
$adapterInvoiceId = $adapter->getInvoiceId($ipn);
|
||||
if ($adapterInvoiceId) {
|
||||
$tx->setInvoice($this->di['em']->getRepository(Invoice::class)->find((int) $adapterInvoiceId));
|
||||
}
|
||||
}
|
||||
|
||||
$invoice = $tx->getInvoice();
|
||||
if (!$invoice instanceof Invoice) {
|
||||
throw new \FOSSBilling\Exception('Transaction :id is not associated with an invoice.', [':id' => $tx->getId()], 702);
|
||||
}
|
||||
|
||||
$adapter = $payGatewayService->getPaymentAdapter($gtw, $invoice);
|
||||
$mpi = $invoiceService->getPaymentInvoice($invoice);
|
||||
|
||||
if (!Environment::isTesting() && $tx->isValidateIpn()) {
|
||||
if (!$adapter->isIpnValid($ipn, $mpi)) {
|
||||
$tx->setOutput($adapter->getOutput());
|
||||
|
||||
throw new \FOSSBilling\Exception('Instant payment notification (IPN) did not pass gateway :id validation', [':id' => $gtw->getGateway()], 706);
|
||||
}
|
||||
$tx->setOutput($adapter->getOutput());
|
||||
}
|
||||
|
||||
if (!method_exists($adapter, 'getTransaction')) {
|
||||
throw new \FOSSBilling\Exception('Payment adapter :adapter does not support action :action', [':adapter' => $gtw->getName(), ':action' => 'getTransaction'], 705);
|
||||
}
|
||||
|
||||
$response = $adapter->getTransaction($ipn, $mpi);
|
||||
if (!$response instanceof \Payment_Transaction) {
|
||||
throw new \FOSSBilling\Exception('Payment gateway :id method getTransaction should return Payment_Transaction object', [':id' => $gtw->getGateway()], 705);
|
||||
}
|
||||
|
||||
// if tx type is already defined, do not set them again
|
||||
if ($response->getType()) {
|
||||
$tx->setType($response->getType());
|
||||
}
|
||||
|
||||
if ($response->getId()) {
|
||||
$tx->setTxnId($response->getId());
|
||||
}
|
||||
|
||||
if ($response->getStatus()) {
|
||||
$tx->setTxnStatus($response->getStatus());
|
||||
}
|
||||
|
||||
if ($response->getSubscriptionId()) {
|
||||
$tx->setSId($response->getSubscriptionId());
|
||||
}
|
||||
|
||||
if ($response->getAmount()) {
|
||||
$tx->setAmount((string) $response->getAmount());
|
||||
}
|
||||
|
||||
if ($response->getCurrency()) {
|
||||
$tx->setCurrency($response->getCurrency());
|
||||
}
|
||||
|
||||
$tx->setStatus(Transaction::STATUS_APPROVED);
|
||||
$tx->setUpdatedAt(new \DateTime());
|
||||
$this->di['em']->flush();
|
||||
|
||||
return $tx;
|
||||
}
|
||||
|
||||
private function _debit(Transaction $tx)
|
||||
{
|
||||
if ($this->_isProcessed($tx)) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
$this->_validateApprovedTransaction($tx);
|
||||
|
||||
$this->debitTransaction($tx);
|
||||
|
||||
$this->_markAsProcessed($tx);
|
||||
|
||||
$invoice = $tx->getInvoice();
|
||||
if ($invoice instanceof Invoice) {
|
||||
try {
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
$invoiceService->tryPayWithCredits($invoice);
|
||||
} catch (\Exception $e) {
|
||||
if (DEBUG) {
|
||||
$this->di['logger']->debug($e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function _refund(Transaction $tx): Transaction
|
||||
{
|
||||
if ($this->_isProcessed($tx)) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
$this->_validateApprovedTransaction($tx);
|
||||
|
||||
$invoice = $tx->getInvoice();
|
||||
if (!$invoice instanceof Invoice) {
|
||||
throw new \FOSSBilling\Exception('Invoice #:id not found', [':id' => $tx->getInvoice()?->getId()], 703);
|
||||
}
|
||||
$note = sprintf('Transaction %s refund', $tx->getId());
|
||||
|
||||
$invoiceService = $this->di['mod_service']('Invoice');
|
||||
$invoiceService->refundInvoice($invoice, $note);
|
||||
|
||||
$this->_markAsProcessed($tx);
|
||||
|
||||
return $tx;
|
||||
}
|
||||
|
||||
private function _subscribe(Transaction $tx): Transaction
|
||||
{
|
||||
if ($this->_isProcessed($tx)) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
$this->_validateApprovedTransaction($tx);
|
||||
|
||||
if (empty($tx->getSId())) {
|
||||
throw new \FOSSBilling\Exception('Cannot create subscription. Subscription ID from payment gateway was not received');
|
||||
}
|
||||
|
||||
$invoice = $tx->getInvoice();
|
||||
if (!$invoice instanceof Invoice) {
|
||||
throw new \FOSSBilling\Exception('Invoice #:id not found', [':id' => $tx->getInvoice()?->getId()], 703);
|
||||
}
|
||||
$subscriptionService = $this->di['mod_service']('Invoice', 'Subscription');
|
||||
$period = $subscriptionService->getSubscriptionPeriod($invoice);
|
||||
|
||||
$s = new Subscription();
|
||||
$s->setClientId($invoice->getClientId() ?? null);
|
||||
$s->setPayGateway($tx->getGateway());
|
||||
$s->setSid($tx->getSId());
|
||||
$s->setPeriod($period);
|
||||
$s->setRelType('invoice');
|
||||
$s->setRelId($invoice->getId() ?? null);
|
||||
$s->setAmount($tx->getAmount());
|
||||
$s->setCurrency($invoice->getCurrency());
|
||||
$s->setStatus('active');
|
||||
$this->di['em']->persist($s);
|
||||
$this->di['em']->flush();
|
||||
|
||||
$this->_markAsProcessed($tx);
|
||||
|
||||
return $tx;
|
||||
}
|
||||
|
||||
private function _unsubscribe(Transaction $tx): Transaction
|
||||
{
|
||||
if ($this->_isProcessed($tx)) {
|
||||
return $tx;
|
||||
}
|
||||
|
||||
$serviceSubscription = $this->di['mod_service']('Invoice', 'Subscription');
|
||||
$model = $this->di['em']->getRepository(Subscription::class)->findOneBySid((string) $tx->getSId());
|
||||
if (!$model instanceof Subscription) {
|
||||
throw new \FOSSBilling\Exception('Subscription #:id was not found. Could not unsubscribe', [':id' => $tx->getSId()]);
|
||||
}
|
||||
|
||||
$serviceSubscription->unsubscribe($model);
|
||||
|
||||
$this->_markAsProcessed($tx);
|
||||
|
||||
return $tx;
|
||||
}
|
||||
|
||||
private function _validateApprovedTransaction(Transaction $tx): void
|
||||
{
|
||||
if ($tx->getStatus() !== Transaction::STATUS_APPROVED) {
|
||||
throw new \FOSSBilling\Exception('Only approved transaction can be processed');
|
||||
}
|
||||
|
||||
$invoice = $tx->getInvoice();
|
||||
if (!$invoice instanceof Invoice) {
|
||||
throw new \FOSSBilling\Exception('Transaction :id is not associated with an invoice.', [':id' => $tx->getId()], 7022);
|
||||
}
|
||||
|
||||
// check that payment currency is correct
|
||||
if ($invoice->getCurrency() != $tx->getCurrency()) {
|
||||
throw new \FOSSBilling\Exception('Transaction currency :code does not match required currency :required', [':code' => $tx->getCurrency(), ':required' => $invoice->getCurrency()], 709);
|
||||
}
|
||||
|
||||
// check that payment status is completed if
|
||||
if ($tx->getTxnStatus() == \Payment_Transaction::STATUS_PENDING) {
|
||||
throw new \FOSSBilling\Exception('Transaction status on payment gateway is Pending. Only Complete or Unknown transactions can be processed.', null, 712);
|
||||
}
|
||||
}
|
||||
|
||||
public function debitTransaction(Transaction $tx): void
|
||||
{
|
||||
$proforma = $tx->getInvoice();
|
||||
if (!$proforma instanceof Invoice) {
|
||||
throw new \FOSSBilling\Exception('Invoice #:id not found', [':id' => $tx->getInvoice()?->getId()], 703);
|
||||
}
|
||||
$client = $this->di['em']->getRepository(Client::class)->find($proforma->getClientId());
|
||||
if (!$client instanceof Client) {
|
||||
throw new \FOSSBilling\Exception('Client #:id not found', [':id' => $proforma->getClientId()]);
|
||||
}
|
||||
|
||||
if ($client->getCurrency() != $proforma->getCurrency()) {
|
||||
throw new \FOSSBilling\Exception('Client currency does not match invoice currency');
|
||||
}
|
||||
|
||||
// do not debit negative or zero amount
|
||||
if ((float) $tx->getAmount() < 0) {
|
||||
throw new \FOSSBilling\Exception('Cannot add negative amount to client balance for debit transaction');
|
||||
}
|
||||
|
||||
$credit = new ClientBalance();
|
||||
$credit->setClient($client);
|
||||
$credit->setType('transaction');
|
||||
$credit->setRelId((string) $tx->getId());
|
||||
$credit->setDescription('Invoice #' . $proforma->getId() . ' payment received from transaction #' . $tx->getId());
|
||||
$credit->setAmount($tx->getAmount());
|
||||
$this->di['em']->persist($credit);
|
||||
$this->di['em']->flush();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<svg class="icon">
|
||||
<use href="#plus" />
|
||||
</svg>
|
||||
{{ 'New Invoice'|trans }}
|
||||
{{ 'New Draft'|trans }}
|
||||
</button>
|
||||
{% endset %}
|
||||
|
||||
@@ -210,13 +210,14 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% set invoices = admin.invoice_get_list({ 'per_page': 25, 'page': request.page }|merge(request)|merge({ 'summary': 1 })) %}
|
||||
{% set params = admin.system_get_params %}
|
||||
{% for invoice in invoices.list %}
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input m-0 align-middle batch-delete-checkbox" data-item-id="{{ invoice.id }}">
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ 'invoice/manage'|url }}/{{ invoice.id }}">{{ invoice.serie_nr }}</a>
|
||||
<a href="{{ 'invoice/manage'|url }}/{{ invoice.id }}">{% if invoice.issued %}{{ invoice.serie_nr }}{% else %}{{ 'Draft'|trans }} #{{ invoice.id }}{% endif %}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href='{{ "client/manage/#{invoice.client.id}"|url }}'>
|
||||
@@ -230,19 +231,29 @@
|
||||
<td class="text-center">{{ invoice.created_at|date('Y-m-d') }}</td>
|
||||
<td class="text-center">{% if invoice.due_at %}{{ invoice.due_at|date('Y-m-d') }}{% else %}-{% endif %}</td>
|
||||
<td class="text-center">{% if invoice.paid_at %}{{ invoice.paid_at|date('Y-m-d') }}{% else %}-{% endif %}</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}{% if not invoice.issued %} {{ mf.text_badge('Draft'|trans, 'text-bg-secondary') }}{% endif %}</td>
|
||||
<td>
|
||||
<a class="btn btn-icon" href='{{ "invoice/manage/#{invoice.id}"|url }}'>
|
||||
<svg class="icon">
|
||||
<use href="#edit" />
|
||||
</svg>
|
||||
</a>
|
||||
{% if invoice.cancellable %}
|
||||
<a class="btn btn-icon" href="{{ 'invoice/cancel'|api_url(query: { 'id': invoice.id }) }}"
|
||||
{{ fb_api_link({modal: {type: 'confirm', title: 'Cancel this invoice without a replacement?'|trans}, reload: true}) }}>
|
||||
<svg class="icon">
|
||||
<use href="#x" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if (invoice.status == 'unpaid' and not invoice.issued) or (params.invoice_immutability|default('strict') == 'relaxed' and invoice.status == 'canceled') %}
|
||||
<a class="btn btn-icon" href="{{ 'invoice/delete'|api_url(query: { 'id': invoice.id }) }}"
|
||||
{{ fb_api_link({modal: {type: 'confirm', title: 'Are you sure?'|trans}, reload: true}) }}>
|
||||
<svg class="icon">
|
||||
<use href="#trash" />
|
||||
</svg>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
@@ -274,7 +285,7 @@
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<form class="modal-content" method="post" action="{{ 'invoice/prepare'|api_url }}" {{ fb_api_form({callback: 'onAfterInvoicePrepared'}) }}>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="newInvoiceModalLabel">{{ 'Create New Invoice'|trans }}</h3>
|
||||
<h3 class="modal-title" id="newInvoiceModalLabel">{{ 'New Draft Invoice'|trans }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ 'Close'|trans }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -301,11 +312,12 @@
|
||||
name="product_id"
|
||||
data-resturl="/api/admin/product/get_pairs">
|
||||
</select>
|
||||
<small class="form-text">{{ 'This saves a draft you can edit on the next page. Nothing is sent to the client until you issue it.'|trans }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ 'Prepare'|trans }}</button>
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save Draft'|trans }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
{% import 'macro_functions.html.twig' as mf %}
|
||||
|
||||
{% block meta_title %}{{ 'Invoice'|trans }} {{ invoice.serie }}{{ "%05s"|format(invoice.nr) }}{% endblock %}
|
||||
{% set number_label = invoice.issued ? invoice.serie_nr : ('Draft'|trans ~ ' #' ~ invoice.id) %}
|
||||
|
||||
{% block meta_title %}{{ 'Invoice'|trans }} {{ number_label }}{% endblock %}
|
||||
|
||||
{% set active_menu = 'invoice' %}
|
||||
|
||||
@@ -18,13 +20,13 @@
|
||||
<li class="breadcrumb-item">
|
||||
<a href="{{ 'invoice'|url }}">{{ 'Invoices'|trans }}</a>
|
||||
</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ invoice.serie }}{{ '%05s'|format(invoice.nr) }}</li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ number_label }}</li>
|
||||
</ol>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% embed 'partial_admin_detail_tabs.html.twig' with {
|
||||
title: 'Invoice'|trans ~ ' ' ~ invoice.serie ~ ("%05s"|format(invoice.nr)),
|
||||
title: 'Invoice'|trans ~ ' ' ~ number_label,
|
||||
subtitle: 'Review billing details, update invoice content, and adjust stored purchase records.'|trans,
|
||||
outer_card_class: 'mb-3'
|
||||
} %}
|
||||
@@ -39,10 +41,7 @@
|
||||
<a class="nav-link" href="#tab-texts" data-bs-toggle="tab" role="tab">{{ 'Texts'|trans }}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" href="#tab-buyer-credentials" data-bs-toggle="tab" role="tab">{{ 'Client Credentials'|trans }}</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" href="#tab-seller-credentials" data-bs-toggle="tab" role="tab">{{ 'Company Credentials'|trans }}</a>
|
||||
<a class="nav-link" href="#tab-history" data-bs-toggle="tab" role="tab">{{ 'History'|trans }}</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
@@ -65,7 +64,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-end">{{ 'Number'|trans }}:</td>
|
||||
<td>{{ invoice.serie }}{{ "%05s"|format(invoice.nr) }}</td>
|
||||
<td>{{ number_label }}{% if not invoice.issued %} {{ mf.text_badge('Draft'|trans, 'text-bg-secondary') }}{% endif %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-end">{{ 'Client'|trans }}:</td>
|
||||
@@ -79,7 +78,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-end">{{ 'Status'|trans }}:</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}{% if not invoice.issued %} {{ mf.text_badge('Draft'|trans, 'text-bg-secondary') }}{% endif %}</td>
|
||||
</tr>
|
||||
{% if invoice.credit_note_for_invoice_id %}
|
||||
<tr>
|
||||
@@ -154,7 +153,12 @@
|
||||
{% block summary_actions %}
|
||||
{% if not invoice.editable %}
|
||||
<div class="alert alert-warning w-100 mb-2" role="alert">
|
||||
{{ 'This invoice is locked. Approved invoices cannot be edited once issued; correct them with a credit note or a replacement invoice.'|trans }}
|
||||
{{ 'This invoice is locked. Issued invoices cannot be edited; correct them with a credit note or a replacement invoice.'|trans }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not invoice.issued %}
|
||||
<div class="alert alert-info w-100 mb-2" role="alert">
|
||||
{{ 'This is a draft. It has no invoice number yet - issuing assigns the next number in the sequence.'|trans }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if invoice.replaced_by_invoice_id %}
|
||||
@@ -175,7 +179,7 @@
|
||||
</svg>
|
||||
<span>{{ 'View as Client'|trans }}</span>
|
||||
</a>
|
||||
{% if invoice.status == 'unpaid' and not invoice.approved %}
|
||||
{% if invoice.deletable %}
|
||||
<a class="btn btn-danger"
|
||||
{{ fb_api_link({href: 'invoice/delete'|api_url(query: {id: invoice.id}), modal: {type: 'confirm', title: 'Are you sure?'|trans}, redirect: 'invoice'|url}) }}>
|
||||
<svg class="icon" width="24" height="24">
|
||||
@@ -184,6 +188,15 @@
|
||||
<span>{{ 'Delete'|trans }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if invoice.cancellable %}
|
||||
<a class="btn btn-warning"
|
||||
{{ fb_api_link({href: 'invoice/cancel'|api_url(query: {id: invoice.id}), modal: {type: 'confirm', title: 'Cancel this invoice without a replacement?'|trans}, reload: true}) }}>
|
||||
<svg class="icon" width="24" height="24">
|
||||
<use href="#x" />
|
||||
</svg>
|
||||
<span>{{ 'Cancel'|trans }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if invoice.status == 'unpaid' %}
|
||||
<a class="btn btn-primary" {{ fb_api_link({href: 'invoice/send_reminder'|api_url(query: {id: invoice.id}), message: 'Payment Reminder Sent'|trans}) }}>
|
||||
<svg class="icon" width="24" height="24">
|
||||
@@ -201,7 +214,7 @@
|
||||
<div class="modal-dialog">
|
||||
<form class="modal-content" method="post" action="{{ 'invoice/mark_as_paid'|api_url(query: { 'id': invoice.id, 'execute': 1 }) }}" name="transactionForm" {{ fb_api_form({action: 'invoice/mark_as_paid'|api_url(query: {id: invoice.id, execute: 1}), reload: true}) }}>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="transactionModalLabel">{{ 'Enter Custom Transaction ID'|trans }}:</h5>
|
||||
<h5 class="modal-title" id="transactionModalLabel">{{ 'Mark as Paid'|trans }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ 'Close'|trans }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -209,13 +222,22 @@
|
||||
<input type="hidden" name="execute" value="1">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div>
|
||||
<label class="form-label" for="mark-paid-gateway">{{ 'Payment Method'|trans }}</label>
|
||||
<select class="form-select" name="gateway_id" id="mark-paid-gateway" onchange="onMarkPaidGatewayChange(this)">
|
||||
{% for gtw in guest.invoice_gateways %}
|
||||
<option value="{{ gtw.id }}" data-code="{{ gtw.code }}" {% if gtw.id == invoice.gateway_id %}selected{% endif %}>{{ gtw.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div id="mark-paid-transaction-row">
|
||||
<label class="form-label" for="transaction-id">{{ 'Transaction ID'|trans }}</label>
|
||||
<input class="form-control" type="text" name="transactionId" id="transaction-id">
|
||||
<small class="form-text">{{ 'Required for the Custom gateway, e.g. the reference you use to reconcile your bank statement.'|trans }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label" for="mark-paid-date">{{ 'Payment Date'|trans }}</label>
|
||||
<input class="form-control" type="datetime-local" name="paid_at" id="mark-paid-date" value="{{ 'now'|date('Y-m-d\\TH:i') }}">
|
||||
</div>
|
||||
<p class="text-secondary mb-0">
|
||||
{{ 'Add the transaction ID of the payment to mark the invoice as paid.'|trans }}<br />
|
||||
{{ 'This should be the transaction ID you use to reconcile your bank statement.'|trans }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -225,6 +247,14 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function onMarkPaidGatewayChange(select) {
|
||||
var row = document.getElementById('mark-paid-transaction-row');
|
||||
var code = select.options[select.selectedIndex].dataset.code;
|
||||
row.style.display = code === 'Custom' ? '' : 'none';
|
||||
}
|
||||
onMarkPaidGatewayChange(document.getElementById('mark-paid-gateway'));
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.status == 'paid' %}
|
||||
@@ -271,7 +301,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if invoice.approved and (invoice.status == 'unpaid' or invoice.status == 'paid') and not invoice.credit_note_for_invoice_id %}
|
||||
{% if invoice.issued and (invoice.status == 'unpaid' or invoice.status == 'paid') and not invoice.credit_note_for_invoice_id %}
|
||||
<a href="#" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#debitModal">
|
||||
<svg class="icon" width="24" height="24">
|
||||
<use href="#receipt" />
|
||||
@@ -319,7 +349,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if invoice.approved and invoice.status == 'unpaid' and not invoice.credit_note_for_invoice_id and not invoice.debit_note_for_invoice_id and not invoice.replaced_by_invoice_id %}
|
||||
{% if invoice.issued and invoice.status == 'unpaid' and not invoice.credit_note_for_invoice_id and not invoice.debit_note_for_invoice_id and not invoice.replaced_by_invoice_id %}
|
||||
<a href="#" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#reissueModal">
|
||||
<svg class="icon" width="24" height="24">
|
||||
<use href="#refresh" />
|
||||
@@ -390,7 +420,7 @@
|
||||
|
||||
{% embed 'partial_admin_related_section.html.twig' with {
|
||||
title: 'Invoice Items'|trans,
|
||||
subtitle: 'Review line items, pricing, taxation, and approval-ready billing content for this invoice.'|trans,
|
||||
subtitle: 'Review line items, pricing, taxation, and issue-ready billing content for this invoice.'|trans,
|
||||
card_class: 'mb-3'
|
||||
} %}
|
||||
{% block section_content %}
|
||||
@@ -474,12 +504,12 @@
|
||||
<div class="card-footer d-flex justify-content-end gap-2">
|
||||
<input type="hidden" name="id" value="{{ invoice.id }}">
|
||||
<input type="submit" value="{{ 'Update'|trans }}" class="btn btn-primary">
|
||||
{% if not invoice.approved %}
|
||||
<button class="btn btn-success" type="submit" name="approve" value="1">
|
||||
{% if not invoice.issued %}
|
||||
<button class="btn btn-success" type="submit" name="issue" value="1">
|
||||
<svg class="icon">
|
||||
<use href="#check" />
|
||||
</svg>
|
||||
{{ 'Approve'|trans }}
|
||||
{{ 'Issue'|trans }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -617,7 +647,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.status == 'unpaid' and has_permission('product', 'manage_promos') %}
|
||||
{% if invoice.editable and has_permission('product', 'manage_promos') %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
@@ -749,53 +779,61 @@
|
||||
<fieldset {% if not invoice.editable %}disabled{% endif %}>
|
||||
{% embed 'partial_admin_form_card.html.twig' with {
|
||||
title: 'Invoice Status and Numbering'|trans,
|
||||
subtitle: 'Control the invoice lifecycle, approval state, and displayed numbering.'|trans,
|
||||
subtitle: 'Control the invoice lifecycle, issue state, and displayed numbering.'|trans,
|
||||
card_class: 'mb-4'
|
||||
} %}
|
||||
{% import 'macro_functions.html.twig' as mf %}
|
||||
{% block form_content %}
|
||||
{% if invoice.issued %}
|
||||
<div class="alert alert-info" role="alert">
|
||||
{{ 'This invoice is issued: its number, status, and issue state are locked. Correct it with a payment, credit note, reissue, or cancel (void) instead.'|trans }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label d-block">{{ 'Status'|trans }}</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioStatusPaid" type="radio" name="status" value="paid"{% if invoice.status == 'paid' %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioStatusPaid" type="radio" name="status" value="paid"{% if invoice.status == 'paid' %} checked{% endif %}{% if invoice.issued %} disabled{% endif %}>
|
||||
<span class="form-check-label">{{ 'Paid'|trans }}</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioStatusUnpaid" type="radio" name="status" value="unpaid"{% if invoice.status == 'unpaid' %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioStatusUnpaid" type="radio" name="status" value="unpaid"{% if invoice.status == 'unpaid' %} checked{% endif %}{% if invoice.issued %} disabled{% endif %}>
|
||||
<span class="form-check-label">{{ 'Unpaid'|trans }}</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioStatusRefunded" type="radio" name="status" value="refunded"{% if invoice.status == 'refunded' %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioStatusRefunded" type="radio" name="status" value="refunded"{% if invoice.status == 'refunded' %} checked{% endif %}{% if invoice.issued %} disabled{% endif %}>
|
||||
<span class="form-check-label">{{ 'Refunded'|trans }}</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioStatusCanceled" type="radio" name="status" value="canceled"{% if invoice.status == 'canceled' %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioStatusCanceled" type="radio" name="status" value="canceled"{% if invoice.status == 'canceled' %} checked{% endif %}{% if invoice.issued %} disabled{% endif %}>
|
||||
<span class="form-check-label">{{ 'Canceled'|trans }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label d-block">{{ 'Approved'|trans }}</label>
|
||||
<label class="form-label d-block">{{ 'Issued'|trans }}</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioApprovedYes" type="radio" name="approved" value="1"{% if invoice.approved %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioIssuedYes" type="radio" name="issued" value="1"{% if invoice.issued %} checked{% endif %} disabled>
|
||||
<span class="form-check-label">{{ 'Yes'|trans }}</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" id="radioApprovedNo" type="radio" name="approved" value="0"{% if not invoice.approved %} checked{% endif %}>
|
||||
<input class="form-check-input" id="radioIssuedNo" type="radio" name="issued" value="0"{% if not invoice.issued %} checked{% endif %} disabled>
|
||||
<span class="form-check-label">{{ 'No'|trans }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% if not invoice.issued %}
|
||||
<div class="form-text">{{ 'Issue the invoice with the Issue button below; the issue state cannot be changed here.'|trans }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="invoice_serie">{{ 'Serie'|trans }}</label>
|
||||
<input class="form-control" id="invoice_serie" type="text" name="serie" value="{{ invoice.serie }}">
|
||||
<input class="form-control" id="invoice_serie" type="text" name="serie" value="{{ invoice.serie }}"{% if invoice.issued %} disabled{% endif %}>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="invoice_number">{{ 'Number'|trans }}</label>
|
||||
<input class="form-control" id="invoice_number" type="text" name="nr" value="{{ invoice.nr }}">
|
||||
<input class="form-control" id="invoice_number" type="text" name="nr" value="{{ invoice.nr }}"{% if invoice.issued %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -912,125 +950,158 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-seller-credentials" role="tabpanel">
|
||||
<div class="tab-pane fade" id="tab-history" role="tabpanel">
|
||||
<div class="card-body">
|
||||
{% set seller = invoice.seller %}
|
||||
<form method="post" action="{{ 'invoice/update'|api_url }}" {{ fb_api_form({message: 'Invoice Updated'|trans}) }}>
|
||||
<fieldset {% if not invoice.editable %}disabled{% endif %}>
|
||||
{% embed 'partial_admin_form_card.html.twig' with {
|
||||
title: 'Company Details at Time of Purchase'|trans,
|
||||
subtitle: 'Review and correct the seller details stored on this invoice.'|trans
|
||||
} %}
|
||||
{% import 'macro_functions.html.twig' as mf %}
|
||||
{% block form_content %}
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="seller_company">{{ 'Company Name'|trans }}</label>
|
||||
<input class="form-control" id="seller_company" type="text" name="seller_company" value="{{ seller.company }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="seller_company_vat">{{ 'Company VAT'|trans }}</label>
|
||||
<input class="form-control" id="seller_company_vat" type="text" name="seller_company_vat" value="{{ seller.company_vat }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="seller_company_number">{{ 'Company Number'|trans }}</label>
|
||||
<input class="form-control" id="seller_company_number" type="text" name="seller_company_number" value="{{ seller.company_number }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="seller_phone">{{ 'Phone'|trans }}</label>
|
||||
<input class="form-control" id="seller_phone" type="text" name="seller_phone" value="{{ seller.phone }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="seller_address">{{ 'Address'|trans }}</label>
|
||||
<input class="form-control" id="seller_address" type="text" name="seller_address" value="{{ seller.address }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="seller_email">{{ 'Email'|trans }}</label>
|
||||
<input class="form-control" id="seller_email" type="text" name="seller_email" value="{{ seller.email }}">
|
||||
</div>
|
||||
{% set journal = admin.invoice_journal({'id': invoice.id}) %}
|
||||
{% set journal_labels = {
|
||||
'created': 'Created'|trans,
|
||||
'issued': 'Issued'|trans,
|
||||
'updated': 'Updated'|trans,
|
||||
'paid': 'Paid'|trans,
|
||||
'refunded': 'Refunded'|trans,
|
||||
'debited': 'Debited'|trans,
|
||||
'canceled': 'Canceled'|trans,
|
||||
'reissued': 'Reissued'|trans,
|
||||
'order_attached': 'Order attached'|trans,
|
||||
'reminder': 'Reminder'|trans,
|
||||
} %}
|
||||
<p class="text-secondary mb-3">{{ 'Every lifecycle transition is recorded here with a snapshot of the invoice at that time, including past buyer and seller details.'|trans }}</p>
|
||||
<table class="table card-table table-vcenter table-striped text-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'Event'|trans }}</th>
|
||||
<th>{{ 'Actor'|trans }}</th>
|
||||
<th>{{ 'Date'|trans }}</th>
|
||||
<th class="w-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in journal %}
|
||||
<tr>
|
||||
<td>{{ mf.text_badge(journal_labels[entry.type]|default(entry.type|replace({'_': ' '})|title), 'text-bg-secondary') }}</td>
|
||||
<td>
|
||||
{% if entry.admin_id %}
|
||||
<a href='{{ "staff/manage/#{entry.admin_id}"|url }}'>{{ 'Admin'|trans }} #{{ entry.admin_id }}</a>
|
||||
{% elseif entry.client_id %}
|
||||
<a href='{{ "client/manage/#{entry.client_id}"|url }}'>{{ 'Client'|trans }} #{{ entry.client_id }}</a>
|
||||
{% else %}
|
||||
{{ 'System'|trans }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.created_at|format_datetime }}</td>
|
||||
<td class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#journalModal{{ entry.id }}">{{ 'View'|trans }}</button>
|
||||
<a href="{{ 'invoice/manage'|url }}/{{ invoice.id }}/journal-download?event={{ entry.id }}" class="btn btn-sm btn-outline-secondary">{{ 'JSON'|trans }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-muted text-center">{{ 'No history recorded yet.'|trans }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% for entry in journal %}
|
||||
<div class="modal fade" id="journalModal{{ entry.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">{{ journal_labels[entry.type]|default(entry.type|replace({'_': ' '})|title) }} - {{ entry.created_at|format_datetime }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ 'Close'|trans }}"></button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_actions %}
|
||||
<button type="submit" class="btn btn-primary">{{ 'Update Company Details'|trans }}</button>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<input type="hidden" name="id" value="{{ invoice.id }}">
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="tab-buyer-credentials" role="tabpanel">
|
||||
<div class="card-body">
|
||||
{% set buyer = invoice.buyer %}
|
||||
<form method="post" action="{{ 'invoice/update'|api_url }}" {{ fb_api_form({message: 'Invoice Updated'|trans}) }}>
|
||||
<fieldset {% if not invoice.editable %}disabled{% endif %}>
|
||||
{% embed 'partial_admin_form_card.html.twig' with {
|
||||
title: 'Client Details at Time of Purchase'|trans,
|
||||
subtitle: 'Review and correct the stored buyer details for this invoice.'|trans
|
||||
} %}
|
||||
{% import 'macro_functions.html.twig' as mf %}
|
||||
{% block form_content %}
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_first_name">{{ 'First Name'|trans }}</label>
|
||||
<input class="form-control" id="buyer_first_name" type="text" name="buyer_first_name" value="{{ buyer.first_name }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_last_name">{{ 'Last Name'|trans }}</label>
|
||||
<input class="form-control" id="buyer_last_name" type="text" name="buyer_last_name" value="{{ buyer.last_name }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_company">{{ 'Company Name'|trans }}</label>
|
||||
<input class="form-control" id="buyer_company" type="text" name="buyer_company" value="{{ buyer.company }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_company_vat">{{ 'Company VAT'|trans }}</label>
|
||||
<input class="form-control" id="buyer_company_vat" type="text" name="buyer_company_vat" value="{{ buyer.company_vat }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_company_number">{{ 'Company Number'|trans }}</label>
|
||||
<input class="form-control" id="buyer_company_number" type="text" name="buyer_company_number" value="{{ buyer.company_number }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_phone">{{ 'Phone'|trans }}</label>
|
||||
<input class="form-control" id="buyer_phone" type="text" name="buyer_phone" value="{{ buyer.phone }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label" for="buyer_address">{{ 'Address'|trans }}</label>
|
||||
<input class="form-control" id="buyer_address" type="text" name="buyer_address" value="{{ buyer.address }}">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="buyer_city">{{ 'City'|trans }}</label>
|
||||
<input class="form-control" id="buyer_city" type="text" name="buyer_city" value="{{ buyer.city }}">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="buyer_state">{{ 'State'|trans }}</label>
|
||||
<input class="form-control" id="buyer_state" type="text" name="buyer_state" value="{{ buyer.state }}">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="buyer_zip">{{ 'Zip'|trans }}</label>
|
||||
<input class="form-control" id="buyer_zip" type="text" name="buyer_zip" value="{{ buyer.zip }}">
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label">{{ 'Country'|trans }}</label>
|
||||
{{ mf.selectbox('buyer_country', country_names(), buyer.country, 0, 'Country'|trans) }}
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="buyer_email">{{ 'Email'|trans }}</label>
|
||||
<input class="form-control" id="buyer_email" type="text" name="buyer_email" value="{{ buyer.email }}">
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% set snapshot = entry.snapshot ?? {} %}
|
||||
<dl class="row small mb-0">
|
||||
{% if snapshot.serie_nr is defined and snapshot.serie_nr %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Number'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ snapshot.serie_nr }}</dd>
|
||||
{% endif %}
|
||||
{% if snapshot.status is defined and snapshot.status %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Status'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ snapshot.status }}</dd>
|
||||
{% endif %}
|
||||
{% if snapshot.total is defined and snapshot.total %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Total'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ snapshot.total|format_currency(invoice.currency ?? default_currency) }}</dd>
|
||||
{% endif %}
|
||||
{% if snapshot.changed_fields is defined and snapshot.changed_fields %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Changed fields'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ snapshot.changed_fields|join(', ') }}</dd>
|
||||
{% endif %}
|
||||
{% if snapshot.reason is defined and snapshot.reason %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Reason'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ snapshot.reason }}</dd>
|
||||
{% endif %}
|
||||
{% if snapshot.replacement_id is defined and snapshot.replacement_id %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Replacement invoice'|trans }}</dt>
|
||||
<dd class="col-sm-8"><a href='{{ "invoice/manage/#{snapshot.replacement_id}"|url }}'>#{{ snapshot.replacement_id }}</a></dd>
|
||||
{% endif %}
|
||||
{% if snapshot.order_id is defined and snapshot.order_id %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Order'|trans }}</dt>
|
||||
<dd class="col-sm-8">#{{ snapshot.order_id }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% if snapshot.buyer is defined and snapshot.buyer %}
|
||||
{% set party = snapshot.buyer %}
|
||||
<h6 class="mt-3">{{ 'Buyer'|trans }}</h6>
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-sm-4 text-muted">{{ 'Name'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.first_name }} {{ party.last_name }}</dd>
|
||||
{% if party.company|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Company'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.company }}</dd>
|
||||
{% endif %}
|
||||
{% if party.company_vat|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Company VAT'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.company_vat }}</dd>
|
||||
{% endif %}
|
||||
{% if party.address|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Address'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.address }}{% if party.city|default('')|trim %}, {{ party.city }}{% endif %}{% if party.zip|default('')|trim %} {{ party.zip }}{% endif %}{% if party.country|default('')|trim %}, {{ party.country }}{% endif %}</dd>
|
||||
{% endif %}
|
||||
{% if party.phone|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Phone'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.phone }}</dd>
|
||||
{% endif %}
|
||||
{% if party.email|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Email'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.email }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
{% if snapshot.seller is defined and snapshot.seller %}
|
||||
{% set party = snapshot.seller %}
|
||||
<h6 class="mt-3">{{ 'Seller'|trans }}</h6>
|
||||
<dl class="row small mb-0">
|
||||
<dt class="col-sm-4 text-muted">{{ 'Company Name'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.company }}</dd>
|
||||
{% if party.company_vat|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Company VAT'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.company_vat }}</dd>
|
||||
{% endif %}
|
||||
{% if party.address|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Address'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.address }}</dd>
|
||||
{% endif %}
|
||||
{% if party.phone|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Phone'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.phone }}</dd>
|
||||
{% endif %}
|
||||
{% if party.email|default('')|trim %}
|
||||
<dt class="col-sm-4 text-muted">{{ 'Email'|trans }}</dt>
|
||||
<dd class="col-sm-8">{{ party.email }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block form_actions %}
|
||||
<button type="submit" class="btn btn-primary">{{ 'Update Client Details'|trans }}</button>
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
<input type="hidden" name="id" value="{{ invoice.id }}">
|
||||
</fieldset>
|
||||
</form>
|
||||
<div class="modal-footer">
|
||||
<a href="{{ 'invoice/manage'|url }}/{{ invoice.id }}/journal-download?event={{ entry.id }}" class="btn btn-outline-secondary">{{ 'Download JSON'|trans }}</a>
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1047,7 +1118,7 @@
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button class="btn btn-primary" type="button" onclick="window.print()">{{ 'Print'|trans }}</button>
|
||||
{% if invoice.status == 'unpaid' and not invoice.approved %}
|
||||
{% if invoice.deletable %}
|
||||
<a class="btn btn-danger" href="{{ 'invoice/delete'|api_url(query: { 'id': invoice.id }) }}"
|
||||
{{ fb_api_link({modal: {type: 'confirm', title: 'Are you sure?'|trans}, redirect: 'invoice'|url}) }}>
|
||||
{{ 'Delete'|trans }}
|
||||
|
||||
@@ -55,16 +55,22 @@
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-check form-switch form-switch-2">
|
||||
<input class="form-check-input" name="invoice_auto_approval" type="checkbox" value="1" {% if params.invoice_auto_approval|default('') == '1' %}checked{% endif %}>
|
||||
<span class="form-check-label">{{ 'Enable Auto Invoice Approval'|trans }}</span>
|
||||
<input class="form-check-input" name="invoice_auto_issue" type="checkbox" value="1" {% if params.invoice_auto_issue|default('') == '1' %}checked{% endif %}>
|
||||
<span class="form-check-label">{{ 'Auto-Issue Wallet Top-Up Invoices'|trans }}</span>
|
||||
</label>
|
||||
<small class="form-text">{{ 'When enabled, client wallet top-up invoices skip the draft stage. All other invoices are always saved as drafts first.'|trans }}</small>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="form-check form-switch form-switch-2">
|
||||
<input class="form-check-input" name="invoice_allow_edit_unpaid" type="checkbox" value="1" {% if params.invoice_allow_edit_unpaid|default('') == '1' %}checked{% endif %}>
|
||||
<span class="form-check-label">{{ 'Allow editing approved invoices while unpaid'|trans }}</span>
|
||||
</label>
|
||||
<small class="form-text">{{ 'Approved invoices are otherwise locked once issued. Only enable this where local rules treat an approved unpaid invoice as an editable quote; edited invoices are re-sent to the client.'|trans }}</small>
|
||||
<label class="form-label d-block">{{ 'Invoice Immutability'|trans }}</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="radioImmutabilityStrict" type="radio" name="invoice_immutability" value="strict" {% if params.invoice_immutability|default('strict') == 'strict' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="radioImmutabilityStrict">{{ 'Strict - Issued invoices cannot be edited or deleted'|trans }}</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="radioImmutabilityRelaxed" type="radio" name="invoice_immutability" value="relaxed" {% if params.invoice_immutability|default('strict') == 'relaxed' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="radioImmutabilityRelaxed">{{ 'Relaxed - Allow editing and deleting unpaid invoices after issue'|trans }}</label>
|
||||
</div>
|
||||
<small class="form-text">{{ 'Issued invoices are legal documents: in strict mode they can only be corrected with a payment, credit note, reissue, or cancel (void) — never edited or deleted. This setting is transitional and will be removed in a future release, leaving strict as the only behavior. Learn why:'|trans }} <a href="https://docs.fossbilling.org/admin-guide/invoices/" target="_blank" rel="noopener">{{ 'invoice immutability'|trans }}</a></small>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-lg-6">
|
||||
@@ -94,22 +100,18 @@
|
||||
<input class="form-control" id="remove_after_days" type="number" min="0" name="remove_after_days" value="{{ params.remove_after_days|default(0) }}">
|
||||
<span class="input-group-text">{{ 'days'|trans }}</span>
|
||||
</div>
|
||||
<small class="form-text">{{ 'Set 0 to keep invoices forever.'|trans }}</small>
|
||||
<small class="form-text">{{ 'Set 0 to keep invoices forever. Applies to invoices past their due date: drafts are deleted, issued invoices are voided but keep their number, and credit or debit notes are never touched.'|trans }}</small>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="invoice_number_padding">{{ 'Invoice Number Padding Length'|trans }}</label>
|
||||
<input class="form-control" id="invoice_number_padding" type="number" name="invoice_number_padding" value="{{ params.invoice_number_padding|default(5) }}" min="0" max="20">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="invoice_series">{{ 'Invoice Prefix / Series'|trans }}</label>
|
||||
<input class="form-control" id="invoice_series" type="text" name="invoice_series" value="{{ params.invoice_series|default('') }}">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="invoice_series_paid">{{ 'Paid Invoice Prefix / Series'|trans }}</label>
|
||||
<input class="form-control" id="invoice_series_paid" type="text" name="invoice_series_paid" value="{{ params.invoice_series_paid|default('') }}">
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<label class="form-label" for="invoice_starting_number">{{ 'Next Paid Invoice Number'|trans }}</label>
|
||||
<div class="col-lg-6">
|
||||
<label class="form-label" for="invoice_starting_number">{{ 'Next Invoice Number'|trans }}</label>
|
||||
<input class="form-control" id="invoice_starting_number" type="text" name="invoice_starting_number" value="{{ params.invoice_starting_number|default('') }}">
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,6 +152,15 @@
|
||||
</label>
|
||||
<small class="form-text">{{ 'Attaches a PDF copy of the invoice to the created, paid, reminder, and overdue emails, so clients who pay by bank transfer can view it without logging in.'|trans }}</small>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<input type="hidden" name="invoice_send_cancel_email" value="0">
|
||||
<label class="form-check form-switch form-switch-2">
|
||||
<input class="form-check-input" type="checkbox" name="invoice_send_cancel_email" value="1" {% if params.invoice_send_cancel_email|default('0') == '1' %}checked{% endif %}>
|
||||
<span class="form-check-label">{{ 'Send Email When an Invoice Is Voided'|trans }}</span>
|
||||
</label>
|
||||
<small class="form-text">{{ 'Emails the client when an unpaid invoice is voided, manually or automatically. Applies to reissued invoices as well.'|trans }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +179,7 @@
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" id="radioInvoiceRefundLogic" type="radio" name="invoice_refund_logic" value="manual" {% if params.invoice_refund_logic|default('') == 'manual' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="radioInvoiceRefundLogic">{{ 'Manual - No actions performed'|trans }}</label>
|
||||
<label class="form-check-label" for="radioInvoiceRefundLogic">{{ 'Manual - Record an offline refund document (money moves outside FOSSBilling)'|trans }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
{% set unpaid_invoices = client.invoice_get_list({"status":"unpaid", "per_page":10, "page":request.page}) %}
|
||||
{% set paid_invoices = client.invoice_get_list({"per_page":10, "page":request.page, "status":"paid"}) %}
|
||||
{% set refunded_invoices = client.invoice_get_list({"per_page":10, "page":request.page, "status":"refunded"}) %}
|
||||
{% set canceled_invoices = client.invoice_get_list({"per_page":10, "page":request.page, "status":"canceled"}) %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card mb-4">
|
||||
@@ -21,7 +22,7 @@
|
||||
<div>
|
||||
<h1 class="mb-1">{{ 'Invoices'|trans }}</h1>
|
||||
<span class="small text-muted">
|
||||
{{ 'All of your invoices can be found here. You can choose to see unpaid, paid, or refunded invoices by clicking the corresponding button.'|trans }}
|
||||
{{ 'All of your invoices can be found here. You can choose to see unpaid, paid, refunded, or canceled invoices by clicking the corresponding button.'|trans }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +42,10 @@
|
||||
type="button" role="tab" aria-controls="nav-refunded"
|
||||
aria-selected="false">{{ 'Refunded'|trans }}{% if refunded_invoices.total %} <span
|
||||
class="badge text-bg-info">{{ refunded_invoices.total }}</span>{% endif %}</button>
|
||||
<button class="nav-link" id="canceled-tab" data-bs-toggle="tab" data-bs-target="#nav-canceled"
|
||||
type="button" role="tab" aria-controls="nav-canceled"
|
||||
aria-selected="false">{{ 'Canceled'|trans }}{% if canceled_invoices.total %} <span
|
||||
class="badge text-bg-secondary">{{ canceled_invoices.total }}</span>{% endif %}</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
@@ -59,7 +64,7 @@
|
||||
<tbody>
|
||||
{% for i, invoice in unpaid_invoices.list %}
|
||||
<tr>
|
||||
<td>{% if invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie ~ "%05s"|format(invoice.nr) }}</td>
|
||||
<td>{% if invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie_nr }}</td>
|
||||
<td>{{ invoice.created_at|format_date }}</td>
|
||||
<td>{{ invoice.due_at|format_date }}</td>
|
||||
<td>{{ invoice.total|format_currency(invoice.currency ?? default_currency) }}</td>
|
||||
@@ -90,7 +95,7 @@
|
||||
<tbody>
|
||||
{% for i, invoice in paid_invoices.list %}
|
||||
<tr>
|
||||
<td>{% if invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie ~ "%05s"|format(invoice.nr) }}</td>
|
||||
<td>{% if invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie_nr }}</td>
|
||||
<td>{{ invoice.created_at|format_date }}</td>
|
||||
<td>{{ invoice.paid_at|format_date }}</td>
|
||||
<td>{{ invoice.total|format_currency(invoice.currency ?? default_currency) }}</td>
|
||||
@@ -119,7 +124,7 @@
|
||||
<tbody>
|
||||
{% for i, invoice in refunded_invoices.list %}
|
||||
<tr>
|
||||
<td>{% if invoice.credit_note_for_invoice_id %}{{ 'Credit Note'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie ~ "%05s"|format(invoice.nr) }}</td>
|
||||
<td>{% if invoice.credit_note_for_invoice_id %}{{ 'Credit Note'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie_nr }}</td>
|
||||
<td>{{ invoice.created_at|format_date }}</td>
|
||||
<td>{{ invoice.total|format_currency(invoice.currency ?? default_currency) }}</td>
|
||||
<td><a href='{{ "invoice/#{invoice.hash}"|url }}' class="btn btn-outline-secondary btn-sm">{{ 'View'|trans }}</a></td>
|
||||
@@ -133,6 +138,34 @@
|
||||
</table>
|
||||
{{ include('partial_pagination.html.twig', { 'list': refunded_invoices, 'url': 'invoice', 'hash': '#nav-refunded' }) }}
|
||||
</div>
|
||||
<div class="tab-pane fade table-responsive" id="nav-canceled" role="tabpanel" aria-labelledby="nav-canceled-tab"
|
||||
tabindex="0">
|
||||
<table class="table table-bordered table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'Title'|trans }}</th>
|
||||
<th>{{ 'Invoice Date'|trans }}</th>
|
||||
<th>{{ 'Total'|trans }}</th>
|
||||
<th>{{ 'Actions'|trans }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i, invoice in canceled_invoices.list %}
|
||||
<tr>
|
||||
<td>{% if invoice.credit_note_for_invoice_id %}{{ 'Credit Note'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} #{{ invoice.serie_nr }}</td>
|
||||
<td>{{ invoice.created_at|format_date }}</td>
|
||||
<td>{{ invoice.total|format_currency(invoice.currency ?? default_currency) }}</td>
|
||||
<td><a href='{{ "invoice/#{invoice.hash}"|url }}' class="btn btn-outline-secondary btn-sm">{{ 'View'|trans }}</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4">{{ 'The list is empty'|trans }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{{ include('partial_pagination.html.twig', { 'list': canceled_invoices, 'url': 'invoice', 'hash': '#nav-canceled' }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
{% import "macro_functions.html.twig" as mf %}
|
||||
|
||||
{% set nr = invoice.serie ~ "%05s"|format(invoice.nr) %}
|
||||
{% set nr = invoice.serie_nr %}
|
||||
{% if invoice.credit_note_for_invoice_id %}
|
||||
{% set doc_label = 'Credit Note'|trans %}
|
||||
{% elseif invoice.debit_note_for_invoice_id %}
|
||||
@@ -11,6 +11,13 @@
|
||||
{% else %}
|
||||
{% set doc_label = 'Invoice'|trans %}
|
||||
{% endif %}
|
||||
{% if invoice.status == 'canceled' %}
|
||||
{% set doc_label = 'Canceled Invoice'|trans %}
|
||||
{% endif %}
|
||||
{% if not invoice.issued %}
|
||||
{% set doc_label = 'Draft Invoice'|trans %}
|
||||
{% set nr = invoice.id %}
|
||||
{% endif %}
|
||||
|
||||
{% block meta_title %}{{ doc_label }} #{{ nr }}{% endblock %}
|
||||
|
||||
@@ -56,6 +63,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if invoice.status == 'canceled' %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-secondary mb-0" role="alert">
|
||||
{{ 'This invoice was canceled and can no longer be paid.'|trans }}
|
||||
{% if invoice.replaced_by_invoice_id %}
|
||||
{{ 'A replacement invoice has been issued. You can find it in your invoices list.'|trans }}
|
||||
<a href="{{ 'invoice'|url }}" class="alert-link">{{ 'View invoices'|trans }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% import 'macro_functions.html.twig' as mf %}
|
||||
{% set nr = invoice.serie ~ "%05s"|format(invoice.nr) %}
|
||||
{% set nr = invoice.issued ? invoice.serie_nr : invoice.id %}
|
||||
{% set seller = invoice.seller %}
|
||||
{% set buyer = invoice.buyer %}
|
||||
{% set company = guest.system_company %}
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
<div class="text-center">
|
||||
<h2 id="invoice-title">{% if invoice.credit_note_for_invoice_id %}{{ 'Credit Note'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} {{ nr }}</h2>
|
||||
<h2 id="invoice-title">{% if not invoice.issued %}{{ 'Draft Invoice'|trans }}{% elseif invoice.credit_note_for_invoice_id %}{{ 'Credit Note'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note'|trans }}{% else %}{{ 'Invoice'|trans }}{% endif %} {{ nr }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
{% block subject %}[{{ guest.system_company.name }}] Invoice Voided{% endblock %}
|
||||
{% block content %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style type="text/css">
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.signature {
|
||||
font-style: italic;
|
||||
color: #999999;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid #cccccc;
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Invoice Voided</h1>
|
||||
<p>Hello {{ c.first_name|default('') }} {{ c.last_name|default('') }},</p>
|
||||
<p>Invoice <strong>{{ invoice.serie_nr|default('') }}</strong> has been voided and can no longer be paid.</p>
|
||||
{% if reason|default('') %}
|
||||
<p><strong>Reason:</strong> {{ reason }}</p>
|
||||
{% endif %}
|
||||
{% if replacement_invoice.serie_nr|default('') %}
|
||||
<p>A replacement invoice <strong>{{ replacement_invoice.serie_nr }}</strong> has been issued. You may view it <a href='{{ "invoice/#{replacement_invoice.hash|default('')}"|url(area: 'client') }}' target="_blank">here.</a></p>
|
||||
{% endif %}
|
||||
|
||||
<p>You may view the voided invoice <a href='{{ "invoice/#{invoice.hash|default('')}"|url(area: 'client') }}' target="_blank">here.</a></p>
|
||||
<p>You can also <a href="{{ 'login'|url(query: { 'email': c.email|default('') }, area: 'client') }}" target="_blank">login</a>, or <a href="{{ 'client/profile'|url(area: 'client') }}" target="_blank">edit your profile.</a></p>
|
||||
|
||||
<div class="signature">{{ guest.system_email.signature }}</div>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
@@ -46,7 +46,7 @@
|
||||
<p>{% if invoice.credit_note_for_invoice_id %}{{ 'Credit Note Number'|trans }}{% elseif invoice.debit_note_for_invoice_id %}{{ 'Debit Note Number'|trans }}{% else %}{{ 'Invoice Number'|trans }}{% endif %}: {{ invoice.serie_nr }}</p>
|
||||
<p>{{ 'Invoice Date'|trans }}: {{ invoice.created_at|format_date }}</p>
|
||||
<p>{{ 'Due Date'|trans }}: {{ invoice.due_at|format_date }}</p>
|
||||
<p>{{ 'Invoice Status'|trans }}: {{ invoice.status|capitalize }}</p>
|
||||
<p>{{ 'Invoice Status'|trans }}: {{ invoice.status|trans|capitalize }}</p>
|
||||
{% if invoice.credit_note_for_invoice_id %}<p>{{ 'Refers to invoice'|trans }}: #{{ invoice.credit_note_for_invoice_id }}</p>{% endif %}
|
||||
{% if invoice.debit_note_for_invoice_id %}<p>{{ 'Refers to invoice'|trans }}: #{{ invoice.debit_note_for_invoice_id }}</p>{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,64 @@ test('refunding a paid invoice issues a linked credit note and settles the origi
|
||||
}
|
||||
});
|
||||
|
||||
test('refunding a paid invoice in manual mode records an offline refund document', function (): void {
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
$productId = null;
|
||||
|
||||
try {
|
||||
$productId = creditNoteCreateProduct(50.0);
|
||||
['id' => $clientId] = creditNoteCreateClient();
|
||||
|
||||
$created = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
'invoice_option' => 'issue-invoice',
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
assertApiResultIsInt($created);
|
||||
$orderId = (int) $created->getResult();
|
||||
|
||||
$order = creditNoteGetOrder($orderId);
|
||||
$invoiceId = (int) $order['unpaid_invoice_id'];
|
||||
|
||||
creditNoteMarkInvoicePaid($invoiceId);
|
||||
|
||||
// Manual mode used to record nothing (null result); it now records an
|
||||
// offline refund document on the credit-note series.
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
assertApiSuccess($params);
|
||||
$originalLogic = $params->getResult()['invoice_refund_logic'] ?? 'credit_note';
|
||||
$setLogic = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_refund_logic' => 'manual',
|
||||
]);
|
||||
assertApiSuccess($setLogic);
|
||||
|
||||
try {
|
||||
$refunded = Tests\Helpers\ApiClient::request('admin/invoice/refund', ['id' => $invoiceId]);
|
||||
assertApiSuccess($refunded);
|
||||
assertApiResultIsInt($refunded);
|
||||
$creditNoteId = (int) $refunded->getResult();
|
||||
expect($creditNoteId)->not->toBe($invoiceId);
|
||||
|
||||
$creditNote = creditNoteGetInvoice($creditNoteId);
|
||||
expect($creditNote['status'])->toBe('refunded');
|
||||
expect((int) $creditNote['credit_note_for_invoice_id'])->toBe($invoiceId);
|
||||
expect((float) $creditNote['total'])->toBeLessThan(0);
|
||||
|
||||
$original = creditNoteGetInvoice($invoiceId);
|
||||
expect($original['status'])->toBe('refunded');
|
||||
} finally {
|
||||
$restore = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_refund_logic' => $originalLogic,
|
||||
]);
|
||||
assertApiSuccess($restore);
|
||||
}
|
||||
} finally {
|
||||
creditNoteCleanupClient();
|
||||
creditNoteDeleteProduct($productId);
|
||||
}
|
||||
});
|
||||
|
||||
function creditNoteCreateProduct(float $price): int
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/product/prepare', [
|
||||
@@ -222,8 +280,8 @@ test('partial refunds accumulate on the original until fully refunded', function
|
||||
assertApiSuccess($added);
|
||||
}
|
||||
|
||||
$approved = Tests\Helpers\ApiClient::request('admin/invoice/approve', ['id' => $invoiceId]);
|
||||
assertApiSuccess($approved);
|
||||
$issued = Tests\Helpers\ApiClient::request('admin/invoice/issue', ['id' => $invoiceId]);
|
||||
assertApiSuccess($issued);
|
||||
creditNoteMarkInvoicePaid($invoiceId);
|
||||
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
|
||||
@@ -36,16 +36,16 @@ test('issuing a debit note charges extra without touching the original', functio
|
||||
]);
|
||||
assertApiSuccess($added);
|
||||
|
||||
// Draft invoices take lines directly; debit notes are for approved ones.
|
||||
// Draft invoices take lines directly; debit notes are for issued ones.
|
||||
$draftDebit = Tests\Helpers\ApiClient::request('admin/invoice/debit', [
|
||||
'id' => $invoiceId,
|
||||
'items' => [['title' => 'E2E too early', 'price' => 5, 'quantity' => 1]],
|
||||
]);
|
||||
expect($draftDebit->wasSuccessful())->toBeFalse();
|
||||
expect($draftDebit->getErrorMessage())->toContain('Only approved unpaid or paid');
|
||||
expect($draftDebit->getErrorMessage())->toContain('Only issued unpaid or paid');
|
||||
|
||||
$approved = Tests\Helpers\ApiClient::request('admin/invoice/approve', ['id' => $invoiceId]);
|
||||
assertApiSuccess($approved);
|
||||
$issued = Tests\Helpers\ApiClient::request('admin/invoice/issue', ['id' => $invoiceId]);
|
||||
assertApiSuccess($issued);
|
||||
|
||||
$emptyDebit = Tests\Helpers\ApiClient::request('admin/invoice/debit', ['id' => $invoiceId]);
|
||||
expect($emptyDebit->wasSuccessful())->toBeFalse();
|
||||
@@ -63,7 +63,7 @@ test('issuing a debit note charges extra without touching the original', functio
|
||||
|
||||
$debitNote = debitNoteGetInvoice($debitNoteId);
|
||||
expect($debitNote['status'])->toBe('unpaid');
|
||||
expect($debitNote['approved'])->toBeTrue();
|
||||
expect($debitNote['issued'])->toBeTrue();
|
||||
expect((int) $debitNote['debit_note_for_invoice_id'])->toBe($invoiceId);
|
||||
expect($debitNote['serie'])->toBe('DN-');
|
||||
expect((float) $debitNote['total'])->toEqual(25.0);
|
||||
|
||||
@@ -38,7 +38,7 @@ test('attaching a product adds a provisioning order line to an editable invoice'
|
||||
$orderId = (int) $created->getResult();
|
||||
|
||||
// Drafts accept attaches without the opt-in setting and stay drafts;
|
||||
// sending is left to the approval path.
|
||||
// sending is left to the issue path.
|
||||
$draftOrder = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
@@ -58,14 +58,14 @@ test('attaching a product adds a provisioning order line to an editable invoice'
|
||||
assertApiSuccess($draftAttach);
|
||||
|
||||
$draft = attachReissueGetInvoice($draftInvoiceId);
|
||||
expect($draft['approved'])->toBeFalse();
|
||||
expect($draft['issued'])->toBeFalse();
|
||||
expect(attachReissueHasOrderLine($draft, $draftOrderId))->toBeTrue();
|
||||
|
||||
$order = attachReissueGetOrder($orderId);
|
||||
$invoiceId = (int) $order['unpaid_invoice_id'];
|
||||
|
||||
$invoice = attachReissueGetInvoice($invoiceId);
|
||||
expect($invoice['approved'])->toBeTrue();
|
||||
expect($invoice['issued'])->toBeTrue();
|
||||
expect($invoice['editable'])->toBeFalse();
|
||||
|
||||
// Locked: attaching is refused like any other edit.
|
||||
@@ -78,9 +78,9 @@ test('attaching a product adds a provisioning order line to an editable invoice'
|
||||
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
assertApiSuccess($params);
|
||||
$originalSetting = $params->getResult()['invoice_allow_edit_unpaid'] ?? '0';
|
||||
$originalSetting = $params->getResult()['invoice_immutability'] ?? 'strict';
|
||||
$enabled = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_allow_edit_unpaid' => '1',
|
||||
'invoice_immutability' => 'relaxed',
|
||||
]);
|
||||
assertApiSuccess($enabled);
|
||||
|
||||
@@ -137,7 +137,7 @@ test('attaching a product adds a provisioning order line to an editable invoice'
|
||||
expect($crossClient->getErrorMessage())->toContain('does not belong');
|
||||
} finally {
|
||||
$restore = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_allow_edit_unpaid' => $originalSetting,
|
||||
'invoice_immutability' => $originalSetting,
|
||||
]);
|
||||
assertApiSuccess($restore);
|
||||
}
|
||||
@@ -180,7 +180,7 @@ test('reissuing cancels the original and moves its lines to a numbered replaceme
|
||||
$draftId = (int) $draftPrepared->getResult();
|
||||
$draftReissue = Tests\Helpers\ApiClient::request('admin/invoice/reissue', ['id' => $draftId]);
|
||||
expect($draftReissue->wasSuccessful())->toBeFalse();
|
||||
expect($draftReissue->getErrorMessage())->toContain('Only approved unpaid');
|
||||
expect($draftReissue->getErrorMessage())->toContain('Only issued unpaid');
|
||||
|
||||
$reissued = Tests\Helpers\ApiClient::request('admin/invoice/reissue', [
|
||||
'id' => $invoiceId,
|
||||
@@ -197,7 +197,7 @@ test('reissuing cancels the original and moves its lines to a numbered replaceme
|
||||
|
||||
$replacement = attachReissueGetInvoice($replacementId);
|
||||
expect($replacement['status'])->toBe('unpaid');
|
||||
expect($replacement['approved'])->toBeTrue();
|
||||
expect($replacement['issued'])->toBeTrue();
|
||||
expect((int) $replacement['replaces_invoice_id'])->toBe($invoiceId);
|
||||
expect($replacement['serie_nr'])->not->toBe($original['serie_nr']);
|
||||
expect($replacement['lines'])->toHaveCount($originalLineCount);
|
||||
@@ -209,7 +209,7 @@ test('reissuing cancels the original and moves its lines to a numbered replaceme
|
||||
// A canceled invoice cannot be reissued twice.
|
||||
$twice = Tests\Helpers\ApiClient::request('admin/invoice/reissue', ['id' => $invoiceId]);
|
||||
expect($twice->wasSuccessful())->toBeFalse();
|
||||
expect($twice->getErrorMessage())->toContain('Only approved unpaid');
|
||||
expect($twice->getErrorMessage())->toContain('Only issued unpaid');
|
||||
|
||||
// The canceled original can no longer be paid either.
|
||||
$gateways = Tests\Helpers\ApiClient::request('admin/invoice/gateway_get_pairs');
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Skip E2E tests if environment is not configured
|
||||
if (!getenv('APP_URL') || !getenv('TEST_API_KEY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
use function Tests\Helpers\assertApiResultIsArray;
|
||||
use function Tests\Helpers\assertApiResultIsInt;
|
||||
use function Tests\Helpers\assertApiSuccess;
|
||||
|
||||
test('issued unpaid invoices can be canceled, and deleted when relaxed', function (): void {
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
$productId = null;
|
||||
|
||||
try {
|
||||
$productId = cancelCreateProduct(50.0);
|
||||
['id' => $clientId] = cancelCreateClient();
|
||||
|
||||
$created = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
'invoice_option' => 'issue-invoice',
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
assertApiResultIsInt($created);
|
||||
$orderId = (int) $created->getResult();
|
||||
|
||||
$order = cancelGetOrder($orderId);
|
||||
$invoiceId = (int) $order['unpaid_invoice_id'];
|
||||
|
||||
$invoice = cancelGetInvoice($invoiceId);
|
||||
expect($invoice['issued'])->toBeTrue();
|
||||
expect($invoice['cancellable'])->toBeTrue();
|
||||
|
||||
// Issued invoices cannot be hard-deleted by default.
|
||||
$deleteBlocked = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId]);
|
||||
expect($deleteBlocked->wasSuccessful())->toBeFalse();
|
||||
|
||||
// Cancel (void) without replacement.
|
||||
$canceled = Tests\Helpers\ApiClient::request('admin/invoice/cancel', [
|
||||
'id' => $invoiceId,
|
||||
'reason' => 'E2E voided',
|
||||
]);
|
||||
assertApiSuccess($canceled);
|
||||
|
||||
$invoice = cancelGetInvoice($invoiceId);
|
||||
expect($invoice['status'])->toBe('canceled');
|
||||
|
||||
// Order links are kept for history.
|
||||
$order = cancelGetOrder($orderId);
|
||||
expect((int) $order['unpaid_invoice_id'])->toBe($invoiceId);
|
||||
|
||||
// Canceled invoices still cannot be deleted while the setting is off.
|
||||
$deleteStillBlocked = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId]);
|
||||
expect($deleteStillBlocked->wasSuccessful())->toBeFalse();
|
||||
|
||||
// Cancel is idempotent-safe: a second cancel is refused.
|
||||
$cancelAgain = Tests\Helpers\ApiClient::request('admin/invoice/cancel', ['id' => $invoiceId]);
|
||||
expect($cancelAgain->wasSuccessful())->toBeFalse();
|
||||
|
||||
// Opt in to deleting issued invoices for cleanup.
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
assertApiSuccess($params);
|
||||
$originalSetting = $params->getResult()['invoice_immutability'] ?? 'strict';
|
||||
$enabled = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_immutability' => 'relaxed',
|
||||
]);
|
||||
assertApiSuccess($enabled);
|
||||
|
||||
try {
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId]);
|
||||
assertApiSuccess($deleted);
|
||||
} finally {
|
||||
$restore = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_immutability' => $originalSetting,
|
||||
]);
|
||||
assertApiSuccess($restore);
|
||||
}
|
||||
|
||||
// Paid invoices can neither be canceled nor deleted.
|
||||
$created2 = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
'invoice_option' => 'issue-invoice',
|
||||
]);
|
||||
assertApiSuccess($created2);
|
||||
$orderId2 = (int) $created2->getResult();
|
||||
$order2 = cancelGetOrder($orderId2);
|
||||
$invoiceId2 = (int) $order2['unpaid_invoice_id'];
|
||||
cancelMarkInvoicePaid($invoiceId2);
|
||||
|
||||
$cancelPaid = Tests\Helpers\ApiClient::request('admin/invoice/cancel', ['id' => $invoiceId2]);
|
||||
expect($cancelPaid->wasSuccessful())->toBeFalse();
|
||||
|
||||
$deletePaid = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId2]);
|
||||
expect($deletePaid->wasSuccessful())->toBeFalse();
|
||||
} finally {
|
||||
cancelCleanupClient();
|
||||
cancelDeleteProduct($productId);
|
||||
}
|
||||
});
|
||||
|
||||
function cancelCreateProduct(float $price): int
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/product/prepare', [
|
||||
'title' => 'E2E Cancel Product ' . uniqid(),
|
||||
'type' => 'custom',
|
||||
'product_category_id' => 1,
|
||||
]);
|
||||
assertApiSuccess($result);
|
||||
assertApiResultIsInt($result);
|
||||
$productId = (int) $result->getResult();
|
||||
|
||||
$update = Tests\Helpers\ApiClient::request('admin/product/update', [
|
||||
'id' => $productId,
|
||||
'status' => 'enabled',
|
||||
'pricing' => ['type' => 'once', 'once' => ['price' => $price, 'setup' => 0]],
|
||||
]);
|
||||
assertApiSuccess($update);
|
||||
|
||||
return $productId;
|
||||
}
|
||||
|
||||
function cancelCreateClient(): array
|
||||
{
|
||||
$email = 'invoice_cancel_' . uniqid() . '@example.com';
|
||||
$created = Tests\Helpers\ApiClient::request('admin/client/create', [
|
||||
'email' => $email,
|
||||
'first_name' => 'Test',
|
||||
'password' => 'A1a' . bin2hex(random_bytes(6)),
|
||||
'send_welcome_email' => 0,
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
$GLOBALS['cancelClientId'] = (int) $created->getResult();
|
||||
|
||||
return ['id' => $GLOBALS['cancelClientId']];
|
||||
}
|
||||
|
||||
function cancelGetOrder(int $orderId): array
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/order/get', ['id' => $orderId]);
|
||||
assertApiSuccess($result);
|
||||
assertApiResultIsArray($result);
|
||||
|
||||
return $result->getResult();
|
||||
}
|
||||
|
||||
function cancelGetInvoice(int $invoiceId): array
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/invoice/get', ['id' => $invoiceId]);
|
||||
assertApiSuccess($result);
|
||||
assertApiResultIsArray($result);
|
||||
|
||||
return $result->getResult();
|
||||
}
|
||||
|
||||
function cancelMarkInvoicePaid(int $invoiceId): void
|
||||
{
|
||||
$gateways = Tests\Helpers\ApiClient::request('admin/invoice/gateway_get_pairs');
|
||||
assertApiSuccess($gateways);
|
||||
$gatewayId = null;
|
||||
foreach ($gateways->getResult() as $id => $title) {
|
||||
if ($title === 'Custom') {
|
||||
$gatewayId = (int) $id;
|
||||
}
|
||||
}
|
||||
if ($gatewayId === null) {
|
||||
throw new RuntimeException('Custom payment gateway not found');
|
||||
}
|
||||
|
||||
$result = Tests\Helpers\ApiClient::request('admin/invoice/mark_as_paid', [
|
||||
'id' => $invoiceId,
|
||||
'gateway_id' => $gatewayId,
|
||||
'transactionId' => 'txn' . uniqid(),
|
||||
]);
|
||||
assertApiSuccess($result);
|
||||
}
|
||||
|
||||
function cancelCleanupClient(): void
|
||||
{
|
||||
if (!isset($GLOBALS['cancelClientId'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/client/delete', ['id' => $GLOBALS['cancelClientId']]);
|
||||
assertApiSuccess($deleted);
|
||||
unset($GLOBALS['cancelClientId']);
|
||||
}
|
||||
|
||||
function cancelDeleteProduct(?int $productId): void
|
||||
{
|
||||
if ($productId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/product/delete', ['id' => $productId]);
|
||||
assertApiSuccess($deleted);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use function Tests\Helpers\assertApiResultIsArray;
|
||||
use function Tests\Helpers\assertApiResultIsInt;
|
||||
use function Tests\Helpers\assertApiSuccess;
|
||||
|
||||
test('approved invoices are locked for editing unless the setting permits it', function (): void {
|
||||
test('issued invoices are locked for editing unless the setting permits it', function (): void {
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
$productId = null;
|
||||
|
||||
@@ -40,7 +40,7 @@ test('approved invoices are locked for editing unless the setting permits it', f
|
||||
$invoiceId = (int) $order['unpaid_invoice_id'];
|
||||
|
||||
$invoice = lockEditGetInvoice($invoiceId);
|
||||
expect($invoice['approved'])->toBeTrue();
|
||||
expect($invoice['issued'])->toBeTrue();
|
||||
expect($invoice['editable'])->toBeFalse();
|
||||
|
||||
// Locked: adding a line is refused.
|
||||
@@ -55,12 +55,12 @@ test('approved invoices are locked for editing unless the setting permits it', f
|
||||
$deleteBlocked = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId]);
|
||||
expect($deleteBlocked->wasSuccessful())->toBeFalse();
|
||||
|
||||
// Opt in to quote-like editing of approved unpaid invoices.
|
||||
// Opt in to quote-like editing of issued unpaid invoices.
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
assertApiSuccess($params);
|
||||
$originalSetting = $params->getResult()['invoice_allow_edit_unpaid'] ?? '0';
|
||||
$originalSetting = $params->getResult()['invoice_immutability'] ?? 'strict';
|
||||
$enabled = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_allow_edit_unpaid' => '1',
|
||||
'invoice_immutability' => 'relaxed',
|
||||
]);
|
||||
assertApiSuccess($enabled);
|
||||
|
||||
@@ -76,7 +76,7 @@ test('approved invoices are locked for editing unless the setting permits it', f
|
||||
expect(lockEditHasLine($invoice, 'E2E discount line', -10.0))->toBeTrue();
|
||||
} finally {
|
||||
$restore = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_allow_edit_unpaid' => $originalSetting,
|
||||
'invoice_immutability' => $originalSetting,
|
||||
]);
|
||||
assertApiSuccess($restore);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Skip E2E tests if environment is not configured
|
||||
if (!getenv('APP_URL') || !getenv('TEST_API_KEY')) {
|
||||
return;
|
||||
}
|
||||
|
||||
use function Tests\Helpers\assertApiResultIsArray;
|
||||
use function Tests\Helpers\assertApiResultIsInt;
|
||||
use function Tests\Helpers\assertApiSuccess;
|
||||
|
||||
test('invoice journal records lifecycle transitions oldest first', function (): void {
|
||||
Tests\Helpers\ApiClient::resetCookies();
|
||||
|
||||
try {
|
||||
['id' => $clientId] = journalCreateClient();
|
||||
|
||||
// Draft invoice: journal starts with created.
|
||||
$prepared = Tests\Helpers\ApiClient::request('admin/invoice/prepare', [
|
||||
'client_id' => $clientId,
|
||||
'items' => [
|
||||
['title' => 'E2E journal line', 'price' => 75, 'quantity' => 1],
|
||||
],
|
||||
]);
|
||||
assertApiSuccess($prepared);
|
||||
assertApiResultIsInt($prepared);
|
||||
$invoiceId = (int) $prepared->getResult();
|
||||
|
||||
expect(array_column(journalGetJournal($invoiceId), 'type'))->toBe(['created']);
|
||||
|
||||
// An edit records updated with only the consumed fields.
|
||||
$updated = Tests\Helpers\ApiClient::request('admin/invoice/update', [
|
||||
'id' => $invoiceId,
|
||||
'notes' => 'E2E journal note',
|
||||
]);
|
||||
assertApiSuccess($updated);
|
||||
|
||||
$journal = journalGetJournal($invoiceId);
|
||||
expect(array_column($journal, 'type'))->toBe(['created', 'updated']);
|
||||
$updateEntry = $journal[array_key_last($journal)];
|
||||
expect($updateEntry)->toHaveKeys(['id', 'type', 'admin_id', 'client_id', 'snapshot', 'created_at'])
|
||||
->and($updateEntry['snapshot']['changed_fields'] ?? null)->toContain('notes')
|
||||
->and($updateEntry['snapshot']['changed_fields'] ?? null)->not->toContain('id');
|
||||
|
||||
// Issuing records issued.
|
||||
$issued = Tests\Helpers\ApiClient::request('admin/invoice/issue', ['id' => $invoiceId]);
|
||||
assertApiSuccess($issued);
|
||||
|
||||
$journal = journalGetJournal($invoiceId);
|
||||
expect(array_column($journal, 'type'))->toBe(['created', 'updated', 'issued']);
|
||||
|
||||
// A void records canceled.
|
||||
$canceled = Tests\Helpers\ApiClient::request('admin/invoice/cancel', [
|
||||
'id' => $invoiceId,
|
||||
'reason' => 'E2E journal void',
|
||||
]);
|
||||
assertApiSuccess($canceled);
|
||||
|
||||
$journal = journalGetJournal($invoiceId);
|
||||
expect(array_column($journal, 'type'))->toBe(['created', 'updated', 'issued', 'canceled']);
|
||||
$cancelEntry = $journal[array_key_last($journal)];
|
||||
expect($cancelEntry['snapshot']['reason'] ?? null)->toBe('E2E journal void');
|
||||
|
||||
// Missing id is refused.
|
||||
$missing = Tests\Helpers\ApiClient::request('admin/invoice/journal', []);
|
||||
expect($missing->wasSuccessful())->toBeFalse();
|
||||
|
||||
// Opt in to deleting issued invoices for cleanup.
|
||||
$params = Tests\Helpers\ApiClient::request('admin/system/get_params');
|
||||
assertApiSuccess($params);
|
||||
$originalSetting = $params->getResult()['invoice_immutability'] ?? 'strict';
|
||||
$enabled = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_immutability' => 'relaxed',
|
||||
]);
|
||||
assertApiSuccess($enabled);
|
||||
|
||||
try {
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/invoice/delete', ['id' => $invoiceId]);
|
||||
assertApiSuccess($deleted);
|
||||
} finally {
|
||||
$restore = Tests\Helpers\ApiClient::request('admin/system/update_params', [
|
||||
'invoice_immutability' => $originalSetting,
|
||||
]);
|
||||
assertApiSuccess($restore);
|
||||
}
|
||||
} finally {
|
||||
journalCleanupClient();
|
||||
}
|
||||
});
|
||||
|
||||
function journalCreateClient(): array
|
||||
{
|
||||
$email = 'invoice_journal_' . uniqid() . '@example.com';
|
||||
$created = Tests\Helpers\ApiClient::request('admin/client/create', [
|
||||
'email' => $email,
|
||||
'first_name' => 'Test',
|
||||
'password' => 'A1a' . bin2hex(random_bytes(6)),
|
||||
'send_welcome_email' => 0,
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
$GLOBALS['journalClientId'] = (int) $created->getResult();
|
||||
|
||||
return ['id' => $GLOBALS['journalClientId']];
|
||||
}
|
||||
|
||||
function journalGetJournal(int $invoiceId): array
|
||||
{
|
||||
$result = Tests\Helpers\ApiClient::request('admin/invoice/journal', ['id' => $invoiceId]);
|
||||
assertApiSuccess($result);
|
||||
assertApiResultIsArray($result);
|
||||
|
||||
return $result->getResult();
|
||||
}
|
||||
|
||||
function journalCleanupClient(): void
|
||||
{
|
||||
if (!isset($GLOBALS['journalClientId'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deleted = Tests\Helpers\ApiClient::request('admin/client/delete', ['id' => $GLOBALS['journalClientId']]);
|
||||
assertApiSuccess($deleted);
|
||||
unset($GLOBALS['journalClientId']);
|
||||
}
|
||||
@@ -145,6 +145,28 @@ test('gets an invoice', function (): void {
|
||||
expect($result)->toBeArray();
|
||||
});
|
||||
|
||||
test('gets an invoice journal', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$serviceMock = Mockery::mock(Service::class);
|
||||
$serviceMock->shouldReceive('getJournalForInvoice')
|
||||
->once()
|
||||
->with(1)
|
||||
->andReturn([['id' => 7, 'type' => 'issued']]);
|
||||
|
||||
$model = createEntity(Invoice::class);
|
||||
\Tests\Helpers\setEntityId($model, 1);
|
||||
|
||||
$di = container();
|
||||
$di['em']->getRepository(Invoice::class)->shouldReceive('find')->atLeast()->once()->andReturn($model);
|
||||
|
||||
$api->setDi($di);
|
||||
$serviceMock->shouldReceive('getInvoiceRepository')->andReturn($di['em']->getRepository(Invoice::class));
|
||||
$api->setService($serviceMock);
|
||||
$api->setIdentity(\Tests\Helpers\admin());
|
||||
|
||||
expect($api->journal(['id' => 1]))->toBe([['id' => 7, 'type' => 'issued']]);
|
||||
});
|
||||
|
||||
test('gets an invoice with promo applications', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$serviceMock = Mockery::mock(Service::class);
|
||||
@@ -243,14 +265,14 @@ test('prepares an invoice', function (): void {
|
||||
expect($result)->toBeInt()->toBe($newInvoiceId);
|
||||
});
|
||||
|
||||
test('approves an invoice', function (): void {
|
||||
test('issues an invoice', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$data = [
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
$serviceMock = Mockery::mock(Service::class);
|
||||
$serviceMock->shouldReceive('approveInvoice')
|
||||
$serviceMock->shouldReceive('issueInvoice')
|
||||
->atLeast()->once()
|
||||
->andReturn(true);
|
||||
|
||||
@@ -263,7 +285,7 @@ test('approves an invoice', function (): void {
|
||||
$serviceMock->shouldReceive('getInvoiceRepository')->andReturn($di['em']->getRepository(Invoice::class));
|
||||
$api->setService($serviceMock);
|
||||
|
||||
$result = $api->approve($data);
|
||||
$result = $api->issue($data);
|
||||
expect($result)->toBeBool()->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -385,6 +407,30 @@ test('reissues an invoice', function (): void {
|
||||
expect($api->reissue($data))->toBe(11);
|
||||
});
|
||||
|
||||
test('cancels an invoice', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$data = [
|
||||
'id' => 1,
|
||||
'reason' => 'Duplicate invoice',
|
||||
];
|
||||
$serviceMock = Mockery::mock(Service::class);
|
||||
$serviceMock->shouldReceive('cancelInvoice')
|
||||
->once()
|
||||
->with(Mockery::type(Invoice::class), $data)
|
||||
->andReturn(true);
|
||||
|
||||
$model = createEntity(Invoice::class);
|
||||
|
||||
$di = container();
|
||||
$di['em']->getRepository(Invoice::class)->shouldReceive('find')->atLeast()->once()->andReturn($model);
|
||||
|
||||
$api->setDi($di);
|
||||
$serviceMock->shouldReceive('getInvoiceRepository')->andReturn($di['em']->getRepository(Invoice::class));
|
||||
$api->setService($serviceMock);
|
||||
|
||||
expect($api->cancel($data))->toBeTrue();
|
||||
});
|
||||
|
||||
test('updates an invoice', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$data = [
|
||||
@@ -409,11 +455,11 @@ test('updates an invoice', function (): void {
|
||||
expect($result)->toBeBool()->toBeTrue();
|
||||
});
|
||||
|
||||
test('updates an invoice before approving it', function (): void {
|
||||
test('updates an invoice before issuing it', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$data = [
|
||||
'id' => 1,
|
||||
'approve' => 1,
|
||||
'issue' => 1,
|
||||
'new_item' => [
|
||||
'title' => 'Hosting',
|
||||
'quantity' => 1,
|
||||
@@ -428,7 +474,7 @@ test('updates an invoice before approving it', function (): void {
|
||||
->ordered()
|
||||
->with($model, $data)
|
||||
->andReturn(true);
|
||||
$serviceMock->shouldReceive('approveInvoice')
|
||||
$serviceMock->shouldReceive('issueInvoice')
|
||||
->once()
|
||||
->ordered()
|
||||
->with($model, $data)
|
||||
@@ -1395,6 +1441,10 @@ test('gets tax list', function (): void {
|
||||
expect($result)->toBeArray();
|
||||
});
|
||||
|
||||
test('does not expose a tax_setup_eu endpoint (removed with the EU VAT seeder)', function (): void {
|
||||
expect(method_exists(Admin::class, 'tax_setup_eu'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('deletes invoices in batch', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$activityMock = Mockery::mock(Admin::class)->makePartial();
|
||||
@@ -1443,6 +1493,23 @@ test('deletes taxes in batch', function (): void {
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
|
||||
test('batch delete aborts on the first failure instead of skipping it', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
$activityMock = Mockery::mock(Admin::class)->makePartial();
|
||||
// Batch deletes are fail-fast by design: a deletion the operator is not
|
||||
// allowed to perform (or that fails validation) must surface instead of
|
||||
// being silently skipped while the rest of the batch proceeds.
|
||||
$activityMock->shouldReceive('delete')->once()->with(['id' => 1])->andReturn(true);
|
||||
$activityMock->shouldReceive('delete')->once()->with(['id' => 2])->andThrow(new FOSSBilling\InformationException('Only unissued, unpaid invoices (drafts) can be deleted'));
|
||||
$activityMock->shouldNotReceive('delete')->with(['id' => 3]);
|
||||
|
||||
$di = container();
|
||||
$activityMock->setDi($di);
|
||||
|
||||
expect(fn () => $activityMock->batch_delete(['ids' => [1, 2, 3]]))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'Only unissued, unpaid invoices');
|
||||
});
|
||||
|
||||
test('gets a tax', function (): void {
|
||||
$api = apiEndpoint(new Admin());
|
||||
|
||||
@@ -1556,7 +1623,7 @@ test('requires an invoice id on invoice endpoints', function ($method): void {
|
||||
})->with([
|
||||
'get',
|
||||
'mark_as_paid',
|
||||
'approve',
|
||||
'issue',
|
||||
'refund',
|
||||
'update',
|
||||
'delete',
|
||||
|
||||
@@ -48,7 +48,7 @@ test('gets invoice list', function (): void {
|
||||
$invoiceRepo = Mockery::mock(InvoiceRepository::class);
|
||||
$invoiceRepo->shouldReceive('getSearchQueryBuilder')
|
||||
->once()
|
||||
->with(['client_id' => 7, 'approved' => true])
|
||||
->with(['client_id' => 7, 'issued' => true])
|
||||
->andReturn(Mockery::mock(Doctrine\ORM\QueryBuilder::class));
|
||||
|
||||
$paginatorMock = Mockery::mock(FOSSBilling\Pagination::class);
|
||||
@@ -129,7 +129,7 @@ test('creates renewal invoice', function (): void {
|
||||
$serviceMock->shouldReceive('generateForOrder')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('approveInvoice');
|
||||
$serviceMock->shouldReceive('issueInvoice');
|
||||
|
||||
$orderRepoMock = Mockery::mock(OrderRepository::class);
|
||||
$orderRepoMock->shouldReceive('findOneBy')
|
||||
@@ -167,7 +167,7 @@ test('creates renewal invoice from a real invoice entity without accessing priva
|
||||
$serviceMock->shouldReceive('generateForOrder')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('approveInvoice');
|
||||
$serviceMock->shouldReceive('issueInvoice');
|
||||
|
||||
$orderRepoMock = Mockery::mock(OrderRepository::class);
|
||||
$orderRepoMock->shouldReceive('findOneBy')
|
||||
@@ -199,7 +199,7 @@ test('creates renewal invoice for free order', function (): void {
|
||||
$serviceMock->shouldReceive('generateForOrder')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('approveInvoice');
|
||||
$serviceMock->shouldReceive('issueInvoice');
|
||||
|
||||
$orderRepoMock = Mockery::mock(OrderRepository::class);
|
||||
$orderRepoMock->shouldReceive('findOneBy')
|
||||
@@ -251,7 +251,7 @@ test('creates funds invoice', function (): void {
|
||||
$serviceMock->shouldReceive('generateFundsInvoice')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('approveInvoice');
|
||||
$serviceMock->shouldReceive('issueInvoice');
|
||||
|
||||
$di = container();
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
@@ -280,7 +280,7 @@ test('creates funds invoice from a real invoice entity without accessing private
|
||||
$serviceMock->shouldReceive('generateFundsInvoice')
|
||||
->atLeast()->once()
|
||||
->andReturn($model);
|
||||
$serviceMock->shouldReceive('approveInvoice');
|
||||
$serviceMock->shouldReceive('issueInvoice');
|
||||
|
||||
$di = container();
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Invoice\Controller\Admin;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
use function Tests\Helpers\container;
|
||||
|
||||
function journalDownloadEntries(): array
|
||||
{
|
||||
return [
|
||||
['id' => 1, 'type' => 'created', 'admin_id' => null, 'client_id' => 5, 'snapshot' => null, 'created_at' => '2026-01-01 00:00:00'],
|
||||
['id' => 2, 'type' => 'issued', 'admin_id' => 1, 'client_id' => 5, 'snapshot' => ['serie_nr' => 'FOSS-0007'], 'created_at' => '2026-01-02 00:00:00'],
|
||||
];
|
||||
}
|
||||
|
||||
function journalDownloadApp(array $query = []): Box_App
|
||||
{
|
||||
$app = Mockery::mock(Box_App::class);
|
||||
$app->shouldReceive('getRequest')
|
||||
->once()
|
||||
->andReturn(Request::create('/', 'GET', $query));
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
function journalDownloadController(array $journal): Admin
|
||||
{
|
||||
$api = new readonly class($journal) {
|
||||
public function __construct(private array $journal)
|
||||
{
|
||||
}
|
||||
|
||||
public function invoice_journal(array $data): array
|
||||
{
|
||||
return $this->journal;
|
||||
}
|
||||
};
|
||||
|
||||
$di = container();
|
||||
$di['is_admin_logged'] = true;
|
||||
$di['api_admin'] = $api;
|
||||
|
||||
$controller = new Admin();
|
||||
$controller->setDi($di);
|
||||
|
||||
return $controller;
|
||||
}
|
||||
|
||||
test('journal download returns the full journal as an attachment', function (): void {
|
||||
$journal = journalDownloadEntries();
|
||||
|
||||
$response = journalDownloadController($journal)->get_journal_download(journalDownloadApp(), 7);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200)
|
||||
->and($response->headers->get('Content-Type'))->toBe('application/json')
|
||||
->and($response->headers->get('Content-Disposition'))->toBe('attachment; filename="invoice-7-journal.json"')
|
||||
->and(json_decode($response->getContent(), true))->toBe($journal);
|
||||
});
|
||||
|
||||
test('journal download filters to a single event', function (): void {
|
||||
$journal = journalDownloadEntries();
|
||||
|
||||
$response = journalDownloadController($journal)->get_journal_download(journalDownloadApp(['event' => 2]), 7);
|
||||
|
||||
expect(json_decode($response->getContent(), true))->toBe([$journal[1]]);
|
||||
});
|
||||
|
||||
test('journal download with an unknown event returns an empty list', function (): void {
|
||||
$response = journalDownloadController(journalDownloadEntries())->get_journal_download(journalDownloadApp(['event' => 999]), 7);
|
||||
|
||||
expect(json_decode($response->getContent(), true))->toBe([]);
|
||||
});
|
||||
@@ -40,3 +40,140 @@ test('invoice browser controller redirects denied invoice access to invoice list
|
||||
expect($response)->toBeInstanceOf(RedirectResponse::class)
|
||||
->and($response->headers->get('Location'))->toBe('/invoice');
|
||||
});
|
||||
|
||||
test('invoice print and thank-you pages render an accessible invoice', function (): void {
|
||||
$api = new class {
|
||||
public function invoice_get(array $data): array
|
||||
{
|
||||
return ['id' => 7, 'hash' => $data['hash']];
|
||||
}
|
||||
};
|
||||
|
||||
$app = Mockery::mock(Box_App::class);
|
||||
$app->shouldReceive('render')
|
||||
->once()
|
||||
->with('mod_invoice_print', ['invoice' => ['id' => 7, 'hash' => 'abc']])
|
||||
->andReturn('print-html');
|
||||
$app->shouldReceive('render')
|
||||
->once()
|
||||
->with('mod_invoice_thankyou', ['invoice' => ['id' => 7, 'hash' => 'abc']])
|
||||
->andReturn('thankyou-html');
|
||||
|
||||
$di = container();
|
||||
$di['api_guest'] = $api;
|
||||
|
||||
$controller = new Client();
|
||||
$controller->setDi($di);
|
||||
|
||||
expect($controller->get_invoice_print($app, 'abc'))->toBe('print-html')
|
||||
->and($controller->get_thankyoupage($app, 'abc'))->toBe('thankyou-html');
|
||||
});
|
||||
|
||||
test('banklink renders the payment form for a payable invoice', function (): void {
|
||||
$api = new class {
|
||||
public function invoice_get(array $data): array
|
||||
{
|
||||
return ['id' => 7];
|
||||
}
|
||||
|
||||
public function invoice_payment(array $data): array
|
||||
{
|
||||
return ['type' => 'html', 'result' => '<form></form>'];
|
||||
}
|
||||
};
|
||||
|
||||
$request = new Symfony\Component\HttpFoundation\Request(['allow_subscription' => '1']);
|
||||
|
||||
$app = Mockery::mock(Box_App::class);
|
||||
$app->shouldReceive('getRequest')->andReturn($request);
|
||||
$app->shouldReceive('render')
|
||||
->once()
|
||||
->with('mod_invoice_banklink', Mockery::type('array'))
|
||||
->andReturn('banklink-html');
|
||||
|
||||
$di = container();
|
||||
$di['api_guest'] = $api;
|
||||
|
||||
$controller = new Client();
|
||||
$controller->setDi($di);
|
||||
|
||||
expect($controller->get_banklink($app, 'abc', 2))->toBe('banklink-html');
|
||||
});
|
||||
|
||||
test('banklink redirects when payment is denied and rethrows other failures', function (): void {
|
||||
$denied = new class {
|
||||
public function invoice_get(array $data): array
|
||||
{
|
||||
return ['id' => 7];
|
||||
}
|
||||
|
||||
public function invoice_payment(array $data): array
|
||||
{
|
||||
throw new FOSSBilling\InformationException('denied', [], 403);
|
||||
}
|
||||
};
|
||||
|
||||
$request = new Symfony\Component\HttpFoundation\Request(['allow_subscription' => '1']);
|
||||
|
||||
$app = Mockery::mock(Box_App::class);
|
||||
$app->shouldReceive('getRequest')->andReturn($request);
|
||||
$app->shouldReceive('redirect')->once()->with('invoice')->andReturn(new RedirectResponse('/invoice'));
|
||||
|
||||
$di = container();
|
||||
$di['api_guest'] = $denied;
|
||||
|
||||
$controller = new Client();
|
||||
$controller->setDi($di);
|
||||
|
||||
expect($controller->get_banklink($app, 'abc', 2))->toBeInstanceOf(RedirectResponse::class);
|
||||
|
||||
// A non-access failure (e.g. the already-paid guard) is not a redirect:
|
||||
// only code 403 means "not yours".
|
||||
$alreadyPaid = new class {
|
||||
public function invoice_get(array $data): array
|
||||
{
|
||||
return ['id' => 7];
|
||||
}
|
||||
|
||||
public function invoice_payment(array $data): array
|
||||
{
|
||||
throw new FOSSBilling\InformationException('This invoice is already paid', [], 400);
|
||||
}
|
||||
};
|
||||
|
||||
$di['api_guest'] = $alreadyPaid;
|
||||
|
||||
expect(fn () => $controller->get_banklink($app, 'abc', 2))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'already paid');
|
||||
});
|
||||
|
||||
test('pdf download returns the response, redirects on denial, and rejects garbage', function (): void {
|
||||
$ok = new class {
|
||||
public function invoice_pdf(array $data): Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
return new Symfony\Component\HttpFoundation\Response('pdf-bytes');
|
||||
}
|
||||
};
|
||||
|
||||
$app = Mockery::mock(Box_App::class);
|
||||
|
||||
$di = container();
|
||||
$di['api_guest'] = $ok;
|
||||
|
||||
$controller = new Client();
|
||||
$controller->setDi($di);
|
||||
|
||||
expect($controller->get_pdf($app, 'abc')->getContent())->toBe('pdf-bytes');
|
||||
|
||||
$denied = new class {
|
||||
public function invoice_pdf(array $data): Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
throw new FOSSBilling\InformationException('denied', [], 403);
|
||||
}
|
||||
};
|
||||
|
||||
$app->shouldReceive('redirect')->once()->with('invoice')->andReturn(new RedirectResponse('/invoice'));
|
||||
$di['api_guest'] = $denied;
|
||||
|
||||
expect($controller->get_pdf($app, 'abc'))->toBeInstanceOf(RedirectResponse::class);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Invoice\Entity\InvoiceEvent;
|
||||
use Doctrine\DBAL\DriverManager;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\ORMSetup;
|
||||
|
||||
test('maps invoice_event table without changing columns', function (): void {
|
||||
$config = ORMSetup::createAttributeMetadataConfig([dirname(__DIR__, 3) . '/Entity'], true);
|
||||
$config->setProxyDir(sys_get_temp_dir());
|
||||
$config->setProxyNamespace('FOSSBilling\\Tests\\DoctrineProxies');
|
||||
$entityManager = new EntityManager(DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]), $config);
|
||||
|
||||
$meta = $entityManager->getClassMetadata(InvoiceEvent::class);
|
||||
|
||||
expect($meta->getTableName())->toBe('invoice_event')
|
||||
->and($meta->getColumnNames())->toBe([
|
||||
'id', 'invoice_id', 'type', 'admin_id', 'client_id', 'snapshot', 'created_at',
|
||||
])
|
||||
->and($meta->getFieldMapping('type')->type)->toBe('string')
|
||||
->and($meta->getFieldMapping('invoiceId')->nullable)->toBeTrue()
|
||||
->and($meta->getFieldMapping('snapshot')->nullable)->toBeTrue();
|
||||
});
|
||||
|
||||
test('invoice event exposes the journal type constants', function (): void {
|
||||
expect(InvoiceEvent::TYPE_CREATED)->toBe('created')
|
||||
->and(InvoiceEvent::TYPE_ISSUED)->toBe('issued')
|
||||
->and(InvoiceEvent::TYPE_UPDATED)->toBe('updated')
|
||||
->and(InvoiceEvent::TYPE_PAID)->toBe('paid')
|
||||
->and(InvoiceEvent::TYPE_REFUNDED)->toBe('refunded')
|
||||
->and(InvoiceEvent::TYPE_DEBITED)->toBe('debited')
|
||||
->and(InvoiceEvent::TYPE_CANCELED)->toBe('canceled')
|
||||
->and(InvoiceEvent::TYPE_REISSUED)->toBe('reissued')
|
||||
->and(InvoiceEvent::TYPE_ORDER_ATTACHED)->toBe('order_attached')
|
||||
->and(InvoiceEvent::TYPE_REMINDER)->toBe('reminder');
|
||||
});
|
||||
|
||||
test('invoice event getters and setters round-trip values', function (): void {
|
||||
$entity = new InvoiceEvent();
|
||||
|
||||
expect($entity->getType())->toBe(InvoiceEvent::TYPE_UPDATED)
|
||||
->and($entity->getInvoiceId())->toBeNull()
|
||||
->and($entity->getSnapshot())->toBeNull();
|
||||
|
||||
$entity->setInvoiceId(7)
|
||||
->setType(InvoiceEvent::TYPE_PAID)
|
||||
->setAdminId(3)
|
||||
->setClientId(5)
|
||||
->setSnapshot(['total' => '10.00']);
|
||||
|
||||
expect($entity->getInvoiceId())->toBe(7)
|
||||
->and($entity->getType())->toBe(InvoiceEvent::TYPE_PAID)
|
||||
->and($entity->getAdminId())->toBe(3)
|
||||
->and($entity->getClientId())->toBe(5)
|
||||
->and($entity->getSnapshot())->toBe(['total' => '10.00']);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright 2022-2026 FOSSBilling
|
||||
* SPDX-License-Identifier: Apache-2.0.
|
||||
*
|
||||
* @copyright FOSSBilling (https://www.fossbilling.org)
|
||||
* @license http://www.apache.org/licenses/LICENSE-2.0 Apache-2.0
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Invoice\Entity\InvoiceEvent;
|
||||
use Box\Mod\Invoice\Repository\InvoiceEventRepository;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
|
||||
test('finds journal entries for an invoice oldest first', function (): void {
|
||||
$first = new InvoiceEvent();
|
||||
$second = new InvoiceEvent();
|
||||
|
||||
$entityManager = Mockery::mock(EntityManager::class);
|
||||
$repository = Mockery::mock(InvoiceEventRepository::class . '[findBy]', [$entityManager, new ClassMetadata(InvoiceEvent::class)]);
|
||||
$repository->shouldReceive('findBy')
|
||||
->once()
|
||||
->with(['invoiceId' => 7], ['id' => 'ASC'])
|
||||
->andReturn([$first, $second]);
|
||||
|
||||
expect($repository->findByInvoiceId(7))->toBe([$first, $second]);
|
||||
});
|
||||
|
||||
test('repository is wired to the InvoiceEvent entity', function (): void {
|
||||
$entityManager = Mockery::mock(EntityManager::class);
|
||||
$repository = new InvoiceEventRepository($entityManager, new ClassMetadata(InvoiceEvent::class));
|
||||
|
||||
expect($repository->getClassName())->toBe(InvoiceEvent::class);
|
||||
});
|
||||
@@ -39,13 +39,13 @@ test('getSearchQueryBuilder orders by id descending with no filters', function (
|
||||
expect($dql)->toBe('SELECT i FROM ' . Invoice::class . ' i ORDER BY i.id DESC');
|
||||
});
|
||||
|
||||
test('getSearchQueryBuilder filters by client_id, status, currency and approved', function (): void {
|
||||
test('getSearchQueryBuilder filters by client_id, status, currency and issued', function (): void {
|
||||
$query = invoiceEntityManager()->getRepository(Invoice::class)
|
||||
->getSearchQueryBuilder([
|
||||
'client_id' => 7,
|
||||
'status' => Invoice::STATUS_PAID,
|
||||
'currency' => 'USD',
|
||||
'approved' => 1,
|
||||
'issued' => 1,
|
||||
])
|
||||
->getQuery();
|
||||
|
||||
@@ -53,35 +53,35 @@ test('getSearchQueryBuilder filters by client_id, status, currency and approved'
|
||||
expect($dql)->toContain('i.clientId = :client_id')
|
||||
->and($dql)->toContain('i.status = :status')
|
||||
->and($dql)->toContain('i.currency = :currency')
|
||||
->and($dql)->toContain('i.approved = :approved')
|
||||
->and($dql)->toContain('i.issued = :issued')
|
||||
->and($dql)->toContain('ORDER BY i.id DESC');
|
||||
|
||||
expect($query->getParameter('client_id')->getValue())->toBe(7)
|
||||
->and($query->getParameter('status')->getValue())->toBe(Invoice::STATUS_PAID)
|
||||
->and($query->getParameter('currency')->getValue())->toBe('USD')
|
||||
->and($query->getParameter('approved')->getValue())->toBeTrue();
|
||||
->and($query->getParameter('issued')->getValue())->toBeTrue();
|
||||
});
|
||||
|
||||
test('getSearchQueryBuilder normalizes the approved filter via Tools::normalizeBoolean', function (): void {
|
||||
test('getSearchQueryBuilder normalizes the issued filter via Tools::normalizeBoolean', function (): void {
|
||||
$repository = invoiceEntityManager()->getRepository(Invoice::class);
|
||||
|
||||
foreach ([1, '1', true, 'on', 'true'] as $truthy) {
|
||||
$qb = $repository->getSearchQueryBuilder(['approved' => $truthy]);
|
||||
expect($qb->getParameter('approved')->getValue())->toBeTrue();
|
||||
$qb = $repository->getSearchQueryBuilder(['issued' => $truthy]);
|
||||
expect($qb->getParameter('issued')->getValue())->toBeTrue();
|
||||
}
|
||||
|
||||
foreach (['false', 'off', '0', 0, false] as $falsey) {
|
||||
$qb = $repository->getSearchQueryBuilder(['approved' => $falsey]);
|
||||
expect($qb->getParameter('approved')->getValue())->toBeFalse();
|
||||
$qb = $repository->getSearchQueryBuilder(['issued' => $falsey]);
|
||||
expect($qb->getParameter('issued')->getValue())->toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
test('getSearchQueryBuilder skips the approved filter when unset or empty', function (): void {
|
||||
test('getSearchQueryBuilder skips the issued filter when unset or empty', function (): void {
|
||||
$repository = invoiceEntityManager()->getRepository(Invoice::class);
|
||||
|
||||
foreach ([null, ''] as $empty) {
|
||||
$qb = $repository->getSearchQueryBuilder(['approved' => $empty]);
|
||||
expect($qb->getDQL())->not->toContain('i.approved');
|
||||
$qb = $repository->getSearchQueryBuilder(['issued' => $empty]);
|
||||
expect($qb->getDQL())->not->toContain('i.issued');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -307,14 +307,14 @@ test('lockAndGetStatus reads the status inside a transaction on every supported
|
||||
expect($status)->toBe(Invoice::STATUS_UNPAID);
|
||||
});
|
||||
|
||||
test('lockAndGetState reads status and approval inside a transaction', function (): void {
|
||||
test('lockAndGetState reads status and issue state inside a transaction', function (): void {
|
||||
$entityManager = invoiceEntityManager();
|
||||
$metadata = [$entityManager->getClassMetadata(Invoice::class)];
|
||||
(new Doctrine\ORM\Tools\SchemaTool($entityManager))->createSchema($metadata);
|
||||
|
||||
$invoice = new Invoice();
|
||||
$invoice->setStatus(Invoice::STATUS_UNPAID);
|
||||
$invoice->setApproved(true);
|
||||
$invoice->setIssued(true);
|
||||
$entityManager->persist($invoice);
|
||||
$entityManager->flush();
|
||||
|
||||
@@ -329,7 +329,7 @@ test('lockAndGetState reads status and approval inside a transaction', function
|
||||
|
||||
expect($state)->toBe([
|
||||
'status' => Invoice::STATUS_UNPAID,
|
||||
'approved' => true,
|
||||
'issued' => true,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -340,7 +340,7 @@ test('lockAndGetState uses DBAL boolean conversion for database text values', fu
|
||||
$connection->shouldReceive('getDatabasePlatform')->once()->andReturn(new Doctrine\DBAL\Platforms\SQLitePlatform());
|
||||
$connection->shouldReceive('fetchAssociative')->once()->andReturn([
|
||||
'status' => Invoice::STATUS_UNPAID,
|
||||
'approved' => 'f',
|
||||
'issued' => 'f',
|
||||
]);
|
||||
$connection->shouldReceive('convertToPHPValue')
|
||||
->once()
|
||||
@@ -356,7 +356,7 @@ test('lockAndGetState uses DBAL boolean conversion for database text values', fu
|
||||
|
||||
expect($repository->lockAndGetState(1))->toBe([
|
||||
'status' => Invoice::STATUS_UNPAID,
|
||||
'approved' => false,
|
||||
'issued' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -233,3 +233,63 @@ test('getSearchQueryBuilder sorts by allowlisted columns', function (array $data
|
||||
'invalid sort falls back to default' => [['sort' => 't.id; DROP TABLE transaction'], 'ORDER BY t.id DESC', false],
|
||||
'invalid direction falls back to ascending' => [['sort' => 'status', 'direction' => 'sideways'], 'ORDER BY t.status ASC, t.id ASC', true],
|
||||
]);
|
||||
|
||||
/*
|
||||
* detachFromInvoice() runs raw SQL against a real table here (not a mocked
|
||||
* connection): the anonymize flag is a data-preservation behavior, and only a
|
||||
* real round trip proves which columns survive it.
|
||||
*/
|
||||
test('detachFromInvoice keeps personal data unless anonymizing', function (): void {
|
||||
$em = transactionEntityManager();
|
||||
(new SchemaTool($em))->createSchema([$em->getClassMetadata(Transaction::class)]);
|
||||
|
||||
$seed = function () use ($em): int {
|
||||
$tx = (new Transaction())
|
||||
->setTxnId('gateway-txn-1')
|
||||
->setAmount(100.0)
|
||||
->setCurrency('EUR')
|
||||
->setIp('203.0.113.7')
|
||||
->setIpn('payer_email=client@example.com')
|
||||
->setNote('admin note')
|
||||
->setError('gateway said no')
|
||||
->setOutput('raw output');
|
||||
$em->persist($tx);
|
||||
$em->flush();
|
||||
$id = $tx->getId();
|
||||
$table = $em->getConnection()->quoteSingleIdentifier('transaction');
|
||||
$em->getConnection()->update($table, ['invoice_id' => 7], ['id' => $id]);
|
||||
$em->clear();
|
||||
|
||||
return $id;
|
||||
};
|
||||
|
||||
$read = function (int $id) use ($em): Transaction {
|
||||
$tx = $em->find(Transaction::class, $id);
|
||||
expect($tx)->toBeInstanceOf(Transaction::class);
|
||||
|
||||
return $tx;
|
||||
};
|
||||
|
||||
// Ordinary deletion: detached, everything intact.
|
||||
$plainId = $seed();
|
||||
$em->getRepository(Transaction::class)->detachFromInvoice(7);
|
||||
$plain = $read($plainId);
|
||||
expect($plain->getInvoice())->toBeNull()
|
||||
->and($plain->getIp())->toBe('203.0.113.7')
|
||||
->and($plain->getIpn())->toBe('payer_email=client@example.com')
|
||||
->and($plain->getNote())->toBe('admin note');
|
||||
|
||||
// Erasure: detached, personal data scrubbed, financial record kept.
|
||||
$erasedId = $seed();
|
||||
$em->getRepository(Transaction::class)->detachFromInvoice(7, true);
|
||||
$erased = $read($erasedId);
|
||||
expect($erased->getInvoice())->toBeNull()
|
||||
->and($erased->getIp())->toBeNull()
|
||||
->and($erased->getIpn())->toBeNull()
|
||||
->and($erased->getNote())->toBeNull()
|
||||
->and($erased->getError())->toBeNull()
|
||||
->and($erased->getOutput())->toBeNull()
|
||||
->and($erased->getTxnId())->toBe('gateway-txn-1')
|
||||
->and((float) $erased->getAmount())->toBe(100.0)
|
||||
->and($erased->getCurrency())->toBe('EUR');
|
||||
});
|
||||
@@ -308,7 +308,7 @@ test('adds new item casts a numeric rel_id to string', function (): void {
|
||||
|
||||
test('addNew refuses locked invoices unless bypassed for internal flows', function (): void {
|
||||
$invoiceModel = createEntity(Invoice::class);
|
||||
$invoiceModel->setApproved(true);
|
||||
$invoiceModel->setIssued(true);
|
||||
$invoiceModel->setStatus(Invoice::STATUS_UNPAID);
|
||||
|
||||
$invoiceServiceMock = Mockery::mock(InvoiceService::class);
|
||||
@@ -339,7 +339,7 @@ test('addNew refuses locked invoices unless bypassed for internal flows', functi
|
||||
|
||||
test('update and remove refuse locked invoices', function (): void {
|
||||
$invoiceModel = createEntity(Invoice::class);
|
||||
$invoiceModel->setApproved(true);
|
||||
$invoiceModel->setIssued(true);
|
||||
$invoiceModel->setStatus(Invoice::STATUS_PAID);
|
||||
|
||||
$invoiceServiceMock = Mockery::mock(InvoiceService::class);
|
||||
@@ -423,7 +423,7 @@ test('generates invoice items from order with a recurring promo and casts rel_id
|
||||
$invoiceServiceMock = Mockery::mock(InvoiceService::class);
|
||||
$invoiceServiceMock->shouldReceive('isInvoiceEditable')->andReturn(true);
|
||||
$invoiceServiceMock->shouldReceive('lockInvoiceState')
|
||||
->andReturn(['status' => Invoice::STATUS_UNPAID, 'approved' => false]);
|
||||
->andReturn(['status' => Invoice::STATUS_UNPAID, 'issued' => false]);
|
||||
$invoiceServiceMock->shouldReceive('isInvoiceStateEditable')->andReturn(true);
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,9 +11,9 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Box\Mod\Client\Entity\ClientBalance;
|
||||
use Box\Mod\Client\Repository\ClientBalanceRepository;
|
||||
use Box\Mod\Invoice\Entity\Invoice;
|
||||
use Box\Mod\Invoice\Entity\PayGateway;
|
||||
use Box\Mod\Invoice\Entity\Subscription;
|
||||
use Box\Mod\Invoice\Entity\Transaction;
|
||||
use Box\Mod\Invoice\Event\AfterAdminTransactionCreateEvent;
|
||||
use Box\Mod\Invoice\Event\AfterAdminTransactionProcessEvent;
|
||||
@@ -22,9 +22,7 @@ use Box\Mod\Invoice\Event\BeforeAdminTransactionCreateEvent;
|
||||
use Box\Mod\Invoice\Event\BeforeAdminTransactionUpdateEvent;
|
||||
use Box\Mod\Invoice\Repository\InvoiceRepository;
|
||||
use Box\Mod\Invoice\Repository\PayGatewayRepository;
|
||||
use Box\Mod\Invoice\Repository\SubscriptionRepository;
|
||||
use Box\Mod\Invoice\Repository\TransactionRepository;
|
||||
use Box\Mod\Invoice\ServiceSubscription;
|
||||
use Box\Mod\Invoice\ServiceTransaction;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher as SymfonyEventDispatcher;
|
||||
@@ -129,6 +127,96 @@ test('updates a transaction subscription link', function (): void {
|
||||
->and($transactionModel->getSPeriod())->toBe('1M');
|
||||
});
|
||||
|
||||
test('processed transaction history fields are frozen', function (): void {
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('flush')->once();
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
$service->getDi()['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 1, 'amount' => '42.50', 'currency' => 'USD']);
|
||||
$transactionModel->setStatus(Transaction::STATUS_PROCESSED);
|
||||
|
||||
// Money and history fields cannot change once the transaction processed.
|
||||
foreach ([['amount' => '99.99'], ['currency' => 'EUR'], ['txn_id' => 'other'], ['invoice_id' => 2]] as $data) {
|
||||
expect(fn () => $service->update($transactionModel, $data))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'record money that already moved');
|
||||
}
|
||||
|
||||
// Operational annotations may still be edited.
|
||||
expect($service->update($transactionModel, ['note' => 'verified with gateway']))->toBeTrue()
|
||||
->and($transactionModel->getNote())->toBe('verified with gateway');
|
||||
});
|
||||
|
||||
test('processed transaction accepts re-submitted unchanged values', function (): void {
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('flush')->once();
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
$service->getDi()['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 1, 'amount' => '42.50', 'currency' => 'USD']);
|
||||
$transactionModel->setStatus(Transaction::STATUS_PROCESSED);
|
||||
|
||||
// The API renders DECIMAL '42.50' as float 42.5 and forms post '' for
|
||||
// nulls: re-submitting those round-tripped values changes nothing.
|
||||
expect($service->update($transactionModel, ['amount' => '42.5', 'currency' => 'USD', 'txn_status' => '', 'note' => 'x']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('transaction cannot be re-pointed to a canceled invoice', function (): void {
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldNotReceive('flush');
|
||||
|
||||
$canceled = createEntity(Invoice::class, ['id' => 9]);
|
||||
$canceled->setStatus(Invoice::STATUS_CANCELED);
|
||||
$invoiceRepository = Mockery::mock(InvoiceRepository::class);
|
||||
$invoiceRepository->shouldReceive('find')->with(9)->andReturn($canceled);
|
||||
$em->shouldReceive('getRepository')->with(Invoice::class)->andReturn($invoiceRepository);
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
$service->getDi()['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 1]);
|
||||
|
||||
expect(fn () => $service->update($transactionModel, ['invoice_id' => 9]))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'canceled, refunded, or replaced');
|
||||
});
|
||||
|
||||
test('processed transactions cannot be deleted', function (): void {
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldNotReceive('remove', 'flush');
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
$service->getDi()['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 1]);
|
||||
$transactionModel->setStatus(Transaction::STATUS_PROCESSED);
|
||||
|
||||
expect(fn () => $service->delete($transactionModel))
|
||||
->toThrow(FOSSBilling\InformationException::class, 'record money that already moved');
|
||||
});
|
||||
|
||||
test('deleting a transaction removes its orphaned balance rows', function (): void {
|
||||
$balance = createEntity(ClientBalance::class, ['id' => 7]);
|
||||
$balanceRepository = Mockery::mock(ClientBalanceRepository::class);
|
||||
$balanceRepository->shouldReceive('findBy')
|
||||
->once()
|
||||
->with(['type' => 'transaction', 'relId' => '1'])
|
||||
->andReturn([$balance]);
|
||||
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(ClientBalance::class)->andReturn($balanceRepository);
|
||||
$em->shouldReceive('remove')->twice();
|
||||
$em->shouldReceive('flush')->once();
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
$service->getDi()['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 1]);
|
||||
|
||||
expect($service->delete($transactionModel))->toBeTrue();
|
||||
});
|
||||
|
||||
test('throws exception when creating transaction with missing invoice id', function (): void {
|
||||
$service = transactionService();
|
||||
$events = [];
|
||||
@@ -214,6 +302,9 @@ test('creates a transaction with safe lifecycle events and excludes raw IPN and
|
||||
|
||||
test('deletes a transaction', function (): void {
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$balanceRepository = Mockery::mock(ClientBalanceRepository::class);
|
||||
$balanceRepository->shouldReceive('findBy')->andReturn([]);
|
||||
$em->shouldReceive('getRepository')->with(ClientBalance::class)->andReturn($balanceRepository);
|
||||
$em->shouldReceive('remove')->atLeast()->once();
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
@@ -288,6 +379,8 @@ test('converts a transaction result without database access', function (): void
|
||||
test('counts transactions', function (): void {
|
||||
$queryResult = [['status' => Transaction::STATUS_RECEIVED, 'counter' => 1]];
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('fetchAllAssociative')
|
||||
->atLeast()->once()
|
||||
->andReturn($queryResult);
|
||||
@@ -311,6 +404,10 @@ test('createAndProcess marks transaction as error when processing throws', funct
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn(Mockery::mock(PayGatewayRepository::class));
|
||||
$em->shouldReceive('flush')->once();
|
||||
$em->shouldReceive('refresh')->with($transactionModel)->once();
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')->once()->andReturn(1);
|
||||
$em->shouldReceive('getConnection')->andReturn($connection);
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
@@ -374,6 +471,10 @@ test('preProcessTransaction returns a boolean result', function (): void {
|
||||
});
|
||||
|
||||
$di = container();
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')->once()->andReturn(1);
|
||||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||||
$di['event_dispatcher'] = $dispatcher;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
@@ -396,10 +497,36 @@ test('preProcessTransaction returns a boolean result', function (): void {
|
||||
->and($events[1]->transactionId)->toBe(5);
|
||||
});
|
||||
|
||||
test('preProcessTransaction skips a transaction claimed by another worker', function (): void {
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 5]);
|
||||
|
||||
$di = container();
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')->once()->andReturn(0);
|
||||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||||
$logger = new Tests\Helpers\TestLogger();
|
||||
$di['logger'] = $logger;
|
||||
|
||||
$service = Mockery::mock(ServiceTransaction::class)->makePartial();
|
||||
$service->shouldNotReceive('processTransaction');
|
||||
$service->setDi($di);
|
||||
|
||||
expect($service->preProcessTransaction($transactionModel))->toBeTrue()
|
||||
->and($logger->calls)->toContain([
|
||||
'method' => 'info',
|
||||
'params' => ['Skipped processing transaction #{id}: already claimed by another worker', ['id' => 5]],
|
||||
]);
|
||||
});
|
||||
|
||||
test('preProcessTransaction returns true when the adapter returns nothing', function (): void {
|
||||
$transactionModel = createEntity(Transaction::class, ['id' => 5]);
|
||||
|
||||
$di = container();
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')->once()->andReturn(1);
|
||||
$di['em']->shouldReceive('getConnection')->andReturn($connection);
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$service = Mockery::mock(ServiceTransaction::class)->makePartial();
|
||||
@@ -425,6 +552,10 @@ test('preProcessTransaction marks error on a generic exception', function (): vo
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn(Mockery::mock(PayGatewayRepository::class));
|
||||
$em->shouldReceive('flush')->once();
|
||||
$em->shouldReceive('refresh')->with($transactionModel)->once();
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')->once()->andReturn(1);
|
||||
$em->shouldReceive('getConnection')->andReturn($connection);
|
||||
|
||||
$events = [];
|
||||
$dispatcher = new SymfonyEventDispatcher();
|
||||
@@ -488,6 +619,8 @@ test('processes the rest of a received transaction batch after a failure', funct
|
||||
test('claimForProcessing includes error status in claim query', function (): void {
|
||||
$execArgs = [];
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('executeStatement')
|
||||
->withArgs(function (string $sql, array $bindings) use (&$execArgs): bool {
|
||||
$execArgs = ['sql' => $sql, 'bindings' => $bindings];
|
||||
@@ -506,7 +639,41 @@ test('claimForProcessing includes error status in claim query', function (): voi
|
||||
->and($execArgs['bindings'])->toContain(Transaction::STATUS_ERROR)
|
||||
->and($execArgs['bindings'])->toContain(Transaction::STATUS_RECEIVED)
|
||||
->and($execArgs['bindings'])->toContain(Transaction::STATUS_PROCESSING)
|
||||
->and($execArgs['sql'])->toContain('IN (?, ?)');
|
||||
->and($execArgs['sql'])->toContain('IN (?, ?)')
|
||||
->and($execArgs['sql'])->toContain('"transaction"');
|
||||
});
|
||||
|
||||
test('claimForProcessing executes on SQLite despite the reserved table name', function (): void {
|
||||
// Regression test: the raw UPDATE used the bare `transaction` table name, which is a
|
||||
// syntax error on SQLite - every claim threw, so no payment could complete there.
|
||||
// A real connection, not a mock, is the only way to prove the quoting works.
|
||||
$config = Doctrine\ORM\ORMSetup::createAttributeMetadataConfig([Symfony\Component\Filesystem\Path::join(__DIR__, '..', '..', '..', 'Entity')], true);
|
||||
$config->setProxyDir(sys_get_temp_dir());
|
||||
$config->setProxyNamespace('FOSSBilling\\Tests\\DoctrineProxies');
|
||||
$entityManager = new Doctrine\ORM\EntityManager(
|
||||
Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]),
|
||||
$config
|
||||
);
|
||||
(new Doctrine\ORM\Tools\SchemaTool($entityManager))->createSchema([
|
||||
$entityManager->getClassMetadata(Transaction::class),
|
||||
]);
|
||||
|
||||
$tx = (new Transaction())->setStatus(Transaction::STATUS_RECEIVED);
|
||||
$entityManager->persist($tx);
|
||||
$entityManager->flush();
|
||||
$id = $tx->getId();
|
||||
$entityManager->clear();
|
||||
|
||||
// claimForProcessing only needs the entity manager, so wire the real one directly.
|
||||
$service = new ServiceTransaction();
|
||||
$di = container();
|
||||
$di['em'] = $entityManager;
|
||||
$service->setDi($di);
|
||||
|
||||
expect($service->claimForProcessing((int) $id))->toBeTrue();
|
||||
|
||||
$entityManager->clear();
|
||||
expect($entityManager->find(Transaction::class, $id)?->getStatus())->toBe(Transaction::STATUS_PROCESSING);
|
||||
});
|
||||
|
||||
test('markTransactionError does not clobber an already processed transaction', function (): void {
|
||||
@@ -534,152 +701,3 @@ test('markTransactionError does not clobber an already processed transaction', f
|
||||
|
||||
expect($transactionModel->getStatus())->toBe(Transaction::STATUS_PROCESSED);
|
||||
});
|
||||
|
||||
test('_subscribe creates and persists a subscription from an approved transaction', function (): void {
|
||||
$gateway = createEntity(PayGateway::class, ['id' => 5]);
|
||||
$invoice = createEntity(Invoice::class);
|
||||
setEntityId($invoice, 10);
|
||||
$invoice->setClientId(7);
|
||||
$invoice->setCurrency('USD');
|
||||
|
||||
$tx = createEntity(Transaction::class, ['id' => 1, 'gateway' => $gateway, 'invoice' => $invoice]);
|
||||
$tx->setStatus(Transaction::STATUS_APPROVED);
|
||||
$tx->setSId('sub_gateway_1');
|
||||
$tx->setAmount('29.99');
|
||||
$tx->setCurrency('USD');
|
||||
$tx->setTxnId('txn_001');
|
||||
|
||||
$transactionRepo = Mockery::mock(TransactionRepository::class);
|
||||
$transactionRepo->shouldReceive('findOneProcessedByTxnId')->andReturnNull();
|
||||
|
||||
$subscriptionService = Mockery::mock(ServiceSubscription::class);
|
||||
$subscriptionService->shouldReceive('getSubscriptionPeriod')->with($invoice)->andReturn('1M');
|
||||
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(Transaction::class)->andReturn($transactionRepo);
|
||||
|
||||
$capturedSubscription = null;
|
||||
$em->shouldReceive('persist')->once()->withArgs(function (object $entity) use (&$capturedSubscription): bool {
|
||||
$capturedSubscription = $entity;
|
||||
|
||||
return $entity instanceof Subscription;
|
||||
});
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $subscriptionService);
|
||||
|
||||
$service = new ServiceTransaction();
|
||||
$service->setDi($di);
|
||||
|
||||
$refl = new ReflectionClass($service);
|
||||
$method = $refl->getMethod('_subscribe');
|
||||
$method->invoke($service, $tx);
|
||||
|
||||
expect($tx->getStatus())->toBe(Transaction::STATUS_PROCESSED);
|
||||
expect($capturedSubscription)->toBeInstanceOf(Subscription::class)
|
||||
->and($capturedSubscription->getSid())->toBe('sub_gateway_1')
|
||||
->and($capturedSubscription->getClientId())->toBe(7)
|
||||
->and($capturedSubscription->getPayGateway())->toBe($gateway)
|
||||
->and($capturedSubscription->getRelType())->toBe('invoice')
|
||||
->and($capturedSubscription->getRelId())->toBe(10)
|
||||
->and($capturedSubscription->getAmount())->toBe('29.99')
|
||||
->and($capturedSubscription->getCurrency())->toBe('USD')
|
||||
->and($capturedSubscription->getPeriod())->toBe('1M')
|
||||
->and($capturedSubscription->getStatus())->toBe('active');
|
||||
});
|
||||
|
||||
test('_unsubscribe looks up the subscription by sid and delegates to the subscription service', function (): void {
|
||||
$tx = createEntity(Transaction::class, ['id' => 1, 'gatewayId' => 5]);
|
||||
$tx->setStatus(Transaction::STATUS_APPROVED);
|
||||
$tx->setSId('sub_gateway_1');
|
||||
$tx->setTxnId('txn_001');
|
||||
|
||||
$transactionRepo = Mockery::mock(TransactionRepository::class);
|
||||
$transactionRepo->shouldReceive('findOneProcessedByTxnId')->andReturnNull();
|
||||
|
||||
$subscription = createEntity(Subscription::class, ['id' => 12]);
|
||||
$subscriptionRepo = Mockery::mock(SubscriptionRepository::class);
|
||||
$subscriptionRepo->shouldReceive('findOneBySid')
|
||||
->once()
|
||||
->with('sub_gateway_1')
|
||||
->andReturn($subscription);
|
||||
|
||||
$subscriptionService = Mockery::mock(ServiceSubscription::class);
|
||||
$subscriptionService->shouldReceive('unsubscribe')
|
||||
->once()
|
||||
->with(Mockery::on(fn ($arg): bool => $arg instanceof Subscription && $arg->getId() === 12));
|
||||
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(Transaction::class)->andReturn($transactionRepo);
|
||||
$em->shouldReceive('getRepository')->with(PayGateway::class)->andReturn(Mockery::mock(PayGatewayRepository::class));
|
||||
$em->shouldReceive('getRepository')->with(Subscription::class)->andReturn($subscriptionRepo);
|
||||
$em->shouldReceive('flush')->atLeast()->once();
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $em;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$di['mod_service'] = $di->protect(fn ($module, $sub = '') => $subscriptionService);
|
||||
|
||||
$service = new ServiceTransaction();
|
||||
$service->setDi($di);
|
||||
|
||||
$refl = new ReflectionClass($service);
|
||||
$method = $refl->getMethod('_unsubscribe');
|
||||
$method->invoke($service, $tx);
|
||||
|
||||
expect($tx->getStatus())->toBe(Transaction::STATUS_PROCESSED);
|
||||
});
|
||||
|
||||
test('debitTransaction records a client balance credit', function (): void {
|
||||
$proforma = createEntity(Invoice::class);
|
||||
|
||||
$proforma->id = 5;
|
||||
$proforma->client_id = 20;
|
||||
$proforma->currency = 'USD';
|
||||
|
||||
$client = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 20, 'currency' => 'USD']);
|
||||
|
||||
$tx = createEntity(Transaction::class, ['id' => 7, 'invoice' => $proforma, 'currency' => 'USD', 'amount' => '25.00']);
|
||||
|
||||
$clientRepo = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
|
||||
$clientRepo->shouldReceive('find')->once()->with(20)->andReturn($client);
|
||||
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\Client::class)->andReturn($clientRepo);
|
||||
$em->shouldReceive('persist')->once()->with(
|
||||
Mockery::on(fn (ClientBalance $balance): bool => $balance->getClient()?->getId() === 20
|
||||
&& $balance->getType() === 'transaction'
|
||||
&& $balance->getRelId() === '7'
|
||||
&& $balance->getDescription() === 'Invoice #5 payment received from transaction #7'
|
||||
&& $balance->getAmount() === '25.00')
|
||||
);
|
||||
$em->shouldReceive('flush')->once();
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
|
||||
$service->debitTransaction($tx);
|
||||
});
|
||||
|
||||
test('debitTransaction rejects a transaction without a client', function (): void {
|
||||
$proforma = createEntity(Invoice::class);
|
||||
|
||||
$proforma->id = 5;
|
||||
$proforma->client_id = 20;
|
||||
$proforma->currency = 'USD';
|
||||
|
||||
$tx = createEntity(Transaction::class, ['id' => 7, 'invoice' => $proforma, 'currency' => 'USD', 'amount' => '25.00']);
|
||||
|
||||
$clientRepo = Mockery::mock(Box\Mod\Client\Repository\ClientRepository::class);
|
||||
$clientRepo->shouldReceive('find')->once()->with(20)->andReturn(null);
|
||||
|
||||
$em = Mockery::mock(EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->with(Box\Mod\Client\Entity\Client::class)->andReturn($clientRepo);
|
||||
|
||||
$service = transactionService(em: $em);
|
||||
|
||||
expect(fn () => $service->debitTransaction($tx))
|
||||
->toThrow(FOSSBilling\Exception::class, 'Client #20 not found');
|
||||
});
|
||||
@@ -1117,7 +1117,7 @@ class Service implements InjectionAwareInterface
|
||||
$invoiceService = $this->di['mod_service']('invoice');
|
||||
|
||||
try {
|
||||
$invoiceService->approveInvoice($invoice, ['id' => $invoice->getId(), 'use_credits' => true]);
|
||||
$invoiceService->issueInvoice($invoice, ['id' => $invoice->getId(), 'use_credits' => true]);
|
||||
|
||||
if ($markInvoicePaid) {
|
||||
$invoiceService->markAsPaidByAdmin($invoice, $data);
|
||||
|
||||
@@ -2167,7 +2167,7 @@ test('createOrder returns success when invoice follow up fails', function (): vo
|
||||
->once()
|
||||
->with(Mockery::any(), null, false)
|
||||
->andReturn($invoiceModel);
|
||||
$invoiceServiceMock->shouldReceive('approveInvoice')
|
||||
$invoiceServiceMock->shouldReceive('issueInvoice')
|
||||
->once()
|
||||
->with($invoiceModel, ['id' => $invoiceModel->id, 'use_credits' => true])
|
||||
->andReturn(true);
|
||||
@@ -3767,7 +3767,7 @@ test('createOrder generates an invoice for a zero-price order with issue-invoice
|
||||
->once()
|
||||
->with(Mockery::any(), null, false)
|
||||
->andReturn($invoiceModel);
|
||||
$invoiceServiceMock->shouldReceive('approveInvoice')
|
||||
$invoiceServiceMock->shouldReceive('issueInvoice')
|
||||
->once()
|
||||
->with($invoiceModel, ['id' => $invoiceModel->id, 'use_credits' => true])
|
||||
->andReturn(true);
|
||||
@@ -3883,7 +3883,7 @@ test('createOrder does not roll back when invoice generation fails for a negativ
|
||||
->once()
|
||||
->with(Mockery::any(), null, false)
|
||||
->andThrow(new FOSSBilling\InformationException('Invoices are not generated for negative amount orders.'));
|
||||
$invoiceServiceMock->shouldReceive('approveInvoice')->never();
|
||||
$invoiceServiceMock->shouldReceive('issueInvoice')->never();
|
||||
|
||||
$persistedEntities = [];
|
||||
$nextOrderId = 1;
|
||||
@@ -4682,7 +4682,7 @@ test('createOrder applies a promo code and records the discount', function (): v
|
||||
->once()
|
||||
->with(Mockery::type(Order::class), null, false)
|
||||
->andReturn($invoiceModel);
|
||||
$invoiceServiceMock->shouldReceive('approveInvoice')->once()->andReturn(true);
|
||||
$invoiceServiceMock->shouldReceive('issueInvoice')->once()->andReturn(true);
|
||||
|
||||
$clientServiceMock = Mockery::mock(Box\Mod\Client\Service::class);
|
||||
$clientServiceMock->shouldReceive('isClientTaxable')->once()->with($modelClient)->andReturn(false);
|
||||
|
||||
@@ -87,25 +87,47 @@ test('admin can apply and remove promos on orders and unpaid invoices', function
|
||||
$promoId = autoPromoCreatePromo($promoCode, 'absolute', 15, ['active' => 1, 'recurring' => 1]);
|
||||
['id' => $clientId] = autoPromoCreateClient();
|
||||
|
||||
// Promo edits happen on the draft: once issued, the invoice is
|
||||
// immutable and post-issuance discounts go through credit notes.
|
||||
$created = Tests\Helpers\ApiClient::request('admin/order/create', [
|
||||
'client_id' => $clientId,
|
||||
'product_id' => $productId,
|
||||
'invoice_option' => 'issue-invoice',
|
||||
'promo_code' => $promoCode,
|
||||
'invoice_option' => 'no-invoice',
|
||||
]);
|
||||
assertApiSuccess($created);
|
||||
assertApiResultIsInt($created);
|
||||
$orderId = (int) $created->getResult();
|
||||
|
||||
$draft = Tests\Helpers\ApiClient::request('admin/invoice/prepare', [
|
||||
'client_id' => $clientId,
|
||||
]);
|
||||
assertApiSuccess($draft);
|
||||
assertApiResultIsInt($draft);
|
||||
$invoiceId = (int) $draft->getResult();
|
||||
|
||||
$attached = Tests\Helpers\ApiClient::request('admin/invoice/attach_order', [
|
||||
'id' => $invoiceId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
assertApiSuccess($attached);
|
||||
|
||||
$added = Tests\Helpers\ApiClient::request('admin/invoice/promo_add', [
|
||||
'id' => $invoiceId,
|
||||
'promo_code' => $promoCode,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
assertApiSuccess($added);
|
||||
expect((float) $added->getResult())->toEqual(15.0);
|
||||
|
||||
$order = autoPromoGetOrder($orderId);
|
||||
expect((int) $order['promo_id'])->toBe($promoId);
|
||||
expect((float) $order['discount'])->toEqual(15.0);
|
||||
|
||||
$invoice = autoPromoGetInvoice((int) $order['unpaid_invoice_id']);
|
||||
$invoice = autoPromoGetInvoice($invoiceId);
|
||||
expect(autoPromoHasDiscountLine($invoice, $orderId, -15.0))->toBeTrue();
|
||||
|
||||
$removed = Tests\Helpers\ApiClient::request('admin/invoice/promo_remove', [
|
||||
'id' => $order['unpaid_invoice_id'],
|
||||
'id' => $invoiceId,
|
||||
'promo_id' => $promoId,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
@@ -114,25 +136,29 @@ test('admin can apply and remove promos on orders and unpaid invoices', function
|
||||
|
||||
$order = autoPromoGetOrder($orderId);
|
||||
expect((float) $order['discount'])->toEqual(0.0);
|
||||
$invoice = autoPromoGetInvoice((int) $order['unpaid_invoice_id']);
|
||||
$invoice = autoPromoGetInvoice($invoiceId);
|
||||
expect(autoPromoHasDiscountLine($invoice, $orderId, -15.0))->toBeFalse();
|
||||
|
||||
$added = Tests\Helpers\ApiClient::request('admin/invoice/promo_add', [
|
||||
'id' => $order['unpaid_invoice_id'],
|
||||
'id' => $invoiceId,
|
||||
'promo_code' => $promoCode,
|
||||
'order_id' => $orderId,
|
||||
]);
|
||||
assertApiSuccess($added);
|
||||
expect((float) $added->getResult())->toEqual(15.0);
|
||||
|
||||
// Issue before paying: the promo edits above are only valid on drafts.
|
||||
$issued = Tests\Helpers\ApiClient::request('admin/invoice/issue', ['id' => $invoiceId]);
|
||||
assertApiSuccess($issued);
|
||||
|
||||
// Pay the checkout invoice first: renewing against an unpaid invoice
|
||||
// would simply return that same invoice instead of a renewal.
|
||||
autoPromoMarkInvoicePaid((int) $order['unpaid_invoice_id']);
|
||||
autoPromoMarkInvoicePaid($invoiceId);
|
||||
|
||||
// Recurring promos carry forward to renewal invoices.
|
||||
$renewal = Tests\Helpers\ApiClient::request('admin/invoice/renewal_invoice', ['id' => $orderId]);
|
||||
assertApiSuccess($renewal);
|
||||
expect((int) $renewal->getResult())->not->toBe((int) $order['unpaid_invoice_id']);
|
||||
expect((int) $renewal->getResult())->not->toBe($invoiceId);
|
||||
$renewalInvoice = autoPromoGetInvoice((int) $renewal->getResult());
|
||||
expect(autoPromoHasDiscountLine($renewalInvoice, $orderId, -15.0))->toBeTrue();
|
||||
} finally {
|
||||
|
||||
@@ -156,7 +156,7 @@ class Service implements InjectionAwareInterface
|
||||
$dbal = $this->di['dbal'];
|
||||
$boundaries = $this->getDayBoundaries();
|
||||
|
||||
$base = "SELECT (COALESCE(SUM(base_income), 0) - COALESCE(SUM(base_refund), 0)) AS income FROM invoice WHERE approved = true AND (status = 'paid' OR status = 'refunded')";
|
||||
$base = "SELECT (COALESCE(SUM(base_income), 0) - COALESCE(SUM(base_refund), 0)) AS income FROM invoice WHERE issued = true AND (status = 'paid' OR status = 'refunded')";
|
||||
$total_query = $base;
|
||||
$yeste_query = $base . ' AND paid_at >= :yesterday_start AND paid_at < :today_start';
|
||||
$today_query = $base . ' AND paid_at >= :today_start AND paid_at < :tomorrow_start';
|
||||
@@ -235,7 +235,7 @@ class Service implements InjectionAwareInterface
|
||||
|
||||
$query = 'SELECT COALESCE(SUM(base_refund), 0) AS refund, COALESCE(SUM(base_income), 0) AS income
|
||||
FROM invoice
|
||||
WHERE approved = true
|
||||
WHERE issued = true
|
||||
AND (status = :status1 OR status = :status2)
|
||||
';
|
||||
|
||||
@@ -270,7 +270,7 @@ class Service implements InjectionAwareInterface
|
||||
$query = "SELECT {$date} AS date, COALESCE(SUM(base_refund), 0) AS refund
|
||||
FROM invoice
|
||||
WHERE created_at BETWEEN :date_from AND :date_to
|
||||
AND approved = true
|
||||
AND issued = true
|
||||
AND (status = :refunded OR (status = :paid AND base_refund > 0))
|
||||
GROUP BY date";
|
||||
|
||||
@@ -307,7 +307,7 @@ class Service implements InjectionAwareInterface
|
||||
$query = "SELECT {$date} AS date, (COALESCE(SUM(base_income), 0) - COALESCE(SUM(base_refund), 0)) AS income
|
||||
FROM invoice
|
||||
WHERE paid_at BETWEEN :date_from AND :date_to
|
||||
AND approved = true
|
||||
AND issued = true
|
||||
AND (status = :paid OR status = :refunded)
|
||||
GROUP BY date";
|
||||
|
||||
|
||||
@@ -28,26 +28,26 @@ function statsConnectionWithRows(): Doctrine\DBAL\Connection
|
||||
$connection = DriverManager::getConnection(['driver' => 'pdo_sqlite', 'memory' => true]);
|
||||
|
||||
foreach (['client', 'client_order', 'invoice', 'support_ticket'] as $table) {
|
||||
$connection->executeStatement("CREATE TABLE {$table} (id INTEGER PRIMARY KEY, created_at TEXT, paid_at TEXT, approved INTEGER DEFAULT 0, status TEXT, base_income REAL DEFAULT 0, base_refund REAL DEFAULT 0)");
|
||||
$connection->executeStatement("CREATE TABLE {$table} (id INTEGER PRIMARY KEY, created_at TEXT, paid_at TEXT, issued INTEGER DEFAULT 0, status TEXT, base_income REAL DEFAULT 0, base_refund REAL DEFAULT 0)");
|
||||
}
|
||||
|
||||
$today = new DateTimeImmutable('today');
|
||||
$rows = [
|
||||
// table, created_at, [paid_at, approved, status, base_income, base_refund]
|
||||
// table, created_at, [paid_at, issued, status, base_income, base_refund]
|
||||
// 3 months back is never inside "last month"'s single-month window, on any run date.
|
||||
['client', $today->modify('-3 months'), []],
|
||||
// Exactly a day before midnight today - unconditionally "yesterday", on any run date.
|
||||
['client', $today->modify('-1 day 12:00:00'), []],
|
||||
['client', $today->modify('10:00:00'), []], // today
|
||||
['client', $today->modify('23:59:59'), []], // today, end of day
|
||||
['invoice', $today->modify('10:00:00'), ['paid_at' => $today->modify('10:00:00'), 'approved' => 1, 'status' => 'paid', 'base_income' => 100.0]],
|
||||
['invoice', $today->modify('10:00:00'), ['paid_at' => $today->modify('10:00:00'), 'issued' => 1, 'status' => 'paid', 'base_income' => 100.0]],
|
||||
];
|
||||
|
||||
foreach ($rows as [$table, $createdAt, $extra]) {
|
||||
$connection->insert($table, [
|
||||
'created_at' => $createdAt->format('Y-m-d H:i:s'),
|
||||
'paid_at' => isset($extra['paid_at']) ? $extra['paid_at']->format('Y-m-d H:i:s') : null,
|
||||
'approved' => $extra['approved'] ?? 0,
|
||||
'issued' => $extra['issued'] ?? 0,
|
||||
'status' => $extra['status'] ?? null,
|
||||
'base_income' => $extra['base_income'] ?? 0,
|
||||
'base_refund' => $extra['base_refund'] ?? 0,
|
||||
|
||||
@@ -379,7 +379,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
{
|
||||
$em = $this->di['em'];
|
||||
foreach ($this->getSupportTicketRepository()->findByClientId((int) $client->getId()) as $ticket) {
|
||||
$em->remove($ticket);
|
||||
$this->removeTicket($ticket);
|
||||
}
|
||||
$em->flush();
|
||||
}
|
||||
@@ -389,6 +389,24 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
$em = $this->di['em'];
|
||||
$id = $model->getId();
|
||||
|
||||
$this->removeTicket($model);
|
||||
$em->flush();
|
||||
|
||||
$this->di['logger']->info('Removed ticket "{id}"', ['id' => $id]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a ticket with its notes, messages, and message edit history.
|
||||
* Shared by admin deletion and client erasure: removing only the ticket
|
||||
* row would orphan message bodies containing personal data.
|
||||
*/
|
||||
private function removeTicket(SupportTicket $ticket): void
|
||||
{
|
||||
$em = $this->di['em'];
|
||||
$id = $ticket->getId();
|
||||
|
||||
foreach ($this->getSupportTicketNoteRepository()->findByTicketId($id ?? 0) as $note) {
|
||||
$em->remove($note);
|
||||
}
|
||||
@@ -399,12 +417,7 @@ class Service implements \FOSSBilling\InjectionAwareInterface
|
||||
$em->remove($message);
|
||||
}
|
||||
|
||||
$em->remove($model);
|
||||
$em->flush();
|
||||
|
||||
$this->di['logger']->info('Removed ticket "{id}"', ['id' => $id]);
|
||||
|
||||
return true;
|
||||
$em->remove($ticket);
|
||||
}
|
||||
|
||||
public function toApiArray(SupportTicket $model, bool $deep = true, \Box\Mod\Staff\Entity\Admin|Client|null $identity = null): array
|
||||
|
||||
@@ -715,6 +715,12 @@ test('removes tickets by client', function (): void {
|
||||
->andReturn([$model]);
|
||||
$service->shouldReceive('getSupportTicketRepository')->atLeast()->once()
|
||||
->andReturn($repo);
|
||||
$service->shouldReceive('getSupportTicketNoteRepository')->atLeast()->once()
|
||||
->andReturn(Mockery::mock(SupportTicketNoteRepository::class)->shouldIgnoreMissing());
|
||||
$service->shouldReceive('getSupportTicketMessageRepository')->atLeast()->once()
|
||||
->andReturn(Mockery::mock(SupportTicketMessageRepository::class)->shouldIgnoreMissing());
|
||||
$service->shouldReceive('getSupportTicketMessageHistoryRepository')->byDefault()
|
||||
->andReturn(Mockery::mock(SupportTicketMessageHistoryRepository::class)->shouldIgnoreMissing());
|
||||
|
||||
$emMock = Mockery::mock(EntityManagerInterface::class);
|
||||
supportWireKbRepositories($emMock);
|
||||
@@ -732,6 +738,64 @@ test('removes tickets by client', function (): void {
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('rmByClient removes tickets with their notes, messages, and history', function (): void {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$ticket = new SupportTicket();
|
||||
setEntityId($ticket, 1);
|
||||
$note = new SupportTicketNote();
|
||||
setEntityId($note, 2);
|
||||
$message = new SupportTicketMessage();
|
||||
setEntityId($message, 3);
|
||||
$history = new SupportTicketMessageHistory();
|
||||
setEntityId($history, 4);
|
||||
|
||||
$repo = Mockery::mock(SupportTicketRepository::class);
|
||||
$repo->shouldReceive('findByClientId')->atLeast()->once()
|
||||
->andReturn([$ticket]);
|
||||
$service->shouldReceive('getSupportTicketRepository')->atLeast()->once()
|
||||
->andReturn($repo);
|
||||
$noteRepo = Mockery::mock(SupportTicketNoteRepository::class);
|
||||
$noteRepo->shouldReceive('findByTicketId')->atLeast()->once()
|
||||
->with(1)
|
||||
->andReturn([$note]);
|
||||
$service->shouldReceive('getSupportTicketNoteRepository')->atLeast()->once()
|
||||
->andReturn($noteRepo);
|
||||
$messageRepo = Mockery::mock(SupportTicketMessageRepository::class);
|
||||
$messageRepo->shouldReceive('findByTicketId')->atLeast()->once()
|
||||
->with(1)
|
||||
->andReturn([$message]);
|
||||
$service->shouldReceive('getSupportTicketMessageRepository')->atLeast()->once()
|
||||
->andReturn($messageRepo);
|
||||
$historyRepo = Mockery::mock(SupportTicketMessageHistoryRepository::class);
|
||||
$historyRepo->shouldReceive('findByMessageId')->atLeast()->once()
|
||||
->with(3)
|
||||
->andReturn([$history]);
|
||||
$service->shouldReceive('getSupportTicketMessageHistoryRepository')->atLeast()->once()
|
||||
->andReturn($historyRepo);
|
||||
|
||||
$emMock = Mockery::mock(EntityManagerInterface::class);
|
||||
supportWireKbRepositories($emMock);
|
||||
$removed = [];
|
||||
$emMock->shouldReceive('remove')->atLeast()->once()
|
||||
->with(Mockery::on(function ($entity) use (&$removed): bool {
|
||||
$removed[] = $entity;
|
||||
|
||||
return true;
|
||||
}));
|
||||
$emMock->shouldReceive('flush')->once();
|
||||
|
||||
$di = container();
|
||||
$di['em'] = $emMock;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
$service->setDi($di);
|
||||
|
||||
$client = createEntity(Client::class);
|
||||
setEntityId($client, 9);
|
||||
|
||||
$service->rmByClient($client);
|
||||
expect($removed)->toContain($note, $message, $history, $ticket);
|
||||
});
|
||||
|
||||
test('removes a ticket, its messages, and their edit history', function (): void {
|
||||
$service = Mockery::mock(Service::class)->makePartial();
|
||||
$note = new SupportTicketNote();
|
||||
|
||||
@@ -778,6 +778,11 @@ class Service
|
||||
* Not subject to canUpdateParam(): this reserves an internal counter rather than applying a
|
||||
* user-driven settings change, and must work in client and cron contexts.
|
||||
*
|
||||
* The reservation nests via SAVEPOINT when the caller already holds a transaction on the
|
||||
* shared connection (which is always the case for the invoice issuance path), so an outer
|
||||
* rollback undoes the advance as well: a failed issuance burns no number and leaves no gap.
|
||||
* Pinned by ReserveNumericParamValueConcurrencyTest's rollback case.
|
||||
*
|
||||
* Callers should invoke this before doing any of their own reads on the shared connection,
|
||||
* not after. On SQLite, an outer transaction that already read something is holding a SHARED
|
||||
* lock the whole time this method runs; if a competing writer is holding RESERVED when this
|
||||
|
||||
@@ -163,3 +163,29 @@ test('reserveNextNumericParamValue never hands out a duplicate value when called
|
||||
sort($reserved);
|
||||
expect($reserved)->toBe(range(0, $totalReservations - 1));
|
||||
});
|
||||
|
||||
/*
|
||||
* The missing half of the guarantee above: a reservation made inside an outer transaction that
|
||||
* then rolls back must not advance the counter. The reservation nests via SAVEPOINT on the one
|
||||
* shared connection, so the outer rollback undoes it - a failed invoice issuance burns no number
|
||||
* and leaves no gap in the series. Single worker, no concurrency needed: rollback semantics are
|
||||
* a single-connection property.
|
||||
*
|
||||
* Worker output is three values: the reservation made inside the doomed transaction, the raw
|
||||
* counter value after the rollback, and a fresh reservation afterwards. All three must be 0.
|
||||
*/
|
||||
test('reserveNextNumericParamValue inside a rolled-back outer transaction leaves no gap', function (): void {
|
||||
$reserved = reserveNumericParamValueConcurrently(
|
||||
<<<'PHP'
|
||||
$connection->beginTransaction();
|
||||
$results[] = $service->reserveNextNumericParamValue('concurrency_counter');
|
||||
$connection->rollBack();
|
||||
$results[] = (int) $connection->fetchOne("SELECT value FROM setting WHERE param = 'concurrency_counter'");
|
||||
$results[] = $service->reserveNextNumericParamValue('concurrency_counter');
|
||||
PHP,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
expect($reserved)->toBe([0, 0, 0]);
|
||||
});
|
||||
@@ -110,10 +110,12 @@ export default function initDatepickers() {
|
||||
const wrapper = input.closest('.input-icon');
|
||||
if (wrapper) {
|
||||
wrapper.classList.add('datepicker-wrapper');
|
||||
wrapper.append(clear);
|
||||
} else {
|
||||
input.after(clear);
|
||||
}
|
||||
// Place the button directly after the input so a trailing
|
||||
// .input-icon-addon keeps Tabler's :last-child right-edge position.
|
||||
// Appending it last would flip the calendar icon to the left,
|
||||
// overlapping the field value.
|
||||
input.after(clear);
|
||||
input.form?.addEventListener('reset', () => {
|
||||
// The browser restores defaultValue after dispatching the reset event.
|
||||
setTimeout(syncInput, 0);
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
{% for invoice in invoicesUnpaid.list %}
|
||||
<tr>
|
||||
<td class="w-1">
|
||||
<a href='{{ "invoice/manage/#{invoice.id}"|url }}'>{{ invoice.serie_nr }}</a>
|
||||
<a href='{{ "invoice/manage/#{invoice.id}"|url }}'>{% if invoice.issued %}{{ invoice.serie_nr }}{% else %}{{ 'Draft'|trans }} #{{ invoice.id }}{% endif %}</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href='{{ "client/manage/#{invoice.client.id}"|url }}'>
|
||||
@@ -58,7 +58,7 @@
|
||||
</td>
|
||||
<td class="text-center">{{ invoice.total|format_currency(invoice.currency ?? default_currency) }}</td>
|
||||
<td class="text-center">{{ invoice.created_at|format_date }}</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}</td>
|
||||
<td>{{ mf.status_badge(invoice.status, 'invoice') }}{% if not invoice.issued %} {{ mf.text_badge('Draft'|trans, 'text-bg-secondary') }}{% endif %}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
{% if status == 'paid' %}{% set badge_class = 'bg-success' %}
|
||||
{% elseif status == 'unpaid' %}{% set badge_class = 'bg-warning' %}
|
||||
{% elseif status == 'refunded' %}{% set badge_class = 'bg-info' %}
|
||||
{% elseif status == 'canceled' %}{% set badge_class = 'bg-secondary' %}
|
||||
{% else %}{% set badge_class = 'bg-primary' %}
|
||||
{% endif %}
|
||||
{% elseif type == 'client' %}
|
||||
|
||||
@@ -98,6 +98,21 @@ test('single dates use ISO values, retain calendar CSS and follow live theme cha
|
||||
await expect(page.getByRole('button', { name: 'Clear date' })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('datepicker keeps the calendar icon clear of the field value', async ({ page }) => {
|
||||
await mount(page, '<form><div class="input-icon" style="width: 320px"><input class="form-control datepicker" name="date" value="2026-09-26"><span class="input-icon-addon"><svg class="icon"></svg></span></div></form>');
|
||||
// The addon must stay the wrapper's last child: Tabler positions a
|
||||
// trailing .input-icon-addon at the field's right edge, while any other
|
||||
// position flips it left, overlapping the value.
|
||||
expect(await page.locator('.input-icon').evaluate(el => el.lastElementChild?.className)).toBe('input-icon-addon');
|
||||
const wrapper = page.locator('.input-icon');
|
||||
const addon = page.locator('.input-icon-addon');
|
||||
const wrapperBox = (await wrapper.boundingBox())!;
|
||||
const addonBox = (await addon.boundingBox())!;
|
||||
expect(Math.abs(wrapperBox.x + wrapperBox.width - (addonBox.x + addonBox.width))).toBeLessThanOrEqual(2);
|
||||
const inputBox = (await page.locator('.datepicker').boundingBox())!;
|
||||
expect(addonBox.x).toBeGreaterThanOrEqual(inputBox.x + inputBox.width / 2);
|
||||
});
|
||||
|
||||
test('supports dates before 1970 and month/year navigation', async ({ page }) => {
|
||||
await mount(page, '<div class="input-icon"><input class="form-control datepicker" value="1950-06-15"></div>');
|
||||
await page.locator('.datepicker').focus();
|
||||
|
||||
@@ -276,7 +276,6 @@
|
||||
"form_field.text_size",
|
||||
"form_field.type",
|
||||
"form_field.updated_at",
|
||||
"invoice.approved",
|
||||
"invoice.base_income",
|
||||
"invoice.base_refund",
|
||||
"invoice.buyer_address",
|
||||
@@ -289,7 +288,6 @@
|
||||
"invoice.buyer_first_name",
|
||||
"invoice.buyer_last_name",
|
||||
"invoice.buyer_phone",
|
||||
"invoice.buyer_phone_cc",
|
||||
"invoice.buyer_state",
|
||||
"invoice.buyer_zip",
|
||||
"invoice.client_id",
|
||||
@@ -304,6 +302,7 @@
|
||||
"invoice.hash",
|
||||
"invoice.hash_expires_at",
|
||||
"invoice.id",
|
||||
"invoice.issued",
|
||||
"invoice.notes",
|
||||
"invoice.nr",
|
||||
"invoice.paid_at",
|
||||
@@ -324,6 +323,13 @@
|
||||
"invoice.text_1",
|
||||
"invoice.text_2",
|
||||
"invoice.updated_at",
|
||||
"invoice_event.admin_id",
|
||||
"invoice_event.client_id",
|
||||
"invoice_event.created_at",
|
||||
"invoice_event.id",
|
||||
"invoice_event.invoice_id",
|
||||
"invoice_event.snapshot",
|
||||
"invoice_event.type",
|
||||
"invoice_item.attempts",
|
||||
"invoice_item.charged",
|
||||
"invoice_item.created_at",
|
||||
|
||||
@@ -253,7 +253,6 @@ function container(): Container
|
||||
$transactionRepository->shouldReceive('findOneBy')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('findOneByTxnIdAndGatewayId')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('findOneByGatewayIdAndIpnHash')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('findOneProcessedByTxnId')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('findActiveByTxnIdAndGatewayId')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('findProcessingOrProcessedByTxnId')->byDefault()->andReturn(null);
|
||||
$transactionRepository->shouldReceive('competingTransactionQuery')->byDefault()->andReturn($payGatewayQueryBuilder);
|
||||
@@ -269,7 +268,7 @@ function container(): Container
|
||||
$invoiceRepository->shouldReceive('findLatestWithNr')->byDefault()->andReturn(null);
|
||||
$invoiceRepository->shouldReceive('findPaid')->byDefault()->andReturn([]);
|
||||
$invoiceRepository->shouldReceive('findByClientId')->byDefault()->andReturn([]);
|
||||
$invoiceRepository->shouldReceive('findUnpaidApprovedNotRemindedBefore')->byDefault()->andReturn([]);
|
||||
$invoiceRepository->shouldReceive('findUnpaidIssuedNotRemindedBefore')->byDefault()->andReturn([]);
|
||||
$invoiceRepository->shouldReceive('findUnpaidOlderThan')->byDefault()->andReturn([]);
|
||||
$invoiceRepository->shouldReceive('findPaidByRelId')->byDefault()->andReturn([]);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use FOSSBilling\Security\RateLimitResult;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
function createRateLimiter(string $requestIp, array $whitelist = [], ?bool $enabled = null): FOSSBilling\Security\RateLimiter
|
||||
function createRateLimiter(string $requestIp, array $whitelist = [], ?bool $enabled = null, ?bool $enforceInDevelopment = null): FOSSBilling\Security\RateLimiter
|
||||
{
|
||||
$di = new Pimple\Container();
|
||||
$di['rate_limit_cache'] = new ArrayAdapter();
|
||||
@@ -14,8 +14,8 @@ function createRateLimiter(string $requestIp, array $whitelist = [], ?bool $enab
|
||||
$request->shouldReceive('getClientIp')->andReturn($requestIp);
|
||||
$di['request'] = $request;
|
||||
|
||||
$limiter = new class($whitelist, $enabled) extends FOSSBilling\Security\RateLimiter {
|
||||
public function __construct(private readonly array $whitelist, private readonly ?bool $enabled)
|
||||
$limiter = new class($whitelist, $enabled, $enforceInDevelopment) extends FOSSBilling\Security\RateLimiter {
|
||||
public function __construct(private readonly array $whitelist, private readonly ?bool $enabled, private readonly ?bool $enforceInDevelopment)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ function createRateLimiter(string $requestIp, array $whitelist = [], ?bool $enab
|
||||
if ($this->enabled !== null) {
|
||||
$config['enabled'] = $this->enabled;
|
||||
}
|
||||
if ($this->enforceInDevelopment !== null) {
|
||||
$config['enforce_in_development'] = $this->enforceInDevelopment;
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
@@ -208,3 +211,34 @@ test('whitelisted IP does not track counters', function (): void {
|
||||
expect($result->getReason())->toBe(RateLimitResult::REASON_WHITELISTED);
|
||||
expect($limiter->listIpCounters())->toBe([]);
|
||||
});
|
||||
|
||||
test('development environment bypasses the limiter unless opted back in', function (): void {
|
||||
withAppEnv('dev', function (): void {
|
||||
$dev = createRateLimiter(requestIp: '1.1.1.1');
|
||||
expect($dev->isEnabled())->toBeFalse();
|
||||
// Well past the policy limit: still allowed, nothing tracked.
|
||||
expect($dev->consume('client_signup', '1.1.1.1', 5)->isLimited())->toBeFalse();
|
||||
expect($dev->consume('client_signup', '1.1.1.1')->isLimited())->toBeFalse();
|
||||
expect($dev->listIpCounters())->toBe([]);
|
||||
});
|
||||
|
||||
withAppEnv('dev', function (): void {
|
||||
$devEnforced = createRateLimiter(requestIp: '1.1.1.1', enforceInDevelopment: true);
|
||||
expect($devEnforced->isEnabled())->toBeTrue();
|
||||
$subject = 'subject-' . uniqid('', true);
|
||||
$devEnforced->consume('client_signup', $subject, 5);
|
||||
expect($devEnforced->consume('client_signup', $subject)->isLimited())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
test('production and test environments enforce the limiter', function (): void {
|
||||
foreach (['prod', 'test', null] as $env) {
|
||||
withAppEnv($env, function (): void {
|
||||
$limiter = createRateLimiter(requestIp: '1.1.1.1');
|
||||
expect($limiter->isEnabled())->toBeTrue();
|
||||
$subject = 'subject-' . uniqid('', true);
|
||||
$limiter->consume('client_signup', $subject, 5);
|
||||
expect($limiter->consume('client_signup', $subject)->isLimited())->toBeTrue();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -458,6 +458,65 @@ test('security iplookup renders with no record (initial page load)', function ()
|
||||
expect($html)->toBeString();
|
||||
});
|
||||
|
||||
test('client manage page renders related-section footers with tab-level lists', function (): void {
|
||||
// Regression test: the orders/invoices/tickets/balance/history/transactions
|
||||
// lists were assigned inside their embed content blocks, invisible to the
|
||||
// footer blocks under strict_variables ("Variable ... does not exist").
|
||||
// They are assigned at tab level now, so the whole page renders.
|
||||
$emptyList = ['list' => [], 'pages' => 1, 'per_page' => 25, 'page' => 1, 'total' => 0];
|
||||
|
||||
$html = (new StrictTemplateRenderer())->renderTemplate(
|
||||
PATH_MODS . '/Client/templates/admin/mod_client_manage.html.twig',
|
||||
[
|
||||
'app_area' => 'admin',
|
||||
'request' => edgeCaseRequest([
|
||||
'order_page' => 1, 'order_per_page' => 25,
|
||||
'invoice_page' => 1, 'invoice_per_page' => 25,
|
||||
'ticket_page' => 1, 'ticket_per_page' => 25,
|
||||
'balance_page' => 1, 'balance_per_page' => 25,
|
||||
'login_page' => 1, 'login_per_page' => 25,
|
||||
'email_page' => 1, 'email_per_page' => 25,
|
||||
'txn_page' => 1, 'txn_per_page' => 25,
|
||||
]),
|
||||
'admin' => edgeCaseAdmin([
|
||||
'order_get_list' => $emptyList,
|
||||
'invoice_get_list' => $emptyList,
|
||||
'support_ticket_get_list' => $emptyList,
|
||||
'client_balance_get_list' => $emptyList,
|
||||
'client_login_history_get_list' => $emptyList,
|
||||
'email_email_get_list' => $emptyList,
|
||||
'invoice_transaction_get_list' => $emptyList,
|
||||
]),
|
||||
'client' => new Tests\Support\PermissiveStub(['id' => 169]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($html)->toContain('Client Orders')
|
||||
->and($html)->toContain('Client Invoices');
|
||||
});
|
||||
|
||||
test('invoice history tab renders translatable labels for every journal type', function (): void {
|
||||
$types = ['created', 'issued', 'updated', 'paid', 'refunded', 'debited', 'canceled', 'reissued', 'order_attached', 'reminder'];
|
||||
$journal = [];
|
||||
foreach ($types as $i => $type) {
|
||||
$journal[] = ['id' => $i + 1, 'type' => $type, 'admin_id' => null, 'client_id' => null, 'snapshot' => null, 'created_at' => '2026-01-01 00:00:00'];
|
||||
}
|
||||
|
||||
$html = (new StrictTemplateRenderer())->renderTemplate(
|
||||
PATH_MODS . '/Invoice/templates/admin/mod_invoice_invoice.html.twig',
|
||||
[
|
||||
'app_area' => 'admin',
|
||||
'request' => edgeCaseRequest(['id' => 1]),
|
||||
'admin' => edgeCaseAdmin(['invoice_journal' => $journal]),
|
||||
'invoice' => new Tests\Support\PermissiveStub(['id' => 1]),
|
||||
],
|
||||
);
|
||||
|
||||
expect($html)->toContain('Order attached')
|
||||
->and($html)->toContain('Reissued')
|
||||
->and($html)->not->toContain('order_attached');
|
||||
});
|
||||
|
||||
test('support admin ticket renders with no notes and no rel', function (): void {
|
||||
$html = (new StrictTemplateRenderer())->renderTemplate(
|
||||
PATH_MODS . '/Support/templates/admin/mod_support_ticket.html.twig',
|
||||
|
||||
@@ -1284,10 +1284,11 @@ function withDbDriverConfig(array $dbConfig, Closure $callback): void
|
||||
* unrelated PDO mock, since migrateThemePackageLayout() now runs unconditionally regardless of
|
||||
* driver (see the dedicated tests above asserting its exact SQL/params).
|
||||
*
|
||||
* Also tolerates seedInvoiceNoteSettings() and storeSchemaMetadataHash() bookkeeping: both run
|
||||
* unconditionally on every platform (portable check-then-write SQL, no MySQL-only syntax), so
|
||||
* any applyCorePatches() test would trip over them otherwise. fetchColumn() reports "missing"
|
||||
* so the seed path exercises its INSERT branch.
|
||||
* Also tolerates seedInvoiceNoteSettings(), migrateInvoiceImmutabilitySetting(), and
|
||||
* storeSchemaMetadataHash() bookkeeping: all run unconditionally on every platform (portable
|
||||
* check-then-write SQL, no MySQL-only syntax), so any applyCorePatches() test would trip over
|
||||
* them otherwise. fetchColumn() reports "missing" so the seed/migration paths exercise their
|
||||
* INSERT branches and the legacy-key deletes run as no-ops.
|
||||
*/
|
||||
function mockPdoAllowingThemeMigrationCalls(): Mockery\MockInterface
|
||||
{
|
||||
@@ -1298,6 +1299,7 @@ function mockPdoAllowingThemeMigrationCalls(): Mockery\MockInterface
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->shouldReceive('prepare')
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'UPDATE setting')
|
||||
|| str_starts_with($sql, 'DELETE FROM setting')
|
||||
|| str_starts_with($sql, 'UPDATE extension_meta')
|
||||
|| $sql === "DELETE FROM extension_meta WHERE extension = 'mod_hook' AND meta_key = 'listener'"
|
||||
|| $sql === "DELETE FROM extension WHERE type = 'hook'"))
|
||||
@@ -1384,6 +1386,142 @@ test('applyCorePatches removes retired hook data on non-MySQL drivers and remain
|
||||
});
|
||||
});
|
||||
|
||||
test('migrateInvoiceImmutabilitySetting folds legacy toggles into the single setting', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$pdo = new PDO('sqlite::memory:');
|
||||
$pdo->exec('CREATE TABLE setting (param TEXT PRIMARY KEY, value TEXT, public INTEGER, created_at TEXT, updated_at TEXT)');
|
||||
$pdo->exec("INSERT INTO setting (param, value, public, created_at, updated_at) VALUES ('invoice_allow_edit_unpaid', '1', 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')");
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
$migrate = fn (): mixed => (new ReflectionMethod($patcher, 'migrateInvoiceImmutabilitySetting'))->invoke($patcher);
|
||||
|
||||
// Either legacy opt-in maps to relaxed, and the legacy rows are removed.
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_immutability'")->fetchColumn())->toBe('relaxed')
|
||||
->and((int) $pdo->query("SELECT COUNT(*) FROM setting WHERE param IN ('invoice_allow_edit_unpaid', 'invoice_allow_delete_approved')")->fetchColumn())->toBe(0);
|
||||
|
||||
// Idempotent: a second run keeps the migrated value.
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_immutability'")->fetchColumn())->toBe('relaxed');
|
||||
|
||||
// Legacy rows left at off converge to strict.
|
||||
$pdo->exec("INSERT INTO setting (param, value, public, created_at, updated_at) VALUES ('invoice_allow_delete_approved', '0', 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')");
|
||||
$pdo->exec("DELETE FROM setting WHERE param = 'invoice_immutability'");
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_immutability'")->fetchColumn())->toBe('strict')
|
||||
->and((int) $pdo->query("SELECT COUNT(*) FROM setting WHERE param IN ('invoice_allow_edit_unpaid', 'invoice_allow_delete_approved')")->fetchColumn())->toBe(0);
|
||||
|
||||
// Fresh installs without any legacy rows get nothing (strict is the code default).
|
||||
$pdo->exec("DELETE FROM setting WHERE param = 'invoice_immutability'");
|
||||
$migrate();
|
||||
expect((int) $pdo->query("SELECT COUNT(*) FROM setting WHERE param = 'invoice_immutability'")->fetchColumn())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('migrateInvoiceAutoIssueSetting carries the renamed toggle over', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$pdo = new PDO('sqlite::memory:');
|
||||
$pdo->exec('CREATE TABLE setting (param TEXT PRIMARY KEY, value TEXT, public INTEGER, created_at TEXT, updated_at TEXT)');
|
||||
$pdo->exec("INSERT INTO setting (param, value, public, created_at, updated_at) VALUES ('invoice_auto_approval', '0', 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')");
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
$migrate = fn (): mixed => (new ReflectionMethod($patcher, 'migrateInvoiceAutoIssueSetting'))->invoke($patcher);
|
||||
|
||||
// The value carries over and the legacy row is removed.
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_auto_issue'")->fetchColumn())->toBe('0')
|
||||
->and((int) $pdo->query("SELECT COUNT(*) FROM setting WHERE param = 'invoice_auto_approval'")->fetchColumn())->toBe(0);
|
||||
|
||||
// Idempotent: a second run keeps the migrated value.
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_auto_issue'")->fetchColumn())->toBe('0');
|
||||
|
||||
// An already-migrated install is left alone.
|
||||
$pdo->exec("UPDATE setting SET value = '1' WHERE param = 'invoice_auto_issue'");
|
||||
$pdo->exec("INSERT INTO setting (param, value, public, created_at, updated_at) VALUES ('invoice_auto_approval', '0', 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')");
|
||||
$migrate();
|
||||
expect($pdo->query("SELECT value FROM setting WHERE param = 'invoice_auto_issue'")->fetchColumn())->toBe('1')
|
||||
->and((int) $pdo->query("SELECT COUNT(*) FROM setting WHERE param = 'invoice_auto_approval'")->fetchColumn())->toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('renameInvoiceApprovedColumn renames the flag while preserving values', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$path = Path::join(sys_get_temp_dir(), 'fossbilling-invoice-rename-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $path);
|
||||
$pdo->exec('CREATE TABLE invoice (id INTEGER PRIMARY KEY, status TEXT, approved TINYINT(1) NOT NULL DEFAULT 0, due_at TEXT)');
|
||||
$pdo->exec('CREATE INDEX invoice_status_approved_due_at_idx ON invoice (status, approved, due_at)');
|
||||
$pdo->exec('INSERT INTO invoice (id, approved) VALUES (1, 1)');
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['dbal'] = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $path]);
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
$rename = fn (): mixed => (new ReflectionMethod($patcher, 'renameInvoiceApprovedColumn'))->invoke($patcher);
|
||||
|
||||
$rename();
|
||||
expect((int) $pdo->query("SELECT COUNT(*) FROM pragma_table_info('invoice') WHERE name = 'issued'")->fetchColumn())->toBe(1)
|
||||
->and((int) $pdo->query("SELECT COUNT(*) FROM pragma_table_info('invoice') WHERE name = 'approved'")->fetchColumn())->toBe(0)
|
||||
->and((int) $pdo->query('SELECT issued FROM invoice WHERE id = 1')->fetchColumn())->toBe(1);
|
||||
|
||||
// Idempotent: a second run and a fresh install (no approved column) are no-ops.
|
||||
$rename();
|
||||
expect((int) $pdo->query('SELECT issued FROM invoice WHERE id = 1')->fetchColumn())->toBe(1);
|
||||
} finally {
|
||||
(new Filesystem())->remove($path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('renameInvoiceStatusIndex swaps the legacy composite index name', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$path = Path::join(sys_get_temp_dir(), 'fossbilling-invoice-index-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$pdo = new PDO('sqlite:' . $path);
|
||||
$pdo->exec('CREATE TABLE invoice (id INTEGER PRIMARY KEY, status TEXT, issued TINYINT(1) NOT NULL DEFAULT 0, due_at TEXT)');
|
||||
$pdo->exec('CREATE INDEX invoice_status_approved_due_at_idx ON invoice (status, issued, due_at)');
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['dbal'] = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $path]);
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
$rename = fn (): mixed => (new ReflectionMethod($patcher, 'renameInvoiceStatusIndex'))->invoke($patcher);
|
||||
|
||||
$rename();
|
||||
$indexes = $pdo->query("SELECT name FROM pragma_index_list('invoice')")->fetchAll(PDO::FETCH_COLUMN);
|
||||
expect($indexes)->toContain('invoice_status_issued_due_at_idx')
|
||||
->and($indexes)->not->toContain('invoice_status_approved_due_at_idx');
|
||||
|
||||
// Idempotent: a second run is a no-op.
|
||||
$rename();
|
||||
$indexes = $pdo->query("SELECT name FROM pragma_index_list('invoice')")->fetchAll(PDO::FETCH_COLUMN);
|
||||
expect($indexes)->toContain('invoice_status_issued_due_at_idx')
|
||||
->and($indexes)->not->toContain('invoice_status_approved_due_at_idx');
|
||||
} finally {
|
||||
(new Filesystem())->remove($path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('retired hook cleanup uses both portable delete statements without parameters', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$listenerStatement = Mockery::mock(PDOStatement::class);
|
||||
@@ -1508,13 +1646,16 @@ test('applyCorePatches migrates the theme setting values on a non-MySQL driver,
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'UPDATE extension_meta')))
|
||||
->andReturn($extensionMetaStatement);
|
||||
|
||||
// seedInvoiceNoteSettings() also runs unconditionally from applyCorePatches() - allow
|
||||
// its check-then-insert bookkeeping without asserting on it here.
|
||||
// seedInvoiceNoteSettings() and migrateInvoiceImmutabilitySetting() also run
|
||||
// unconditionally from applyCorePatches() - allow their check-then-write
|
||||
// bookkeeping without asserting on it here. fetchColumn() reports "missing"
|
||||
// so the migration takes its skip-INSERT path and the legacy-key deletes
|
||||
// run as no-ops.
|
||||
$seedStatement = Mockery::mock(PDOStatement::class);
|
||||
$seedStatement->shouldReceive('execute')->andReturnTrue();
|
||||
$seedStatement->shouldReceive('fetchColumn')->andReturn(false);
|
||||
$pdo->shouldReceive('prepare')
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting')))
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting') || str_starts_with($sql, 'DELETE FROM setting')))
|
||||
->andReturn($seedStatement);
|
||||
allowRetiredHookCleanupCalls($pdo);
|
||||
|
||||
@@ -1557,13 +1698,14 @@ test('applyCorePatches migrates saved theme settings/presets in extension_meta o
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'UPDATE setting')))
|
||||
->andReturn($otherStatement);
|
||||
|
||||
// seedInvoiceNoteSettings() also runs unconditionally from applyCorePatches() - allow
|
||||
// its check-then-insert bookkeeping without asserting on it here.
|
||||
// seedInvoiceNoteSettings() and migrateInvoiceImmutabilitySetting() also run
|
||||
// unconditionally from applyCorePatches() - allow their check-then-write
|
||||
// bookkeeping without asserting on it here.
|
||||
$seedStatement = Mockery::mock(PDOStatement::class);
|
||||
$seedStatement->shouldReceive('execute')->andReturnTrue();
|
||||
$seedStatement->shouldReceive('fetchColumn')->andReturn(false);
|
||||
$pdo->shouldReceive('prepare')
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting')))
|
||||
->with(Mockery::on(fn (string $sql): bool => str_starts_with($sql, 'SELECT value FROM setting') || str_starts_with($sql, 'INSERT INTO setting') || str_starts_with($sql, 'DELETE FROM setting')))
|
||||
->andReturn($seedStatement);
|
||||
allowRetiredHookCleanupCalls($pdo);
|
||||
|
||||
@@ -2226,6 +2368,104 @@ test('ensureSchemaInSync restores note columns missing from an older schema, the
|
||||
}
|
||||
});
|
||||
|
||||
test('ensureSchemaInSync renames the issue flag on a code-only deploy instead of duplicating it', function (): void {
|
||||
// A code-only deploy (no version bump) never runs the version-gated
|
||||
// patch121, so the drift healer must rename approved to issued itself -
|
||||
// the additive sync alone would add an empty issued column next to the
|
||||
// data-bearing approved one (or leave queries crashing on it).
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$dbFile = Path::join(sys_get_temp_dir(), 'fossbilling-schema-sync-rename-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$connection = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $dbFile]);
|
||||
$entityManager = FOSSBilling\Doctrine\EntityManagerFactory::create($connection);
|
||||
FOSSBilling\Doctrine\SchemaInstaller::createSchema($entityManager);
|
||||
|
||||
// Rewind the invoice table to its pre-rename shape with live data.
|
||||
$connection->executeStatement('DROP INDEX invoice_status_issued_due_at_idx');
|
||||
$connection->executeStatement('ALTER TABLE invoice RENAME COLUMN issued TO approved');
|
||||
$connection->executeStatement('CREATE INDEX invoice_status_approved_due_at_idx ON invoice (status, approved, due_at)');
|
||||
$connection->executeStatement("INSERT INTO invoice (status, approved) VALUES ('unpaid', 1)");
|
||||
|
||||
$columnNames = static fn (): array => array_column(
|
||||
$connection->fetchAllAssociative('PRAGMA table_info(invoice)'),
|
||||
'name'
|
||||
);
|
||||
expect($columnNames())->toContain('approved');
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbFile);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['em'] = $entityManager;
|
||||
$di['dbal'] = $connection;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
|
||||
expect($patcher->ensureSchemaInSync())->toBeTrue();
|
||||
expect($columnNames())->toContain('issued')
|
||||
->and($columnNames())->not->toContain('approved')
|
||||
->and((int) $pdo->query('SELECT issued FROM invoice')->fetchColumn())->toBe(1);
|
||||
|
||||
$names = array_column($connection->fetchAllAssociative("SELECT name FROM pragma_index_list('invoice')"), 'name');
|
||||
expect($names)->toContain('invoice_status_issued_due_at_idx')
|
||||
->and($names)->not->toContain('invoice_status_approved_due_at_idx');
|
||||
|
||||
// The recorded hash now matches, so the next request is a single-SELECT no-op.
|
||||
expect($patcher->ensureSchemaInSync())->toBeFalse();
|
||||
} finally {
|
||||
(new Filesystem())->remove($dbFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('ensureSchemaInSync drops the dead buyer country-code column on a code-only deploy', function (): void {
|
||||
// A code-only deploy never runs the version-gated patch122, so the drift
|
||||
// healer must drop buyer_phone_cc itself - the additive sync only ever
|
||||
// adds, so without this the unmapped column lingers forever.
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$dbFile = Path::join(sys_get_temp_dir(), 'fossbilling-schema-sync-drop-cc-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$connection = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $dbFile]);
|
||||
$entityManager = FOSSBilling\Doctrine\EntityManagerFactory::create($connection);
|
||||
FOSSBilling\Doctrine\SchemaInstaller::createSchema($entityManager);
|
||||
|
||||
// Re-add the removed column as it exists on pre-cleanup installs.
|
||||
$connection->executeStatement('ALTER TABLE invoice ADD COLUMN buyer_phone_cc VARCHAR(255) DEFAULT NULL');
|
||||
|
||||
$columnNames = static fn (): array => array_column(
|
||||
$connection->fetchAllAssociative('PRAGMA table_info(invoice)'),
|
||||
'name'
|
||||
);
|
||||
expect($columnNames())->toContain('buyer_phone_cc');
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbFile);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['em'] = $entityManager;
|
||||
$di['dbal'] = $connection;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
|
||||
expect($patcher->ensureSchemaInSync())->toBeTrue();
|
||||
expect($columnNames())->not->toContain('buyer_phone_cc');
|
||||
|
||||
// The recorded hash now matches, so the next request is a single-SELECT no-op.
|
||||
expect($patcher->ensureSchemaInSync())->toBeFalse();
|
||||
} finally {
|
||||
(new Filesystem())->remove($dbFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('invoice reissue patch follows the credit and debit note patch', function (): void {
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 119);
|
||||
|
||||
@@ -2233,6 +2473,284 @@ test('invoice reissue patch follows the credit and debit note patch', function (
|
||||
->and($patches[120][1])->toBe('patch120');
|
||||
});
|
||||
|
||||
test('invoice issued-rename patch follows the reissue patch', function (): void {
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 120);
|
||||
|
||||
expect($patches)->toHaveKey(121)
|
||||
->and($patches[121][1])->toBe('patch121');
|
||||
});
|
||||
|
||||
test('invoice buyer-phone-cc removal patch follows the issued-rename patch', function (): void {
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 121);
|
||||
|
||||
expect($patches)->toHaveKey(122)
|
||||
->and($patches[122][1])->toBe('patch122');
|
||||
});
|
||||
|
||||
test('invoice buyer-phone-cc removal patch drops the dead column', function (): void {
|
||||
$invoiceColumns = Mockery::mock(PDOStatement::class);
|
||||
$invoiceColumns->expects('execute')->with([])->andReturnTrue();
|
||||
$invoiceColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([['Field' => 'buyer_phone_cc']]);
|
||||
|
||||
$dropColumn = Mockery::mock(PDOStatement::class);
|
||||
$dropColumn->expects('execute')->with([])->andReturnTrue();
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `invoice`')->andReturn($invoiceColumns);
|
||||
$pdo->expects('prepare')
|
||||
->with('ALTER TABLE `invoice` DROP COLUMN `buyer_phone_cc`')
|
||||
->andReturn($dropColumn);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch122'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice buyer-phone-cc removal patch is a no-op once the column is gone', function (): void {
|
||||
$invoiceColumns = Mockery::mock(PDOStatement::class);
|
||||
$invoiceColumns->expects('execute')->with([])->andReturnTrue();
|
||||
$invoiceColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([['Field' => 'buyer_phone']]);
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `invoice`')->andReturn($invoiceColumns);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch122'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice journal patch follows the buyer-phone-cc removal patch', function (): void {
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 122);
|
||||
|
||||
expect($patches)->toHaveKey(123)
|
||||
->and($patches[123][1])->toBe('patch123');
|
||||
});
|
||||
|
||||
test('invoice journal patch creates the journal table for existing installs', function (): void {
|
||||
$tableCheck = Mockery::mock(PDOStatement::class);
|
||||
$tableCheck->expects('execute')->with(['table' => 'invoice_event'])->andReturnTrue();
|
||||
$tableCheck->expects('fetchColumn')->with()->andReturn(false);
|
||||
|
||||
$createTable = Mockery::mock(PDOStatement::class);
|
||||
$createTable->expects('execute')->with([])->andReturnTrue();
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')
|
||||
->with('SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table LIMIT 1')
|
||||
->andReturn($tableCheck);
|
||||
$pdo->expects('prepare')
|
||||
->with("CREATE TABLE `invoice_event` (`id` bigint(20) NOT NULL AUTO_INCREMENT, `invoice_id` bigint(20) DEFAULT NULL, `type` varchar(50) NOT NULL DEFAULT 'updated', `admin_id` bigint(20) DEFAULT NULL, `client_id` bigint(20) DEFAULT NULL, `snapshot` JSON DEFAULT NULL, `created_at` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `invoice_event_invoice_id_idx` (`invoice_id`))")
|
||||
->andReturn($createTable);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch123'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice journal patch is a no-op once the table exists', function (): void {
|
||||
$tableCheck = Mockery::mock(PDOStatement::class);
|
||||
$tableCheck->expects('execute')->with(['table' => 'invoice_event'])->andReturnTrue();
|
||||
$tableCheck->expects('fetchColumn')->with()->andReturn(1);
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')
|
||||
->with('SELECT 1 FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :table LIMIT 1')
|
||||
->andReturn($tableCheck);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch123'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice journal backfill patch follows the journal table patch', function (): void {
|
||||
$patches = (new ReflectionMethod(UpdatePatcher::class, 'getPatches'))->invoke(new UpdatePatcher(), 123);
|
||||
|
||||
expect($patches)->toHaveKey(124)
|
||||
->and($patches[124][1])->toBe('patch124');
|
||||
});
|
||||
|
||||
test('invoice journal backfill writes one baseline entry per invoice missing from the journal', function (): void {
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$dbFile = Path::join(sys_get_temp_dir(), 'fossbilling-journal-backfill-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$connection = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $dbFile]);
|
||||
$entityManager = FOSSBilling\Doctrine\EntityManagerFactory::create($connection);
|
||||
FOSSBilling\Doctrine\SchemaInstaller::createSchema($entityManager);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO invoice (status, issued, serie, nr, client_id, buyer_first_name, created_at) VALUES
|
||||
('unpaid', 0, 'FOSS', NULL, 5, NULL, :now),
|
||||
('paid', 1, 'FOSS', '7', 5, 'Ada', :now),
|
||||
('canceled', 1, 'FOSS', '8', NULL, NULL, :now)",
|
||||
['now' => $now]
|
||||
);
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbFile);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['dbal'] = $connection;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'backfillInvoiceJournal'))->invoke($patcher);
|
||||
|
||||
$entries = $connection->fetchAllAssociative('SELECT invoice_id, type, client_id, snapshot FROM invoice_event ORDER BY invoice_id');
|
||||
expect($entries)->toHaveCount(3)
|
||||
->and($entries[0]['type'])->toBe('created')
|
||||
->and($entries[1]['type'])->toBe('paid')
|
||||
->and($entries[2]['type'])->toBe('canceled');
|
||||
|
||||
$paidSnapshot = json_decode((string) $entries[1]['snapshot'], true);
|
||||
expect($paidSnapshot['serie_nr'])->toBe('FOSS00007')
|
||||
->and($paidSnapshot['buyer']['first_name'])->toBe('Ada')
|
||||
->and($paidSnapshot['total'])->toBeNull();
|
||||
|
||||
// Idempotent: a second run writes nothing more.
|
||||
(new ReflectionMethod($patcher, 'backfillInvoiceJournal'))->invoke($patcher);
|
||||
expect($connection->fetchOne('SELECT COUNT(*) FROM invoice_event'))->toBe(3);
|
||||
} finally {
|
||||
(new Filesystem())->remove($dbFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('journal baseline writes are claimed per invoice', function (): void {
|
||||
// Two back-to-back writes for the same invoice - what overlapping healers
|
||||
// would attempt - must leave exactly one baseline entry behind.
|
||||
withNonMysqlDbDriver(function (): void {
|
||||
$dbFile = Path::join(sys_get_temp_dir(), 'fossbilling-journal-claim-' . bin2hex(random_bytes(8)) . '.sqlite');
|
||||
|
||||
try {
|
||||
$connection = Doctrine\DBAL\DriverManager::getConnection(['driver' => 'pdo_sqlite', 'path' => $dbFile]);
|
||||
$entityManager = FOSSBilling\Doctrine\EntityManagerFactory::create($connection);
|
||||
FOSSBilling\Doctrine\SchemaInstaller::createSchema($entityManager);
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$connection->executeStatement(
|
||||
"INSERT INTO invoice (status, issued, serie, nr, client_id, created_at) VALUES ('unpaid', 0, 'FOSS', NULL, 5, :now)",
|
||||
['now' => $now]
|
||||
);
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbFile);
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
$di['dbal'] = $connection;
|
||||
$di['logger'] = new Tests\Helpers\TestLogger();
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
$writeBaseline = (new ReflectionMethod($patcher, 'writeBackfillJournalEntry'))->getClosure($patcher);
|
||||
$row = [
|
||||
'id' => 1, 'nr' => null, 'serie' => 'FOSS', 'status' => 'unpaid', 'issued' => 0,
|
||||
'client_id' => 5, 'paid_at' => null, 'due_at' => null, 'created_at' => $now,
|
||||
];
|
||||
|
||||
$writeBaseline($connection, $row, 5);
|
||||
$writeBaseline($connection, $row, 5);
|
||||
|
||||
expect($connection->fetchOne('SELECT COUNT(*) FROM invoice_event'))->toBe(1);
|
||||
} finally {
|
||||
(new Filesystem())->remove($dbFile);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('invoice issued-rename patch copies values and drops the legacy column', function (): void {
|
||||
$invoiceColumns = Mockery::mock(PDOStatement::class);
|
||||
$invoiceColumns->expects('execute')->with([])->twice()->andReturnTrue();
|
||||
$invoiceColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->twice()->andReturn([['Field' => 'approved']]);
|
||||
|
||||
$addIssuedColumn = Mockery::mock(PDOStatement::class);
|
||||
$addIssuedColumn->expects('execute')->with([])->andReturnTrue();
|
||||
$copyValues = Mockery::mock(PDOStatement::class);
|
||||
$copyValues->expects('execute')->with([])->andReturnTrue();
|
||||
$dropApprovedColumn = Mockery::mock(PDOStatement::class);
|
||||
$dropApprovedColumn->expects('execute')->with([])->andReturnTrue();
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `invoice`')->twice()->andReturn($invoiceColumns, $invoiceColumns);
|
||||
$pdo->expects('prepare')
|
||||
->with('ALTER TABLE `invoice` ADD COLUMN `issued` TINYINT(1) NOT NULL DEFAULT 0 AFTER `approved`')
|
||||
->andReturn($addIssuedColumn);
|
||||
$pdo->expects('prepare')
|
||||
->with('UPDATE `invoice` SET `issued` = `approved`')
|
||||
->andReturn($copyValues);
|
||||
$pdo->expects('prepare')
|
||||
->with('ALTER TABLE `invoice` DROP COLUMN `approved`')
|
||||
->andReturn($dropApprovedColumn);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch121'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice issued-rename patch is a no-op once the renamed column exists', function (): void {
|
||||
$invoiceColumns = Mockery::mock(PDOStatement::class);
|
||||
$invoiceColumns->expects('execute')->with([])->andReturnTrue();
|
||||
$invoiceColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->andReturn([['Field' => 'issued']]);
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `invoice`')->andReturn($invoiceColumns);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch121'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice issued-rename patch heals a crash between column add and value copy', function (): void {
|
||||
// Both columns exist with issued still defaulted: a crash after ADD but
|
||||
// before UPDATE. The copy must still run before the legacy column drops.
|
||||
$invoiceColumns = Mockery::mock(PDOStatement::class);
|
||||
$invoiceColumns->expects('execute')->with([])->twice()->andReturnTrue();
|
||||
$invoiceColumns->expects('fetchAll')->with(PDO::FETCH_ASSOC)->twice()->andReturn([['Field' => 'approved'], ['Field' => 'issued']]);
|
||||
|
||||
$copyValues = Mockery::mock(PDOStatement::class);
|
||||
$copyValues->expects('execute')->with([])->andReturnTrue();
|
||||
$dropApprovedColumn = Mockery::mock(PDOStatement::class);
|
||||
$dropApprovedColumn->expects('execute')->with([])->andReturnTrue();
|
||||
|
||||
$pdo = Mockery::mock(PDO::class);
|
||||
$pdo->expects('prepare')->with('SHOW COLUMNS FROM `invoice`')->twice()->andReturn($invoiceColumns, $invoiceColumns);
|
||||
$pdo->expects('prepare')
|
||||
->with('UPDATE `invoice` SET `issued` = `approved`')
|
||||
->andReturn($copyValues);
|
||||
$pdo->expects('prepare')
|
||||
->with('ALTER TABLE `invoice` DROP COLUMN `approved`')
|
||||
->andReturn($dropApprovedColumn);
|
||||
|
||||
$di = new Pimple\Container();
|
||||
$di['pdo'] = $pdo;
|
||||
|
||||
$patcher = new UpdatePatcher();
|
||||
$patcher->setDi($di);
|
||||
(new ReflectionMethod($patcher, 'patch121'))->invoke($patcher);
|
||||
});
|
||||
|
||||
test('invoice reissue patch adds the missing replacement columns and indexes for existing installs', function (): void {
|
||||
// Regression test for https://github.com/FOSSBilling/FOSSBilling/issues/4392: the
|
||||
// invoice reissue release added entity columns with no MySQL patch, so installs
|
||||
|
||||
@@ -209,6 +209,7 @@ function paypalEmMocks(?object $invoiceModel = null, ?object $existingSubscripti
|
||||
$subRepo = Mockery::mock(Box\Mod\Invoice\Repository\SubscriptionRepository::class);
|
||||
$subRepo->shouldReceive('findOneBy')->byDefault()->andReturn($existingSubscription);
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->byDefault()->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('fetchAllAssociative')->byDefault()->andReturn([]);
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->shouldReceive('getRepository')->byDefault()->andReturnUsing(static fn (string $class): object => match ($class) {
|
||||
@@ -351,7 +352,7 @@ describe('PayPal subscription IPN handling', function (): void {
|
||||
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
||||
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
||||
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
||||
$invoiceModel->shouldReceive('isApproved')->byDefault()->andReturn(true);
|
||||
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
||||
|
||||
$em = paypalEmMocks($invoiceModel);
|
||||
$di = container();
|
||||
@@ -403,7 +404,7 @@ describe('PayPal subscription IPN handling', function (): void {
|
||||
$paidInvoice->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_PAID);
|
||||
$renewal = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
||||
$renewal->shouldReceive('getId')->byDefault()->andReturn(99);
|
||||
$renewal->shouldReceive('isApproved')->byDefault()->andReturn(true);
|
||||
$renewal->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
||||
|
||||
$invoiceService = Mockery::mock();
|
||||
$invoiceService->shouldReceive('generateRenewalInvoiceForSubscriptionPayment')->once()->with('I-ABC123', 9)->andReturn($renewal);
|
||||
@@ -675,7 +676,7 @@ describe('PayPal subscription IPN handling', function (): void {
|
||||
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
||||
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
||||
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
||||
$invoiceModel->shouldReceive('isApproved')->byDefault()->andReturn(true);
|
||||
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
||||
|
||||
$invoiceService = Mockery::mock();
|
||||
$invoiceService->shouldReceive('getTotalWithTax')->once()->andReturn(120.00);
|
||||
@@ -1149,7 +1150,7 @@ describe('PayPal subscription IPN handling', function (): void {
|
||||
$invoiceModel = Mockery::mock(Box\Mod\Invoice\Entity\Invoice::class);
|
||||
$invoiceModel->shouldReceive('getId')->byDefault()->andReturn(16);
|
||||
$invoiceModel->shouldReceive('getStatus')->byDefault()->andReturn(Box\Mod\Invoice\Entity\Invoice::STATUS_UNPAID);
|
||||
$invoiceModel->shouldReceive('isApproved')->byDefault()->andReturn(true);
|
||||
$invoiceModel->shouldReceive('isIssued')->byDefault()->andReturn(true);
|
||||
|
||||
$stored = Mockery::mock(Box\Mod\Invoice\Entity\Subscription::class);
|
||||
$stored->shouldReceive('getRelType')->byDefault()->andReturn('invoice');
|
||||
@@ -1216,6 +1217,7 @@ describe('PayPal subscription IPN handling', function (): void {
|
||||
|
||||
test('isIpnDuplicate tolerates IPNs missing optional keys', function (): void {
|
||||
$connection = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
$connection->shouldReceive('quoteSingleIdentifier')->with('transaction')->andReturn('"transaction"');
|
||||
$connection->shouldReceive('fetchAllAssociative')->once()->andReturn([]);
|
||||
$em = Mockery::mock(Doctrine\ORM\EntityManagerInterface::class);
|
||||
$em->shouldReceive('getConnection')->andReturn($connection);
|
||||
|
||||
@@ -617,7 +617,7 @@ test('skips subscription update webhooks without a local subscription', function
|
||||
});
|
||||
|
||||
describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
test('links transaction to invoice before claim attempt', 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;
|
||||
@@ -647,9 +647,10 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
$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->shouldReceive('claimForProcessing')
|
||||
->andReturn(false);
|
||||
$transactionService->shouldNotReceive('claimForProcessing');
|
||||
|
||||
['em' => $em, 'txRepo' => $txRepo, 'invoiceRepo' => $invoiceRepo] = buildEntityManagerMocks();
|
||||
$txRepo->shouldReceive('findProcessingOrProcessedByTxnId')
|
||||
@@ -662,13 +663,20 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
|
||||
$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) {
|
||||
$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();
|
||||
});
|
||||
@@ -676,7 +684,7 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
$this->adapter->setDi($di);
|
||||
|
||||
$apiAdmin = Mockery::mock();
|
||||
$apiAdmin->shouldNotReceive('client_balance_add_funds');
|
||||
$apiAdmin->shouldReceive('client_balance_add_funds')->once();
|
||||
|
||||
invokePrivateMethod($this->adapter, 'handleInvoicePaymentSucceeded', [
|
||||
$apiAdmin,
|
||||
@@ -685,8 +693,7 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
1,
|
||||
]);
|
||||
|
||||
// Even though claimForProcessing returned false (causing early return),
|
||||
// the invoice_id should have been persisted via em->flush().
|
||||
// Processing proceeds: the invoice link is persisted and funds move.
|
||||
expect($tx->getInvoice()?->getId())->toBe(99);
|
||||
});
|
||||
|
||||
@@ -733,18 +740,17 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
|
||||
$invoiceModel->id = 77;
|
||||
$invoiceModel->status = Invoice::STATUS_UNPAID;
|
||||
$invoiceModel->approved = 0;
|
||||
$invoiceModel->issued = 0;
|
||||
$invoiceModel->currency = 'EUR';
|
||||
|
||||
$transactionService = Mockery::mock();
|
||||
$transactionService->shouldReceive('claimForProcessing')
|
||||
->andReturn(true);
|
||||
$transactionService->shouldNotReceive('claimForProcessing');
|
||||
|
||||
$invoiceService = Mockery::mock();
|
||||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')
|
||||
->with($invoiceModel)
|
||||
->andReturn(false);
|
||||
$invoiceService->shouldReceive('approveInvoice')
|
||||
$invoiceService->shouldReceive('issueInvoice')
|
||||
->with($invoiceModel, ['use_credits' => false])
|
||||
->andReturn(true);
|
||||
$invoiceService->shouldReceive('payInvoiceWithCredits')
|
||||
@@ -783,7 +789,7 @@ describe('handleInvoicePaymentSucceeded invoice linking', function (): void {
|
||||
1,
|
||||
]);
|
||||
|
||||
// The unpaid original invoice should be approved and paid via the fallback
|
||||
// The unpaid original invoice should be issued and paid via the fallback
|
||||
expect($tx->getInvoice()?->getId())->toBe(77);
|
||||
});
|
||||
});
|
||||
@@ -954,16 +960,15 @@ describe('handleInvoicePaymentSucceeded with invoice_payment event (API 2026-06-
|
||||
|
||||
$invoiceModel->id = 42;
|
||||
$invoiceModel->status = Invoice::STATUS_UNPAID;
|
||||
$invoiceModel->approved = 0;
|
||||
$invoiceModel->issued = 0;
|
||||
|
||||
$transactionService = Mockery::mock();
|
||||
$transactionService->shouldReceive('claimForProcessing')
|
||||
->andReturn(true);
|
||||
$transactionService->shouldNotReceive('claimForProcessing');
|
||||
|
||||
$invoiceService = Mockery::mock();
|
||||
$invoiceService->shouldReceive('isInvoiceTypeDeposit')
|
||||
->andReturn(false);
|
||||
$invoiceService->shouldReceive('approveInvoice')
|
||||
$invoiceService->shouldReceive('issueInvoice')
|
||||
->andReturn(true);
|
||||
$invoiceService->shouldReceive('payInvoiceWithCredits')
|
||||
->andReturn(true);
|
||||
@@ -1066,7 +1071,7 @@ describe('handlePaymentIntentSucceededWebhook', function (): void {
|
||||
$invoiceModel = createEntity(Invoice::class);
|
||||
|
||||
$invoiceModel->id = 15;
|
||||
$invoiceModel->approved = 1;
|
||||
$invoiceModel->issued = 1;
|
||||
$invoiceModel->client_id = 7;
|
||||
|
||||
$dbalMock = Mockery::mock(Doctrine\DBAL\Connection::class);
|
||||
@@ -1074,8 +1079,7 @@ describe('handlePaymentIntentSucceededWebhook', function (): void {
|
||||
$clientModel = createEntity(Box\Mod\Client\Entity\Client::class, ['id' => 7]);
|
||||
|
||||
$transactionService = Mockery::mock();
|
||||
$transactionService->shouldReceive('claimForProcessing')
|
||||
->andReturn(true);
|
||||
$transactionService->shouldNotReceive('claimForProcessing');
|
||||
|
||||
$invoiceService = Mockery::mock();
|
||||
$invoiceService->shouldReceive('getTotalWithTax')->andReturn(29.99);
|
||||
@@ -1129,7 +1133,7 @@ describe('processPaymentIntent', function (): void {
|
||||
$invoice = createEntity(Invoice::class, [
|
||||
'id' => 15,
|
||||
'client_id' => 7,
|
||||
'approved' => true,
|
||||
'issued' => true,
|
||||
]);
|
||||
|
||||
$tx = buildTransaction();
|
||||
@@ -1155,7 +1159,7 @@ describe('processPaymentIntent', function (): void {
|
||||
$invoiceService->shouldReceive('payInvoiceWithCredits')->once()->with($invoice)->andReturn(true);
|
||||
|
||||
$transactionService = Mockery::mock();
|
||||
$transactionService->shouldReceive('claimForProcessing')->once()->with(402)->andReturn(true);
|
||||
$transactionService->shouldNotReceive('claimForProcessing');
|
||||
|
||||
$clientService = Mockery::mock();
|
||||
$clientService->shouldReceive('addFunds')->once();
|
||||
|
||||
@@ -890,7 +890,7 @@ foreach ($manualClients as $mi => $cid) {
|
||||
['title' => 'Demo manual line - service', 'price' => 10, 'quantity' => 2],
|
||||
],
|
||||
]);
|
||||
api('admin/invoice/approve', ['id' => (int) $iid]);
|
||||
api('admin/invoice/issue', ['id' => (int) $iid]);
|
||||
note(" manual invoice id={$iid} client={$cid}");
|
||||
$manualInvoiceIds[] = (int) $iid;
|
||||
$manifest['invoices_manual'][] = ['id' => (int) $iid, 'client_id' => $cid];
|
||||
|
||||
Reference in new issue
Block a user