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; } }