From 423da476f569bb234cc63aeec763dc2cb1bcd606 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Wed, 7 Oct 2026 10:46:46 +0200 Subject: [PATCH] feat(ledger): mirror PayPal accounts into the money ledger PayPal checkouts that run through Stripe settle into our PayPal account. Stripe's ledger row books the gross and Stripe's fee, but PayPal takes its own fee inside the PayPal account, so net revenue was overstated. - Ledger rows carry `provider` (stripe, paypal) and `account`; existing rows default to stripe. - shop:import-paypal-ledger reads every account in shop.paypal.accounts through the Transaction Search API (read-only) and upserts one row per PayPal transaction. Stripe-originated payments and refunds add a `paypal_fee` row (amount 0, net -fee); payments PayPal swept on to Stripe are booked as `paypal_fee_passthrough`, because Stripe's fee already holds PayPal's. Direct PayPal sales count in full; holds, sweeps and withdrawals are non-revenue. - `paypal_fee` joins the default revenue types. - Stripe rows keep their payment intent in meta so PayPal rows find the buyer. - Shop::ledgerTotalsByAccount() splits the totals per account. Co-Authored-By: Claude Opus 5.5 (1M context) --- config/shop.php | 29 ++ ...d_account_to_stripe_transactions_table.php | 58 +++ docs/06-ledger.md | 41 ++ .../ShopImportPaypalLedgerCommand.php | 99 ++++ src/Facades/Shop.php | 1 + .../PaymentProvider/PaypalLedgerService.php | 442 ++++++++++++++++++ src/Services/ShopService.php | 51 ++ src/ShopServiceProvider.php | 1 + tests/Feature/Ledger/PaypalLedgerTest.php | 269 +++++++++++ 9 files changed, 991 insertions(+) create mode 100644 database/migrations/2026_10_07_000001_add_provider_and_account_to_stripe_transactions_table.php create mode 100644 docs/06-ledger.md create mode 100644 src/Console/Commands/ShopImportPaypalLedgerCommand.php create mode 100644 src/Services/PaymentProvider/PaypalLedgerService.php create mode 100644 tests/Feature/Ledger/PaypalLedgerTest.php diff --git a/config/shop.php b/config/shop.php index c845d45..67cae72 100644 --- a/config/shop.php +++ b/config/shop.php @@ -90,6 +90,35 @@ return [ 'adjustment', 'dispute', 'dispute_reversal', + // PayPal's own fee on a payment that settled in a PayPal account + // (amount 0, net = -fee). See `paypal` below. + 'paypal_fee', + ], + ], + + /* + * PayPal accounts that receive money (read-only, via the Transaction Search + * reporting API). `shop:import-paypal-ledger` mirrors each account into the + * same ledger as Stripe, with `provider` = paypal and `account` = the PayPal + * merchant id, so revenue sums every account the business is paid into. + * + * PayPal checkouts that run through Stripe settle into the PayPal account: + * Stripe's ledger row books the gross and Stripe's fee, the PayPal row adds + * PayPal's own fee (`paypal_fee`). Direct PayPal sales (not via Stripe) are + * booked in full as `payment`. The REST app needs the "Transaction search" + * permission; nothing is ever written to PayPal. + * + * Add one entry per account; an entry without credentials is skipped. + */ + 'paypal' => [ + 'base_url' => env('PAYPAL_BASE_URL', 'https://api-m.paypal.com'), + + 'accounts' => [ + [ + 'name' => 'default', + 'client_id' => env('PAYPAL_CLIENT_ID'), + 'secret' => env('PAYPAL_SECRET'), + ], ], ], diff --git a/database/migrations/2026_10_07_000001_add_provider_and_account_to_stripe_transactions_table.php b/database/migrations/2026_10_07_000001_add_provider_and_account_to_stripe_transactions_table.php new file mode 100644 index 0000000..d2c41d4 --- /dev/null +++ b/database/migrations/2026_10_07_000001_add_provider_and_account_to_stripe_transactions_table.php @@ -0,0 +1,58 @@ +string('provider', 16)->default('stripe')->after('stripe_id')->index(); + }); + } + + if (! Schema::hasColumn($table, 'account')) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->string('account')->nullable()->after('provider')->index(); + }); + } + } + + public function down(): void + { + $table = config('shop.tables.stripe_transactions', 'stripe_transactions'); + + if (! Schema::hasTable($table)) { + return; + } + + foreach (['account', 'provider'] as $column) { + if (Schema::hasColumn($table, $column)) { + Schema::table($table, function (Blueprint $blueprint) use ($column) { + $blueprint->dropIndex([$column]); + $blueprint->dropColumn($column); + }); + } + } + } +}; diff --git a/docs/06-ledger.md b/docs/06-ledger.md new file mode 100644 index 0000000..bb41f6b --- /dev/null +++ b/docs/06-ledger.md @@ -0,0 +1,41 @@ +# Money ledger (Stripe + PayPal accounts) + +The `stripe_transactions` table is a user-independent ledger of every money movement, one row per transaction in the account where it happened. Amounts are signed cents: `amount` is the gross, `fee` the provider's fee, `net = amount - fee`. Revenue figures (`Shop::ledgerTotals`, `revenueLedgerByDay`, `customerLedgerTotals`, `ledgerAmountByCustomer`) sum the rows whose `source_type` is in `shop.ledger.revenue_types`. + +`provider` says which system a row comes from (`stripe`, `paypal`) and `account` which account of it (the PayPal merchant id; null for Stripe). `Shop::ledgerTotalsByAccount($from, $until)` splits the totals per account. + +## Filling it + +- Stripe: `shop:import-stripe-ledger` pages all BalanceTransactions; `Shop::syncLedgerForCharge()` writes a charge in real time from the webhook. +- PayPal: `shop:import-paypal-ledger` pages PayPal's Transaction Search API for every configured account. Read-only. Without `--since` it resumes 14 days before the account's newest row; the first run reaches back three years. Schedule it hourly or daily, after the Stripe import. + +## PayPal setup + +Create a REST app in the PayPal developer dashboard (live) with the "Transaction search" permission, then set: + +```dotenv +PAYPAL_CLIENT_ID=... +PAYPAL_SECRET=... +``` + +More accounts go into `shop.paypal.accounts` (publish the config), one entry each with `name`, `client_id`, `secret` and an optional `base_url` (`https://api-m.sandbox.paypal.com` for sandbox). + +## How PayPal rows count + +PayPal checkouts that run through Stripe settle into the PayPal account. Stripe's row books the buyer's gross (Stripe reports 0 for these, the importer reads the charge amount) and Stripe's fee. PayPal takes its own fee inside the PayPal account, so the PayPal import adds one row per payment or refund: + +| PayPal transaction | `source_type` | amount | fee | counts as revenue | +| --- | --- | --- | --- | --- | +| Stripe payment that settled in PayPal | `paypal_fee` | 0 | PayPal fee | yes (lowers net) | +| Stripe refund that settled in PayPal | `paypal_fee` | 0 | fee PayPal gave back (negative) | yes | +| Stripe payment swept on to Stripe | `paypal_fee_passthrough` | 0 | PayPal fee | no, Stripe's fee already holds it | +| Direct PayPal sale / refund / chargeback | `payment` / `payment_refund` / `dispute` | gross | PayPal fee | yes | +| PayPal account fee (T01xx) | `paypal_fee` | 0 | fee | yes | +| Withdrawal to bank (T04xx) | `payout` | signed | | no | +| Money sent (T00xx outgoing) | `payment_sent` | signed | | no | +| Holds, releases, sweeps, conversions | `transfer` | signed | | no | +| Not settled yet (status ≠ S) | `pending` | signed | | no | + +A payment counts as swept when PayPal shows a T20xx transfer on the same transaction. Stripe's `custom_field` (`acct_…:pi_…`) links PayPal rows to the Stripe payment; the buyer is copied from the Stripe row with that payment intent. + +If you override `shop.ledger.revenue_types`, add `paypal_fee`, or PayPal's fees will not lower net revenue. diff --git a/src/Console/Commands/ShopImportPaypalLedgerCommand.php b/src/Console/Commands/ShopImportPaypalLedgerCommand.php new file mode 100644 index 0000000..82bfdd5 --- /dev/null +++ b/src/Console/Commands/ShopImportPaypalLedgerCommand.php @@ -0,0 +1,99 @@ +accounts(); + if ($only = $this->option('account')) { + $accounts = array_values(array_filter($accounts, fn ($a) => $a['name'] === $only)); + } + + if (! $accounts) { + $this->info('No PayPal account configured (shop.paypal.accounts), nothing to import.'); + + return self::SUCCESS; + } + + if (! $paypal->hasProviderColumns()) { + $this->error('The ledger table has no provider/account columns yet. Run the laravel-shop migrations first.'); + + return self::FAILURE; + } + + $dry = (bool) $this->option('dry-run'); + $failed = false; + + foreach ($accounts as $account) { + try { + $since = $this->option('since') + ? Carbon::parse($this->option('since'))->startOfDay() + : $paypal->resumeFrom($paypal->balances($account)['account_id'] ?? null); + + $result = $paypal->import($account, $since, $dry); + } catch (\Throwable $e) { + $this->error(sprintf('[%s] %s', $account['name'], $e->getMessage())); + $failed = true; + + continue; + } + + $byType = []; + foreach ($result['rows'] as $row) { + $byType[$row['source_type']] ??= ['rows' => 0, 'amount' => 0, 'fee' => 0, 'net' => 0]; + $byType[$row['source_type']]['rows']++; + $byType[$row['source_type']]['amount'] += $row['amount']; + $byType[$row['source_type']]['fee'] += $row['fee']; + $byType[$row['source_type']]['net'] += $row['net']; + } + ksort($byType); + + $this->info(sprintf( + '[%s] %s %s since %s: %d transactions, %d new, %d existing.', + $account['name'], + $dry ? '[dry-run] would import' : 'Imported', + $result['account'], + $since->toDateString(), + $result['seen'], + $result['created'], + $result['updated'] + )); + + if ($byType) { + $this->table( + ['type', 'rows', 'amount', 'fee', 'net'], + array_map(fn ($type, $t) => [ + $type, + $t['rows'], + number_format($t['amount'] / 100, 2), + number_format($t['fee'] / 100, 2), + number_format($t['net'] / 100, 2), + ], array_keys($byType), $byType) + ); + } + } + + return $failed ? self::FAILURE : self::SUCCESS; + } +} diff --git a/src/Facades/Shop.php b/src/Facades/Shop.php index 87fade7..b940805 100644 --- a/src/Facades/Shop.php +++ b/src/Facades/Shop.php @@ -67,6 +67,7 @@ use Illuminate\Support\Facades\Facade; * @method static \Illuminate\Support\Carbon|null ledgerEarliest() * @method static \Illuminate\Support\Collection revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until) * @method static array ledgerTotals(\DateTimeInterface $from, \DateTimeInterface $until) + * @method static \Illuminate\Support\Collection ledgerTotalsByAccount(\DateTimeInterface $from, \DateTimeInterface $until) * @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 array ledgerRowFromBalanceTransaction($txn, $source = null) diff --git a/src/Services/PaymentProvider/PaypalLedgerService.php b/src/Services/PaymentProvider/PaypalLedgerService.php new file mode 100644 index 0000000..2d4e266 --- /dev/null +++ b/src/Services/PaymentProvider/PaypalLedgerService.php @@ -0,0 +1,442 @@ + */ + protected array $tokens = []; + + /** + * Configured accounts that have credentials. + * + * @return list + */ + public function accounts(): array + { + $base = (string) config('shop.paypal.base_url', 'https://api-m.paypal.com'); + $accounts = []; + + foreach ((array) config('shop.paypal.accounts', []) as $i => $account) { + $id = $account['client_id'] ?? null; + $secret = $account['secret'] ?? null; + if (! is_string($id) || $id === '' || ! is_string($secret) || $secret === '') { + continue; + } + + $accounts[] = [ + 'name' => (string) ($account['name'] ?? "paypal-{$i}"), + 'client_id' => $id, + 'secret' => $secret, + 'base_url' => rtrim((string) ($account['base_url'] ?? $base), '/'), + ]; + } + + return $accounts; + } + + /** + * Balances per currency plus the merchant `account_id` and PayPal's + * `last_refresh_time` (reporting data lags real time by up to ~3 hours). + * + * @param array{name: string, client_id: string, secret: string, base_url: string} $account + * @return array + */ + public function balances(array $account): array + { + return $this->get($account, '/v1/reporting/balances', [ + 'as_of_time' => now()->utc()->format('Y-m-d\TH:i:s\Z'), + ]); + } + + /** + * Every transaction on the account between two instants, oldest window + * first. Splits the range into 31-day windows and follows pagination. + * + * @param array{name: string, client_id: string, secret: string, base_url: string} $account + * @return list> + */ + public function transactions(array $account, \DateTimeInterface $from, \DateTimeInterface $until): array + { + $all = []; + $start = Carbon::instance($from)->utc(); + $until = Carbon::instance($until)->utc(); + + while ($start->lt($until)) { + $end = $start->copy()->addDays(self::WINDOW_DAYS)->subSecond()->min($until); + $page = 1; + + do { + $body = $this->get($account, '/v1/reporting/transactions', [ + 'start_date' => $start->format('Y-m-d\TH:i:s\Z'), + 'end_date' => $end->format('Y-m-d\TH:i:s\Z'), + 'fields' => 'transaction_info,payer_info', + 'page_size' => 500, + 'page' => $page, + ]); + + foreach ((array) ($body['transaction_details'] ?? []) as $t) { + $all[] = $t; + } + + $pages = (int) ($body['total_pages'] ?? 1); + $page++; + } while ($page <= $pages); + + $start = $end->copy()->addSecond(); + } + + return $all; + } + + /** + * Turn a batch of PayPal `transaction_details` into ledger rows. The batch + * is classified as a whole, because whether a Stripe payment was swept on + * to Stripe is told by a separate transfer row on the same transaction. + * + * @param list> $transactions + * @return list> + */ + public function ledgerRows(array $transactions, ?string $account): array + { + $swept = []; + foreach ($transactions as $t) { + $info = (array) ($t['transaction_info'] ?? []); + if (str_starts_with((string) ($info['transaction_event_code'] ?? ''), 'T20') && $this->stripeReference($info)) { + $swept[$this->groupKey($info)] = true; + } + } + + $rows = []; + foreach ($transactions as $t) { + if ($row = $this->ledgerRow($t, $account, $swept)) { + $rows[] = $row; + } + } + + return $rows; + } + + /** + * Idempotently upsert one PayPal ledger row (key = PayPal transaction id), + * filling the buyer from the Stripe ledger when the payment came through + * Stripe. Never downgrades a known buyer back to null. Returns 'created' + * or 'updated'. + * + * @param array $row + */ + public function record(array $row): string + { + $model = $this->ledgerModel(); + $row = $this->attributeBuyer($row); + $existing = $model::query()->where('stripe_id', $row['stripe_id'])->first(); + + if ($existing) { + $row['customer_id'] ??= $existing->customer_id; + $row['customer_email'] ??= $existing->customer_email; + $existing->fill($row)->save(); + + return 'updated'; + } + + $model::query()->create($row); + + return 'created'; + } + + /** + * Fetch and record one account's transactions. Stops at PayPal's last + * reporting refresh, so the window never asks for data PayPal has not + * published yet. + * + * @param array{name: string, client_id: string, secret: string, base_url: string} $account + * @return array{account: string, seen: int, created: int, updated: int, rows: list>} + */ + public function import(array $account, \DateTimeInterface $from, bool $dryRun = false): array + { + $balances = $this->balances($account); + $accountId = (string) ($balances['account_id'] ?? $account['name']); + $until = isset($balances['last_refresh_time']) + ? Carbon::parse($balances['last_refresh_time'])->min(now()) + : now()->subHours(3); + + $rows = $this->ledgerRows($this->transactions($account, $from, $until), $accountId); + $created = 0; + $updated = 0; + + foreach ($rows as $row) { + if ($dryRun) { + $this->ledgerModel()::query()->where('stripe_id', $row['stripe_id'])->exists() ? $updated++ : $created++; + + continue; + } + + $this->record($row) === 'created' ? $created++ : $updated++; + } + + return ['account' => $accountId, 'seen' => count($rows), 'created' => $created, 'updated' => $updated, 'rows' => $rows]; + } + + /** Where an incremental import of this account should start. */ + public function resumeFrom(?string $accountId, int $overlapDays = 14): Carbon + { + // PayPal keeps three years of history; that is the first run's reach. + $floor = now()->subYears(3)->addDay()->startOfDay(); + + if (! $accountId || ! $this->hasProviderColumns()) { + return $floor; + } + + $latest = $this->ledgerModel()::query() + ->where('provider', 'paypal') + ->where('account', $accountId) + ->max('created'); + + return $latest ? Carbon::parse($latest)->subDays($overlapDays)->max($floor) : $floor; + } + + public function hasProviderColumns(): bool + { + $table = (new ($this->ledgerModel()))->getTable(); + + return Schema::hasColumn($table, 'provider') && Schema::hasColumn($table, 'account'); + } + + /** + * @param array $t + * @param array $swept + * @return array|null + */ + protected function ledgerRow(array $t, ?string $account, array $swept): ?array + { + $info = (array) ($t['transaction_info'] ?? []); + $id = $info['transaction_id'] ?? null; + if (! is_string($id) || $id === '') { + return null; + } + + $code = (string) ($info['transaction_event_code'] ?? ''); + $group = substr($code, 0, 3); + $amount = $this->cents($info['transaction_amount']['value'] ?? 0); + // PayPal signs the fee from the balance's view (-0.47 charged, +0.08 + // returned on a refund); the ledger stores the fee as a cost. + $paypalFee = $this->cents($info['fee_amount']['value'] ?? 0); + $fee = -$paypalFee; + $stripeRef = $this->stripeReference($info); + $status = $info['transaction_status'] ?? null; + + $meta = array_filter([ + 'event_code' => $code, + 'status' => $status, + 'paypal_amount' => $amount, + 'paypal_fee' => $paypalFee, + 'reference_id' => $info['paypal_reference_id'] ?? null, + 'stripe_reference' => $stripeRef, + 'subject' => $info['transaction_subject'] ?? null, + ], fn ($v) => $v !== null && $v !== ''); + + if ($status !== 'S') { + // Pending, denied or reversed: not money yet. Re-classified on a + // later import once PayPal settles it. + $type = 'pending'; + } elseif ($stripeRef && (($group === 'T00' && $amount > 0) || $group === 'T11')) { + // Stripe books the gross (and the refund); PayPal adds only its fee. + $type = isset($swept[$this->groupKey($info)]) ? 'paypal_fee_passthrough' : 'paypal_fee'; + $amount = 0; + } elseif ($stripeRef) { + $type = 'transfer'; + } else { + [$type, $amount, $fee] = match (true) { + $group === 'T00' && $amount > 0 => ['payment', $amount, $fee], + $group === 'T00' => ['payment_sent', $amount, $fee], + $group === 'T11' => ['payment_refund', $amount, $fee], + $group === 'T12' => ['dispute', $amount, $fee], + // Account fees arrive as the transaction amount itself. + $group === 'T01' => ['paypal_fee', 0, $fee - $amount], + $group === 'T04' => ['payout', $amount, $fee], + default => ['transfer', $amount, $fee], + }; + } + + $created = isset($info['transaction_initiation_date']) ? Carbon::parse($info['transaction_initiation_date']) : null; + $payer = (array) ($t['payer_info'] ?? []); + + return [ + 'stripe_id' => $id, + 'provider' => 'paypal', + 'account' => $account, + 'source_type' => $type, + 'reporting_category' => $code ?: null, + 'source_id' => $stripeRef ?? $id, + 'amount' => $amount, + 'fee' => $fee, + 'net' => $amount - $fee, + 'currency' => isset($info['transaction_amount']['currency_code']) ? strtolower((string) $info['transaction_amount']['currency_code']) : null, + 'customer_id' => null, + 'customer_email' => is_string($payer['email_address'] ?? null) ? $payer['email_address'] : null, + 'description' => isset($info['transaction_subject']) ? mb_substr((string) $info['transaction_subject'], 0, 255) : null, + 'created' => $created, + 'available_on' => $created, + 'meta' => $meta, + ]; + } + + /** + * Copy the buyer from the Stripe side: the Stripe charge row for a payment + * (matched on its payment intent), else the PayPal payment a refund refers + * to. The PayPal payer email stays as the fallback. + * + * @param array $row + * @return array + */ + protected function attributeBuyer(array $row): array + { + $ref = $row['meta']['stripe_reference'] ?? null; + if (! $ref) { + return $row; + } + + $model = $this->ledgerModel(); + $match = null; + + if (str_starts_with($ref, 'pi_')) { + $match = $model::query()->where('meta->payment_intent', $ref)->whereNotNull('customer_id')->first(); + } + + if (! $match && ($origin = $row['meta']['reference_id'] ?? null)) { + $match = $model::query()->where('stripe_id', $origin)->whereNotNull('customer_id')->first(); + } + + if ($match) { + $row['customer_id'] = $match->customer_id; + $row['customer_email'] = $match->customer_email ?? $row['customer_email']; + } + + return $row; + } + + /** + * The Stripe object a PayPal transaction belongs to. Stripe writes + * `acct_…:pi_…:…` (payments) or `acct_…:pyr_…:…:refund` into PayPal's + * custom field. + * + * @param array $info + */ + protected function stripeReference(array $info): ?string + { + $custom = (string) ($info['custom_field'] ?? ''); + + return preg_match('/^acct_[A-Za-z0-9]+:((?:pi|py|ch|pyr|re)_[A-Za-z0-9]+)/', $custom, $m) ? $m[1] : null; + } + + /** + * The PayPal payment a row belongs to: refunds, holds and transfers point + * at it through `paypal_reference_id`; the payment itself is its own id. + * + * @param array $info + */ + protected function groupKey(array $info): string + { + return ($info['paypal_reference_id_type'] ?? null) === 'TXN' && ! empty($info['paypal_reference_id']) + ? (string) $info['paypal_reference_id'] + : (string) ($info['transaction_id'] ?? ''); + } + + protected function cents(mixed $value): int + { + return (int) round(((float) $value) * 100); + } + + /** + * @param array{name: string, client_id: string, secret: string, base_url: string} $account + * @param array $query + * @return array + */ + protected function get(array $account, string $path, array $query = []): array + { + $response = Http::withToken($this->token($account)) + ->acceptJson() + ->timeout(30) + ->retry(2, 1000, throw: false) + ->get($account['base_url'] . $path, $query); + + if ($response->failed()) { + throw new \RuntimeException(sprintf( + 'PayPal %s failed for account "%s": %s %s', + $path, + $account['name'], + $response->status(), + (string) ($response->json('message') ?? $response->json('error_description') ?? '') + )); + } + + return (array) $response->json(); + } + + /** @param array{name: string, client_id: string, secret: string, base_url: string} $account */ + protected function token(array $account): string + { + $cached = $this->tokens[$account['client_id']] ?? null; + if ($cached && $cached['expires'] > time() + 60) { + return $cached['token']; + } + + $response = Http::asForm() + ->withBasicAuth($account['client_id'], $account['secret']) + ->acceptJson() + ->timeout(30) + ->post($account['base_url'] . '/v1/oauth2/token', ['grant_type' => 'client_credentials']); + + $token = $response->json('access_token'); + if ($response->failed() || ! is_string($token)) { + throw new \RuntimeException(sprintf( + 'PayPal authentication failed for account "%s": %s', + $account['name'], + (string) ($response->json('error_description') ?? $response->status()) + )); + } + + $this->tokens[$account['client_id']] = ['token' => $token, 'expires' => time() + (int) ($response->json('expires_in') ?? 300)]; + + return $token; + } + + /** @return class-string<\Illuminate\Database\Eloquent\Model> */ + protected function ledgerModel(): string + { + return config('shop.models.stripe_transaction', StripeTransaction::class); + } +} diff --git a/src/Services/ShopService.php b/src/Services/ShopService.php index 4d369ac..a46cd5e 100644 --- a/src/Services/ShopService.php +++ b/src/Services/ShopService.php @@ -1025,6 +1025,47 @@ class ShopService ]; } + /** + * {@see ledgerTotals} split per account the money moved through: one entry + * per `provider` + `account` (Stripe, each PayPal account). The entries sum + * to ledgerTotals. Before the provider columns are migrated everything is + * reported as the one Stripe account. + * + * @return \Illuminate\Support\Collection + */ + public function ledgerTotalsByAccount(\DateTimeInterface $from, \DateTimeInterface $until): \Illuminate\Support\Collection + { + $model = $this->ledgerModel(); + $table = (new $model)->getTable(); + + if (! \Illuminate\Support\Facades\Schema::hasColumn($table, 'provider')) { + return collect([['provider' => 'stripe', 'account' => null] + $this->ledgerTotals($from, $until)]); + } + + return $model::query() + ->whereBetween('created', [$from, $until]) + ->whereIn('source_type', $this->ledgerRevenueTypes()) + ->groupBy('provider', 'account') + ->select('provider', 'account') + ->selectRaw('SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as gross') + ->selectRaw('SUM(CASE WHEN amount < 0 THEN amount ELSE 0 END) as refunds') + ->selectRaw('SUM(fee) as fees') + ->selectRaw('SUM(net) as net') + ->selectRaw('COUNT(*) as count') + ->orderBy('provider') + ->get() + ->map(fn ($r) => [ + 'provider' => (string) $r->provider, + 'account' => $r->account, + 'gross' => (int) $r->gross, + 'refunds' => (int) $r->refunds, + 'fees' => (int) $r->fees, + 'net' => (int) $r->net, + 'count' => (int) $r->count, + ]) + ->values(); + } + /** * Money one buyer actually paid us, attributed from the user-independent * ledger by ANY of their Stripe customer id(s) OR buyer email(s) — so a @@ -1150,6 +1191,16 @@ class ShopService 'fee_details' => isset($txn->fee_details) ? json_decode(json_encode($txn->fee_details), true) : null, ]; + // The PaymentIntent links this row to the same payment in another + // account's ledger (PayPal writes it into its custom field). + $paymentIntent = $source->payment_intent ?? $chargeSource->payment_intent ?? null; + if (is_object($paymentIntent)) { + $paymentIntent = $paymentIntent->id ?? null; + } + if (is_string($paymentIntent) && $paymentIntent !== '') { + $meta['payment_intent'] = $paymentIntent; + } + if ($external = $this->externallySettledAmount($txn, $source)) { $meta['settled_outside_stripe'] = true; $meta['stripe_amount'] = $amount; diff --git a/src/ShopServiceProvider.php b/src/ShopServiceProvider.php index 86aa85d..f319bb4 100644 --- a/src/ShopServiceProvider.php +++ b/src/ShopServiceProvider.php @@ -77,6 +77,7 @@ class ShopServiceProvider extends ServiceProvider \Blax\Shop\Console\Commands\ShopAddExampleProducts::class, \Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class, \Blax\Shop\Console\Commands\ShopImportStripeLedgerCommand::class, + \Blax\Shop\Console\Commands\ShopImportPaypalLedgerCommand::class, ]); } diff --git a/tests/Feature/Ledger/PaypalLedgerTest.php b/tests/Feature/Ledger/PaypalLedgerTest.php new file mode 100644 index 0000000..c7113a7 --- /dev/null +++ b/tests/Feature/Ledger/PaypalLedgerTest.php @@ -0,0 +1,269 @@ + array_filter(array_merge([ + 'transaction_id' => $id, + 'transaction_event_code' => $code, + 'transaction_initiation_date' => '2026-09-20T16:01:50Z', + 'transaction_amount' => ['currency_code' => 'EUR', 'value' => $amount], + 'fee_amount' => $fee === null ? null : ['currency_code' => 'EUR', 'value' => $fee], + 'transaction_status' => 'S', + ], $extra), fn ($v) => $v !== null)]; + } + + private function viaStripe(string $stripeId): array + { + return ['custom_field' => "acct_1QlaslLPtmuUEnJP:{$stripeId}:5f21ce203ae409b5"]; + } + + /** Stripe's own row for a PayPal payment that settled in PayPal (after 8aa727f). */ + private function stripePaypalRow(string $pi, int $gross, int $stripeFee, string $customer): void + { + StripeTransaction::create([ + 'stripe_id' => 'txn_' . substr($pi, 3), + 'source_type' => 'payment', + 'source_id' => 'py_' . substr($pi, 3), + 'amount' => $gross, + 'fee' => $stripeFee, + 'net' => $gross - $stripeFee, + 'currency' => 'eur', + 'customer_id' => $customer, + 'customer_email' => 'buyer@x.io', + 'created' => Carbon::parse('2026-09-20 16:01:50'), + 'meta' => ['settled_outside_stripe' => true, 'payment_intent' => $pi], + ]); + } + + private function import(array $transactions): void + { + $service = app(PaypalLedgerService::class); + foreach ($service->ledgerRows($transactions, self::ACCOUNT) as $row) { + $service->record($row); + } + } + + private function totals(): array + { + return Shop::ledgerTotals(Carbon::parse('2026-01-01'), Carbon::parse('2026-12-31')); + } + + #[Test] + public function paypal_fee_of_a_stripe_payment_lowers_net_but_not_gross(): void + { + $this->stripePaypalRow('pi_A', 2040, 14, 'cus_A'); + + $this->import([$this->pp('3BV48659EV009350B', 'T0003', '20.40', '-1.00', $this->viaStripe('pi_A'))]); + + $row = StripeTransaction::where('stripe_id', '3BV48659EV009350B')->firstOrFail(); + $this->assertSame('paypal', $row->provider); + $this->assertSame(self::ACCOUNT, $row->account); + $this->assertSame('paypal_fee', $row->source_type); + $this->assertSame(0, $row->amount); + $this->assertSame(100, $row->fee); + $this->assertSame(-100, $row->net); + $this->assertSame(2040, $row->meta->paypal_amount); + $this->assertSame('cus_A', $row->customer_id); + + $totals = $this->totals(); + $this->assertSame(2040, $totals['gross']); + $this->assertSame(14 + 100, $totals['fees']); + $this->assertSame(2040 - 14 - 100, $totals['net']); + + // The buyer's amount is unchanged; their net carries both fees. + $buyer = Shop::customerLedgerTotals('cus_A'); + $this->assertSame(2040, $buyer['amount']); + $this->assertSame(2040 - 114, $buyer['net']); + } + + #[Test] + public function payments_swept_into_stripe_are_not_charged_twice(): void + { + // Nov 2025 shape: Stripe's row already includes PayPal's 0.47 as a + // payment_method_passthrough_fee, and PayPal swept the money on. + StripeTransaction::create([ + 'stripe_id' => 'txn_swept', 'source_type' => 'payment', 'source_id' => 'py_swept', + 'amount' => 280, 'fee' => 58, 'net' => 222, 'currency' => 'eur', 'created' => Carbon::parse('2026-09-20'), + ]); + StripeTransaction::create([ + 'stripe_id' => 'txn_swept_refund', 'source_type' => 'payment_refund', 'source_id' => 'pyr_1', + 'amount' => -280, 'fee' => -8, 'net' => -272, 'currency' => 'eur', 'created' => Carbon::parse('2026-09-20'), + ]); + + $ref = ['paypal_reference_id' => 'PAY1', 'paypal_reference_id_type' => 'TXN']; + $this->import([ + $this->pp('PAY1', 'T0003', '2.80', '-0.47', $this->viaStripe('pi_S')), + $this->pp('HOLD1', 'T5000', '-2.33', null, $this->viaStripe('pi_S') + $ref), + $this->pp('REL1', 'T5001', '2.33', null, $this->viaStripe('pi_S') + $ref), + $this->pp('SWEEP1', 'T2002', '-2.33', null, $this->viaStripe('pi_S') + $ref), + $this->pp('REF1', 'T1107', '-2.80', '0.08', $this->viaStripe('pyr_1') + $ref), + $this->pp('SWEEP2', 'T2002', '2.72', null, $this->viaStripe('pyr_1') + $ref), + ]); + + $this->assertSame('paypal_fee_passthrough', StripeTransaction::where('stripe_id', 'PAY1')->value('source_type')); + $this->assertSame('paypal_fee_passthrough', StripeTransaction::where('stripe_id', 'REF1')->value('source_type')); + $this->assertSame('transfer', StripeTransaction::where('stripe_id', 'SWEEP1')->value('source_type')); + + // Only Stripe's rows count: 2.80 - 2.80 gross, 0.58 - 0.08 fees. + $totals = $this->totals(); + $this->assertSame(280, $totals['gross']); + $this->assertSame(-280, $totals['refunds']); + $this->assertSame(50, $totals['fees']); + $this->assertSame(-50, $totals['net']); + } + + #[Test] + public function refund_settled_in_paypal_returns_part_of_the_fee(): void + { + $this->import([ + $this->pp('PAY2', 'T0003', '19.00', '-0.96', $this->viaStripe('pi_R')), + $this->pp('REF2', 'T1107', '-19.00', '0.57', $this->viaStripe('pyr_2') + ['paypal_reference_id' => 'PAY2', 'paypal_reference_id_type' => 'TXN']), + ]); + + $refund = StripeTransaction::where('stripe_id', 'REF2')->firstOrFail(); + $this->assertSame('paypal_fee', $refund->source_type); + $this->assertSame(0, $refund->amount); + $this->assertSame(-57, $refund->fee); + $this->assertSame(57, $refund->net); + $this->assertSame(-39, $this->totals()['net']); + } + + #[Test] + public function direct_paypal_sales_count_in_full_and_withdrawals_do_not(): void + { + $this->import([ + $this->pp('DIRECT1', 'T0006', '50.00', '-2.00', ['transaction_subject' => 'Gift voucher']), + $this->pp('DREF1', 'T1107', '-10.00', '0.20', ['paypal_reference_id' => 'DIRECT1', 'paypal_reference_id_type' => 'TXN']), + $this->pp('ACCFEE', 'T0106', '-1.50'), + $this->pp('OUT1', 'T0400', '-30.00'), + $this->pp('SENT1', 'T0001', '-12.00'), + $this->pp('PEND1', 'T0006', '9.00', '-0.50', ['transaction_status' => 'P']), + ]); + + $types = StripeTransaction::pluck('source_type', 'stripe_id'); + $this->assertSame('payment', $types['DIRECT1']); + $this->assertSame('payment_refund', $types['DREF1']); + $this->assertSame('paypal_fee', $types['ACCFEE']); + $this->assertSame('payout', $types['OUT1']); + $this->assertSame('payment_sent', $types['SENT1']); + $this->assertSame('pending', $types['PEND1']); + + $totals = $this->totals(); + $this->assertSame(5000, $totals['gross']); + $this->assertSame(-1000, $totals['refunds']); + $this->assertSame(200 - 20 + 150, $totals['fees']); + $this->assertSame(5000 - 1000 - 330, $totals['net']); + } + + #[Test] + public function totals_split_per_account_and_sum_to_the_whole(): void + { + $this->stripePaypalRow('pi_A', 2040, 14, 'cus_A'); + StripeTransaction::create([ + 'stripe_id' => 'txn_card', 'source_type' => 'charge', 'amount' => 5000, 'fee' => 100, 'net' => 4900, + 'currency' => 'eur', 'created' => Carbon::parse('2026-09-21'), + ]); + $this->import([$this->pp('PAYA', 'T0003', '20.40', '-1.00', $this->viaStripe('pi_A'))]); + + $from = Carbon::parse('2026-01-01'); + $until = Carbon::parse('2026-12-31'); + $split = Shop::ledgerTotalsByAccount($from, $until)->keyBy('provider'); + + $this->assertSame(7040, $split['stripe']['gross']); + $this->assertSame(7040 - 114, $split['stripe']['net']); + $this->assertSame(self::ACCOUNT, $split['paypal']['account']); + $this->assertSame(-100, $split['paypal']['net']); + $this->assertSame(Shop::ledgerTotals($from, $until)['net'], $split->sum('net')); + } + + #[Test] + public function stripe_rows_remember_their_payment_intent(): void + { + Shop::recordBalanceTransaction((object) [ + 'id' => 'txn_pi', 'type' => 'payment', 'amount' => 0, 'fee' => 14, 'net' => -14, + 'currency' => 'eur', 'created' => 1730000000, + 'source' => (object) ['id' => 'py_pi', 'object' => 'charge', 'amount' => 2040, 'payment_intent' => 'pi_X', 'customer' => 'cus_X'], + ]); + + $row = StripeTransaction::where('stripe_id', 'txn_pi')->firstOrFail(); + $this->assertSame('pi_X', $row->meta->payment_intent); + $this->assertSame('stripe', $row->fresh()->provider); + } + + #[Test] + public function command_imports_each_account_read_only_and_is_idempotent(): void + { + config()->set('shop.paypal.accounts', [ + ['name' => 'main', 'client_id' => 'id-main', 'secret' => 'secret-main'], + ['name' => 'unset', 'client_id' => null, 'secret' => null], + ]); + $this->stripePaypalRow('pi_A', 2040, 14, 'cus_A'); + + Http::fake([ + '*/v1/oauth2/token' => Http::response(['access_token' => 'tok', 'expires_in' => 3600]), + '*/v1/reporting/balances*' => Http::response([ + 'account_id' => self::ACCOUNT, + 'last_refresh_time' => now()->subHours(2)->utc()->format('Y-m-d\TH:i:s\Z'), + 'balances' => [], + ]), + '*/v1/reporting/transactions*' => function ($request) { + // One transaction, served only by the window that contains it. + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + $start = Carbon::parse($query['start_date']); + $end = Carbon::parse($query['end_date']); + $at = Carbon::parse('2026-09-20T16:01:50Z'); + + return Http::response([ + 'transaction_details' => $at->between($start, $end) + ? [$this->pp('3BV48659EV009350B', 'T0003', '20.40', '-1.00', $this->viaStripe('pi_A'))] + : [], + 'total_pages' => 1, + ]); + }, + ]); + + $this->travelTo(Carbon::parse('2026-10-07 12:00')); + + $this->artisan('shop:import-paypal-ledger', ['--since' => '2026-09-01'])->assertSuccessful(); + $this->artisan('shop:import-paypal-ledger')->assertSuccessful(); + + $this->assertSame(1, StripeTransaction::where('provider', 'paypal')->count()); + $this->assertSame(-100, (int) StripeTransaction::where('provider', 'paypal')->sum('net')); + + Http::assertNotSent(fn ($request) => $request->method() !== 'GET' && ! str_ends_with($request->url(), '/v1/oauth2/token')); + } + + #[Test] + public function command_without_accounts_is_a_no_op(): void + { + config()->set('shop.paypal.accounts', [['name' => 'default', 'client_id' => null, 'secret' => null]]); + Http::fake(); + + $this->artisan('shop:import-paypal-ledger')->assertSuccessful(); + + Http::assertNothingSent(); + } +}