feat(ledger): per-customer revenue attribution primitives

customerLedgerTotals() sums the user-independent Stripe ledger for one
buyer by ANY of their customer id(s) OR email(s) — so subscription
payments (which product_purchases stores as amount=0) and deleted- or
guest-customer charges are still attributed. ledgerAmountByCustomer()
returns [customer_id => amount] for per-user rollups in one query.

Both are additive and filter on ledgerRevenueTypes(); empty identity
returns zero rather than matching everything. Covered by two new
LedgerMetricsTest cases (full suite 1458 green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-15 08:39:40 +02:00
parent 86e6469d1f
commit f4624b2903
3 changed files with 134 additions and 0 deletions

View File

@ -67,6 +67,8 @@ use Illuminate\Support\Facades\Facade;
* @method static \Illuminate\Support\Carbon|null ledgerEarliest() * @method static \Illuminate\Support\Carbon|null ledgerEarliest()
* @method static \Illuminate\Support\Collection revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until) * @method static \Illuminate\Support\Collection revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static array ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until) * @method static array ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until)
* @method static array customerLedgerTotals(string|array $customerIds = [], string|array $emails = [], ?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null)
* @method static \Illuminate\Support\Collection ledgerAmountByCustomer(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null)
* *
* Cart Queries: * Cart Queries:
* @method static \Illuminate\Database\Eloquent\Builder carts() * @method static \Illuminate\Database\Eloquent\Builder carts()

View File

@ -1035,4 +1035,91 @@ class ShopService
'count' => (int) ($row->count ?? 0), 'count' => (int) ($row->count ?? 0),
]; ];
} }
/**
* Money one buyer actually paid us, attributed from the user-independent
* ledger by ANY of their Stripe customer id(s) OR buyer email(s) — so a
* customer whose local user was deleted, or who paid as a guest before
* signing up, is still attributed. Returns the signed split in cents.
*
* `amount` = SUM(amount) over revenue-type rows = gross charges minus
* refunds: the euros we kept, before Stripe's per-charge fee. This is the
* figure that catches subscription payments the per-user product_purchases
* sum misses (they are stored there as amount=0).
*
* @param string|array<int,string> $customerIds Stripe customer id(s) (cus_…)
* @param string|array<int,string> $emails buyer email(s)
* @return array{amount: int, gross: int, refunds: int, fees: int, net: int, count: int}
*/
public function customerLedgerTotals(
string|array $customerIds = [],
string|array $emails = [],
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null,
): array {
$customerIds = array_values(array_filter((array) $customerIds, static fn ($v) => is_string($v) && $v !== ''));
$emails = array_values(array_filter((array) $emails, static fn ($v) => is_string($v) && $v !== ''));
$zero = ['amount' => 0, 'gross' => 0, 'refunds' => 0, 'fees' => 0, 'net' => 0, 'count' => 0];
// No identity to match on → nothing attributable. Guard so an empty
// whereIn can never degrade into a match-everything.
if (! $customerIds && ! $emails) {
return $zero;
}
$row = $this->ledgerModel()::query()
->whereIn('source_type', $this->ledgerRevenueTypes())
->where(function ($q) use ($customerIds, $emails) {
if ($customerIds) {
$q->orWhereIn('customer_id', $customerIds);
}
if ($emails) {
$q->orWhereIn('customer_email', $emails);
}
})
->when($from, fn ($q) => $q->where('created', '>=', $from))
->when($until, fn ($q) => $q->where('created', '<=', $until))
->selectRaw('SUM(amount) as amount')
->selectRaw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as gross')
->selectRaw('SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) as refunds')
->selectRaw('SUM(fee) as fees')
->selectRaw('SUM(net) as net')
->selectRaw('COUNT(*) as count')
->first();
return [
'amount' => (int) ($row->amount ?? 0),
'gross' => (int) ($row->gross ?? 0),
'refunds' => (int) ($row->refunds ?? 0),
'fees' => (int) ($row->fees ?? 0),
'net' => (int) ($row->net ?? 0),
'count' => (int) ($row->count ?? 0),
];
}
/**
* Money received (net of refunds) grouped by Stripe customer id, in cents:
* `[customer_id => amount]` over the revenue-bearing ledger. The caller maps
* customer ids to local users (e.g. users.stripe_id) to attribute revenue
* per user in one pass. Rows with no customer id (a guest charge carrying
* only an email) are omitted here — attribute those by email via
* {@see customerLedgerTotals}.
*
* @return \Illuminate\Support\Collection<string, int>
*/
public function ledgerAmountByCustomer(
?\DateTimeInterface $from = null,
?\DateTimeInterface $until = null,
): \Illuminate\Support\Collection {
return $this->ledgerModel()::query()
->whereIn('source_type', $this->ledgerRevenueTypes())
->whereNotNull('customer_id')
->when($from, fn ($q) => $q->where('created', '>=', $from))
->when($until, fn ($q) => $q->where('created', '<=', $until))
->groupBy('customer_id')
->selectRaw('customer_id, SUM(amount) as amount')
->pluck('amount', 'customer_id')
->map(fn ($v) => (int) $v);
}
} }

