Commit Graph

24 Commits

Author SHA1 Message Date
Fabian @ Blax Software 945d7e70aa fix(ledger): count bounced direct debits and Stripe account fees
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>
2026-10-07 10:51:09 +02:00
Fabian @ Blax Software 423da476f5 feat(ledger): mirror PayPal accounts into the money ledger
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>
2026-10-07 10:46:46 +02:00
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 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 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 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 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 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 0cfbdf221d BF edgecases, R structure, A tests 2026-05-18 13:05:38 +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 6e9c9043ae I order, A handy methods 2025-12-29 10:26:51 +01:00
Fabian @ Blax Software 9c1fcd6cfd AM orders 2025-12-29 09:59:02 +01:00
Fabian @ Blax Software c43910b927 I checkout session, pool cart price_id 2025-12-17 18:33:34 +01:00
Fabian @ Blax Software 3045f72304 BFI cart, A stripe logic, checkout & tests, A tests, RA pool product 2025-12-15 14:10:59 +01:00
Fabian @ Blax Software 4a303c0f3f I category methods, IRA product relations & tests 2025-12-05 09:21:07 +01:00
a6a2f5842 a4fedcdb58 A tests, I product action 2025-11-29 20:09:19 +01:00
a6a2f5842 929e87bc28 AI payment process & payment provider files 2025-11-26 11:09:52 +01:00
a6a2f5842 856686e292 A traits, concerns, services 2025-11-26 00:05:46 +01:00
a6a2f5842 fda536deea BFIM bugfixed failed tests 2025-11-22 18:09:45 +01:00
a6a2f5842 8fa194e7e6 BF added model table config 2025-11-21 15:22:40 +01:00
a6a2f5842 d610cc5717 init 2025-11-21 11:49:41 +01:00