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>
Add Product::TAX_CLASS_MULTI_PURPOSE_VOUCHER and TaxService::ratesFor($product, ...),
which applies no tax rate to a product whose tax class is untaxed at sale
(config shop.tax.untaxed_classes, default the multi-purpose voucher). Under EU
VAT (Directive Art. 30a/30b, DE § 3 Abs. 15 UStG) selling a multi-purpose
voucher is not a taxable supply; VAT is due when it is redeemed, on the full
price of what it pays for. rates() is unchanged; ratesFor() is additive.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- shop.cart.prices_as_options (env SHOP_CART_PRICES_AS_OPTIONS, default off):
adding a ProductPrice of a plain (non-pool, non-booking) product stores the
product as the line with that price_id, so cart, checkout line and invoice
show the product and its option. Lines merge only with the same option.
- CartItemResource adds price_id, parameters (what the buyer attached, e.g. a
brief) and purchasable.virtual (no shipping needed).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
shop.stripe.line_item_price_name (env SHOP_STRIPE_LINE_ITEM_PRICE_NAME,
default off) appends the price's name to the line item ('Flat Art – A4'),
so a product sold in several named prices shows the buyer which one they
pay for. Off by default: existing shops see no change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Webhook handlers stored euros in cent columns (charge.succeeded,
charge.refunded, refund.created, payment_intent.succeeded, the success
redirect, dispute/invoice notes). All amounts are cents now; purchases get
their own line amount as amount_paid.
- One Stripe refund arrives as refund.created AND charge.refunded, in either
order. New Order::applyStripeRefund() / applyStripeRefundedTotal() raise the
order's Stripe refund total (meta.stripe_refunded, meta.stripe_refunds) to
what Stripe confirmed, so nothing is counted twice. refund.created now finds
Checkout orders by payment_intent.
- New ShopService::refundOrder($order, $cents, $reason) refunds through the
Stripe API (idempotency key per order/amount/state) and records it, plus
refundableAmount(). Bind \Stripe\StripeClient to swap the client.
- charge.succeeded remembers meta.stripe_charge_id; with
shop.ledger.sync_on_webhook (default off) charge/refund/dispute webhooks
upsert the balance transactions into stripe_transactions.
Co-Authored-By: Claude Opus 5.5 (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>
Add a generic conditional-pricing mechanic: a ProductPrice may carry
meta.requires (e.g. {"role":"seat"}) so it only applies to buyers who
already hold a prerequisite (loyalty / bundle pricing). Product::resolvePriceFor
returns the cheapest price the buyer is eligible for, delegating the
'does the buyer satisfy this requirement' test to a host-bound
EntitlementChecker contract. Defaults to DenyAllEntitlementChecker so an
unconfigured host never leaks a discount. Adds requires()/isConditional()/
requiresLabel()/requiresSatisfiedBy() and conditional()/standalone() scopes.
Blax\Shop\Services\TaxService::rates($exempt, $rates=null) is one source of
truth that turns "which rate(s)" (config shop.tax.rates or an explicit list)
and "is this customer exempt" into the array Stripe expects. It fails loud
(TaxRateNotConfiguredException) when shop.tax.require is set and no rate is
configured for a non-exempt charge, instead of silently billing 0% VAT.
New config('shop.tax') block (SHOP_TAX_RATES / SHOP_TAX_REQUIRE). Unit tested.
Consumers (learn-atc #1509) delegate getApplicableTaxRates() here so no path
can apply the rate twice (Stripe rejects duplicate tax rates) or drop VAT to 0%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A seat-based product (meta.seat_based) sold at quantity N provisions N
assignable LicenseSeat rows instead of granting the buyer. Assigning a seat
runs the product's existing ProductAction grants against the seat holder via
an explicit `grantee` (resolution: grantee ?? purchaser ?? subscription->user),
so a seat confers exactly the product's access with zero duplicated grant logic.
- LicenseSeat model: assign / reassign / reclaim / retire / refreshGrant
- SeatService: idempotent provisioning for one-time purchases and subscriptions
(sizes the pool to quantity; renewal extends expiry + re-grants held seats)
- SeatAssigned / SeatReassigned / SeatRevoked events
- Product::isSeatBased(); ProductPurchase + Subscription provision on
completion / lifecycle hooks
- license_seats migration + factory; config `shop.seats` block
- 15 new tests (purchase, subscription, renewal, retire, delegation, gating)
Backward compatible: with no `grantee` the buyer grant is unchanged, and seat
behavior is gated behind the per-product meta flag + config `shop.seats.enabled`.
Full package suite green: 1425 tests / 3812 assertions / 0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the package's missing subscription lifecycle so any host app gets
duration-aware, product-linked subscriptions without re-implementing billing:
- Models: Subscription (extends Laravel\Cashier\Subscription) + SubscriptionItem,
in the package's UUID convention, resolved through shop.models/shop.tables.
Subscription gains product()/resolveProduct(), callProductActions() (runs the
product's ProductActions with the subscription + an access-expiry override),
and recordStarted()/recordRenewed()/recordCanceled() lifecycle hooks.
- Events: SubscriptionStarted / SubscriptionRenewed / SubscriptionCanceled,
carrying the Cashier subscription so host subclasses work too.
- Migration: UUID subscriptions / subscription_items tables (hasTable-guarded,
config table names, nullable product_id + current_period_* columns).
- ShopServiceProvider points Cashier at these models by default; opt out via
shop.subscriptions.register_cashier_models for apps that subclass Cashier.
Additive and backward-compatible (registration is config-gated, tables are
guarded). Adds SubscriptionLifecycleTest; full suite 1409 green. Docs + README.