Search by

contenir / contenir-commerce

peptolab

Commerce domain for Contenir sites on contenir-db-model 2: orders and their lifecycle, artworks, GST-inclusive money, artist enquiries, the email log, and Stripe checkout and refunds.

v2.0.0-RC2 2026-10-06 05:29 UTC

README

Continuous Integration codecov

Formerly contenir/commerce; the old package is abandoned in favour of this one.

The commerce domain for Contenir gallery sites, built on contenir-db-model 2:

  • orders and their lifecycle (CheckoutService, CompletionService, FulfilmentService, and the OrderManager façade over them; OrderStatus): pending, paid, awaiting pickup, collected, refunded, cancelled, with every transition enforced;
  • artworks and retail products, with availability checked when an order is created and when checkout begins, prices checked against the stored artwork, and each work claimed atomically when payment completes, so it sells once;
  • tax-inclusive money in integer cents (Money, TaxRate), with exact rounding at any rate;
  • artist enquiries and their uploaded files, and the transactional email log;
  • Stripe hosted Checkout, session expiry and refunds behind PaymentGatewayInterface, with every Stripe error surfaced as PaymentFailedException;
  • extensible entities: abstract bases a site extends with columns of its own, selected by configuration.

It ships a ConfigProvider (Mezzio and other PSR-11 containers) and a Module (laminas-mvc), and has no MVC or Mezzio plumbing of its own. Version 2.0 is not compatible with 0.2; see UPGRADE-2.0.md.

Requirements

  • PHP 8.3, 8.4 or 8.5
  • contenir/contenir-db-model 2.x (on php-db/phpdb 0.6, with a platform package such as php-db/phpdb-mysql)
  • stripe/stripe-php 22.x
  • Any PSR-11 container, for the optional factories

Install

2.0 is a release candidate (2.0.0-RC2): contenir-db-model 2 is itself at RC and builds on php-db/phpdb 0.6, which has no stable release yet. Composer only honours stability flags in the root package, so a site needs these in its own composer.json:

{
    "require": {
        "contenir/contenir-commerce": "^2.0@RC",
        "contenir/contenir-db-model": "^2.0@RC",
        "php-db/phpdb": "0.6.x-dev@dev"
    }
}

Alternatively set "minimum-stability": "dev" with "prefer-stable": true in the site's composer.json and require contenir/contenir-commerce normally.

With laminas-component-installer the Contenir\Commerce\ConfigProvider (Mezzio) or the Contenir\Commerce module (laminas-mvc) is added to your configuration automatically. contenir-db-model's own ConfigProvider or module supplies the EntityManager.

Quick start

use Contenir\Commerce\Money\Money;
use Contenir\Commerce\Order\CustomerDetails;
use Contenir\Commerce\Order\OrderManager;
use Contenir\Commerce\Order\PurchaseItem;

$orders = $container->get(OrderManager::class);

// Build each item from the artwork row on the server. A price that differs from the stored artwork's is refused
// with PurchaseItemMismatchException.
$order = $orders->createPendingOrder(
    [new PurchaseItem($artwork->artworkId, 'Headland, Dawn', $artwork->getPrice(), 'June Hollis')],
    new CustomerDetails('Avery Buyer', 'avery@example.test'),
);

$session = $orders->beginCheckout(
    $order,
    'https://www.example.com/thank-you?session_id={CHECKOUT_SESSION_ID}',
    'https://www.example.com/cart',
);
// Redirect the buyer to $session->url.

// Later, from the Stripe webhook and the thank-you page (safe to repeat):
$result = $orders->completeFromCheckoutSession($sessionId);
$result->outcome; // CompletionOutcome::Completed, AlreadyCompleted, NotPaid, RefundedRace or RefundedCancelled

// In the CMS: cancelling a pending order also expires its Stripe session.
$orders->cancelOrder($order);

A class that needs only one part of the lifecycle can inject CheckoutService, CompletionService or FulfilmentService instead; OrderManager delegates to them.

$price = Money::fromCents(185_000);
$price->gstComponent()->amount; // 16818
$price->format();               // "$1,850.00"

Configuration

The Stripe secret key, normally in an untracked local config file, and optional commerce settings:

return [
    'stripe' => [
        'secret_key' => 'sk_live_...',
    ],
    'contenir_commerce' => [
        'order_reference_prefix' => 'LR',   // LR-2026-0001
        'currency'               => 'AUD',
        'tax_rate'               => 10,     // percent, included in prices
        'tax_label'              => 'GST',
        'artwork_entity'         => App\Entity\Artwork::class, // and the other *_entity keys
    ],
];

The values shown are the defaults (apart from artwork_entity); invalid ones throw ConfigurationException. Without a Stripe key the container still builds: PaymentGatewayInterface resolves to UnconfiguredGateway, which throws PaymentFailedException as soon as money would move. See docs/configuration.md and, for columns of your own, docs/entities.md.

Public API

Class Purpose
Order\OrderManager Create, check out, complete, expire, pick up, collect, refund and cancel orders (a façade)
Order\CheckoutService, Order\CompletionService, Order\FulfilmentService The same steps, split by who takes them
Order\OrderStatus The lifecycle and its allowed transitions
Order\CompletionOutcome, Order\CompletionResult What completing a checkout session did
Order\PurchaseItem, Order\CustomerDetails Inputs to createPendingOrder()
Money\Money, Money\TaxRate Tax-inclusive amounts in integer cents, and tax rates in parts per million
Config\CommerceSettings The reference prefix, currency, tax rate and tax label
Artwork\ArtworkStatus, Artwork\ItemType, Enquiry\EnquiryStatus Stored states and their labels
Model\Entity\Abstract*Entity, Model\Entity\*Entity Attribute-mapped rows (artwork, order, order item, artist enquiry, enquiry file, email log): abstract bases and their final defaults
Model\Repository\*Repository Typed finders for each entity; ArtworkRepository::claim()
Payment\PaymentGatewayInterface The payment provider: checkout sessions, their expiry, and refunds
Payment\StripeGateway, Payment\UnconfiguredGateway The shipped gateways
Payment\CheckoutRequest, Payment\CheckoutLineItem, Payment\CheckoutSession, Payment\RefundResult Gateway values
Exception\* Every exception implements Exception\ExceptionInterface
ConfigProvider, Module, Order\Factory\*, Payment\Factory\*, Config\Factory\*, Model\Repository\Factory\* Container wiring

Every concrete class is final. The extension points are PaymentGatewayInterface and the Abstract*Entity bases. Time is always read through the PSR-20 ClockInterface (Clock\SystemClock by default).

The docs folder covers each area:

Development

The QA toolchain is contenir/contenir-qa-tools. Mago is a standalone binary, installed separately (brew install mago).

composer check             # everything below
composer cs-check          # mago format --check && mago lint
composer static-analysis   # mago analyze
composer test              # unit suite: no I/O; Stripe through an offline HTTP client
composer test-integration  # integration suite: in-memory SQLite, real EntityManager and ServiceManager
composer test-coverage     # both suites, clover.xml for Codecov
composer mutation-test     # Infection over both suites (needs Xdebug or PCOV)

No test touches the network: StripeGateway is tested through stripe-php's pluggable HTTP client, and the order services through a scriptable fake gateway. The concurrency tests interleave two completions over two connections to one in-memory SQLite database.

License

MIT. See LICENSE.md.