Taocarts 知识

PayPal Reconciliation: How We Solved the 2 AM Fee Discrepancy

📅 2026-05-26 博客文章

PayPal Reconciliation: How We Solved the 2 AM Fee Discrepancy

The notification pinged at 2 AM. A senior developer from a client's operations team had pulled the PayPal settlement report against our system's order ledger. The difference: roughly $200 in fees that neither side could fully explain. Not a fraud case. Not a chargeback. Just the accumulated gap between what PayPal charged and what our fee calculation expected.

This wasn't a one-off. It was structural.

The Real Problem Isn't the API — It's the Fee Math

Most developers treat PayPal integration as a solved problem. You call v2/checkout/orders, get a COMPLETED status, mark the order paid. But the complexity lives in two places most tutorials skip:

  1. The tiered fee structure — PayPal's 3.4%-4.4% + fixed fee isn't one equation. The percentage varies by transaction type (goods vs services), payment method (card vs PayPal balance), and currency.
  2. The reconciliation gap — PayPal batches settlements differently. A single order might be split across multiple payouts if the payment was held or partially refunded.

The impact? A mid-volume platform processing hundreds of orders weekly could lose a noticeable amount per month to unaccounted fee variance. Worse, the operations team spends days each month cross-referencing PayPal reports with the internal order table.

Our Approach: Append-Only Fee Ledger

Instead of recalculating fees from PayPal's transaction details (which requires parsing variable-fee tier logic), we decided to store PayPal's final settlement fees as a source-of-truth ledger and compare them against our internal estimates.

Step 1: Capture Fees at Settlement Time

PayPal's webhook events include PAYMENT.CAPTURE.COMPLETED which provides seller_receivable_breakdown. This includes paypal_fee in the response.

// ProcessPayPalCapture.php
public function handle(array $payload): void
{
    $captureId = $payload['resource']['id'] ?? '';
    $orderId = $payload['resource']['custom_id'] ?? '';

    if (!$captureId || !$orderId) {
        \Log::warning('PayPal webhook missing capture ID or custom_id', [
            'webhook_id' => $payload['id'] ?? null,
        ]);
        return;
    }

    $breakdown = $payload['resource']['seller_receivable_breakdown'] ?? [];
    $paypalFee = $breakdown['paypal_fee']['value'] ?? null;

    if ($paypalFee === null) {
        \Log::warning('PayPal webhook missing seller_receivable_breakdown.paypal_fee', [
            'capture_id' => $captureId,
        ]);
        return;
    }

    FeeLedger::create([
        'order_id'      => $orderId,
        'capture_id'    => $captureId,
        'amount'        => $paypalFee,
        'currency'      => $breakdown['paypal_fee']['currency'] ?? 'USD',
        'received_at'   => $payload['create_time'] ?? now(),
    ]);
}

The key detail: we store the fee at webhook time, before any manual reconciliation. This isolates the PayPal truth from our internal calculations.

Step 2: Compare With Internal Estimate

Our internal estimate uses a simplified model — the 3.4% + $0.30 tier most common for goods payments. The mismatch reveals the hidden cost of variable pricing.

// EstimateFee.php
public static function forOrder(float $orderTotal, string $currency = 'USD'): float
{
    $percentageRate = 0.034;
    $fixedFee = 0.30;

    if ($currency !== 'USD') {
        // Currency conversion adds 2.5% surcharge
        $surcharge = $orderTotal * 0.025;
        return round($orderTotal * $percentageRate + $fixedFee + $surcharge, 2);
    }

    return round($orderTotal * $percentageRate + $fixedFee, 2);
}

This isn't perfect — we don't parse PayPal's dynamic fee tiers. But it's close enough for operational alerts. When the difference exceeds a threshold (we use 0.20 of the fee), the system flags the order for manual review.

Step 3: Daily Reconciliation Report

The real value comes from a cron job that compares PayPal's captured fees against our ledger.

// DailyReconciliationCommand.php
public function handle(): void
{
    $startDate = now()->subDays(1)->startOfDay();
    $unreconciled = FeeLedger::query()
        ->whereNull('reconciled_at')
        ->where('created_at', '>=', $startDate)
        ->get();

    $mismatches = [];
    foreach ($unreconciled as $entry) {
        $order = Order::find($entry->order_id);

        if ($order === null) {
            $mismatches[] = "Order {$entry->order_id} not found in system";
            continue;
        }

        $estimatedFee = EstimateFee::forOrder($order->total_amount, $order->currency);
        $difference = abs($entry->amount - $estimatedFee);

        if ($difference > 0.20) {
            $mismatches[] = sprintf(
                'Order %s: PayPal fee %.2f | Estimated %.2f | Diff %.2f',
                $entry->order_id,
                $entry->amount,
                $estimatedFee,
                $difference
            );
        }

        $entry->update(['reconciled_at' => now()]);
    }

    if (count($mismatches) > 0) {
        Notification::sendToOpsTeam(new ReconciliationAlert($mismatches));
    }
}

What We Discovered

After running this for a few weeks, three patterns emerged:

Currency conversion costs are real. When customers pay in a non-USD currency but the settlement happens in USD, PayPal adds a conversion surcharge. This isn't shown in the initial authorization — only in the capture breakdown. We found it added roughly 2.5% on top of the processing fee.

The fixed fee varies by country. PayPal's $0.30 is for US merchants. For some regions, it's $0.35 or equivalent in local currency. Small difference per transaction, but it adds up.

Refund fee handling is inconsistent. PayPal retains a portion of the fee on refunded transactions. The refund webhook event often arrives before the settlement report, so the ledger needs a separate reconciliation pass for refunds.

A Practical Recommendation

If you're building a cross-border platform, don't treat PayPal as a flat-fee payment gateway. The fee logic is dynamic, and the settlement reports lag behind real-time webhook events. Design your reconciliation pipeline from day one — your operations team will thank you.

In Taocarts, the reconciliation architecture uses a similar pattern: a separate fee_ledger table that stores PayPal's settlement breakdown independently of the order table. The key insight was decoupling "what PayPal says we were charged" from "what we think we should have been charged." The first is truth, the second is a diagnostic tool.

One stumbling block we ran into: PayPal's webhook retries can deliver duplicate events. If your fee ledger inserts are not idempotent, you will double-count fees. Add a unique constraint on (capture_id, event_type) or use the webhook's id field as a deduplication key.

Closing the Loop

That 2 AM notification taught us something important: the PayPal fee isn't a simple percentage. It's a negotiation between currency, payment method, region, and transaction type. The only way to audit it reliably is to store the fee at settlement time and compare it with a transparent estimation model.

Have you found edge cases in PayPal's fee structure that your reconciliation scripts miss? I'm curious how others handle the currency conversion surcharge in particular.

DESCRIPTION: How to handle PayPal fee reconciliation and find hidden costs using webhook data and estimation models.

wechat wechat qr