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) <noreply@anthropic.com>
This commit is contained in:
parent
459b561567
commit
423da476f5
|
|
@ -90,6 +90,35 @@ return [
|
||||||
'adjustment',
|
'adjustment',
|
||||||
'dispute',
|
'dispute',
|
||||||
'dispute_reversal',
|
'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'),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The money ledger spans more than one account: Stripe, plus any PayPal
|
||||||
|
* account a payment settles into. `provider` says which system a row came
|
||||||
|
* from (`stripe`, `paypal`), `account` which account of that provider (the
|
||||||
|
* PayPal merchant account id; null = the app's one Stripe account).
|
||||||
|
*
|
||||||
|
* Existing rows are all Stripe, which the column default covers. Guarded so a
|
||||||
|
* re-run or a consumer that already added the columns is left untouched.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$table = config('shop.tables.stripe_transactions', 'stripe_transactions');
|
||||||
|
|
||||||
|
if (! Schema::hasTable($table)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasColumn($table, 'provider')) {
|
||||||
|
Schema::table($table, function (Blueprint $blueprint) {
|
||||||
|
$blueprint->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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Console\Commands;
|
||||||
|
|
||||||
|
use Blax\Shop\Services\PaymentProvider\PaypalLedgerService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror every configured PayPal account into the money ledger, read-only via
|
||||||
|
* PayPal's Transaction Search API. Idempotent: upserts on the PayPal
|
||||||
|
* transaction id. Without --since an account resumes 14 days before its
|
||||||
|
* newest row (the first run reaches back PayPal's three years).
|
||||||
|
*/
|
||||||
|
class ShopImportPaypalLedgerCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'shop:import-paypal-ledger
|
||||||
|
{--dry-run : Report what would change, write nothing}
|
||||||
|
{--since= : Only import transactions on/after this date (YYYY-MM-DD)}
|
||||||
|
{--account= : Only this configured account (its `name`)}';
|
||||||
|
|
||||||
|
protected $description = 'Mirror PayPal account transactions into the money ledger (read-only).';
|
||||||
|
|
||||||
|
public function handle(PaypalLedgerService $paypal): int
|
||||||
|
{
|
||||||
|
$accounts = $paypal->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -67,6 +67,7 @@ use Illuminate\Support\Facades\Facade;
|
||||||
* @method static \Illuminate\Support\Carbon|null ledgerEarliest()
|
* @method static \Illuminate\Support\Carbon|null ledgerEarliest()
|
||||||
* @method static \Illuminate\Support\Collection revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until)
|
* @method static \Illuminate\Support\Collection revenueLedgerByDay(\DateTimeInterface $from, \DateTimeInterface $until)
|
||||||
* @method static array ledgerTotals(\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 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 \Illuminate\Support\Collection ledgerAmountByCustomer(?\DateTimeInterface $from = null, ?\DateTimeInterface $until = null)
|
||||||
* @method static array ledgerRowFromBalanceTransaction($txn, $source = null)
|
* @method static array ledgerRowFromBalanceTransaction($txn, $source = null)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,442 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Blax\Shop\Services\PaymentProvider;
|
||||||
|
|
||||||
|
use Blax\Shop\Models\StripeTransaction;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors PayPal accounts into the money ledger (the `stripe_transactions`
|
||||||
|
* table, rows with `provider` = paypal), read-only through PayPal's
|
||||||
|
* Transaction Search reporting API.
|
||||||
|
*
|
||||||
|
* Why: PayPal checkouts that run through Stripe settle into our PayPal account.
|
||||||
|
* Stripe's ledger row books the buyer's gross and Stripe's fee, but PayPal
|
||||||
|
* takes its own fee inside the PayPal account, which Stripe never sees. So for
|
||||||
|
* every Stripe-originated PayPal payment or refund this books one `paypal_fee`
|
||||||
|
* row (amount 0, fee = PayPal's fee, net = -fee). Summing the ledger then
|
||||||
|
* gives gross once and both fees.
|
||||||
|
*
|
||||||
|
* When PayPal sweeps a Stripe payment straight on to Stripe (a T20xx transfer
|
||||||
|
* on the same transaction), Stripe already charged PayPal's fee as a
|
||||||
|
* `payment_method_passthrough_fee` inside its own fee, so that row is booked
|
||||||
|
* as `paypal_fee_passthrough`, which is not a revenue type and is not counted
|
||||||
|
* twice.
|
||||||
|
*
|
||||||
|
* Direct PayPal sales (no Stripe reference) are booked in full: `payment`,
|
||||||
|
* `payment_refund`, `dispute`, account fees as `paypal_fee`. Holds, sweeps,
|
||||||
|
* currency conversions and withdrawals move money that is already counted and
|
||||||
|
* use non-revenue types (`transfer`, `payout`, `payment_sent`, `pending`).
|
||||||
|
*/
|
||||||
|
class PaypalLedgerService
|
||||||
|
{
|
||||||
|
/** PayPal's Transaction Search allows at most 31 days per request. */
|
||||||
|
protected const WINDOW_DAYS = 31;
|
||||||
|
|
||||||
|
/** @var array<string, array{token: string, expires: int}> */
|
||||||
|
protected array $tokens = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configured accounts that have credentials.
|
||||||
|
*
|
||||||
|
* @return list<array{name: string, client_id: string, secret: string, base_url: string}>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>> $transactions
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
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<string, mixed> $t
|
||||||
|
* @param array<string, bool> $swept
|
||||||
|
* @return array<string, mixed>|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<string, mixed> $row
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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<string, mixed> $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<string, mixed> $query
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<int, array{provider: string, account: string|null, gross: int, refunds: int, fees: int, net: int, count: int}>
|
||||||
|
*/
|
||||||
|
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
|
* 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
|
* 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,
|
'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)) {
|
if ($external = $this->externallySettledAmount($txn, $source)) {
|
||||||
$meta['settled_outside_stripe'] = true;
|
$meta['settled_outside_stripe'] = true;
|
||||||
$meta['stripe_amount'] = $amount;
|
$meta['stripe_amount'] = $amount;
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@ class ShopServiceProvider extends ServiceProvider
|
||||||
\Blax\Shop\Console\Commands\ShopAddExampleProducts::class,
|
\Blax\Shop\Console\Commands\ShopAddExampleProducts::class,
|
||||||
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
|
\Blax\Shop\Console\Commands\ShopSetupStripeWebhooksCommand::class,
|
||||||
\Blax\Shop\Console\Commands\ShopImportStripeLedgerCommand::class,
|
\Blax\Shop\Console\Commands\ShopImportStripeLedgerCommand::class,
|
||||||
|
\Blax\Shop\Console\Commands\ShopImportPaypalLedgerCommand::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,269 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Blax\Shop\Tests\Feature\Ledger;
|
||||||
|
|
||||||
|
use Blax\Shop\Facades\Shop;
|
||||||
|
use Blax\Shop\Models\StripeTransaction;
|
||||||
|
use Blax\Shop\Services\PaymentProvider\PaypalLedgerService;
|
||||||
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PayPal checkouts that run through Stripe settle into our PayPal account:
|
||||||
|
* Stripe's ledger row carries the gross and Stripe's fee, the PayPal account
|
||||||
|
* mirror adds PayPal's own fee. Shapes below are trimmed real Transaction
|
||||||
|
* Search responses.
|
||||||
|
*/
|
||||||
|
class PaypalLedgerTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private const ACCOUNT = 'PPMERCHANT1';
|
||||||
|
|
||||||
|
private function pp(string $id, string $code, string $amount, ?string $fee = null, array $extra = []): array
|
||||||
|
{
|
||||||
|
return ['transaction_info' => 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue