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:
parent
423da476f5
commit
945d7e70aa
|
|
@ -90,6 +90,15 @@ return [
|
||||||
'adjustment',
|
'adjustment',
|
||||||
'dispute',
|
'dispute',
|
||||||
'dispute_reversal',
|
'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
|
// PayPal's own fee on a payment that settled in a PayPal account
|
||||||
// (amount 0, net = -fee). See `paypal` below.
|
// (amount 0, net = -fee). See `paypal` below.
|
||||||
'paypal_fee',
|
'paypal_fee',
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
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.
|
`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
|
## Filling it
|
||||||
|
|
|
||||||
|
|
@ -954,6 +954,7 @@ class ShopService
|
||||||
{
|
{
|
||||||
return config('shop.ledger.revenue_types', [
|
return config('shop.ledger.revenue_types', [
|
||||||
'charge', 'payment', 'refund', 'payment_refund', 'adjustment', 'dispute', 'dispute_reversal',
|
'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;
|
$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 [
|
return [
|
||||||
'stripe_id' => $txn->id,
|
'stripe_id' => $txn->id,
|
||||||
'source_type' => $txn->type ?? null,
|
'source_type' => $txn->type ?? null,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,41 @@ class LedgerMetricsTest extends TestCase
|
||||||
$this->assertSame(1, $totals['count']);
|
$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]
|
#[Test]
|
||||||
public function customer_totals_attribute_by_id_or_email(): void
|
public function customer_totals_attribute_by_id_or_email(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue