feat(ledger): real-time balance-transaction upsert for webhook sync
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) <noreply@anthropic.com>
This commit is contained in:
parent
f4624b2903
commit
1849f55558
|
|
@ -86,9 +86,8 @@ class ShopImportStripeLedgerCommand extends Command
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$existingId = $model::query()->where('stripe_id', $row['stripe_id'])->value('id');
|
$status = \Blax\Shop\Facades\Shop::recordBalanceTransaction($txn);
|
||||||
$model::query()->updateOrCreate(['stripe_id' => $row['stripe_id']], $row);
|
$status === 'updated' ? $updated++ : $created++;
|
||||||
$existingId ? $updated++ : $created++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->newLine();
|
$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<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
protected function rowFromTransaction($txn): array
|
protected function rowFromTransaction($txn): array
|
||||||
{
|
{
|
||||||
$source = is_object($txn->source ?? null) ? $txn->source : null;
|
return \Blax\Shop\Facades\Shop::ledgerRowFromBalanceTransaction($txn);
|
||||||
$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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,9 @@ use Illuminate\Support\Facades\Facade;
|
||||||
* @method static array ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until)
|
* @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 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 \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:
|
* Cart Queries:
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder carts()
|
* @method static \Illuminate\Database\Eloquent\Builder carts()
|
||||||
|
|
|
||||||
|
|
@ -1122,4 +1122,131 @@ class ShopService
|
||||||
->pluck('amount', 'customer_id')
|
->pluck('amount', 'customer_id')
|
||||||
->map(fn ($v) => (int) $v);
|
->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,60 @@ class LedgerMetricsTest extends TestCase
|
||||||
$this->assertSame(2, $map->count());
|
$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]
|
#[Test]
|
||||||
public function earliest_returns_the_first_created_timestamp(): void
|
public function earliest_returns_the_first_created_timestamp(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue