Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions app/Billing/Invoicing/InvoiceOperations.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Billing\Seller\SellerCatalog;
use App\Billing\Support\WeightedAllocator;
use App\Billing\Tax\TaxContextFactory;
use App\Models\CreditNote;
use App\Models\Invoice;
use App\Models\InvoiceLine;
use App\Models\Organization;
Expand Down Expand Up @@ -86,8 +87,43 @@ public function refund(Invoice $invoice, ?int $netMinor, RefundReason $reason, s
: RefundRequest::partial($actionId, $invoice->organization_id, $engineInvoice, Money::ofMinor($netMinor, $invoice->currency), $reason, $at, $invoice->gateway_reference);

// The refunder's ledger post, refund-record save and CreditNoteIssued listener
// (which writes the durable credit note) all commit together.
return $this->db->transaction(fn (): Refund => $this->refunder->refund($request));
// (which writes the durable credit note) all commit together — and, once the credit
// notes cover the invoice, so does the status transition to Refunded.
return $this->db->transaction(function () use ($request, $invoice): Refund {
$refund = $this->refunder->refund($request);

$this->settleRefundedStatus($invoice);

return $refund;
});
}

/**
* Move a fully-reversed invoice to {@see InvoiceStatus::Refunded}.
*
* `Refunded` existed in the enum and was assigned NOWHERE in the app: its only other
* appearance was the approval preview, which showed the operator `status → refunded` before
* they approved. The refund then executed and left the invoice `paid`, so the console
* promised a transition the code never performed.
*
* Cumulative, not per-refund: several partial refunds that together cover the invoice leave
* it as fully reversed just as a single full one does. A partial refund correctly leaves the
* invoice `Paid` — it *is* still paid, with part reversed, and the credit notes carry the
* detail.
*/
private function settleRefundedStatus(Invoice $invoice): void
{
if ($invoice->status === InvoiceStatus::Refunded) {
return;
}

$refunded = (int) CreditNote::query()
->where('invoice_id', $invoice->id)
->sum('gross_minor');

if ($refunded >= $invoice->total_minor && $invoice->total_minor > 0) {
$invoice->forceFill(['status' => InvoiceStatus::Refunded])->save();
}
}

public function markPaid(Invoice $invoice, ?string $reference): void
Expand Down
12 changes: 11 additions & 1 deletion app/Billing/Nexus/InvoiceSalesLedger.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,17 @@ private function platformActivity(SubdivisionCode $state, Carbon $windowStart, C

$base = Invoice::query()
->where('seller', $this->sellers->default()->id)
->whereIn('status', [InvoiceStatus::Open, InvoiceStatus::Paid])
// `Refunded` is counted, deliberately. Until the refund path started writing that
// status, a fully-reversed invoice stayed `Paid` and was measured here; dropping it
// now would silently REDUCE measured sales as a side effect of an unrelated fix.
//
// Counting it is also the safer reading: most post-Wayfair thresholds are stated on
// GROSS sales, and the two errors are not symmetric — over-counting prompts a
// registration the seller may not strictly need, while under-counting misses one
// they do, which is back taxes plus penalties. Where a state permits netting
// returns, that is a per-state refinement to make against the dataset's own rules,
// not a blanket exclusion to assume here.
->whereIn('status', [InvoiceStatus::Open, InvoiceStatus::Paid, InvoiceStatus::Refunded])
->where('issued_at', '>=', $windowStart)
->where('issued_at', '<', $windowEnd)
->whereIn('organization_id', $organizationIds);
Expand Down
28 changes: 28 additions & 0 deletions tests/Feature/InvoiceLifecycleConsoleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,32 @@ public function test_credit_note_shows_on_invoice_and_in_the_list(): void
$this->withSession($this->session)->get('/credit-notes/'.$note->id)
->assertOk()->assertSee($invoice->number);
}

public function test_a_full_refund_leaves_the_invoice_in_the_status_the_approval_preview_promised(): void
{
$invoice = $this->invoicedOrg('org_refund_full');
$invoice->forceFill(['status' => 'paid', 'paid_at' => now()])->save();

$this->withSession($this->session)->post('/invoices/'.$invoice->id.'/refund', [
'mode' => 'full', 'reason' => 'goodwill', 'idempotency_key' => 'k-full-status',
])->assertRedirect()->assertSessionHas('status');

// RefundInvoiceAction's approval preview shows the operator `status -> refunded` before
// they approve. Before this, the refund executed and the invoice stayed `paid`, so the
// console promised a transition the code never performed.
$this->assertSame(InvoiceStatus::Refunded, $invoice->refresh()->status);
}

public function test_a_partial_refund_leaves_the_invoice_paid(): void
{
$invoice = $this->invoicedOrg('org_refund_part');
$invoice->forceFill(['status' => 'paid', 'paid_at' => now()])->save();

$this->withSession($this->session)->post('/invoices/'.$invoice->id.'/refund', [
'mode' => 'partial', 'amount' => '10.00', 'reason' => 'goodwill', 'idempotency_key' => 'k-part-status',
])->assertRedirect()->assertSessionHas('status');

// Still paid — it IS paid, with part reversed; the credit note carries the detail.
$this->assertSame(InvoiceStatus::Paid, $invoice->refresh()->status);
}
}
Loading