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:
parent
47e7300e98
commit
86e6469d1f
|
|
@ -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',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue