Files
clinicpro/migrations/Version20260728160000.php
T
hamedandClaude Opus 5 6ab1eb6483 fix(tenant): scope the patient wallet ledger to the environment reading it
ownsRecord guards the patient record, not the rows underneath it, so
GET /api/v1/patient/{uuid}/wallet/transactions — and the recent_transactions
in the balance summary — returned the patient's entire history. Clinic A
could read what the patient paid at clinic B, down to the name of the staff
member who entered it.

The wallet stays the person's: the balance is still the sum of that user's
credits minus debits across every environment. Scoping it would show a
patient part of their own money and would make the running balance_after
meaningless. So this is attribution per row, not ownership per wallet.

The columns are deliberately named recorded_entity_type / recorded_entity_id
rather than entity_type / entity_id. TenantFilter keys on the latter and
would then scope the balance query too — the exact bug this avoids. The
naming is load-bearing, and both the entity and the architecture doc say so.

Rows that cannot be attributed — entered before this split, or outside any
environment such as a representation's commission — stay NULL and remain
visible everywhere; hiding them would make an existing patient's history
look deleted. The migration reports how many there are (0 in dev, all
attributable from payments and session references).

Consequence, documented in both docs/api/patient.md and the wallet tab: the
listed rows no longer sum to the displayed balance.

Removing the fix turns 3 of the 6 new tests red.

Tests: 902 backend (+6), 570 frontend. PHPStan unchanged at 17.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 15:21:55 +03:30

82 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Record which environment a wallet transaction was entered in.
*
* The wallet itself stays the person's: the balance is derived from the sum of
* that user's credits minus debits, across every environment. But the ledger is
* also shown to a clinic through /api/v1/patient/{uuid}/wallet/transactions, and
* without this column clinic A could read what the patient paid at clinic B,
* down to the name of the staff member who entered it.
*
* The columns are deliberately NOT called entity_type/entity_id: TenantFilter
* keys on those names and would then scope the balance query too, which would
* show a patient half their own money. This is attribution, not ownership.
*
* Rows that cannot be attributed stay NULL and remain visible everywhere —
* hiding them would make an existing patient's history look deleted.
*/
final class Version20260728160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Record the environment a wallet transaction was entered in, without scoping the balance';
}
public function up(Schema $schema): void
{
$this->connection->executeStatement(
'ALTER TABLE wallet_transactions
ADD recorded_entity_type VARCHAR(10) NULL, ADD recorded_entity_id INT NULL'
);
// Gateway-backed rows: the payment already carries the environment.
$this->connection->executeStatement(
'UPDATE wallet_transactions w JOIN payments p ON p.id = w.payment_id
SET w.recorded_entity_type = p.entity_type, w.recorded_entity_id = p.entity_id
WHERE w.payment_id IS NOT NULL'
);
// Desk payments taken against a visit: reference is "session:{uuid}".
$this->connection->executeStatement(
"UPDATE wallet_transactions w
JOIN patient_sessions s ON s.uuid = SUBSTRING(w.reference, 9)
JOIN patient_records r ON r.id = s.record_id
SET w.recorded_entity_type = r.entity_type, w.recorded_entity_id = r.entity_id
WHERE w.recorded_entity_type IS NULL AND w.reference LIKE 'session:%'"
);
$unattributed = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM wallet_transactions WHERE recorded_entity_type IS NULL'
);
$this->write(sprintf(
' wallet_transactions: %d row(s) could not be attributed to an environment and stay visible in all of them.',
$unattributed,
));
$this->connection->executeStatement(
'CREATE INDEX idx_wallet_user_recorded_entity
ON wallet_transactions (user_id, recorded_entity_type, recorded_entity_id)'
);
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_wallet_user_recorded_entity ON wallet_transactions');
$this->addSql('ALTER TABLE wallet_transactions DROP recorded_entity_type, DROP recorded_entity_id');
}
/** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */
public function isTransactional(): bool
{
return false;
}
}