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',
|
||||
'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',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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 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()
|
||||
|
|
|
|||
|
|
@ -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\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<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\ShopAddExampleProducts::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->up();
|
||||
|
||||
$migration = include __DIR__ . '/../database/migrations/2026_09_03_000001_create_stripe_transactions_table.php';
|
||||
$migration->up();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue