Without credentials the PayPal import was a silent no-op, so an app could
lose its PayPal fees from revenue without anyone noticing. With
shop.paypal.required on, a missing account, missing migration or a failed
account import throws (after every account was tried), which the host's
scheduler failure handling can alert on. Default off.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
payment_failure_refund (a SEPA/ACH payment that bounced, with its failure
fee) and Stripe's account-level fees (stripe_fee, stripe_fx_fee, tax_fee)
were not revenue types, so a bounced payment still counted as income and
Billing/Tax fees never lowered net. Both are revenue types now. Account
fees arrive from Stripe as a negative amount; the ledger stores them as
fee with amount 0 so they do not show up as refunds. The daily full
import rewrites existing rows.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
PayPal pays into the merchant's PayPal balance, so Stripe's balance
transaction for it has amount 0 and only the fee. The ledger copied that
0, so every PayPal buyer showed no revenue. When a payment or refund
balance transaction is 0 and its charge/refund says otherwise, record the
charge's gross (net = gross - fee) and keep Stripe's own figures in meta.
syncLedgerForCharge also accepts py_ charge ids now, so PayPal payments
reach the ledger from the webhook instead of only the daily import.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Adds the primitives a host webhook needs to keep the ledger current in
real time, so the importer is a backstop rather than the only path:
- ledgerRowFromBalanceTransaction(): the canonical row builder (the
import command now delegates to it), with refund→charge buyer fallback.
- recordBalanceTransaction(): idempotent upsert on the txn_ id that never
downgrades a known customer_id/email to null (so a webhook's resolved
buyer isn't clobbered by a later importer pass that couldn't resolve it).
- syncLedgerForCharge(): retrieves a charge + its refunds and upserts their
balance transactions, user-independent, never throws (logs + returns 0).
Facade @methods + 3 LedgerMetricsTest cases (upsert idempotency, no-downgrade,
non-charge-id guard). Full suite 1461 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
customerLedgerTotals() sums the user-independent Stripe ledger for one
buyer by ANY of their customer id(s) OR email(s) — so subscription
payments (which product_purchases stores as amount=0) and deleted- or
guest-customer charges are still attributed. ledgerAmountByCustomer()
returns [customer_id => amount] for per-user rollups in one query.
Both are additive and filter on ledgerRevenueTypes(); empty identity
returns zero rather than matching everything. Covered by two new
LedgerMetricsTest cases (full suite 1458 green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror every Stripe balance transaction (signed gross/fee/net) into a new
stripe_transactions table decoupled from any local user, so revenue net of
fees and refunds is a plain SUM(net) and a deleted customer never removes
their money history.
- StripeTransaction model (config-resolved, HasUuids) + guarded, publishable
migration; config tables.stripe_transactions + models.stripe_transaction +
ledger.revenue_types.
- Shop::revenueLedgerByDay / ledgerTotals / ledgerEarliest (+ facade @method).
- shop:import-stripe-ledger: pages ALL balance transactions globally (not
per-customer), upserts on txn_ id, --dry-run.
- LedgerMetricsTest (4). Full suite green (1456).
Additive only; existing consumers unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>