2025-11-21 10:49:41 +00:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
2026-05-15 08:27:59 +00:00
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Whether the package should auto-run its migrations.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Default: true — fresh installs work plug-and-play (composer require +
|
|
|
|
|
|
* php artisan migrate). The package's own migrations live in vendor/ and
|
|
|
|
|
|
* are auto-loaded.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Set to false if you have already published migrations to your project's
|
|
|
|
|
|
* database/migrations directory and want to manage the schema yourself.
|
|
|
|
|
|
* If you publish *and* leave this true, Laravel's migrator will see the
|
|
|
|
|
|
* same filenames in both locations and run each migration once — but
|
|
|
|
|
|
* that requires the published filename to match the source filename. If
|
|
|
|
|
|
* you've published with a different timestamp prefix, disable this flag
|
|
|
|
|
|
* to avoid re-runs.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'run_migrations' => true,
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
// Table names (customizable for multi-tenancy)
|
|
|
|
|
|
'tables' => [
|
2025-11-29 19:09:19 +00:00
|
|
|
|
'cart_items' => 'cart_items',
|
|
|
|
|
|
'carts' => 'carts',
|
2025-12-29 08:59:02 +00:00
|
|
|
|
'orders' => 'orders',
|
|
|
|
|
|
'order_notes' => 'order_notes',
|
2025-11-29 19:09:19 +00:00
|
|
|
|
'payment_methods' => 'payment_methods',
|
|
|
|
|
|
'payment_provider_identities' => 'payment_provider_identities',
|
|
|
|
|
|
'product_action_runs' => 'product_action_runs',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'product_attributes' => 'product_attributes',
|
2025-11-29 19:09:19 +00:00
|
|
|
|
'product_categories' => 'product_categories',
|
2025-12-05 08:21:07 +00:00
|
|
|
|
'product_relations' => 'product_relations',
|
2025-11-29 19:09:19 +00:00
|
|
|
|
'product_prices' => 'product_prices',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'product_purchases' => 'product_purchases',
|
2025-12-05 08:21:07 +00:00
|
|
|
|
'product_actions' => 'product_actions',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'product_stocks' => 'product_stocks',
|
2026-05-15 08:27:59 +00:00
|
|
|
|
'product_price_tiers' => 'product_price_tiers',
|
2025-11-29 19:09:19 +00:00
|
|
|
|
'products' => 'products',
|
2025-12-05 08:21:07 +00:00
|
|
|
|
'cart_discounts' => 'cart_discounts',
|
2026-06-02 09:40:15 +00:00
|
|
|
|
'subscriptions' => 'subscriptions',
|
|
|
|
|
|
'subscription_items' => 'subscription_items',
|
2026-07-29 08:07:58 +00:00
|
|
|
|
'license_seats' => 'license_seats',
|
2026-09-03 08:01:07 +00:00
|
|
|
|
'stripe_transactions' => 'stripe_transactions',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
// Model classes (allow overriding in main instance)
|
|
|
|
|
|
'models' => [
|
|
|
|
|
|
'product' => \Blax\Shop\Models\Product::class,
|
|
|
|
|
|
'product_price' => \Blax\Shop\Models\ProductPrice::class,
|
|
|
|
|
|
'product_category' => \Blax\Shop\Models\ProductCategory::class,
|
|
|
|
|
|
'product_stock' => \Blax\Shop\Models\ProductStock::class,
|
2026-05-15 08:27:59 +00:00
|
|
|
|
'product_price_tier' => \Blax\Shop\Models\ProductPriceTier::class,
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'product_attribute' => \Blax\Shop\Models\ProductAttribute::class,
|
2025-11-21 14:22:40 +00:00
|
|
|
|
'product_purchase' => \Blax\Shop\Models\ProductPurchase::class,
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'cart' => \Blax\Shop\Models\Cart::class,
|
|
|
|
|
|
'cart_item' => \Blax\Shop\Models\CartItem::class,
|
2025-12-29 08:59:02 +00:00
|
|
|
|
'order' => \Blax\Shop\Models\Order::class,
|
|
|
|
|
|
'order_note' => \Blax\Shop\Models\OrderNote::class,
|
2025-11-26 10:09:52 +00:00
|
|
|
|
'payment_provider_identity' => \Blax\Shop\Models\PaymentProviderIdentity::class,
|
|
|
|
|
|
'payment_method' => \Blax\Shop\Models\PaymentMethod::class,
|
2026-06-02 09:40:15 +00:00
|
|
|
|
'subscription' => \Blax\Shop\Models\Subscription::class,
|
|
|
|
|
|
'subscription_item' => \Blax\Shop\Models\SubscriptionItem::class,
|
2026-07-29 08:07:58 +00:00
|
|
|
|
'license_seat' => \Blax\Shop\Models\LicenseSeat::class,
|
2026-09-03 08:01:07 +00:00
|
|
|
|
'stripe_transaction' => \Blax\Shop\Models\StripeTransaction::class,
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* Stripe BalanceTransaction ledger.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `revenue_types` are the BalanceTransaction `type`s that count toward
|
|
|
|
|
|
* "money the business earned, net of fees and refunds". Charges add, refunds
|
|
|
|
|
|
* and disputes subtract; per-charge Stripe fees are already inside each row's
|
|
|
|
|
|
* `net`. Bank movements (`payout`, `transfer`) are excluded — they move money
|
|
|
|
|
|
* that was already counted, they are not revenue or cost.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'ledger' => [
|
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 08:50:25 +00:00
|
|
|
|
/*
|
|
|
|
|
|
* Sync the charge's balance transactions into the ledger whenever a
|
|
|
|
|
|
* charge, refund or dispute webhook arrives (one Stripe API call each).
|
|
|
|
|
|
* Off by default: the ledger then only fills via shop:import-stripe-ledger.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'sync_on_webhook' => env('SHOP_LEDGER_SYNC_ON_WEBHOOK', false),
|
|
|
|
|
|
|
2026-09-03 08:01:07 +00:00
|
|
|
|
'revenue_types' => [
|
|
|
|
|
|
'charge',
|
|
|
|
|
|
'payment',
|
|
|
|
|
|
'refund',
|
|
|
|
|
|
'payment_refund',
|
|
|
|
|
|
'adjustment',
|
|
|
|
|
|
'dispute',
|
|
|
|
|
|
'dispute_reversal',
|
|
|
|
|
|
],
|
2026-06-02 09:40:15 +00:00
|
|
|
|
],
|
|
|
|
|
|
|
2026-08-26 10:35:14 +00:00
|
|
|
|
/*
|
|
|
|
|
|
* Conditional-price entitlement checker.
|
|
|
|
|
|
*
|
|
|
|
|
|
* A ProductPrice can carry `meta.requires` (e.g. {"role":"seat"}) so it is
|
|
|
|
|
|
* only offered to buyers who already hold something else — a subscription,
|
|
|
|
|
|
* role, or prior purchase (loyalty / bundle pricing). The package resolves
|
|
|
|
|
|
* the cheapest *eligible* price but delegates "does this buyer satisfy the
|
|
|
|
|
|
* requirement" to a host-provided \Blax\Shop\Contracts\EntitlementChecker,
|
|
|
|
|
|
* because entitlement semantics are app-specific (laravel-roles, Cashier,
|
|
|
|
|
|
* a custom table). Point this at your implementation's class-string.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Left null, the package binds \Blax\Shop\Services\DenyAllEntitlementChecker
|
|
|
|
|
|
* — conditional prices are never offered until a real checker is wired up
|
|
|
|
|
|
* (fail-closed: a forgotten binding can't leak a discount to everyone).
|
|
|
|
|
|
*/
|
|
|
|
|
|
'entitlement_checker' => null,
|
|
|
|
|
|
|
2026-06-02 09:40:15 +00:00
|
|
|
|
/*
|
|
|
|
|
|
* Subscriptions are Cashier-backed. The package binds its own
|
|
|
|
|
|
* Cashier-extending Subscription / SubscriptionItem models (above) so it
|
|
|
|
|
|
* can link a subscription to a product and run product actions on the
|
|
|
|
|
|
* billing lifecycle. Set `subscriptions.register_cashier_models` to false
|
|
|
|
|
|
* if the host app wants to point Cashier at its own models instead.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'subscriptions' => [
|
|
|
|
|
|
'register_cashier_models' => env('SHOP_REGISTER_CASHIER_MODELS', true),
|
|
|
|
|
|
// Stripe interval => the product-action event fired on a NEW subscription.
|
|
|
|
|
|
'started_event' => 'subscription.started',
|
|
|
|
|
|
'renewed_event' => 'subscription.renewed',
|
|
|
|
|
|
'canceled_event' => 'subscription.canceled',
|
2025-11-21 10:49:41 +00:00
|
|
|
|
],
|
|
|
|
|
|
|
2026-07-29 08:07:58 +00:00
|
|
|
|
/*
|
|
|
|
|
|
* Assignable license seats.
|
|
|
|
|
|
*
|
|
|
|
|
|
* When a product is seat-based (Product::isSeatBased() — driven by
|
|
|
|
|
|
* `meta.seat_based = true`), a purchase/subscription of `quantity = N`
|
|
|
|
|
|
* provisions N assignable LicenseSeat rows instead of granting the buyer.
|
|
|
|
|
|
* Assigning a seat fires the product's actions for `assigned_event` with an
|
|
|
|
|
|
* explicit `grantee`; reclaiming/reassigning fires `revoked_event`. Host
|
|
|
|
|
|
* action jobs should honor `grantee` (falling back to the buyer) so the
|
|
|
|
|
|
* same jobs serve both personal purchases and seat assignments.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'seats' => [
|
|
|
|
|
|
'enabled' => env('SHOP_SEATS_ENABLED', true),
|
|
|
|
|
|
'assigned_event' => 'seat.assigned',
|
|
|
|
|
|
'revoked_event' => 'seat.revoked',
|
|
|
|
|
|
],
|
|
|
|
|
|
|
2026-08-21 06:23:56 +00:00
|
|
|
|
/*
|
|
|
|
|
|
* Tax rates applied to taxable charges and subscriptions.
|
|
|
|
|
|
*
|
|
|
|
|
|
* TaxService::rates() is the single source of truth for the applied VAT
|
|
|
|
|
|
* rate on every checkout path. Point `rates` at your Stripe tax-rate id(s)
|
|
|
|
|
|
* (e.g. 'txr_...' for 19% German VAT) via SHOP_TAX_RATES, either one id or
|
|
|
|
|
|
* a comma-separated list. The host app decides tax-exemption (reverse-charge
|
|
|
|
|
|
* / zero-rated) and passes it to TaxService::rates($exempt); a repo may also
|
|
|
|
|
|
* hand rates() an explicit list instead of reading this config.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Set SHOP_TAX_REQUIRE=true in production once a rate is configured: a
|
|
|
|
|
|
* non-exempt charge with no rate then throws instead of silently billing 0%.
|
|
|
|
|
|
*/
|
|
|
|
|
|
'tax' => [
|
|
|
|
|
|
'rates' => array_values(array_filter(
|
|
|
|
|
|
array_map('trim', explode(',', (string) env('SHOP_TAX_RATES', ''))),
|
|
|
|
|
|
static fn ($id) => $id !== '',
|
|
|
|
|
|
)),
|
|
|
|
|
|
'require' => (bool) env('SHOP_TAX_REQUIRE', false),
|
|
|
|
|
|
],
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
// API Routes configuration
|
|
|
|
|
|
'routes' => [
|
|
|
|
|
|
'enabled' => true,
|
|
|
|
|
|
'prefix' => 'api/shop',
|
|
|
|
|
|
'middleware' => ['api'],
|
|
|
|
|
|
'name_prefix' => 'shop.',
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
// Stock management
|
|
|
|
|
|
'stock' => [
|
|
|
|
|
|
'track_inventory' => true,
|
|
|
|
|
|
'allow_backorders' => false,
|
|
|
|
|
|
'low_stock_threshold' => 5,
|
|
|
|
|
|
'log_changes' => true,
|
|
|
|
|
|
'auto_release_expired' => true,
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
// Product actions (extensible by main instance)
|
|
|
|
|
|
'actions' => [
|
|
|
|
|
|
'path' => app_path('Jobs/ProductAction'),
|
|
|
|
|
|
'namespace' => 'App\\Jobs\\ProductAction',
|
|
|
|
|
|
'auto_discover' => true,
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
// Stripe integration (optional)
|
|
|
|
|
|
'stripe' => [
|
|
|
|
|
|
'enabled' => env('SHOP_STRIPE_ENABLED', false),
|
|
|
|
|
|
'sync_prices' => true,
|
2025-12-15 13:10:59 +00:00
|
|
|
|
'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
|
2025-12-29 08:59:02 +00:00
|
|
|
|
|
2026-09-25 08:28:34 +00:00
|
|
|
|
// Append the price's name to the Checkout line item ("Flat Art – A4") when a
|
|
|
|
|
|
// product is sold in several named prices. Off by default.
|
|
|
|
|
|
'line_item_price_name' => env('SHOP_STRIPE_LINE_ITEM_PRICE_NAME', false),
|
|
|
|
|
|
|
2025-12-29 08:59:02 +00:00
|
|
|
|
// Webhook events that the shop package listens for
|
|
|
|
|
|
// You can customize this list to add/remove events as needed
|
|
|
|
|
|
'webhook_events' => [
|
|
|
|
|
|
// Checkout Session Events
|
|
|
|
|
|
'checkout.session.completed',
|
|
|
|
|
|
'checkout.session.async_payment_succeeded',
|
|
|
|
|
|
'checkout.session.async_payment_failed',
|
|
|
|
|
|
'checkout.session.expired',
|
|
|
|
|
|
|
|
|
|
|
|
// Charge Events
|
|
|
|
|
|
'charge.succeeded',
|
|
|
|
|
|
'charge.failed',
|
|
|
|
|
|
'charge.refunded',
|
|
|
|
|
|
'charge.dispute.created',
|
|
|
|
|
|
'charge.dispute.closed',
|
|
|
|
|
|
|
|
|
|
|
|
// Payment Intent Events
|
|
|
|
|
|
'payment_intent.succeeded',
|
|
|
|
|
|
'payment_intent.payment_failed',
|
|
|
|
|
|
'payment_intent.canceled',
|
|
|
|
|
|
|
|
|
|
|
|
// Refund Events
|
|
|
|
|
|
'refund.created',
|
|
|
|
|
|
'refund.updated',
|
|
|
|
|
|
|
|
|
|
|
|
// Invoice Events (for subscriptions)
|
|
|
|
|
|
'invoice.payment_succeeded',
|
|
|
|
|
|
'invoice.payment_failed',
|
|
|
|
|
|
],
|
2025-11-21 10:49:41 +00:00
|
|
|
|
],
|
|
|
|
|
|
|
2025-12-17 17:33:34 +00:00
|
|
|
|
// Currency configuration
|
|
|
|
|
|
'currency' => env('SHOP_CURRENCY', 'usd'),
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
// Cache configuration
|
|
|
|
|
|
'cache' => [
|
|
|
|
|
|
'enabled' => env('SHOP_CACHE_ENABLED', true),
|
|
|
|
|
|
'ttl' => 3600,
|
|
|
|
|
|
'prefix' => 'shop:',
|
|
|
|
|
|
],
|
|
|
|
|
|
|
|
|
|
|
|
// Cart configuration
|
|
|
|
|
|
'cart' => [
|
2026-09-25 08:37:59 +00:00
|
|
|
|
// Adding a ProductPrice of a plain product stores the product with that price as the
|
|
|
|
|
|
// line (name, photo, checkout, invoice show the product and option). Off by default.
|
|
|
|
|
|
'prices_as_options' => env('SHOP_CART_PRICES_AS_OPTIONS', false),
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
'expire_after_days' => 30,
|
|
|
|
|
|
'auto_cleanup' => true,
|
|
|
|
|
|
'merge_on_login' => true,
|
2025-12-29 09:26:51 +00:00
|
|
|
|
|
|
|
|
|
|
// Cart expiration: mark carts as expired after this many minutes of inactivity
|
|
|
|
|
|
'expiration_minutes' => env('SHOP_CART_EXPIRATION_MINUTES', 60),
|
|
|
|
|
|
|
|
|
|
|
|
// Cart deletion: delete unused carts after this many hours of inactivity
|
|
|
|
|
|
'deletion_hours' => env('SHOP_CART_DELETION_HOURS', 24),
|
2025-11-21 10:49:41 +00:00
|
|
|
|
],
|
|
|
|
|
|
|
2025-12-29 08:59:02 +00:00
|
|
|
|
// Order configuration
|
|
|
|
|
|
'orders' => [
|
|
|
|
|
|
'number_prefix' => env('SHOP_ORDER_PREFIX', 'ORD-'),
|
|
|
|
|
|
'auto_complete_virtual' => true, // Auto-complete orders with only virtual products
|
|
|
|
|
|
'auto_complete_paid' => false, // Auto-complete orders when fully paid
|
|
|
|
|
|
],
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
// API Response format
|
|
|
|
|
|
'api' => [
|
|
|
|
|
|
'include_meta' => true,
|
|
|
|
|
|
'wrap_response' => true,
|
|
|
|
|
|
'response_key' => 'data',
|
|
|
|
|
|
],
|
2025-11-25 23:05:46 +00:00
|
|
|
|
|
2026-05-15 08:27:59 +00:00
|
|
|
|
/*
|
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
|
| Loan / rental defaults
|
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
| Consumed by ProductPurchase::extend() and ProductPurchase::canExtend()
|
|
|
|
|
|
| when no overrides are passed. Lets a host app (library, equipment-rental
|
|
|
|
|
|
| etc.) tune the lifecycle without subclassing the model.
|
|
|
|
|
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
'loan' => [
|
|
|
|
|
|
// Loan policy knobs (used by ProductPurchase lifecycle helpers).
|
|
|
|
|
|
// Pricing lives on the ProductPrice itself (billing_scheme=tiered +
|
|
|
|
|
|
// associated product_price_tiers rows), not here.
|
|
|
|
|
|
'default_duration_weeks' => env('SHOP_LOAN_DURATION_WEEKS', 2),
|
|
|
|
|
|
'extension_weeks' => env('SHOP_LOAN_EXTENSION_WEEKS', 1),
|
|
|
|
|
|
'max_extensions' => env('SHOP_LOAN_MAX_EXTENSIONS', 2),
|
|
|
|
|
|
],
|
|
|
|
|
|
|
2026-05-18 11:05:38 +00:00
|
|
|
|
/*
|
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
|
| API pagination
|
|
|
|
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
| Consumed by the public API controllers (Http/Controllers/Api/*).
|
|
|
|
|
|
| `per_page` — default page size when the request doesn't specify one.
|
|
|
|
|
|
| `max_per_page` — upper bound the controller will honour regardless of
|
|
|
|
|
|
| what the client asks for, so a malicious or careless
|
|
|
|
|
|
| caller can't request all rows in one request.
|
|
|
|
|
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
'pagination' => [
|
|
|
|
|
|
'per_page' => env('SHOP_API_PER_PAGE', 24),
|
|
|
|
|
|
'max_per_page' => env('SHOP_API_MAX_PER_PAGE', 100),
|
|
|
|
|
|
],
|
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
|
];
|