From 8aa727fb5e59b3683d2d6c1eb0cefc770750c56b Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Sun, 27 Sep 2026 10:52:32 +0200 Subject: [PATCH] fix(ledger): count PayPal payments at their charge amount PayPal pays into the merchant's PayPal balance, so Stripe's balance transaction for it has amount 0 and only the fee. The ledger copied that 0, so every PayPal buyer showed no revenue. When a payment or refund balance transaction is 0 and its charge/refund says otherwise, record the charge's gross (net = gross - fee) and keep Stripe's own figures in meta. syncLedgerForCharge also accepts py_ charge ids now, so PayPal payments reach the ledger from the webhook instead of only the daily import. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Models/StripeTransaction.php | 5 ++ src/Services/ShopService.php | 81 +++++++++++++++++++--- tests/Feature/Ledger/LedgerMetricsTest.php | 74 ++++++++++++++++++++ 3 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/Models/StripeTransaction.php b/src/Models/StripeTransaction.php index aae3dc5..109410f 100644 --- a/src/Models/StripeTransaction.php +++ b/src/Models/StripeTransaction.php @@ -11,6 +11,11 @@ use Illuminate\Database\Eloquent\Model; * One row per Stripe BalanceTransaction — the immutable, user-independent money * ledger. Signed cents: refunds/disputes carry negative `amount`/`net`. * + * Payments that settle outside Stripe (PayPal pays into the merchant's PayPal + * balance) come from Stripe with `amount` 0 and only the fee. For those rows + * `amount` is the charge's gross and `net` is gross minus fee; Stripe's own + * figures stay in `meta` (`settled_outside_stripe`, `stripe_amount`, `stripe_net`). + * * @property string $stripe_id * @property string|null $source_type * @property string|null $reporting_category diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index c3a2463..4d369ac 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1141,27 +1141,89 @@ class ShopService ?? ($s->customer_email ?? null); } + $amount = (int) ($txn->amount ?? 0); + $fee = (int) ($txn->fee ?? 0); + $net = (int) ($txn->net ?? 0); + $currency = $txn->currency ?? null; + $meta = [ + 'status' => $txn->status ?? null, + 'fee_details' => isset($txn->fee_details) ? json_decode(json_encode($txn->fee_details), true) : null, + ]; + + if ($external = $this->externallySettledAmount($txn, $source)) { + $meta['settled_outside_stripe'] = true; + $meta['stripe_amount'] = $amount; + $meta['stripe_net'] = $net; + [$amount, $currency] = [$external['amount'], $external['currency'] ?? $currency]; + $net = $amount - $fee; + } + 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, + 'amount' => $amount, + 'fee' => $fee, + 'net' => $net, + 'currency' => $currency, '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, - ], + 'meta' => $meta, ]; } + /** + * Signed gross for a payment or refund that settled outside the Stripe + * balance, or null when Stripe's own `amount` already holds it. + * + * PayPal pays into the merchant's PayPal balance, so its balance transaction + * carries amount 0 and only Stripe's fee. The buyer still paid the charge's + * full amount, so read it off the charge (or refund) instead. `$source` is + * whatever the caller resolved: the expanded charge/refund from the importer, + * or the charge the webhook retrieved (for a refund, the matching refund is + * looked up on it by balance transaction id). + * + * @return array{amount: int, currency: string|null}|null + */ + protected function externallySettledAmount($txn, $source): ?array + { + if ((int) ($txn->amount ?? 0) !== 0 || ! is_object($source)) { + return null; + } + + $kind = $source->object ?? null; + $type = $txn->type ?? null; + + if (in_array($type, ['charge', 'payment'], true) && $kind === 'charge') { + $gross = (int) ($source->amount_captured ?? $source->amount ?? 0); + + return $gross > 0 ? ['amount' => $gross, 'currency' => $source->currency ?? null] : null; + } + + if (! in_array($type, ['refund', 'payment_refund'], true)) { + return null; + } + + $refund = $kind === 'refund' ? $source : null; + if ($kind === 'charge') { + foreach (($source->refunds->data ?? []) as $r) { + $btId = is_object($r->balance_transaction ?? null) ? $r->balance_transaction->id : ($r->balance_transaction ?? null); + if ($btId === $txn->id) { + $refund = $r; + break; + } + } + } + + $refunded = (int) ($refund->amount ?? 0); + + return $refunded > 0 ? ['amount' => -$refunded, 'currency' => $refund->currency ?? null] : null; + } + /** * Idempotently upsert one BalanceTransaction into the ledger (key = `txn_` * id). Never downgrades a known customer_id/email back to null — so the @@ -1199,7 +1261,8 @@ class ShopService */ public function syncLedgerForCharge(?string $chargeId): int { - if (! is_string($chargeId) || $chargeId === '' || ! str_starts_with($chargeId, 'ch_')) { + // ch_ = card and most methods; py_ = PayPal and other "payment" charges. + if (! is_string($chargeId) || ! (str_starts_with($chargeId, 'ch_') || str_starts_with($chargeId, 'py_'))) { return 0; } diff --git a/tests/Feature/Ledger/LedgerMetricsTest.php b/tests/Feature/Ledger/LedgerMetricsTest.php index 4443794..d4fc516 100644 --- a/tests/Feature/Ledger/LedgerMetricsTest.php +++ b/tests/Feature/Ledger/LedgerMetricsTest.php @@ -168,6 +168,80 @@ class LedgerMetricsTest extends TestCase $this->assertSame('b@x.io', StripeTransaction::where('stripe_id', 'txn_ref1')->value('customer_email')); } + #[Test] + public function paypal_payment_counts_the_charge_gross_not_stripes_zero(): void + { + // PayPal settles into the merchant's PayPal balance: Stripe's balance + // transaction is amount 0 with only the fee (real prod shape). + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_pp1', 'type' => 'payment', 'reporting_category' => 'charge', + 'amount' => 0, 'fee' => 14, 'net' => -14, 'currency' => 'eur', 'created' => 1730000000, + 'source' => (object) [ + 'id' => 'py_pp1', 'object' => 'charge', 'amount' => 2040, 'amount_captured' => 2040, + 'currency' => 'eur', 'customer' => 'cus_P', 'billing_details' => (object) ['email' => 'p@x.io'], + ], + ]); + + $row = StripeTransaction::where('stripe_id', 'txn_pp1')->firstOrFail(); + $this->assertSame(2040, $row->amount); + $this->assertSame(14, $row->fee); + $this->assertSame(2026, $row->net); + $this->assertTrue($row->meta->settled_outside_stripe); + $this->assertSame(0, $row->meta->stripe_amount); + $this->assertSame(-14, $row->meta->stripe_net); + $this->assertSame(2040, Shop::customerLedgerTotals('cus_P')['amount']); + } + + #[Test] + public function paypal_refund_from_the_webhook_charge_nets_against_the_buyer(): void + { + // The webhook passes the retrieved charge as the source for its refunds; + // the refund is matched on the charge by balance transaction id. + $charge = (object) [ + 'id' => 'py_pp2', 'object' => 'charge', 'amount' => 2040, 'amount_captured' => 2040, + 'currency' => 'eur', 'customer' => 'cus_Q', + 'refunds' => (object) ['data' => [ + (object) ['id' => 'pyr_other', 'amount' => 999, 'currency' => 'eur', 'balance_transaction' => 'txn_other'], + (object) ['id' => 'pyr_1', 'amount' => 500, 'currency' => 'eur', 'balance_transaction' => (object) ['id' => 'txn_ppr1']], + ]], + ]; + + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_pp2', 'type' => 'payment', 'amount' => 0, 'fee' => 14, 'net' => -14, + 'currency' => 'eur', 'created' => 1730000000, + ], $charge); + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_ppr1', 'type' => 'payment_refund', 'amount' => 0, 'fee' => 0, 'net' => 0, + 'currency' => 'eur', 'created' => 1730000100, + ], $charge); + + $this->assertSame(-500, StripeTransaction::where('stripe_id', 'txn_ppr1')->value('amount')); + $this->assertSame(1540, Shop::customerLedgerTotals('cus_Q')['amount']); + } + + #[Test] + public function zero_amount_rows_without_a_paid_charge_stay_zero(): void + { + // No expanded charge to read from, or a charge that captured nothing: + // keep Stripe's figures as they are. + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_z1', 'type' => 'payment', 'amount' => 0, 'fee' => 14, 'net' => -14, + 'currency' => 'eur', 'created' => 1730000000, 'source' => 'py_unexpanded', + ]); + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_z2', 'type' => 'payment', 'amount' => 0, 'fee' => 14, 'net' => -14, + 'currency' => 'eur', 'created' => 1730000000, + 'source' => (object) ['id' => 'py_z2', 'object' => 'charge', 'amount' => 2040, 'amount_captured' => 0], + ]); + + foreach (['txn_z1', 'txn_z2'] as $id) { + $row = StripeTransaction::where('stripe_id', $id)->firstOrFail(); + $this->assertSame(0, $row->amount); + $this->assertSame(-14, $row->net); + $this->assertObjectNotHasProperty('settled_outside_stripe', $row->meta); + } + } + #[Test] public function sync_ledger_for_charge_ignores_non_charge_ids(): void {