From 31bfb7a0e9abb3b6d79aa9daf88cecf0c7cb2401 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 22 Aug 2026 14:27:26 +0200 Subject: [PATCH] feat(payments): add payments:restore-legacy-links Walks the transitional link table and re-applies preserved draft-invoice credit through the allocation service once an invoice leaves DRAFT, deleting each link as it is restored. Mismatched links are reported and never auto-applied. Supports --dry-run. --- .../Console/RestoreLegacyPaymentLinks.php | 213 ++++++++++++++++ .../ReceivablesServiceProvider.php | 5 + .../LegacyPaymentLinkRestoreTest.php | 237 ++++++++++++++++++ 3 files changed, 455 insertions(+) create mode 100644 app/Domains/Receivables/Console/RestoreLegacyPaymentLinks.php create mode 100644 tests/Feature/Receivables/LegacyPaymentLinkRestoreTest.php diff --git a/app/Domains/Receivables/Console/RestoreLegacyPaymentLinks.php b/app/Domains/Receivables/Console/RestoreLegacyPaymentLinks.php new file mode 100644 index 00000000..e3235feb --- /dev/null +++ b/app/Domains/Receivables/Console/RestoreLegacyPaymentLinks.php @@ -0,0 +1,213 @@ +info('No legacy payment links were recorded by the upgrade.'); + + return self::SUCCESS; + } + + $dryRun = (bool) $this->option('dry-run'); + + if ($dryRun) { + $this->warn('Dry run: nothing below is written.'); + } + + $rows = []; + + // What an earlier link in this run would have taken out of an invoice, + // so a dry run does not promise the same balance to two payments. + $reserved = []; + + foreach ($this->links('draft') as $link) { + $payment = Payment::query()->find($link->payment_id); + $invoice = Invoice::query()->find($link->invoice_id); + + if (! $payment || ! $invoice) { + $rows[] = $this->row($link, null, self::OUTCOME_SKIPPED, $payment + ? 'invoice no longer exists' + : 'payment no longer exists'); + + continue; + } + + if ($invoice->status === Invoice::STATUS_DRAFT) { + $rows[] = $this->row($link, null, self::OUTCOME_WAITING, 'invoice is still a draft'); + + continue; + } + + $unallocated = (int) $payment->amount - (int) $payment->allocations()->sum('amount'); + + if ($unallocated < 1) { + $rows[] = $this->row($link, 0, self::OUTCOME_SKIPPED, 'no unallocated credit'); + + continue; + } + + $available = $this->availableBalance($invoice, $payment, $balances) - ($reserved[$invoice->id] ?? 0); + + if ($available < 1) { + $rows[] = $this->row($link, 0, self::OUTCOME_SKIPPED, 'no invoice balance available'); + + continue; + } + + $applicable = min($unallocated, $available); + + if ($dryRun) { + $reserved[$invoice->id] = ($reserved[$invoice->id] ?? 0) + $applicable; + $rows[] = $this->row($link, $applicable, self::OUTCOME_RESTORED, 'would apply the credit'); + + continue; + } + + try { + $allocationService->applyCustomerCredits((int) $payment->company_id, (int) $payment->customer_id, [[ + 'payment_id' => (int) $payment->id, + 'invoice_id' => (int) $invoice->id, + 'amount' => $applicable, + ]]); + } catch (ValidationException $exception) { + $rows[] = $this->row($link, $applicable, self::OUTCOME_SKIPPED, $this->refusal($exception)); + + continue; + } + + DB::table('legacy_payment_links')->where('id', $link->id)->delete(); + + $rows[] = $this->row($link, $applicable, self::OUTCOME_RESTORED, 'credit applied'); + } + + foreach ($this->links('mismatch') as $link) { + $rows[] = $this->row($link, null, self::OUTCOME_MISMATCH, 'kept for reference; never repaired automatically'); + } + + $this->report($rows); + + return self::SUCCESS; + } + + /** + * The recorded links of one kind, oldest first. + */ + private function links(string $reason): Collection + { + return DB::table('legacy_payment_links') + ->where('reason', $reason) + ->orderBy('id') + ->get(); + } + + /** + * What the invoice can still take from this payment. + * + * The same arithmetic the allocation service guards with: the total less + * the credit notes written against it, less whatever other payments already + * cover. This payment's own allocations are deliberately not subtracted — + * applying credit replaces its allocation set rather than adding to it. + */ + private function availableBalance(Invoice $invoice, Payment $payment, InvoiceBalanceUpdater $balances): int + { + $allocatedByOthers = (int) PaymentAllocation::query() + ->where('invoice_id', $invoice->id) + ->where('payment_id', '!=', $payment->id) + ->sum('amount'); + + return max(0, (int) $invoice->total - $balances->creditedTotal($invoice) - $allocatedByOthers); + } + + /** + * The service's refusal, flattened into one readable line. + */ + private function refusal(ValidationException $exception): string + { + return collect($exception->errors())->flatten()->implode('; '); + } + + /** + * One line of the report. + */ + private function row(object $link, ?int $amount, string $outcome, string $detail): array + { + return [ + 'payment_id' => (int) $link->payment_id, + 'invoice_id' => (int) $link->invoice_id, + 'amount' => $amount === null ? '-' : (string) $amount, + 'outcome' => $outcome, + 'detail' => $detail, + ]; + } + + /** + * Print the table and the tally underneath it. + */ + private function report(array $rows): void + { + if ($rows === []) { + $this->info('No legacy payment links are waiting to be restored.'); + + return; + } + + $this->table(['Payment', 'Invoice', 'Amount', 'Outcome', 'Detail'], $rows); + + $counts = collect($rows)->countBy('outcome'); + + foreach ([ + self::OUTCOME_RESTORED, + self::OUTCOME_WAITING, + self::OUTCOME_SKIPPED, + self::OUTCOME_MISMATCH, + ] as $outcome) { + $this->line(sprintf('%-18s %d', $outcome.':', $counts->get($outcome, 0))); + } + } +} diff --git a/app/Domains/Receivables/ReceivablesServiceProvider.php b/app/Domains/Receivables/ReceivablesServiceProvider.php index 17dd61b5..64409f93 100644 --- a/app/Domains/Receivables/ReceivablesServiceProvider.php +++ b/app/Domains/Receivables/ReceivablesServiceProvider.php @@ -7,6 +7,7 @@ use App\Adapters\Receivables\MoneyPaymentExchangeRateRecorder; use App\Adapters\Receivables\SalesInvoiceBalanceUpdater; use App\Adapters\Receivables\SalesPaymentNumberAssigner; use App\Domains\Receivables\Application\PaymentService; +use App\Domains\Receivables\Console\RestoreLegacyPaymentLinks; use App\Domains\Receivables\Contracts\InvoiceBalanceUpdater; use App\Domains\Receivables\Contracts\PaymentEmailSender; use App\Domains\Receivables\Contracts\PaymentExchangeRateRecorder; @@ -32,6 +33,10 @@ class ReceivablesServiceProvider extends ServiceProvider public function boot(): void { + $this->commands([ + RestoreLegacyPaymentLinks::class, + ]); + Gate::policy(Payment::class, PaymentPolicy::class); Gate::policy(PaymentMethod::class, PaymentMethodPolicy::class); $abilities = [ diff --git a/tests/Feature/Receivables/LegacyPaymentLinkRestoreTest.php b/tests/Feature/Receivables/LegacyPaymentLinkRestoreTest.php new file mode 100644 index 00000000..0deb6428 --- /dev/null +++ b/tests/Feature/Receivables/LegacyPaymentLinkRestoreTest.php @@ -0,0 +1,237 @@ + true, '--class' => 'DatabaseSeeder']); + Artisan::call('db:seed', ['--force' => true, '--class' => 'DemoSeeder']); +}); + +/** + * An invoice in whatever stage the caller names, with every money column + * consistent with a single outstanding total. + */ +function legacyLinkInvoice(int $total, string $status = Invoice::STATUS_DRAFT): Invoice +{ + return Invoice::factory()->create([ + 'type' => Invoice::TYPE_INVOICE, + 'status' => $status, + 'sent' => $status !== Invoice::STATUS_DRAFT, + 'viewed' => false, + 'paid_status' => Invoice::STATUS_UNPAID, + 'sub_total' => $total, + 'total' => $total, + 'due_amount' => $total, + 'exchange_rate' => 3, + 'base_sub_total' => $total * 3, + 'base_total' => $total * 3, + 'base_due_amount' => $total * 3, + ]); +} + +/** + * Money from the invoice's own contact, in the invoice's own currency. + */ +function legacyLinkPayment(Invoice $invoice, int $amount, string $notes = 'Cheque 4471.'): Payment +{ + return Payment::factory()->create([ + ...$invoice->only(['company_id', 'customer_id', 'currency_id']), + 'amount' => $amount, + 'base_amount' => $amount * 3, + 'exchange_rate' => 3, + 'notes' => $notes, + ]); +} + +/** + * The row the migration would have filed for this pair. + */ +function legacyLinkRow(Payment $payment, Invoice $invoice, string $reason = 'draft'): void +{ + DB::table('legacy_payment_links')->insert([ + 'payment_id' => $payment->id, + 'invoice_id' => $invoice->id, + 'reason' => $reason, + 'created_at' => now(), + 'updated_at' => now(), + ]); +} + +/** + * The note the migration appends to a payment it parks. + */ +function legacyLinkNote(Invoice $invoice): string +{ + return sprintf( + 'Recorded against draft invoice %s before the 3.x upgrade; retained as unapplied customer credit.', + $invoice->invoice_number + ); +} + +test('the command leaves a parked payment alone while its invoice is still a draft', function () { + $invoice = legacyLinkInvoice(60); + $payment = legacyLinkPayment($invoice, 100, 'Cheque 4471.'."\n".legacyLinkNote($invoice)); + legacyLinkRow($payment, $invoice); + + foreach ([['--dry-run' => true], []] as $options) { + $this->artisan('payments:restore-legacy-links', $options) + ->expectsOutputToContain('waiting-on-draft') + ->assertExitCode(0); + } + + expect(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse() + ->and(DB::table('legacy_payment_links')->where('payment_id', $payment->id)->count())->toBe(1) + ->and($invoice->fresh()->due_amount)->toBe(60) + ->and($payment->fresh()->notes)->toContain(legacyLinkNote($invoice)); +}); + +test('a dry run reports the restorable payment without writing anything', function () { + $invoice = legacyLinkInvoice(60); + $payment = legacyLinkPayment($invoice, 100); + legacyLinkRow($payment, $invoice); + $invoice->update(['status' => Invoice::STATUS_SENT, 'sent' => true]); + + $this->artisan('payments:restore-legacy-links', ['--dry-run' => true]) + ->expectsOutputToContain('Dry run') + ->expectsOutputToContain('restored') + ->assertExitCode(0); + + expect(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse() + ->and(DB::table('legacy_payment_links')->where('payment_id', $payment->id)->count())->toBe(1) + ->and($invoice->fresh()->due_amount)->toBe(60) + ->and($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID); +}); + +test('the command applies the parked credit once the invoice is issued and forgets the link', function () { + $invoice = legacyLinkInvoice(60); + $payment = legacyLinkPayment($invoice, 100); + legacyLinkRow($payment, $invoice); + $invoice->update(['status' => Invoice::STATUS_SENT, 'sent' => true]); + + $this->artisan('payments:restore-legacy-links') + ->expectsOutputToContain('restored') + ->assertExitCode(0); + + $allocation = PaymentAllocation::where('payment_id', $payment->id)->sole(); + + // The service prorates the base amount: 300 base units of a 100 unit + // payment, 60 of which land on this invoice. + expect((int) $allocation->invoice_id)->toBe($invoice->id) + ->and((int) $allocation->amount)->toBe(60) + ->and((int) $allocation->base_amount)->toBe(180) + ->and($invoice->fresh()->due_amount)->toBe(0) + ->and($invoice->fresh()->base_due_amount)->toBe(0) + ->and($invoice->fresh()->status)->toBe(Invoice::STATUS_COMPLETED) + ->and($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_PAID) + ->and(DB::table('legacy_payment_links')->where('payment_id', $payment->id)->exists())->toBeFalse() + ->and((int) $payment->fresh()->amount - 60)->toBe(40); + + // A second sweep has nothing left to act on. + $this->artisan('payments:restore-legacy-links')->assertExitCode(0); + + expect(PaymentAllocation::where('payment_id', $payment->id)->count())->toBe(1) + ->and($invoice->fresh()->due_amount)->toBe(0); +}); + +test('a mismatched legacy link is reported but never repaired', function () { + $invoice = legacyLinkInvoice(60, Invoice::STATUS_SENT); + $stranger = Customer::factory()->create([ + 'company_id' => $invoice->company_id, + 'currency_id' => $invoice->currency_id, + ]); + $payment = Payment::factory()->create([ + 'company_id' => $invoice->company_id, + 'customer_id' => $stranger->id, + 'currency_id' => $invoice->currency_id, + 'amount' => 100, + 'base_amount' => 300, + 'exchange_rate' => 3, + ]); + legacyLinkRow($payment, $invoice, 'mismatch'); + + $this->artisan('payments:restore-legacy-links') + ->expectsOutputToContain('mismatch-retained') + ->assertExitCode(0); + + expect(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse() + ->and(DB::table('legacy_payment_links')->where('payment_id', $payment->id)->value('reason'))->toBe('mismatch') + ->and($invoice->fresh()->due_amount)->toBe(60); +}); + +test('the migration files a declined draft link, annotates the payment, and restores the invoice balance', function () { + $invoice = legacyLinkInvoice(100); + $invoice->update([ + 'due_amount' => 0, + 'base_due_amount' => 0, + 'paid_status' => Invoice::STATUS_PAID, + ]); + $payment = legacyLinkPayment($invoice, 100, 'Cheque 4471.'); + + Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index()); + DB::table('payments')->where('id', $payment->id)->update(['invoice_id' => $invoice->id]); + DB::table('migrations') + ->where('migration', '2026_08_02_230400_replace_payment_invoice_with_allocations') + ->delete(); + + Log::spy(); + + Artisan::call('migrate', [ + '--path' => 'database/migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php', + '--force' => true, + ]); + + Log::shouldHaveReceived('warning') + ->withArgs(fn (string $message, array $context = []): bool => $message === 'Payment legacy invoice link was retained as unapplied customer credit.' + && (int) ($context['payment_id'] ?? 0) === $payment->id) + ->once(); + + $link = DB::table('legacy_payment_links')->where('payment_id', $payment->id)->first(); + + expect(Schema::hasColumn('payments', 'invoice_id'))->toBeFalse() + ->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse() + ->and($link)->not->toBeNull() + ->and($link->reason)->toBe('draft') + ->and((int) $link->invoice_id)->toBe($invoice->id) + ->and($payment->fresh()->notes)->toBe('Cheque 4471.'."\n".legacyLinkNote($invoice)) + ->and($invoice->fresh()->due_amount)->toBe(100) + ->and($invoice->fresh()->base_due_amount)->toBe(300) + ->and($invoice->fresh()->status)->toBe(Invoice::STATUS_DRAFT) + ->and($invoice->fresh()->paid_status)->toBe(Invoice::STATUS_UNPAID); +}); + +test('the migration files a mismatched legacy link without annotating the payment', function () { + $invoice = legacyLinkInvoice(100, Invoice::STATUS_SENT); + $stranger = Customer::factory()->create([ + 'company_id' => $invoice->company_id, + 'currency_id' => $invoice->currency_id, + ]); + $payment = Payment::factory()->create([ + 'company_id' => $invoice->company_id, + 'customer_id' => $stranger->id, + 'currency_id' => $invoice->currency_id, + 'amount' => 100, + 'base_amount' => 300, + 'exchange_rate' => 3, + 'notes' => 'Cheque 4471.', + ]); + + Schema::table('payments', fn ($table) => $table->unsignedInteger('invoice_id')->nullable()->index()); + DB::table('payments')->where('id', $payment->id)->update(['invoice_id' => $invoice->id]); + + (require database_path('migrations/2026_08_02_230400_replace_payment_invoice_with_allocations.php'))->up(); + + expect(DB::table('legacy_payment_links')->where('payment_id', $payment->id)->value('reason'))->toBe('mismatch') + ->and($payment->fresh()->notes)->toBe('Cheque 4471.') + ->and(PaymentAllocation::where('payment_id', $payment->id)->exists())->toBeFalse(); +});