mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-04 22:31:01 +00:00
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.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Receivables\Console;
|
||||
|
||||
use App\Domains\Receivables\Application\PaymentAllocationService;
|
||||
use App\Domains\Receivables\Contracts\InvoiceBalanceUpdater;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Receivables\Models\PaymentAllocation;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Re-attach the payments the 3.x upgrade could not carry over.
|
||||
*
|
||||
* The allocation migration refuses to allocate a payment to a draft invoice,
|
||||
* because a draft owes nothing. Rather than lose the association it files it in
|
||||
* `legacy_payment_links` and leaves the money as unapplied customer credit.
|
||||
* This command walks that file: for every payment whose invoice has since been
|
||||
* issued it applies the credit and forgets the link, and for every one still
|
||||
* waiting on a draft it leaves both exactly where they are, so it can be run
|
||||
* as often as the operator likes.
|
||||
*
|
||||
* Links filed as a mismatch — a missing invoice, a credit note, a target
|
||||
* belonging to another company, contact or currency — are never repaired here.
|
||||
* They are reported so somebody can decide what the association ought to have
|
||||
* been, and that is all.
|
||||
*/
|
||||
class RestoreLegacyPaymentLinks extends Command
|
||||
{
|
||||
protected $signature = 'payments:restore-legacy-links {--dry-run : Report what would be restored without writing}';
|
||||
|
||||
protected $description = 'Apply payments the upgrade parked as unapplied credit to the invoices that have since been issued';
|
||||
|
||||
private const OUTCOME_RESTORED = 'restored';
|
||||
|
||||
private const OUTCOME_WAITING = 'waiting-on-draft';
|
||||
|
||||
private const OUTCOME_SKIPPED = 'skipped';
|
||||
|
||||
private const OUTCOME_MISMATCH = 'mismatch-retained';
|
||||
|
||||
/**
|
||||
* Report on, and unless asked not to, repair every restorable legacy link.
|
||||
*/
|
||||
public function handle(PaymentAllocationService $allocationService, InvoiceBalanceUpdater $balances): int
|
||||
{
|
||||
if (! Schema::hasTable('legacy_payment_links')) {
|
||||
$this->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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
// The two halves of the draft-invoice rescue: the migration that files a
|
||||
// declined legacy link instead of destroying it, and the command that applies
|
||||
// the parked credit once the invoice is finally issued.
|
||||
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Receivables\Models\PaymentAllocation;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Artisan::call('db:seed', ['--force' => 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();
|
||||
});
|
||||
Reference in New Issue
Block a user