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:
parent
86e6469d1f
commit
f4624b2903
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue