From 1849f555587fd8de7b4469f4f26f735cf8dfd1f8 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Tue, 15 Sep 2026 09:36:40 +0200 Subject: [PATCH] feat(ledger): real-time balance-transaction upsert for webhook sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the primitives a host webhook needs to keep the ledger current in real time, so the importer is a backstop rather than the only path: - ledgerRowFromBalanceTransaction(): the canonical row builder (the import command now delegates to it), with refund→charge buyer fallback. - recordBalanceTransaction(): idempotent upsert on the txn_ id that never downgrades a known customer_id/email to null (so a webhook's resolved buyer isn't clobbered by a later importer pass that couldn't resolve it). - syncLedgerForCharge(): retrieves a charge + its refunds and upserts their balance transactions, user-independent, never throws (logs + returns 0). Facade @methods + 3 LedgerMetricsTest cases (upsert idempotency, no-downgrade, non-charge-id guard). Full suite 1461 green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ShopImportStripeLedgerCommand.php | 42 +----- src/Facades/Shop.php | 3 + src/Services/ShopService.php | 127 ++++++++++++++++++ tests/Feature/Ledger/LedgerMetricsTest.php | 54 ++++++++ 4 files changed, 190 insertions(+), 36 deletions(-) diff --git a/src/Console/Commands/ShopImportStripeLedgerCommand.php b/src/Console/Commands/ShopImportStripeLedgerCommand.php index a90bdd1..47b0608 100644 --- a/src/Console/Commands/ShopImportStripeLedgerCommand.php +++ b/src/Console/Commands/ShopImportStripeLedgerCommand.php @@ -86,9 +86,8 @@ class ShopImportStripeLedgerCommand extends Command continue; } - $existingId = $model::query()->where('stripe_id', $row['stripe_id'])->value('id'); - $model::query()->updateOrCreate(['stripe_id' => $row['stripe_id']], $row); - $existingId ? $updated++ : $created++; + $status = \Blax\Shop\Facades\Shop::recordBalanceTransaction($txn); + $status === 'updated' ? $updated++ : $created++; } $this->newLine(); @@ -105,43 +104,14 @@ class ShopImportStripeLedgerCommand extends Command } /** - * Flatten a Stripe BalanceTransaction (with `source` expanded) into a ledger row. + * Flatten a Stripe BalanceTransaction (with `source` expanded) into a ledger + * row. Delegates to the shared builder so the import and the real-time + * webhook produce identical rows. * * @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, - ], - ]; + return \Blax\Shop\Facades\Shop::ledgerRowFromBalanceTransaction($txn); } } diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index 1c2c126..331305e 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -69,6 +69,9 @@ use Illuminate\Support\Facades\Facade; * @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) + * @method static array ledgerRowFromBalanceTransaction($txn, $source = null) + * @method static string recordBalanceTransaction($txn, $source = null) + * @method static int syncLedgerForCharge(?string $chargeId) * * Cart Queries: * @method static \Illuminate\Database\Eloquent\Builder carts() diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 6377f8a..73b1acb 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1122,4 +1122,131 @@ class ShopService ->pluck('amount', 'customer_id') ->map(fn ($v) => (int) $v); } + + /** + * Flatten a Stripe BalanceTransaction into a ledger row. Pass an explicit + * `$source` (the Charge/Refund already retrieved for a webhook) to attribute + * the buyer; otherwise the transaction's own expanded `source` is used (the + * importer path). A refund's buyer is read from its underlying charge so + * refunds net against the right customer. + * + * @return array + */ + public function ledgerRowFromBalanceTransaction($txn, $source = null): array + { + $source = is_object($source) ? $source : (is_object($txn->source ?? null) ? $txn->source : null); + $sourceId = $source?->id ?? (is_string($txn->source ?? null) ? $txn->source : null); + + $chargeSource = is_object($source->charge ?? null) ? $source->charge : null; + + $customerId = null; + $customerEmail = null; + foreach ([$source, $chargeSource] as $s) { + if (! is_object($s)) { + continue; + } + $customerId = $customerId ?? ($s->customer ?? null); + $customerEmail = $customerEmail + ?? ($s->billing_details->email ?? null) + ?? ($s->receipt_email ?? null) + ?? ($s->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, + ], + ]; + } + + /** + * Idempotently upsert one BalanceTransaction into the ledger (key = `txn_` + * id). Never downgrades a known customer_id/email back to null — so the + * real-time webhook, which resolves the buyer, is not clobbered by a later + * importer pass that couldn't. Returns 'created' or 'updated'. + */ + public function recordBalanceTransaction($txn, $source = null): string + { + $row = $this->ledgerRowFromBalanceTransaction($txn, $source); + $existing = $this->ledgerModel()::query()->where('stripe_id', $row['stripe_id'])->first(); + + if ($existing) { + if ($row['customer_id'] === null && $existing->customer_id !== null) { + $row['customer_id'] = $existing->customer_id; + } + if ($row['customer_email'] === null && $existing->customer_email !== null) { + $row['customer_email'] = $existing->customer_email; + } + $existing->fill($row)->save(); + + return 'updated'; + } + + $this->ledgerModel()::query()->create($row); + + return 'created'; + } + + /** + * Real-time ledger sync for a single charge — the webhook's main path. + * Retrieves the charge with its balance transaction and any refunds' balance + * transactions and upserts each, user-independent (a deleted customer's money + * is still recorded). Returns the number of rows touched. Never throws: on + * any Stripe/DB error it logs and returns 0, so it can't break the webhook. + */ + public function syncLedgerForCharge(?string $chargeId): int + { + if (! is_string($chargeId) || $chargeId === '' || ! str_starts_with($chargeId, 'ch_')) { + return 0; + } + + $secret = config('services.stripe.secret') ?: config('cashier.secret') ?: env('STRIPE_SECRET'); + if (! $secret) { + return 0; + } + + try { + \Stripe\Stripe::setApiKey($secret); + $charge = \Stripe\Charge::retrieve([ + 'id' => $chargeId, + 'expand' => ['balance_transaction', 'refunds.data.balance_transaction'], + ]); + + $count = 0; + if (is_object($charge->balance_transaction ?? null)) { + $this->recordBalanceTransaction($charge->balance_transaction, $charge); + $count++; + } + foreach (($charge->refunds->data ?? []) as $refund) { + if (is_object($refund->balance_transaction ?? null)) { + // Attribute the refund to the charge's buyer. + $this->recordBalanceTransaction($refund->balance_transaction, $charge); + $count++; + } + } + + return $count; + } catch (\Throwable $e) { + \Illuminate\Support\Facades\Log::warning('[shop:ledger] syncLedgerForCharge failed', [ + 'charge' => $chargeId, + 'error' => $e->getMessage(), + ]); + + return 0; + } + } } diff --git a/tests/Feature/Ledger/LedgerMetricsTest.php b/tests/Feature/Ledger/LedgerMetricsTest.php index b5b1fc1..4443794 100644 --- a/tests/Feature/Ledger/LedgerMetricsTest.php +++ b/tests/Feature/Ledger/LedgerMetricsTest.php @@ -123,6 +123,60 @@ class LedgerMetricsTest extends TestCase $this->assertSame(2, $map->count()); } + // Fixtures mirror what stripe-php hands us at runtime — the code reads + // properties off the objects, so plain objects exercise it exactly. (Real + // Stripe objects are avoided here: their constructFrom touches the global + // object-type map, which is fragile under the full suite's autoload state.) + #[Test] + public function records_and_upserts_a_balance_transaction(): void + { + $txn = (object) [ + 'id' => 'txn_rec1', 'type' => 'charge', 'amount' => 2000, 'fee' => 69, 'net' => 1931, + 'currency' => 'eur', 'created' => 1730000000, + 'source' => (object) ['id' => 'ch_rec1', 'customer' => 'cus_A', 'billing_details' => (object) ['email' => 'a@x.io']], + ]; + + $this->assertSame('created', Shop::recordBalanceTransaction($txn)); + $row = StripeTransaction::where('stripe_id', 'txn_rec1')->firstOrFail(); + $this->assertSame(2000, $row->amount); + $this->assertSame('cus_A', $row->customer_id); + $this->assertSame('a@x.io', $row->customer_email); + $this->assertSame('ch_rec1', $row->source_id); + + // Re-recording the same txn upserts in place (no duplicate) and updates. + $txn->net = 1900; + $this->assertSame('updated', Shop::recordBalanceTransaction($txn)); + $this->assertSame(1, StripeTransaction::where('stripe_id', 'txn_rec1')->count()); + $this->assertSame(1900, StripeTransaction::where('stripe_id', 'txn_rec1')->value('net')); + } + + #[Test] + public function never_downgrades_a_known_customer_to_null(): void + { + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_ref1', 'type' => 'refund', 'amount' => -2000, 'net' => -2000, 'currency' => 'eur', 'created' => 1730000000, + 'source' => (object) ['id' => 'ch_ref1', 'customer' => 'cus_B', 'receipt_email' => 'b@x.io'], + ]); + $this->assertSame('cus_B', StripeTransaction::where('stripe_id', 'txn_ref1')->value('customer_id')); + + // A later importer pass that can't resolve the buyer must NOT null it out. + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_ref1', 'type' => 'refund', 'amount' => -2000, 'net' => -2000, 'currency' => 'eur', 'created' => 1730000000, + 'source' => 're_orphan', + ]); + $this->assertSame('cus_B', StripeTransaction::where('stripe_id', 'txn_ref1')->value('customer_id')); + $this->assertSame('b@x.io', StripeTransaction::where('stripe_id', 'txn_ref1')->value('customer_email')); + } + + #[Test] + public function sync_ledger_for_charge_ignores_non_charge_ids(): void + { + // Guard returns before any Stripe call for null / empty / non-ch_ ids. + $this->assertSame(0, Shop::syncLedgerForCharge(null)); + $this->assertSame(0, Shop::syncLedgerForCharge('')); + $this->assertSame(0, Shop::syncLedgerForCharge('pi_123')); + } + #[Test] public function earliest_returns_the_first_created_timestamp(): void {