fix(payments): preserve declined legacy invoice links durably

A payment recorded against a draft invoice in 2.x was silently unlinked by
the allocations migration: the warning was the only trace, and the column
drop destroyed the association. Declined links are now written to a
transitional legacy_payment_links table (reason draft or mismatch) and
draft-linked payments get an explanatory note, so the money remains
identifiable unapplied customer credit instead of anonymous credit.
This commit is contained in:
Darko Gjorgjijoski
2026-08-22 14:27:26 +02:00
parent 975d14d86f
commit aaee3087b1
@@ -26,6 +26,23 @@ return new class extends Migration
});
}
// The forensic record of every legacy link this migration refused to
// turn into an allocation. Dropping payments.invoice_id destroys the
// only trace of the association, and a payment recorded against a
// draft invoice is a bookkeeping habit rather than a data fault: the
// link is worth keeping so it can be restored once the invoice is
// issued. Created on a fresh install too, so the repair command and
// its queries never have to test for the table's existence.
if (! Schema::hasTable('legacy_payment_links')) {
Schema::create('legacy_payment_links', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedBigInteger('payment_id')->unique();
$table->unsignedInteger('invoice_id');
$table->string('reason');
$table->timestamps();
});
}
// A fresh install replays the historical payments migration before
// reaching this migration. Existing v2/v3 databases arrive here with
// the same nullable legacy column; a retry after a partial run does
@@ -71,6 +88,8 @@ return new class extends Migration
public function down(): void
{
if (! Schema::hasTable('payment_allocations')) {
Schema::dropIfExists('legacy_payment_links');
return;
}
@@ -104,6 +123,10 @@ return new class extends Migration
});
Schema::dropIfExists('payment_allocations');
// Dropped last, after the refusal guards above: a rollback that is
// refused keeps the forensic link table, since the unapplied credit
// it documents is exactly what caused the refusal.
Schema::dropIfExists('legacy_payment_links');
}
private function backfillLegacyAllocations(): void
@@ -128,6 +151,8 @@ return new class extends Migration
'invoice_id' => $payment->invoice_id,
]);
$this->recordLegacyLink($payment, $invoice);
return;
}
@@ -156,6 +181,74 @@ return new class extends Migration
});
}
/**
* Keep the declined association on file before the column carrying it is
* dropped.
*
* A link is filed as 'draft' when the invoice is otherwise a perfectly
* good target and the only objection is that it never left the draft
* stage — the case `payments:restore-legacy-links` can put right once the
* invoice is issued. Everything else is a 'mismatch': a missing invoice, a
* credit note, or a target belonging to another company, contact or
* currency. Those are never repaired automatically and are kept purely so
* the operator can see what the association used to be.
*/
private function recordLegacyLink(object $payment, ?object $invoice): void
{
// A rerun after the insert but before the legacy column disappears
// must not file the link, or the note, a second time.
if (DB::table('legacy_payment_links')->where('payment_id', $payment->id)->exists()) {
return;
}
$isDraftLink = $this->isDraftLegacyLink($payment, $invoice);
DB::table('legacy_payment_links')->insert([
'payment_id' => $payment->id,
'invoice_id' => $payment->invoice_id,
'reason' => $isDraftLink ? 'draft' : 'mismatch',
'created_at' => now(),
'updated_at' => now(),
]);
if ($isDraftLink) {
$this->annotateDraftPayment($payment, $invoice);
}
}
/**
* Whether the draft status is the sole reason the link was declined.
*/
private function isDraftLegacyLink(object $payment, ?object $invoice): bool
{
return $invoice
&& (int) $payment->company_id === (int) $invoice->company_id
&& (int) $payment->customer_id === (int) $invoice->customer_id
&& (int) $payment->currency_id === (int) $invoice->currency_id
&& ($invoice->type ?? 'INVOICE') === 'INVOICE'
&& $invoice->status === 'DRAFT';
}
/**
* Tell the story on the receipt itself.
*
* The forensic table is invisible to whoever opens the payment, so the
* plain-text note is what explains why the money now sits as unapplied
* credit. It is appended, never substituted for whatever was there.
*/
private function annotateDraftPayment(object $payment, object $invoice): void
{
$note = sprintf(
'Recorded against draft invoice %s before the 3.x upgrade; retained as unapplied customer credit.',
$invoice->invoice_number
);
$existing = (string) ($payment->notes ?? '');
DB::table('payments')->where('id', $payment->id)->update([
'notes' => $existing === '' ? $note : $existing."\n".$note,
]);
}
private function isValidLegacyLink(object $payment, ?object $invoice): bool
{
return $invoice