A manual import overlapping the scheduled one could insert the same PayPal
transaction twice and fail on the unique key. The loser now updates the
row the other run created.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
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>
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>
- A product with a single price showed "Shinsou Standee – Default" on
Checkout; the price name is now only added when the product sells in
more than one price.
- A line added by price id was named after the price ("A4 – A4"); it is
named after the price's product now ("Flat Art – A4").
- The test for this lived inside the fake Stripe Session class at the end
of the file and never ran; it is a real test again.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
What charge.succeeded does, for shops that get no webhooks (local dev, a
broken endpoint): the order remembers its charge id and the charge's balance
transactions reach the ledger. Idempotent; never fails the confirmation.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- checkoutSession() keeps pending purchases in step with the cart: a buyer
who cancels on Stripe and edits the cart pays for (and is invoiced) the
cart as it is, not the first attempt.
- checkout.session.completed is idempotent and locked per payment: a
redelivered event, a concurrent charge.succeeded or the success page
record the payment once. An unpaid (async) session creates the order but
records no payment until Stripe settles it.
- New StripeCheckoutConfirmation + WS cart.confirm {session_id}: the
success page settles a paid session through the webhook's code path, so
the order exists the moment the buyer is back.
- CartService::guest() never hands a converted cart back to its session.
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>
auth.login passed its whole validated payload to Auth::attempt(). When the
client sent a stale bearer `token` next to email/password (an expired
session, or a token another app left on the same origin), the provider
turned it into `where token = ...` on users. That threw "Unknown column
'token'" and every correct password came back as invalid credentials.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Shipping rates, promotion codes and tax chosen on the Stripe Checkout page
only exist on the session. checkout.session.completed now copies
amount_subtotal, total_details.amount_shipping/amount_discount/amount_tax and
amount_total onto the (not yet paid) order before recording the payment, so
the order's totals match what was charged and the order is settled. The
chosen shipping rate id goes to meta.stripe_shipping_rate. The shipping
address is also read from collected_information.shipping_details (Stripe API
2025-03-31+), falling back from shipping_details.
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>
OrderPaid was defined but never dispatched. Order::booted() now fires it from
the `updated` hook exactly when paid_at goes null -> value (recordPayment()
reaching amount_total, the Stripe checkout webhook, or a direct update). Later
saves of a paid order, partial payments and overpayments do not re-fire it, and
a host subclass configured via shop.models.order gets it with its own instance.
StripeWebhookController, ShopService and Cart::checkout used the base
Blax\Shop\Models\Order statically (createFromCart, find, where, scopes,
aggregates), so a host app's configured Order subclass — with its own boot
hooks — was bypassed on the real webhook path. They now resolve
config('shop.models.order') through a small orderModel() helper. Return types
are unchanged (subclass instances satisfy Order); Order::formatMoney stays a
pure static.
Tests: OrderPaidEventTest (once-only on recordPayment, partial payments,
direct paid_at update, host subclass) and StripeWebhookOrderModelTest
(handleCheckoutSessionCompleted builds the configured subclass and its boot
hook runs; webhook/ShopService/Cart finders return it). Suite: 1485 tests green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- lang/de and lang/pl for cart.php and auth.php (informal register), every
key of lang/en translated; README documents the `shop::` namespace and
the lang/vendor/shop/<locale>/ override path.
- StripeWebhookController::handleCheckoutSessionCompleted now persists the
session's customer_details as Order.billing_address and shipping_details
as Order.shipping_address (falls back to billing when Stripe sent no
shipping block). Snapshot shape: name, email, phone, line1, line2,
postal_code, city, state, country, uid (first tax id). Only empty columns
are filled; a session without either block leaves the order untouched.
Optional host-added customer_email/customer_name columns are filled when
present and empty. Accepts Stripe objects, stdClass and arrays.
- Controller formatted with pint (composer lint).
Tests: tests/Unit/Stripe/StripeWebhookAddressTest.php (5),
tests/Feature/ShopServiceProvider/TranslationsTest.php (4). Full suite
green (1477, 7 skipped).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds the reusable pieces a headless WS-first shop needs, so every consumer
(immohost, nirioci, future shops) shares one implementation instead of
re-inventing it in app code:
- CartService::adopt() — resolve + re-bind a guest cart to a stable connection
id (socket/session), so the cart follows a stateless client across reconnects.
- CartService::mergeGuestIntoUser() — actually honour shop.cart.merge_on_login,
which was dead config: re-parent guest items onto the user's cart at login
(preserves claimed stock and resolved prices; no re-add side effects).
- Cart facade passthrough checkoutSessionLink() for the {redirect} checkout shape.
- CartResource / CartItemResource: a stable, presentation-agnostic wire shape
(money in integer minor units + explicit currency; best-effort image).
- Blax\Shop\Websocket\Controllers\{Cart,Auth}Controller: thin, config-resolved,
translatable base WS controllers a host exposes via one-line subclass stubs.
laravel-websockets stays a soft (suggest) dependency; the Websocket dir is
excluded from coverage since its logic lives in the covered CartService.
- shop:: translation namespace (en) for the controller messages.
Tests: tests/Unit/Cart/CartAdoptMergeTest.php (7). Full suite green (1468).
Co-Authored-By: Claude Opus 4.8 (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>
Add ProductPrice.license_percent + license_min_amount and
ProductPrice::licenseFeePerPeriod() = max(percent% of unit_amount, minimum).
The shop amortizes it over the licence-term bracket (<=quarter / 6mo / 12mo),
so a monthly subscriber billed 3x in a quarter costs ONE quarterly fee — the
faithful reading of a per-unit, quarterly-accounted content licence.
subscriptionMetrics() now returns license_cost and net_mrr nets it out.
Additive: a null rule contributes 0. Covered by MrrMetricsTest; full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add ProductPrice.cost_amount (integer cents) — the cost to deliver one unit at
that price: manufacturing, shipping, a content license or royalty; per billing
period for recurring prices. Shop now reports margin natively: subscriptionMetrics()
returns `cogs` + `net_mrr`, and Shop::netMrr() gives MRR after cost of goods.
Additive: cost_amount defaults to 0, so existing consumers are unchanged.
Covered by MrrMetricsTest; full suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Shop::mrr(), activeSubscribers(), mrrByProduct(), subscriptionMetrics()
and subscriberStatuses() to ShopService. Counts active/past_due/trialing
subscribers (not `ends_at IS NULL`, which silently drops still-billing
cancel-at-period-end subs and under-reports MRR), normalizes every interval
to a monthly run-rate (year/12, quarter/3, week & day by the average
days-per-month constant so they match Stripe), and returns `unpriced_items`
so a stale or incomplete price mirror is surfaced, never a silent undercount.
Additive only: no existing method changed. Covered by MrrMetricsTest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ProductPrice gains meta.percent_off support: percentOff()/isPercentage() and
effectiveAmount($base) — a percentage price resolves against a base (the
product's cheapest standalone price), so it can express e.g. '90% off with
Full Seat' rather than a fixed amount. Product::resolvePriceFor now ranks the
eligible prices by EFFECTIVE amount (percentage-aware) and returns the cheapest,
so a product linked to multiple prerequisites with different fixed/percentage
discounts always resolves to the cheapest the buyer qualifies for. Adds
ProductPrice::setConditionalPricing($requires, $percentOff, $label) — a
merge-safe authoring seam host admin UIs can call instead of hand-poking meta.
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>
morphs('action') creates a bigint action_id, but ProductAction uses HasUuids — so ProductAction::callForProduct's run-logging dies with 'Incorrect integer value: <uuid>' on the first dispatch. uuidMorphs('action') matches the uuid key. Fresh-install fix; existing installs need an in-place widen.
callProductActions() now resolves and runs actions for ALL subscription line items (new resolveProducts()), not just items()->first(), so combined/configurator bundle subscriptions fulfill every product instead of silently granting only the first. resolveProduct() is kept for single-product callers; product_id is still cached to the first resolved product. Adds a multi-item bundle regression test to SubscriptionLifecycleTest.
Real consumer data has products typed 'subscription' (a service sold on a
recurring basis) alongside 'service'/'simple'. Add ProductType::SUBSCRIPTION so
a host whose catalogue uses that value can cast products through the enum
without a value clash. No stock semantics (like SERVICE).
- ProductType::SERVICE for intangible/served products (subscriptions, licences,
consulting) — no stock, behaves like SIMPLE for cart purposes. Lets hosts
whose catalogue includes services adopt the package without a value clash.
- it_creates_separate_line_items_for_multiple_products pinned its two prices to
one_time; without a type the factory randomised it and could mix recurring +
one-time, tripping MixedCheckoutModeException intermittently.
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.
Introduce a first-class PurchaseCompleted lifecycle event so host apps can run
fulfillment (grant access, send receipts, provision licences) without coupling
to the ProductAction table or to a concrete purchasable model. It fires when a
purchase is created already-COMPLETED and when one transitions into COMPLETED,
and is transition-guarded so it does not re-fire on unrelated saves of an
already-completed purchase.
Also generalise the built-in ProductAction fulfillment in ProductPurchase:
the actionable product is now resolved via config('shop.models.product') /
'...product_price' (instead of a hard instanceof the bundled Product), and
callActions() is only invoked when the resolved product exposes it — so apps
overriding the models, or using IsSimplePurchasable host models, complete
cleanly. Existing behaviour for the bundled Product is unchanged.
Adds 4 EventsWiredUpTest cases; full suite 1404 green. Docs + README updated.
PurchaseResourceTest::it_translates_e_commerce_columns_into_loan_vocabulary
relied on the real wall clock falling inside the fixture's loan window
(2026-05-14..2026-05-28); once that window passed, the loan resolved to
`overdue` and the test failed. Freeze the clock inside the window like its
sibling tests already do.
Update the README test/assertion badges and the Testing section to the current
suite size (1400 tests, 3755 assertions) after the subscription-checkout tests.
Cart::checkoutSession() now inspects each line's price type and selects the
session mode automatically: `subscription` when the cart carries any recurring
price, `payment` otherwise. Recurring lines reuse a synced Stripe Price
(stripe_price_id) when present and fall back to dynamic price_data with a
`recurring` block otherwise; quarterly cadence maps to a 3-month interval since
Stripe has no native quarter. The cart id is propagated via subscription_data
metadata for webhook mapping.
Mixing recurring and one-time prices in one cart throws the new
MixedCheckoutModeException, since a Stripe Checkout session is single-mode.
The recurring resolver tolerates both the package's enum-cast price model and a
host model storing type/interval as plain strings, so it keeps working when
shop.models.product_price is overridden.
- Implement CommandReinstallTest to verify the behavior of the shop:reinstall command, including force and fresh flags, and confirmation prompts.
- Create CommandReleaseExpiredStocksTest to test the shop:release-expired-stocks command, ensuring it correctly releases expired stock claims based on configuration.
- Add CommandStatsTest to validate the shop:stats command, checking counts and revenue calculations for products, purchases, carts, and orders.
- Introduce LoanShopCommandsTest to cover loanable products in stock management, ensuring accurate reporting of assigned, used, and available stock.
- Implement LoanStockEventsTest to verify that stock events are dispatched correctly during loan operations, including checkouts and returns.
- Add NextAvailableAtTest to ensure the nextAvailableAt method behaves correctly for loanable products, considering loans and claims.
- Introduced events for stock management including StockBecameLow, StockClaimed, StockClaimExpired, StockDecreased, StockDepleted, StockIncreased, StockReleased, StockReplenished, StockFullyAvailable, and StockFullyAvailable.
- Added events for Stripe payment processing: StripePaymentFailed, StripePaymentSucceeded, StripePriceSynced, StripeProductSynced, StripeRefundProcessed, and StripeWebhookReceived.
- Created tests for command availability, listing, stocks, and event dispatching to ensure proper functionality and integration.
Renames the host-attached IsLoanableProduct trait to MayBeLoanableProduct
and mixes it directly into Product, matching the existing MayBePoolProduct
shape. Hosts opt in with a single DEFAULT_TYPE constant — no more manual
`use ...Trait` ceremony to forget:
class Book extends Product
{
public const DEFAULT_TYPE = ProductType::LOANABLE;
}
The boot hook reads DEFAULT_TYPE on the concrete class and only applies
the LOANABLE creating-defaults (type, status, is_visible, manage_stock)
when it matches; type-specific helpers (checkOutTo, total_quantity,
available_quantity) early-out via isLoanable() so they're harmless on
non-loanable products. checkOutTo now throws NotLoanableProductException
when called on the wrong type, mirroring NotPoolProductException.
Also fixes total_quantity for the loan lifecycle: previously summed every
INCREASE entry in product_stocks, which inflated the displayed total
after each loan cycle because returns fire increaseStock(). Now reports
physical inventory as availableStock + activeLoans.
README gains a Testing section that surfaces the current phpunit summary
line, plus passing and assertion-count badges linking to it.
- Added strict types declaration to multiple traits for better type safety.
- Updated method signatures in traits to use nullable types where applicable.
- Improved documentation for traits, including host-model contracts and method descriptions.
- Added new tests to ensure correct behavior of loan checkout and stock management.
- Fixed regression in order number generation to ensure proper string formatting.
- Ensured that currency codes sent to Stripe are consistently lowercased.