feat(tenant): mark the financial tables with their owning environment

Phase 6 of the tenant series. GlobalTables::DEFERRED is now empty and the
coverage test asserts it stays that way.

payments carries the (entity_type, entity_id) pair and belongs to the
receiving side, never the payer: an appointment payment takes the
appointment's environment, a subscription takes the environment its buyer
owns, and an SMS wallet top-up takes the wallet's. The patient never chose
an environment, so TenantFilter stays off for them and they still see their
own payment.

Three corrections to the analysis the phase was planned on, each backed by
the code or the data rather than the plan:

- A third payment type exists. Payment::TYPE_SMS_WALLET is created in
  SmsWalletController and already carries its environment in the metadata;
  without assigning it the write would fail at flush.
- clinic_subscriptions has no user_id, and its trial rows carry no payment,
  so it cannot drive the subscription backfill. The environment is derived
  the way handleSubscriptionActivation derives it — and that method now
  reads the pair off the payment instead of re-deriving it, so a payment and
  the subscription it buys can no longer land on different environments.
- WalletTransaction is not a child of Payment. payment_id is nullable and
  none of the four creation sites set it; the wallet is a person's, with a
  running balance per user. It and Settlement, which withdraws from that same
  wallet, are global with a recorded reason instead.

bank_accounts and pos_devices move from the registering user to the
environment. Their pair is deliberately nullable: nothing in the existing
data says which of a multi-environment owner's cards belongs where, and
guessing would point real money at the wrong account. Ambiguous rows stay
unassigned and the migration reports how many. The cost is that such a row
is invisible in every environment, so the owner reaches it through a
user-scoped lookup that runs outside the filter, and assigns it with
PATCH .../{uuid}/environment. The admin panel marks those rows and offers
the assignment.

Tests: 896 backend (+11), 570 frontend (+4). PHPStan unchanged at its 17
pre-existing errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 15:06:28 +03:30
co-authored by Claude Opus 5
parent d2f4b5c428
commit c9d4348c46
40 changed files with 1582 additions and 163 deletions
+103
View File
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 6 of the tenant-marking series: payments, the root of the financial
* chain. payment_logs, financial_breakdowns and secretary_earnings inherit the
* environment through their foreign keys and get no column of their own.
*
* The environment of a payment is the receiving side, never the payer:
*
* appointment → the environment of the appointment (marked in phase 2)
* subscription → the environment the buyer owns (doctor first, then clinic —
* the same order PaymentManager::handleSubscriptionActivation
* uses to create the subscription itself)
* sms_wallet → the environment already recorded in the payment metadata
*
* clinic_subscriptions cannot drive the subscription backfill: it links to a
* payment (payment_id) rather than to a user, and trial rows carry no payment at
* all, so a payment whose subscription was never activated has no row to join.
*
* Statements run through $this->connection rather than addSql() because the
* NOT NULL guard has to sit between the backfill and the tightening; addSql()
* defers everything to the end of up().
*/
final class Version20260728140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Mark payments with the environment that receives them';
}
public function up(Schema $schema): void
{
$this->connection->executeStatement(
'ALTER TABLE payments ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL'
);
// Appointment payments: the appointment already carries the pair.
$this->connection->executeStatement(
'UPDATE payments p JOIN appointments a ON a.id = p.appointment_id
SET p.entity_type = a.entity_type, p.entity_id = a.entity_id
WHERE p.appointment_id IS NOT NULL'
);
// SMS wallet charges: the environment was stored in the metadata when the
// charge was started, which is also what PaymentManager reads on callback.
$this->connection->executeStatement(
"UPDATE payments
SET entity_type = JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.entity_type')),
entity_id = JSON_EXTRACT(metadata, '$.entity_id')
WHERE entity_type IS NULL
AND type = 'sms_wallet'
AND JSON_EXTRACT(metadata, '$.entity_id') IS NOT NULL"
);
// Subscription payments: the environment the payer owns.
$this->connection->executeStatement(
"UPDATE payments p JOIN doctors d ON d.user_id = p.user_id
SET p.entity_type = 'doctor', p.entity_id = d.id
WHERE p.entity_type IS NULL"
);
$this->connection->executeStatement(
"UPDATE payments p JOIN clinics c ON c.user_id = p.user_id
SET p.entity_type = 'clinic', p.entity_id = c.id
WHERE p.entity_type IS NULL"
);
// A payment left without an environment is a kind this analysis has not
// seen. Guessing one would put real money in the wrong ledger.
$remaining = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM payments WHERE entity_type IS NULL OR entity_id IS NULL'
);
$this->abortIf(
$remaining > 0,
"Backfill left {$remaining} payments without an environment; classify them by hand before rerunning."
);
$this->connection->executeStatement(
'ALTER TABLE payments MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL'
);
$this->connection->executeStatement(
'CREATE INDEX idx_payments_entity_date ON payments (entity_type, entity_id, created_at)'
);
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_payments_entity_date ON payments');
$this->addSql('ALTER TABLE payments DROP entity_type, DROP entity_id');
}
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
public function isTransactional(): bool
{
return false;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Phase 6 of the tenant-marking series: bank accounts and POS devices move from
* the user to the environment. A doctor who runs both a private practice and a
* clinic keeps separate card readers for each, so the owning environment — not
* the person who registered the device — is what a payment method belongs to.
*
* user_id stays: it still records who registered the device.
*
* Unlike every other tenant table these two columns stay NULLABLE on purpose.
* Nothing in the existing data says which of a multi-environment owner's cards
* belongs to which environment, and guessing would point real money at the wrong
* account. Those rows are left unassigned for the owner to resolve, and the
* migration reports how many there are so the number is visible in the deploy
* output rather than discovered later.
*
* Consequence, deliberate and documented in docs/architecture/tenancy.md: an
* unassigned row is invisible in every environment, because TenantFilter compares
* for equality and NULL equals nothing. The owner reaches it through the
* "unassigned" list, which is read outside the filter and scoped by user_id.
*/
final class Version20260728141500 extends AbstractMigration
{
private const TABLES = ['bank_accounts', 'pos_devices'];
public function getDescription(): string
{
return 'Move bank accounts and POS devices from their registering user to an environment';
}
public function up(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->connection->executeStatement(
"ALTER TABLE {$table} ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL"
);
// Only owners with exactly one environment can be resolved without a guess.
$this->connection->executeStatement(
"UPDATE {$table} t
JOIN (SELECT u.id AS user_id,
MAX(d.id) AS doctor_id,
MAX(c.id) AS clinic_id,
COUNT(DISTINCT d.id) + COUNT(DISTINCT c.id) AS envs
FROM users u
LEFT JOIN doctors d ON d.user_id = u.id
LEFT JOIN clinics c ON c.user_id = u.id
GROUP BY u.id) x ON x.user_id = t.user_id
SET t.entity_type = IF(x.clinic_id IS NOT NULL, 'clinic', 'doctor'),
t.entity_id = IFNULL(x.clinic_id, x.doctor_id)
WHERE x.envs = 1"
);
$unassigned = (int) $this->connection->fetchOne(
"SELECT COUNT(*) FROM {$table} WHERE entity_type IS NULL"
);
$this->write(sprintf(
' %s: %d row(s) left without an environment — their owner has more than one and must choose.',
$table,
$unassigned,
));
$this->connection->executeStatement(
"CREATE INDEX idx_{$table}_entity ON {$table} (entity_type, entity_id)"
);
}
}
public function down(Schema $schema): void
{
foreach (self::TABLES as $table) {
$this->addSql("DROP INDEX idx_{$table}_entity ON {$table}");
$this->addSql("ALTER TABLE {$table} DROP entity_type, DROP entity_id");
}
}
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
public function isTransactional(): bool
{
return false;
}
}