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:
parent
4df7e1373a
commit
8aa727fb5e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue