From 86e6469d1fd94e969f0c1347c5e37f440bd3080a Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 3 Sep 2026 10:01:07 +0200 Subject: [PATCH] feat(ledger): user-independent Stripe BalanceTransaction ledger + daily net stats Mirror every Stripe balance transaction (signed gross/fee/net) into a new stripe_transactions table decoupled from any local user, so revenue net of fees and refunds is a plain SUM(net) and a deleted customer never removes their money history. - StripeTransaction model (config-resolved, HasUuids) + guarded, publishable migration; config tables.stripe_transactions + models.stripe_transaction + ledger.revenue_types. - Shop::revenueLedgerByDay / ledgerTotals / ledgerEarliest (+ facade @method). - shop:import-stripe-ledger: pages ALL balance transactions globally (not per-customer), upserts on txn_ id, --dry-run. - LedgerMetricsTest (4). Full suite green (1456). Additive only; existing consumers unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shop.php | 23 +++ ...00001_create_stripe_transactions_table.php | 73 +++++++++ .../ShopImportStripeLedgerCommand.php | 147 ++++++++++++++++++ src/Facades/Shop.php | 6 + src/Models/StripeTransaction.php | 50 ++++++ src/Services/ShopService.php | 95 +++++++++++ src/ShopServiceProvider.php | 1 + tests/Feature/Ledger/LedgerMetricsTest.php | 91 +++++++++++ tests/TestCase.php | 3 + 9 files changed, 489 insertions(+) create mode 100644 database/migrations/2026_09_03_000001_create_stripe_transactions_table.php create mode 100644 src/Console/Commands/ShopImportStripeLedgerCommand.php create mode 100644 src/Models/StripeTransaction.php create mode 100644 tests/Feature/Ledger/LedgerMetricsTest.php diff --git a/config/shop.php b/config/shop.php index 52d649a..3565ef6 100644 --- a/config/shop.php +++ b/config/shop.php @@ -41,6 +41,7 @@ return [ 'subscriptions' => 'subscriptions', 'subscription_items' => 'subscription_items', 'license_seats' => 'license_seats', + 'stripe_transactions' => 'stripe_transactions', ], // Model classes (allow overriding in main instance) @@ -61,6 +62,28 @@ return [ 'subscription' => \Blax\Shop\Models\Subscription::class, 'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class, 'license_seat' => \Blax\Shop\Models\LicenseSeat::class, + 'stripe_transaction' => \Blax\Shop\Models\StripeTransaction::class, + ], + + /* + * Stripe BalanceTransaction ledger. + * + * `revenue_types` are the BalanceTransaction `type`s that count toward + * "money the business earned, net of fees and refunds". Charges add, refunds + * and disputes subtract; per-charge Stripe fees are already inside each row's + * `net`. Bank movements (`payout`, `transfer`) are excluded — they move money + * that was already counted, they are not revenue or cost. + */ + 'ledger' => [ + 'revenue_types' => [ + 'charge', + 'payment', + 'refund', + 'payment_refund', + 'adjustment', + 'dispute', + 'dispute_reversal', + ], ], /* diff --git a/database/migrations/2026_09_03_000001_create_stripe_transactions_table.php b/database/migrations/2026_09_03_000001_create_stripe_transactions_table.php new file mode 100644 index 0000000..7786902 --- /dev/null +++ b/database/migrations/2026_09_03_000001_create_stripe_transactions_table.php @@ -0,0 +1,73 @@ +uuid('id')->primary(); + + // The Stripe BalanceTransaction id (`txn_…`) — the upsert key. + $blueprint->string('stripe_id')->unique(); + + // Stripe `type` (charge, payment, refund, payment_refund, adjustment, + // dispute, stripe_fee, payout, …) and `reporting_category`. + $blueprint->string('source_type')->nullable()->index(); + $blueprint->string('reporting_category')->nullable(); + + // The object that produced it: ch_/pi_/re_/di_/in_/po_. + $blueprint->string('source_id')->nullable()->index(); + + // Signed money, in cents. Refunds/disputes are negative. + $blueprint->bigInteger('amount')->default(0); // gross + $blueprint->bigInteger('fee')->default(0); // Stripe fee + $blueprint->bigInteger('net')->default(0); // amount - fee + $blueprint->string('currency', 8)->nullable(); + + // Customer snapshot — survives the local user being deleted. + $blueprint->string('customer_id')->nullable()->index(); + $blueprint->string('customer_email')->nullable(); + + $blueprint->string('description')->nullable(); + + // Stripe's own timestamps (NOT our write time). + $blueprint->timestamp('created')->nullable()->index(); + $blueprint->timestamp('available_on')->nullable(); + + $blueprint->json('meta')->nullable(); + + $blueprint->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists(config('shop.tables.stripe_transactions', 'stripe_transactions')); + } +}; diff --git a/src/Console/Commands/ShopImportStripeLedgerCommand.php b/src/Console/Commands/ShopImportStripeLedgerCommand.php new file mode 100644 index 0000000..a90bdd1 --- /dev/null +++ b/src/Console/Commands/ShopImportStripeLedgerCommand.php @@ -0,0 +1,147 @@ +error('No Stripe secret key configured (services.stripe.secret / cashier.secret / STRIPE_SECRET).'); + + return self::FAILURE; + } + + Stripe::setApiKey($secret); + + $dry = (bool) $this->option('dry-run'); + $model = config('shop.models.stripe_transaction', \Blax\Shop\Models\StripeTransaction::class); + + $params = [ + 'limit' => max(1, min(100, (int) $this->option('limit'))), + 'expand' => ['data.source'], + ]; + if ($since = $this->option('since')) { + $params['created'] = ['gte' => Carbon::parse($since)->startOfDay()->timestamp]; + } + + $created = 0; + $updated = 0; + $seen = 0; + $netCents = 0; + + try { + $iterator = \Stripe\BalanceTransaction::all($params)->autoPagingIterator(); + } catch (\Throwable $e) { + $this->error('Stripe API error: ' . $e->getMessage()); + + return self::FAILURE; + } + + foreach ($iterator as $txn) { + $seen++; + $row = $this->rowFromTransaction($txn); + $netCents += $row['net']; + + $exists = $model::query()->where('stripe_id', $row['stripe_id'])->exists(); + + if ($dry) { + $exists ? $updated++ : $created++; + + if ($created + $updated <= 8) { + $this->line(sprintf( + ' %s %-16s %10s %s %s', + $exists ? 'exists' : 'NEW ', + $row['source_type'], + number_format($row['net'] / 100, 2), + $row['stripe_id'], + $row['customer_email'] ?? $row['customer_id'] ?? '—' + )); + } + + continue; + } + + $existingId = $model::query()->where('stripe_id', $row['stripe_id'])->value('id'); + $model::query()->updateOrCreate(['stripe_id' => $row['stripe_id']], $row); + $existingId ? $updated++ : $created++; + } + + $this->newLine(); + $this->info(sprintf( + '%s %d balance transactions: %d new, %d existing. Ledger net over scanned rows: %s.', + $dry ? '[dry-run] would import' : 'Imported', + $seen, + $created, + $updated, + number_format($netCents / 100, 2) + )); + + return self::SUCCESS; + } + + /** + * Flatten a Stripe BalanceTransaction (with `source` expanded) into a ledger row. + * + * @return array + */ + protected function rowFromTransaction($txn): array + { + $source = is_object($txn->source ?? null) ? $txn->source : null; + $sourceId = $source?->id ?? (is_string($txn->source ?? null) ? $txn->source : null); + + // Customer snapshot — best effort from the expanded source object. + $customerId = null; + $customerEmail = null; + if ($source) { + $customerId = $source->customer ?? null; + $customerEmail = ($source->billing_details->email ?? null) + ?? ($source->receipt_email ?? null) + ?? ($source->customer_email ?? null); + } + + return [ + 'stripe_id' => $txn->id, + 'source_type' => $txn->type ?? null, + 'reporting_category' => $txn->reporting_category ?? null, + 'source_id' => $sourceId, + 'amount' => (int) ($txn->amount ?? 0), + 'fee' => (int) ($txn->fee ?? 0), + 'net' => (int) ($txn->net ?? 0), + 'currency' => $txn->currency ?? null, + 'customer_id' => is_string($customerId) ? $customerId : null, + 'customer_email' => is_string($customerEmail) ? $customerEmail : null, + 'description' => $txn->description ?? null, + 'created' => isset($txn->created) ? Carbon::createFromTimestamp($txn->created) : null, + 'available_on' => isset($txn->available_on) ? Carbon::createFromTimestamp($txn->available_on) : null, + 'meta' => [ + 'status' => $txn->status ?? null, + 'fee_details' => isset($txn->fee_details) ? json_decode(json_encode($txn->fee_details), true) : null, + ], + ]; + } +} diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index 735bef0..c4c57b1 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -62,6 +62,12 @@ use Illuminate\Support\Facades\Facade; * @method static \Illuminate\Support\Collection mrrByProduct() * @method static array subscriptionMetrics() * + * Stripe Ledger (net-of-fees, user-independent): + * @method static array ledgerRevenueTypes() + * @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) + * * Cart Queries: * @method static \Illuminate\Database\Eloquent\Builder carts() * @method static \Illuminate\Database\Eloquent\Builder activeCarts() diff --git a/src/Models/StripeTransaction.php b/src/Models/StripeTransaction.php new file mode 100644 index 0000000..aae3dc5 --- /dev/null +++ b/src/Models/StripeTransaction.php @@ -0,0 +1,50 @@ + 'integer', + 'fee' => 'integer', + 'net' => 'integer', + 'created' => 'datetime', + 'available_on' => 'datetime', + 'meta' => 'object', + ]; + + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->setTable(config('shop.tables.stripe_transactions', 'stripe_transactions')); + } +} diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 49ed5b6..1e923a3 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -13,6 +13,7 @@ use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPurchase; +use Blax\Shop\Models\StripeTransaction; use Blax\Shop\Models\Subscription; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; @@ -940,4 +941,98 @@ class ShopService return number_format($amount, 2) . ' ' . strtoupper($currency); } + + // ========================================================================= + // STRIPE LEDGER (net-of-fees, user-independent) + // ========================================================================= + + /** + * The configured {@see StripeTransaction} model class. + * + * @return class-string<\Illuminate\Database\Eloquent\Model> + */ + protected function ledgerModel(): string + { + return config('shop.models.stripe_transaction', StripeTransaction::class); + } + + /** + * BalanceTransaction `type`s that count as revenue (net of fees/refunds). + * + * @return array + */ + public function ledgerRevenueTypes(): array + { + return config('shop.ledger.revenue_types', [ + 'charge', 'payment', 'refund', 'payment_refund', 'adjustment', 'dispute', 'dispute_reversal', + ]); + } + + /** + * The earliest ledger row's Stripe `created` timestamp — the "start" of the + * business's money history, for an all-time cumulative view. Null when empty. + */ + public function ledgerEarliest(): ?Carbon + { + $min = $this->ledgerModel()::query()->min('created'); + + return $min ? Carbon::parse($min) : null; + } + + /** + * Revenue-bearing ledger movements grouped by Stripe day, in cents. + * + * One row per day with signed `gross` (SUM amount), `fee` (SUM fee) and + * `net` (SUM net = the money kept after fees and refunds) plus the row + * `count`. Refund/dispute days push `gross`/`net` down. Feed the `net` + * column into a cumulative sum for a break-even balance line. + * + * @return \Illuminate\Support\Collection + */ + public function revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection + { + return $this->ledgerModel()::query() + ->whereBetween('created', [$from, $until]) + ->whereIn('source_type', $this->ledgerRevenueTypes()) + ->selectRaw('DATE(created) as date, SUM(amount) as gross, SUM(fee) as fee, SUM(net) as net, COUNT(*) as count') + ->groupBy('date') + ->orderBy('date') + ->get() + ->map(fn ($r) => (object) [ + 'date' => (string) $r->date, + 'gross' => (int) $r->gross, + 'fee' => (int) $r->fee, + 'net' => (int) $r->net, + 'count' => (int) $r->count, + ]); + } + + /** + * Window totals over the revenue-bearing ledger, in cents: gross sales + * (positive charge lines), the negative refund total, Stripe fees, and the + * resulting net collected. `count` is the number of ledger rows in range. + * + * @return array{gross: int, refunds: int, fees: int, net: int, count: int} + */ + public function ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until): array + { + // CASE WHEN (not GREATEST/LEAST) so the split is portable to SQLite tests. + $row = $this->ledgerModel()::query() + ->whereBetween('created', [$from, $until]) + ->whereIn('source_type', $this->ledgerRevenueTypes()) + ->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 [ + '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), + ]; + } } diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index c03391c..39e51bd 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -74,6 +74,7 @@ class ShopServiceProvider extends ServiceProvider \Blax\Shop\Console\Commands\ShopStocksClaimsCommand::class, \Blax\Shop\Console\Commands\ShopAddExampleProducts::class, \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class, + \Blax\Shop\Console\Commands\ShopImportStripeLedgerCommand::class, ]); } diff --git a/tests/Feature/Ledger/LedgerMetricsTest.php b/tests/Feature/Ledger/LedgerMetricsTest.php new file mode 100644 index 0000000..66b04b3 --- /dev/null +++ b/tests/Feature/Ledger/LedgerMetricsTest.php @@ -0,0 +1,91 @@ + 'txn_' . bin2hex(random_bytes(6)), + 'source_type' => 'charge', + 'currency' => 'eur', + 'created' => Carbon::parse('2025-10-01'), + ], $attrs)); + } + + #[Test] + public function net_is_gross_minus_fees_and_refunds_per_day(): void + { + // Two charges and a refund on the same day. + $this->txn(['amount' => 2550, 'fee' => 104, 'net' => 2446, 'created' => Carbon::parse('2025-11-05 09:00')]); + $this->txn(['amount' => 8700, 'fee' => 281, 'net' => 8419, 'created' => Carbon::parse('2025-11-05 14:00')]); + $this->txn(['source_type' => 'refund', 'amount' => -2550, 'fee' => 0, 'net' => -2550, 'created' => Carbon::parse('2025-11-05 18:00')]); + + $days = Shop::revenueLedgerByDay(Carbon::parse('2025-11-01'), Carbon::parse('2025-11-30')); + + $this->assertCount(1, $days); + $row = $days->first(); + $this->assertSame('2025-11-05', $row->date); + $this->assertSame(2550 + 8700 - 2550, $row->gross); + $this->assertSame(104 + 281, $row->fee); + $this->assertSame(2446 + 8419 - 2550, $row->net); + $this->assertSame(3, $row->count); + } + + #[Test] + public function totals_split_gross_refunds_fees_and_net(): void + { + $this->txn(['amount' => 2550, 'fee' => 104, 'net' => 2446]); + $this->txn(['amount' => 4900, 'fee' => 172, 'net' => 4728]); + $this->txn(['source_type' => 'refund', 'amount' => -2550, 'fee' => 0, 'net' => -2550]); + + $totals = Shop::ledgerTotals(Carbon::parse('2025-01-01'), Carbon::parse('2026-12-31')); + + $this->assertSame(2550 + 4900, $totals['gross']); + $this->assertSame(-2550, $totals['refunds']); + $this->assertSame(104 + 172, $totals['fees']); + $this->assertSame(2446 + 4728 - 2550, $totals['net']); + $this->assertSame(3, $totals['count']); + } + + #[Test] + public function payouts_and_transfers_are_excluded_from_revenue(): void + { + $this->txn(['amount' => 5000, 'fee' => 175, 'net' => 4825]); + // A payout moves already-counted money to the bank — not revenue. + $this->txn(['source_type' => 'payout', 'amount' => -4825, 'fee' => 0, 'net' => -4825]); + + $totals = Shop::ledgerTotals(Carbon::parse('2025-01-01'), Carbon::parse('2026-12-31')); + + $this->assertSame(4825, $totals['net'], 'payout must not drag net down'); + $this->assertSame(1, $totals['count']); + } + + #[Test] + public function earliest_returns_the_first_created_timestamp(): void + { + $this->assertNull(Shop::ledgerEarliest()); + + $this->txn(['amount' => 1000, 'net' => 1000, 'created' => Carbon::parse('2025-10-15')]); + $this->txn(['amount' => 1000, 'net' => 1000, 'created' => Carbon::parse('2025-10-03')]); + + $this->assertSame('2025-10-03', Shop::ledgerEarliest()->toDateString()); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index bd38689..60b21c3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -76,5 +76,8 @@ abstract class TestCase extends Orchestra $migration = include __DIR__ . '/../database/migrations/2026_01_01_000003_create_license_seats_table.php'; $migration->up(); + + $migration = include __DIR__ . '/../database/migrations/2026_09_03_000001_create_stripe_transactions_table.php'; + $migration->up(); } }