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) <noreply@anthropic.com>
This commit is contained in:
Fabian @ Blax Software 2026-09-27 10:52:32 +02:00
parent 4df7e1373a
commit 8aa727fb5e
3 changed files with 151 additions and 9 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -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
{