From 945d7e70aaa48a26fde08982e81647801396b5bc Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 7 Oct 2026 10:51:09 +0200 Subject: [PATCH] fix(ledger): count bounced direct debits and Stripe account fees payment_failure_refund (a SEPA/ACH payment that bounced, with its failure fee) and Stripe's account-level fees (stripe_fee, stripe_fx_fee, tax_fee) were not revenue types, so a bounced payment still counted as income and Billing/Tax fees never lowered net. Both are revenue types now. Account fees arrive from Stripe as a negative amount; the ledger stores them as fee with amount 0 so they do not show up as refunds. The daily full import rewrites existing rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- config/shop.php | 9 ++++++ docs/06-ledger.md | 2 ++ src/Services/ShopService.php | 11 +++++++ tests/Feature/Ledger/LedgerMetricsTest.php | 35 ++++++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/config/shop.php b/config/shop.php index 67cae72..e5471f2 100644 --- a/config/shop.php +++ b/config/shop.php @@ -90,6 +90,15 @@ return [ 'adjustment', 'dispute', 'dispute_reversal', + // A direct debit (SEPA, ACH) that bounced after it was booked as a + // payment: the gross comes back off, plus the failure fee. + 'payment_failure_refund', + 'refund_failure', + // Account-level Stripe fees (Billing, Tax, FX). Stored as fee with + // amount 0, so they lower net without counting as refunds. + 'stripe_fee', + 'stripe_fx_fee', + 'tax_fee', // PayPal's own fee on a payment that settled in a PayPal account // (amount 0, net = -fee). See `paypal` below. 'paypal_fee', diff --git a/docs/06-ledger.md b/docs/06-ledger.md index bb41f6b..ea8c44e 100644 --- a/docs/06-ledger.md +++ b/docs/06-ledger.md @@ -2,6 +2,8 @@ The `stripe_transactions` table is a user-independent ledger of every money movement, one row per transaction in the account where it happened. Amounts are signed cents: `amount` is the gross, `fee` the provider's fee, `net = amount - fee`. Revenue figures (`Shop::ledgerTotals`, `revenueLedgerByDay`, `customerLedgerTotals`, `ledgerAmountByCustomer`) sum the rows whose `source_type` is in `shop.ledger.revenue_types`. +Besides charges, refunds and disputes, the default revenue types include bounced direct debits (`payment_failure_refund`, `refund_failure`) and Stripe's account-level fees (`stripe_fee`, `stripe_fx_fee`, `tax_fee`). Stripe reports those fees as a negative amount; the ledger stores them as `fee` with amount 0, so they lower net without showing up as refunds. + `provider` says which system a row comes from (`stripe`, `paypal`) and `account` which account of it (the PayPal merchant id; null for Stripe). `Shop::ledgerTotalsByAccount($from, $until)` splits the totals per account. ## Filling it diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index a46cd5e..c9013c2 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -954,6 +954,7 @@ class ShopService { return config('shop.ledger.revenue_types', [ 'charge', 'payment', 'refund', 'payment_refund', 'adjustment', 'dispute', 'dispute_reversal', + 'payment_failure_refund', 'refund_failure', 'stripe_fee', 'stripe_fx_fee', 'tax_fee', 'paypal_fee', ]); } @@ -1209,6 +1210,16 @@ class ShopService $net = $amount - $fee; } + // Stripe books its account-level fees (Billing, Tax, FX) as a negative + // amount. Store them as a fee with amount 0, so they lower net but are + // not mistaken for refunds in the gross/refunds split. + if (in_array($txn->type ?? null, ['stripe_fee', 'stripe_fx_fee', 'tax_fee'], true) && $amount !== 0) { + $meta['stripe_amount'] = $amount; + $fee -= $amount; + $amount = 0; + $net = -$fee; + } + return [ 'stripe_id' => $txn->id, 'source_type' => $txn->type ?? null, diff --git a/tests/Feature/Ledger/LedgerMetricsTest.php b/tests/Feature/Ledger/LedgerMetricsTest.php index d4fc516..623ce77 100644 --- a/tests/Feature/Ledger/LedgerMetricsTest.php +++ b/tests/Feature/Ledger/LedgerMetricsTest.php @@ -78,6 +78,41 @@ class LedgerMetricsTest extends TestCase $this->assertSame(1, $totals['count']); } + #[Test] + public function stripe_account_fees_count_as_fees_not_refunds(): void + { + // Stripe books Billing/Tax fees as a negative amount (real prod shape). + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_billing', 'type' => 'stripe_fee', 'reporting_category' => 'fee', + 'amount' => -14, 'fee' => 0, 'net' => -14, 'currency' => 'eur', 'created' => strtotime('2025-10-01'), + ]); + $this->txn(['amount' => 2550, 'fee' => 104, 'net' => 2446]); + + $row = StripeTransaction::where('stripe_id', 'txn_billing')->firstOrFail(); + $this->assertSame(0, $row->amount); + $this->assertSame(14, $row->fee); + $this->assertSame(-14, $row->net); + $this->assertSame(-14, $row->meta->stripe_amount); + + $totals = Shop::ledgerTotals(Carbon::parse('2025-01-01'), Carbon::parse('2026-12-31')); + $this->assertSame(2550, $totals['gross']); + $this->assertSame(0, $totals['refunds']); + $this->assertSame(118, $totals['fees']); + $this->assertSame(2432, $totals['net']); + } + + #[Test] + public function a_bounced_direct_debit_takes_the_payment_back_with_its_fee(): void + { + // SEPA: booked as a payment, then failed with a 3.50 failure fee. + $this->txn(['source_type' => 'payment', 'amount' => 240, 'fee' => 35, 'net' => 205, 'customer_id' => 'cus_S']); + $this->txn(['source_type' => 'payment_failure_refund', 'amount' => -240, 'fee' => 315, 'net' => -555, 'customer_id' => 'cus_S']); + + $totals = Shop::ledgerTotals(Carbon::parse('2025-01-01'), Carbon::parse('2026-12-31')); + $this->assertSame(-350, $totals['net']); + $this->assertSame(0, Shop::customerLedgerTotals('cus_S')['amount']); + } + #[Test] public function customer_totals_attribute_by_id_or_email(): void {