Commit Graph

169 Commits

Author SHA1 Message Date
Fabian @ Blax Software 459b561567 feat(tax): multi-purpose vouchers are sold without VAT
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>
2026-10-01 09:55:01 +02:00
Fabian @ Blax Software 8aa727fb5e fix(ledger): count PayPal payments at their charge amount
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>
2026-09-27 10:52:32 +02:00
Fabian @ Blax Software 4df7e1373a fix(stripe): name Checkout lines after the product, options only when there are some
- 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>
2026-09-25 13:02:03 +02:00
Fabian @ Blax Software 16fdf33f20 feat(stripe): checkout confirmation links the charge and syncs the ledger
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>
2026-09-25 12:32:54 +02:00
Fabian @ Blax Software a999156c08 fix(ws): cart.confirm reads the cart fresh before deciding it was converted
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:05:09 +02:00
Fabian @ Blax Software 6aae653dac fix(stripe): safe return from Checkout, retries and duplicate events
- 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>
2026-09-25 12:03:38 +02:00
Fabian @ Blax Software 4f0084b7e4 feat(cart): price options and richer cart items (both additive)
- 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>
2026-09-25 10:37:59 +02:00
Fabian @ Blax Software 961ed5af4c feat(stripe): optionally name the chosen price on Checkout line items
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>
2026-09-25 10:28:34 +02:00
Fabian @ Blax Software a85bb176d6 fix(ws-auth): only email and password are login credentials
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>
2026-09-25 10:22:15 +02:00
Fabian @ Blax Software 637ad392ce fix(stripe): take checkout totals (shipping, discount, tax) from the session
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>
2026-09-24 11:30:55 +02:00
Fabian @ Blax Software 53832f49ce fix(stripe): cents everywhere, idempotent refunds, admin refunds, webhook ledger sync
- 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>
2026-09-24 10:50:25 +02:00
Fabian @ Blax Software 08e459959e style: pint the files touched by the OrderPaid / shop.models.order fix
Formatting only (laravel preset via pint.json): import ordering, FQCN -> use,
`new X()` -> `new X`, `!` spacing, phpdoc trims, braces on one-line continue.
No behaviour change; suite re-run green (1485 tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 08:24:59 +02:00
Fabian @ Blax Software 0d9823df04 fix(orders): dispatch OrderPaid once; honour shop.models.order on webhook + service paths
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>
2026-09-24 08:22:55 +02:00
Fabian @ Blax Software b4125c1ee1 feat(i18n,stripe): de/pl translations + order address snapshots from Checkout Session
- 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>
2026-09-21 13:39:40 +02:00
Fabian @ Blax Software 68d02b01fc feat(cart): WS-first storefront primitives + shared cart/auth controllers
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>
2026-09-17 10:53:28 +02:00
Fabian @ Blax Software 1849f55558 feat(ledger): real-time balance-transaction upsert for webhook sync
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>
2026-09-15 09:36:40 +02:00
Fabian @ Blax Software f4624b2903 feat(ledger): per-customer revenue attribution primitives
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>
2026-09-15 08:39:40 +02:00
Fabian @ Blax Software 86e6469d1f feat(ledger): user-independent Stripe BalanceTransaction ledger + daily net stats
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>
2026-09-03 10:01:07 +02:00
Fabian @ Blax Software 47e7300e98 feat(cost): royalty / minimum-licence-fee margin (Aircademy-style)
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>
2026-08-28 16:03:04 +02:00
Fabian @ Blax Software 9e3e4b2270 feat(cost): per-price cost basis (COGS) + net-MRR margin
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>
2026-08-28 15:12:42 +02:00
Fabian @ Blax Software f6f8916bed feat(metrics): subscription MRR API on Shop facade
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>
2026-08-28 13:38:42 +02:00
Fabian @ Blax Software 2c31a23255 feat(pricing): percentage conditional prices + cheapest-effective resolver + authoring helper
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.
2026-08-26 15:52:24 +02:00
Fabian @ Blax Software 07a794c028 feat(pricing): conditional prices via meta.requires + entitlement-checker resolver
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.
2026-08-26 12:35:14 +02:00
Fabian @ Blax Software f10d5c2f04 feat(tax): configurable TaxService applied across every checkout path
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>
2026-08-21 08:23:56 +02:00
Fabian @ Blax Software b154d8fea2 feat(seats): assignable license seats — buy N, assign/reassign/reclaim per user
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>
2026-07-29 10:07:58 +02:00
Fabian @ Blax Software 7d35c224f3 fix: product_action_runs.action_id must be uuidMorphs (ProductAction is HasUuids)
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.
2026-06-10 11:33:09 +02:00
Fabian @ Blax Software 8594af4236 fix(subscriptions): grant every product in a multi-item (bundle) subscription
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.
2026-06-09 19:29:14 +02:00
Fabian @ Blax Software 02fd5640b4 feat(enums): add SUBSCRIPTION product type
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).
2026-06-03 10:37:09 +02:00
Fabian @ Blax Software db827bbb8c feat(enums): add SERVICE product type; fix flaky multi-product checkout test
- 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.
2026-06-03 09:49:12 +02:00
Fabian @ Blax Software d4ae9339ac feat(subscriptions): Cashier-backed subscription lifecycle with product link + events
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.
2026-06-02 11:40:15 +02:00
Fabian @ Blax Software 96b9a19287 feat(fulfillment): add model-agnostic PurchaseCompleted event
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.
2026-06-02 11:19:21 +02:00
Fabian @ Blax Software 30dc3755d2 test: pin clock in loan-vocabulary test; docs: bump suite counts to 1400/3755
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.
2026-06-02 09:48:26 +02:00
Fabian @ Blax Software d4008caec0 feat(checkout): support subscription/recurring carts in Stripe Checkout
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.
2026-06-02 09:34:32 +02:00
Fabian @ Blax Software 4712133eac IAM "max usage" feature 2026-05-19 14:01:52 +02:00
Fabian @ Blax Software 6d22e130ed A cart availability + test 2026-05-19 11:17:35 +02:00
Fabian @ Blax Software 0cfbdf221d BF edgecases, R structure, A tests 2026-05-18 13:05:38 +02:00
Fabian @ Blax Software ab8ea6afec BF stock calculation 2026-05-18 11:54:11 +02:00
Fabian @ Blax Software 0bb3b63a32 BF model methods 2026-05-18 08:16:09 +02:00
Fabian @ Blax Software 1ed301b759 R loan purchase to physical loan, A physical loan stuff 2026-05-17 15:20:58 +02:00
Fabian @ Blax Software da6d89f668 BFI commands, A test 2026-05-17 14:09:03 +02:00
Fabian @ Blax Software 2bc64c6369 BF stocks, A test 2026-05-17 13:57:20 +02:00
Fabian @ Blax Software 99fd71f4ae BF commands, A command tests, I traits
- 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.
2026-05-17 13:25:34 +02:00
Fabian @ Blax Software ee64e86345 BF commands 2026-05-17 11:53:19 +02:00
Fabian @ Blax Software f55c7e11df A events, IA commands
- 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.
2026-05-17 11:24:43 +02:00
Fabian @ Blax Software 8eb1802ef8 feat: promote IsLoanableProduct to MayBeLoanableProduct on Product
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.
2026-05-16 12:17:38 +02:00
Fabian @ Blax Software afdcd8bc75 feat: Enhance traits with strict types and improve method signatures
- 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.
2026-05-15 20:26:24 +02:00
Fabian @ Blax Software 7415da3531 I loanprovider 2026-05-15 12:17:58 +02:00
Fabian @ Blax Software d6a03f413a D unused files 2026-05-15 11:01:02 +02:00
Fabian @ Blax Software fe41475c84 I loanable product type, tiered pricing, lifecycle events, host helpers 2026-05-15 10:27:59 +02:00
Fabian @ Blax Software 80bc7293b1 I readme 2026-05-14 10:44:31 +02:00