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 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>