diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index c4c57b1..1c2c126 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -67,6 +67,8 @@ use Illuminate\Support\Facades\Facade; * @method static \Illuminate\Support\Carbon|null ledgerEarliest() * @method static \Illuminate\Support\Collection revenueLedgerByDay(\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: * @method static \Illuminate\Database\Eloquent\Builder carts() diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 1e923a3..6377f8a 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1035,4 +1035,91 @@ class ShopService '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 $customerIds Stripe customer id(s) (cus_…) + * @param string|array $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 + */ + 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); + } } diff --git a/tests/Feature/Ledger/LedgerMetricsTest.php b/tests/Feature/Ledger/LedgerMetricsTest.php index 66b04b3..b5b1fc1 100644 --- a/tests/Feature/Ledger/LedgerMetricsTest.php +++ b/tests/Feature/Ledger/LedgerMetricsTest.php @@ -78,6 +78,51 @@ class LedgerMetricsTest extends TestCase $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] public function earliest_returns_the_first_created_timestamp(): void {