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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-10-07 10:51:09 +02:00
parent 423da476f5
commit 945d7e70aa
4 changed files with 57 additions and 0 deletions

View File

@ -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',

View File

@ -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

View File

@ -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,

View File

@ -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
{