View File

@ -78,6 +78,51 @@ class LedgerMetricsTest extends TestCase
$this->assertSame(1, $totals['count']); $this->assertSame(1, $totals['count']);
} }
#[Test]
public function customer_totals_attribute_by_id_or_email(): void
{
// Two charges + a refund under one customer.
$this->txn(['amount' => 2000, 'fee' => 69, 'net' => 1931, 'customer_id' => 'cus_A', 'customer_email' => 'a@x.io']);
$this->txn(['amount' => 5000, 'fee' => 172, 'net' => 4828, 'customer_id' => 'cus_A', 'customer_email' => 'a@x.io']);
$this->txn(['source_type' => 'refund', 'amount' => -2000, 'fee' => 0, 'net' => -2000, 'customer_id' => 'cus_A']);
// A guest charge that carries only the email (no customer id yet).
$this->txn(['amount' => 1500, 'fee' => 55, 'net' => 1445, 'customer_id' => null, 'customer_email' => 'a@x.io']);
// Another customer's money must never leak in.
$this->txn(['amount' => 9900, 'net' => 9560, 'customer_id' => 'cus_B', 'customer_email' => 'b@x.io']);
// By customer id alone: the two cus_A charges minus the refund.
$byId = Shop::customerLedgerTotals('cus_A');
$this->assertSame(2000 + 5000 - 2000, $byId['amount']);
$this->assertSame(2000 + 5000, $byId['gross']);
$this->assertSame(-2000, $byId['refunds']);
$this->assertSame(69 + 172, $byId['fees']);
$this->assertSame(3, $byId['count']);
// By id OR email: also pulls in the email-only guest charge.
$byBoth = Shop::customerLedgerTotals('cus_A', 'a@x.io');
$this->assertSame(2000 + 5000 - 2000 + 1500, $byBoth['amount']);
$this->assertSame(4, $byBoth['count']);
// No identity → zero, never a match-everything.
$this->assertSame(0, Shop::customerLedgerTotals([], [])['amount']);
$this->assertSame(0, Shop::customerLedgerTotals('', '')['count']);
}
#[Test]
public function amount_by_customer_groups_and_omits_null_customer(): void
{
$this->txn(['amount' => 2000, 'net' => 1931, 'customer_id' => 'cus_A']);
$this->txn(['amount' => 5000, 'net' => 4828, 'customer_id' => 'cus_A']);
$this->txn(['amount' => 9900, 'net' => 9560, 'customer_id' => 'cus_B']);
$this->txn(['amount' => 1500, 'net' => 1445, 'customer_id' => null]); // guest — omitted
$map = Shop::ledgerAmountByCustomer();
$this->assertSame(7000, $map['cus_A']);
$this->assertSame(9900, $map['cus_B']);
$this->assertSame(2, $map->count());
}
#[Test] #[Test]
public function earliest_returns_the_first_created_timestamp(): void public function earliest_returns_the_first_created_timestamp(): void
{ {