From b1e09b71adf593c7cdff441d2887934e9803f477 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 14 Sep 2026 15:13:16 -0300 Subject: [PATCH] feat(migrations): implement backfill for legacy refunds and add migration tests --- ...ve_refunded_amount_from_purchase_items.php | 78 ++++++++- ...oveRefundedAmountFromPurchaseItemsTest.php | 151 ++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php diff --git a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php index 8c7c7a8..ffc3ae6 100644 --- a/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php +++ b/database/migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php @@ -9,6 +9,8 @@ return new class extends Migration { public function up(): void { + $this->backfillLegacyRefunds(); + $mismatchedItem = DB::table('compra_items as purchase_items') ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') ->where('purchase_items.refunded_amount', '>', 0) @@ -24,7 +26,7 @@ return new class extends Migration if ($mismatchedItem !== null) { throw new RuntimeException( "No se puede eliminar compra_items.refunded_amount: el ítem {$mismatchedItem->id} " - .'contiene un importe histórico que no está respaldado por ticket_refunds.' + .'contiene un importe histórico que no se pudo respaldar con ticket_refunds.' ); } @@ -49,4 +51,78 @@ return new class extends Migration ->update(['refunded_amount' => $refund->refund_total]); }, column: 'purchase_item_id'); } + + private function backfillLegacyRefunds(): void + { + $items = DB::table('compra_items as purchase_items') + ->leftJoin('ticket_refunds as refunds', 'refunds.purchase_item_id', '=', 'purchase_items.id') + ->where('purchase_items.refunded_amount', '>', 0) + ->groupBy( + 'purchase_items.id', + 'purchase_items.refunded_amount', + 'purchase_items.precio_unitario', + ) + ->selectRaw( + 'purchase_items.id, purchase_items.refunded_amount, purchase_items.precio_unitario, ' + .'COALESCE(SUM(refunds.amount), 0) as refund_total' + ) + ->orderBy('purchase_items.id') + ->get(); + + foreach ($items as $item) { + $legacyAmountInCents = (int) round( + ((float) $item->refunded_amount - (float) $item->refund_total) * 100 + ); + + if ($legacyAmountInCents <= 0) { + continue; + } + + $tickets = DB::table('tickets as tickets') + ->leftJoin('ticket_refunds as refunds', 'refunds.ticket_id', '=', 'tickets.id') + ->where('tickets.source_purchase_item_id', $item->id) + ->whereNotNull('tickets.refunded_at') + ->whereNull('refunds.id') + ->orderBy('tickets.refunded_at') + ->orderBy('tickets.id') + ->get(['tickets.id', 'tickets.refunded_at']); + + $ticketCount = $tickets->count(); + $unitPriceInCents = (int) round((float) $item->precio_unitario * 100); + + if ($ticketCount === 0 + || $unitPriceInCents <= 0 + || $legacyAmountInCents > $ticketCount * $unitPriceInCents) { + continue; + } + + $baseAmountInCents = intdiv($legacyAmountInCents, $ticketCount); + $remainderInCents = $legacyAmountInCents % $ticketCount; + + if ($baseAmountInCents === 0) { + continue; + } + + $refunds = $tickets->values()->map(function (object $ticket, int $index) use ( + $item, + $baseAmountInCents, + $remainderInCents, + $unitPriceInCents, + ): array { + $amountInCents = $baseAmountInCents + ($index < $remainderInCents ? 1 : 0); + + return [ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $item->id, + 'created_by_user_id' => null, + 'type' => $amountInCents === $unitPriceInCents ? 'total' : 'partial', + 'amount' => number_format($amountInCents / 100, 2, '.', ''), + 'created_at' => $ticket->refunded_at, + 'updated_at' => $ticket->refunded_at, + ]; + })->all(); + + DB::table('ticket_refunds')->insert($refunds); + } + } }; diff --git a/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php new file mode 100644 index 0000000..c26aa95 --- /dev/null +++ b/tests/Feature/Migrations/RemoveRefundedAmountFromPurchaseItemsTest.php @@ -0,0 +1,151 @@ +originalConnection = DB::getDefaultConnection(); + config()->set('database.connections.refund_migration_test', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ]); + DB::setDefaultConnection('refund_migration_test'); + + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->decimal('precio_unitario', 10, 2); + $table->decimal('total', 10, 2); + $table->decimal('refunded_amount', 10, 2)->default(0); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->foreignId('source_purchase_item_id')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->unique(); + $table->foreignId('purchase_item_id'); + $table->foreignId('created_by_user_id')->nullable(); + $table->string('type', 16); + $table->decimal('amount', 10, 2); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + DB::purge('refund_migration_test'); + DB::setDefaultConnection($this->originalConnection); + + parent::tearDown(); + } + + public function test_it_backfills_a_refund_created_before_ticket_refunds_existed(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + DB::table('tickets')->insert([ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ]); + + $this->migration()->up(); + + $this->assertFalse(Schema::hasColumn('compra_items', 'refunded_amount')); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + 'created_at' => '2026-09-13 18:30:00', + ]); + } + + public function test_it_only_backfills_the_amount_not_already_in_ticket_refunds(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '200.00', + 'refunded_amount' => '140.00', + ]); + DB::table('tickets')->insert([ + [ + 'id' => 501, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-13 18:30:00', + ], + [ + 'id' => 502, + 'source_purchase_item_id' => 254, + 'refunded_at' => '2026-09-14 10:00:00', + ], + ]); + DB::table('ticket_refunds')->insert([ + 'ticket_id' => 502, + 'purchase_item_id' => 254, + 'created_by_user_id' => 7, + 'type' => 'total', + 'amount' => '100.00', + 'created_at' => '2026-09-14 10:00:00', + 'updated_at' => '2026-09-14 10:00:00', + ]); + + $this->migration()->up(); + + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => 501, + 'purchase_item_id' => 254, + 'created_by_user_id' => null, + 'type' => 'partial', + 'amount' => 40, + ]); + $this->assertSame(2, DB::table('ticket_refunds')->count()); + } + + public function test_it_still_refuses_to_drop_an_amount_without_a_refunded_ticket(): void + { + DB::table('compra_items')->insert([ + 'id' => 254, + 'precio_unitario' => '100.00', + 'total' => '100.00', + 'refunded_amount' => '40.00', + ]); + + try { + $this->migration()->up(); + $this->fail('The migration should preserve an amount that cannot be backfilled.'); + } catch (RuntimeException $exception) { + $this->assertStringContainsString('ítem 254', $exception->getMessage()); + $this->assertTrue(Schema::hasColumn('compra_items', 'refunded_amount')); + } + } + + private function migration(): object + { + return require database_path( + 'migrations/2026_09_14_040000_remove_refunded_amount_from_purchase_items.php' + ); + } +}