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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-03 10:01:07 +02:00
parent 47e7300e98
commit 86e6469d1f
9 changed files with 489 additions and 0 deletions

View File

@ -41,6 +41,7 @@ return [
'subscriptions' => 'subscriptions', 'subscriptions' => 'subscriptions',
'subscription_items' => 'subscription_items', 'subscription_items' => 'subscription_items',
'license_seats' => 'license_seats', 'license_seats' => 'license_seats',
'stripe_transactions' => 'stripe_transactions',
], ],
// Model classes (allow overriding in main instance) // Model classes (allow overriding in main instance)
@ -61,6 +62,28 @@ return [
'subscription' => \Blax\Shop\Models\Subscription::class, 'subscription' => \Blax\Shop\Models\Subscription::class,
'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class, 'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class,
'license_seat' => \Blax\Shop\Models\LicenseSeat::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',
],
], ],
/* /*

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* A user-independent mirror of Stripe's BalanceTransaction ledger.
*
* Every money movement Stripe records (charge, refund, dispute, adjustment, …)
* gets one immutable row keyed on the `txn_` id, carrying the signed gross
* `amount`, the Stripe `fee`, and the resulting `net` — so "revenue net of
* fees and refunds" is a plain `SUM(net)`, and a deleted customer never
* removes their history (nothing here references a local user).
*
* Money columns are signed `bigInteger` (cents): a refund's amount/net are
* negative. Guarded with `hasTable` so a consumer that already owns the table
* name is left untouched — repoint `shop.tables.stripe_transactions` to keep
* both.
*/
return new class extends Migration
{
public function up(): void
{
$table = config('shop.tables.stripe_transactions', 'stripe_transactions');
if (Schema::hasTable($table)) {
return;
}
Schema::create($table, function (Blueprint $blueprint) {
$blueprint->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'));
}
};

View File

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Stripe\Stripe;
/**
* Mirror Stripe's BalanceTransaction ledger into the local `stripe_transactions`
* table. Pages EVERY balance transaction on the account — globally, not per
* customer — so a charge whose customer was deleted (or never existed locally)
* is still recorded. Idempotent: upserts on the `txn_` id and preserves Stripe's
* own `created`/`available_on` timestamps.
*/
class ShopImportStripeLedgerCommand extends Command
{
protected $signature = 'shop:import-stripe-ledger
{--dry-run : Report what would change, write nothing}
{--since= : Only import transactions created on/after this date (YYYY-MM-DD)}
{--limit=100 : Stripe page size (max 100)}';
protected $description = 'Mirror all Stripe balance transactions into the local ledger (user-independent).';
public function handle(): int
{
$secret = config('services.stripe.secret')
?: config('cashier.secret')
?: env('STRIPE_SECRET');
if (! $secret) {
$this->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<string, mixed>
*/
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,
],
];
}
}

View File

@ -62,6 +62,12 @@ use Illuminate\Support\Facades\Facade;
* @method static \Illuminate\Support\Collection mrrByProduct() * @method static \Illuminate\Support\Collection mrrByProduct()
* @method static array subscriptionMetrics() * @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: * Cart Queries:
* @method static \Illuminate\Database\Eloquent\Builder carts() * @method static \Illuminate\Database\Eloquent\Builder carts()
* @method static \Illuminate\Database\Eloquent\Builder activeCarts() * @method static \Illuminate\Database\Eloquent\Builder activeCarts()

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
/**
* One row per Stripe BalanceTransaction — the immutable, user-independent money
* ledger. Signed cents: refunds/disputes carry negative `amount`/`net`.
*
* @property string $stripe_id
* @property string|null $source_type
* @property string|null $reporting_category
* @property string|null $source_id
* @property int $amount
* @property int $fee
* @property int $net
* @property string|null $currency
* @property string|null $customer_id
* @property string|null $customer_email
* @property string|null $description
* @property \Illuminate\Support\Carbon|null $created
* @property \Illuminate\Support\Carbon|null $available_on
* @property object|null $meta
*/
class StripeTransaction extends Model
{
use HasUuids;
protected $guarded = [];
protected $casts = [
'amount' => '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'));
}
}

View File

@ -13,6 +13,7 @@ use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory; use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Models\ProductPrice; use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\StripeTransaction;
use Blax\Shop\Models\Subscription; use Blax\Shop\Models\Subscription;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@ -940,4 +941,98 @@ class ShopService
return number_format($amount, 2) . ' ' . strtoupper($currency); 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<int, string>
*/
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<int, object{date: string, gross: int, fee: int, net: int, count: int}>
*/
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),
];
}
} }

View File

@ -74,6 +74,7 @@ class ShopServiceProvider extends ServiceProvider
\Blax\Shop\Console\Commands\ShopStocksClaimsCommand::class, \Blax\Shop\Console\Commands\ShopStocksClaimsCommand::class,
\Blax\Shop\Console\Commands\ShopAddExampleProducts::class, \Blax\Shop\Console\Commands\ShopAddExampleProducts::class,
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class, \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
\Blax\Shop\Console\Commands\ShopImportStripeLedgerCommand::class,
]); ]);
} }

View File

@ -0,0 +1,91 @@
<?php
namespace Blax\Shop\Tests\Feature\Ledger;
use Blax\Shop\Facades\Shop;
use Blax\Shop\Models\StripeTransaction;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
/**
* Covers the Stripe ledger stat primitives: net-of-fees daily rollup, window
* totals, earliest-record anchor, and the revenue-type filter.
*
* The point of the ledger is that it is user-independent — none of these rows
* reference a user, so a deleted customer never removes their money history.
*/
class LedgerMetricsTest extends TestCase
{
use RefreshDatabase;
private function txn(array $attrs): StripeTransaction
{
return StripeTransaction::create(array_merge([
'stripe_id' => '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());
}
}

View File

@ -76,5 +76,8 @@ abstract class TestCase extends Orchestra
$migration = include __DIR__ . '/../database/migrations/2026_01_01_000003_create_license_seats_table.php'; $migration = include __DIR__ . '/../database/migrations/2026_01_01_000003_create_license_seats_table.php';
$migration->up(); $migration->up();
$migration = include __DIR__ . '/../database/migrations/2026_09_03_000001_create_stripe_transactions_table.php';
$migration->up();
} }
} }