Aller au contenu

omnibase/marketplace

Marketplace for base-bundle: stores, products and variants, carts and orders, payment through pluggable gateways, sales rules (discounts, fees, taxes), reviews

Étage
Structure préparée · Omnibase
Version
main
Vérifié en réel
Son README ne le dit pas
Licence
MIT
composer require omnibase/marketplace:dev-main

The shop of an omnibase site: stores, products and their variants, carts, orders, VAT, promotions, shipping through omnisense/omnibus, payments through omnisense/omnitrade - and what any trade adds to it:

Proposer une modification docs/index.md

Installation

Requirements

PHP 8.2 or later, Symfony 7.4 or 8 (config, dependency-injection, form, http-kernel, security-bundle, lock), Doctrine ORM, omnisense/omnibase 3.x or 4.x, omnibridge/symfony 1.x, omnibase/admin, omnisense/omnistate with omnistate/annuaire-entreprises and omnistate/vies (business customers' VAT and company numbers), dompdf/dompdf (the invoices' PDF) and horstoeko/zugferd (their Factur-X data).

Suggested: omnisense/omnitrade (payments: omnitrade/stripe, omnitrade/paypal...), omnisense/omnibus (carriers: omnibus/mondial-relay, omnibus/ups...), omnisense/omnibill with omnibill/email or omnibill/afnor (sending the invoices), omnisense/omnisign (signing a quote in the page).

The bundle

composer require omnibase/marketplace:dev-main
// config/bundles.php
Base\Marketplace\MarketplaceBundle::class => ['all' => true],

The routes

MarketplaceBundle::getPath() is the package root, so the path includes src/:

# config/routes.yaml
marketplace_controller:
    resource: "@MarketplaceBundle/src/Controller/Client"
    type: attribute
    prefix: /

The seller

Products, stores and orders point at Base\Marketplace\Model\MerchantInterface, which only asks for getCompanyName(); bind it to a class of the application:

# config/packages/doctrine.yaml
doctrine:
    orm:
        resolve_target_entities:
            Base\Marketplace\Model\MerchantInterface: App\Entity\User

The schema and the assets

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate
bin/console assets:install

The configuration

Every key has a default (Configuration reference). The shop's own:

# config/packages/marketplace.yaml
marketplace:
    default_currency: EUR     # a store's currency wins over this
    products_per_page: 24
    orders_per_page: 20
    cart_max_quantity: 99     # per line; Product::getMaxQuantity() can lower it
    guest_cart: false
    quick_order: { enabled: true, link_ttl: 2592000 }
    gateways:                 # settings per payment method, keyed by its slug
        virement:
            currencies: [EUR] # keeps the method off orders in other currencies
            instructions: "IBAN FR76 ..., reference of the order in the label."

The layout

The pages extend layout1.html.twig and fill its content, aside, title and stylesheets blocks. Every class is .marketplace-*, driven by custom properties on .marketplace; a store's page also carries .marketplace-store-<slug>. A site overrides any template under templates/bundles/MarketplaceBundle/client/ (_price, _product_media and _banner exist for that), calling the original with @!Marketplace/client/....

The cron

The commands that run on a schedule are given with their cron lines where they are explained: marketplace:subscriptions --sync in Plans, subscriptions, rights and credits, marketplace:referrals in Referrals, marketplace:wishlist:refresh-prices in Lists of wishes; every command is listed in the reference.

The demo

The repository holds a demo application under example/app/ and a Dockerfile that builds it: the bundles.php, routes.yaml, doctrine.yaml, security.yaml and marketplace.yaml an application needs, a gateway of its own (src/Payment/DemoGateway.php) and a user with a username.

Proposer une modification docs/installation.md

The shop

What every shop on omnibase/marketplace has, whatever its trade: stores, products, carts, orders, VAT, promotions, shipping, payments and the quick order. The trades' additions have pages of their own.

Products and stores

A product travels by post unless its class says otherwise: Product::isShippable() returns true. An application sells its own things by subclassing Product with a #[DiscriminatorEntry] value of its own; it overrides getMaxQuantity() to return 1 for what is owned once (an avatar item, a licence). A store's currency wins over marketplace.default_currency.

Promotions and coupons

Base\Marketplace\Service\Pricing prices every cart when it is shown and again at checkout, and never reprices a paid order. It works from the discounts the back office defines:

  • Promotions run by themselves between validAt and expiredAt, highest priority first: an event, a sale.
  • Coupons are codes the member types in the cart. quota and quotaPerCustomer cap how many paid orders may use one, owner reserves it to one member. A coupon marked individualUse stands alone, with no promotion and no other coupon.

Each discount holds three kinds of attributes:

Kind What it does Adapters
Rules all must hold for the order total price, cart contains, cart quantity
Scopes choose the lines a per-line action touches; with none, every line store, product, taxon, user, region, order
Actions take something off percentage, fixed amount

An action set to apply to items cuts each line in scope; otherwise it cuts the order. Nothing is ever cut below zero.

Shipping

Checkout asks for a delivery address and a shipping method only when some line is shippable. A shipping method in the order's currency is offered with its charge: RATE_FLAT costs its unit price once, RATE_PRIORITY costs it per shipping unit (from the product's weight, or one per item).

The staff see the paid orders waiting at /admin/commandes/a-expedier (marketplace_shipping_queue, ROLE_ADMIN); its former address, /commandes/a-expedier, redirects there. They mark one shipped with its tracking number, which creates a Shipment, and later delivered. The member follows the parcel on their order page; a tracking URL may hold {number}.

The carrier is omnisense/omnibus's: a shipping method names a gateway configured under omnibus.gateways (gatewayName), and when that package is installed Service\Shipping books the parcel there (book(): the label, the tracking number on the shipment) and follows it (track()). What the buyer pays stays the method's own price; ratesFor() says what the carrier's tariff would be. The sender is marketplace.shipping.sender.

Payments

A payment method is a row: a slug, a label, and its gateway's name() as gatewayFactory. Real money goes through omnisense/omnitrade: a method whose gateway names an omnitrade.gateways entry is served by the bridge Payment\Omnitrade\OmnitradeGateway, found by PaymentGatewayRegistry after the application's own gateways. The bridge describes the order as an omnitrade Payment (its lines when they add up to the net price, else one line; the buyer; where to come back), takes the provider's answer as a PaymentResult (paid, a page to send the buyer to, pending, refused) and keeps the provider's reference on the Transaction. marketplace.gateways.<slug> holds what is the method's own (currencies, instructions, method).

The buyer comes back to marketplace_payment_return, which asks the provider where the payment stands; the provider's webhook posts to marketplace_payment_webhook. Both confirm the same transaction, once (Checkout::confirm() is idempotent). Refunds go back the same way (OmnitradeGateway::refund()).

For Stripe, marketplace:stripe:webhook creates the webhook endpoint (https://<host>/marketplace/<gateway>/webhook, the omnitrade gateway's name, with the Checkout events) if Stripe lacks it, completes its events otherwise; --dry-run says what it would do, --recreate makes a new endpoint (hence a new signing secret), --url gives the address. Stripe gives the signing secret only when the endpoint is created: the command prints it, for STRIPE_WEBHOOK_SECRET.

An application takes payment its own way with a service implementing the gateway interface, picked up by autoconfiguration:

final class PepettesGateway implements PaymentGatewayInterface
{
    public static function name(): string { return 'pepettes'; }
    public function supports(Order $order, PaymentMethod $method): bool { return 'PEP' === $order->getCurrency(); }
    public function pay(Order $order, Transaction $transaction, PaymentMethod $method): PaymentResult
    {
        // debit, then:
        return PaymentResult::paid(); // or pending(), redirect($url), refused('reason.key', [...])
    }
}

A refusal reason is a translation key in the marketplace domain.

After a sale

Base\Marketplace\Event\OrderPaidEvent is dispatched once the order is confirmed and its stock decremented: where an application delivers virtual goods, credits an account, or tells the staff to ship. The other events are listed in the reference.

The quick order

A product bought in one step, with no account and no basket: an e-mail address, the payment.

{% include '@Marketplace/client/_quick_order.html.twig' with {product: product} %}

Service\QuickOrder::buy() makes the order for that address (the member it belongs to, or a new one whose password nobody knows), Checkout::settle() takes the payment with the shop's first able method, and the buyer ends on the order's own page: a signed link (QuickOrder::doneUrl(), valid marketplace.quick_order.link_ttl seconds) that is also good to mail. Event\QuickOrderDoneEvent lets an application put something on that page (files to download, a pickup time) by setting a response.

The form is Form\QuickOrderType (marketplace_quick_order_form(product, back) gives it to a template), guarded as omnisense/omnibase guards a form (action quick_order: a trap, the time it takes, the lists, the captcha when the site has omnisense/omnishield); a signed-in member is asked no address. A buyer back from the payment provider in the same browser lands on that page too (EventListener\QuickOrderReturnListener).

Proposer une modification docs/the-shop.md

Brands, typed sheets, filters, pairings

What a catalogue needs beyond a title and a price, for any trade - the example is a wine merchant's cellar, but nothing here knows about wine: the wine is data.

Brand

Base\Marketplace\Entity\Brand is a thread (its title the name, its content the story, translated like any thread) with a logo, a country, a region, a gallery and a website. Product::$brand names it; a variant reads its principal's.

$estate = (new Brand('Château Lafleur-Gazin'))->setCountry('FR')->setRegion('Pomerol');
$product->setBrand($estate);

A brand's page is the application's: marketplace.brand_route names the route taking {slug} (Brand::__toLink()); null, brands have no link. The catalogue synchronisation files Shopify's vendor and WooCommerce's brand under the brand of that name, created when missing.

Back office: BrandCrudController (Boutique › Marques).

AttributeSet: a typed sheet per taxon, without code

The attributes are omnisense/omnibase's (an adapter: its code, its label in each language, its type - text, number, choice...). An AttributeSet says which ones a kind of product has, for a Product\Taxon (a taxon without a set takes its parent's; a set without taxon is every product's), each in a Field: its position, required, filterable and how (choice among the values met, or a range), its unit ("% vol.", "°C") and whether the product's page shows it.

$appellation = new TextAdapter('Appellation', 'appellation');
$alcohol = new NumberAdapter('Degré', 'alcohol');
$set = new AttributeSet('Vin', $wines);
$set->addField(new Field($appellation, filterable: true));
$set->addField(new Field($alcohol, filterable: true, filter: Field::FILTER_RANGE, unit: '% vol.'));

$attribute = new Product\Attribute($appellation, 'Pomerol');
$product->addAttribute($attribute->setProduct($product));
$product->getAttributeValue('appellation');   // "Pomerol" (a variant without it reads its principal's)

For a wine: appellation, region, grapes, colour, alcohol, serving temperature, keeping, tasting notes; its vintages and formats are Variants.

Back office: AttributeSetCrudController (Boutique › Fiches types).

The list's filters

Service\AttributeFilters builds the filters from the set's filterable fields and applies the visitor's choice, given in the query string:

$selection = $filters->selection($request);            // ?f[colour][]=Rouge&f[alcohol][min]=13
$facets = $filters->facets($products, $set, $locale);  // [{code, label, filter, unit, values: {Rouge: 4, Blanc: 2}, min, max}]
$shown = $filters->apply($products, $selection, $locale);

A value holding a list ("Merlot, Cabernet franc") counts once per item. It works in memory over the products given - right for a few hundred references; a large catalogue filters in Typesense (glitchr/typesense-bundle).

In a template, a product's sheet:

{% for row in marketplace_attributes(product, app.request.locale) %}
    <dt>{{ row.label }}</dt><dd>{{ row.value }} {{ row.unit }}</dd>
{% endfor %}
{% set set = marketplace_attribute_set(product) %}

Pairings, cross-sells, upsells

Product\Association: a product, a target, a type (AssociationType::PAIRING, CROSS_SELL, UPSELL), a note by language and a position.

$margaux->associate($comte, AssociationType::PAIRING, ['fr' => 'Sur un comté de 24 mois', 'en' => 'With a 24-month Comté']);
{% for association in marketplace_associations(product, 'pairing') %}
    <a href="{{ association.target.__toLink() }}">{{ association.target.title }}</a> {{ association.note(app.request.locale) }}
{% endfor %}

Only targets still for sale are listed. @Marketplace/client/_product_trade.html.twig shows the brand, the sheet, the lots, the health notice, the pairings and the link to a quotation.

A blog post's products

A thread (a blog post of omnibase/blog, an event of omnibase/agenda) features products through omnisense/omnibase's Thread::$connexes, in either direction:

{% for product in marketplace_products_of(post) %}…{% endfor %}

A food-and-wine pairing is then an article citing its bottles.

Upgrading an application

New tables marketplace_attribute_set, marketplace_attribute_set_field, marketplace_product_association, marketplace_quote, marketplace_quote_line, marketplace_platform_link, the Brand thread's table; new columns on the product (brand_id, packSize, minimumQuantity, ageRestricted), on the product taxon (ageRestricted) and on the order (quoteReference):

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

Proposer une modification docs/catalogue.md

Lots, the age gate, where the shop delivers

Selling by the lot

$product->setPackSize(6);          // a case of 6: the cart holds 6, 12, 18...
$product->setMinimumQuantity(12);  // and at least 12 (rounded up to the lot)
$product->boundQuantity(7);        // 12
$product->boundQuantity(40, 30);   // 30 at most: 30

A variant without its own takes its principal's. The cart (Service\Cart) rounds what is added up to the lot and the minimum, and down under cart_max_quantity, the product's own maximum and its stock; when not even the minimum fits, it refuses (cart.error.minimum, or cart.error.sold_out for the stock). Adding "one" of a case of 6 adds 6. Checkout checks it again before paying (an order made from a quote is exempt: its quantities are the quote's). marketplace.cart_max_quantity must leave room for a few cases (120 for a cellar).

The age gate

Products sold to adults only - Product::$ageRestricted, or one of its taxa (Product\Taxon::$ageRestricted, inherited down the tree) - stand behind an interstitial, once: the answer is kept a year in the marketplace_age cookie (the age confirmed: a yes at 18 does not open a page asking 20).

marketplace:
    age_gate:
        enabled: true
        default: 18
        ages: { ja: 20, JP: 20 }    # by language or country; a country wins
        notice: "L'abus d'alcool est dangereux pour la santé, à consommer avec modération."
        site: false                 # true: on every page

The visitor's country is read from a country cookie or a CF-IPCountry / X-Country header when the application sets one. The notice may be a translation key (@marketplace.age_gate.notice is the Évin wording in French, a Japanese one in Japanese).

{% if marketplace_age_gate(product) %}{% include '@Marketplace/client/_age_gate.html.twig' with {away: 'https://…'} %}{% endif %}
{% if marketplace_age_gate(true) %}…{% endif %}   {# a page that is restricted as a whole: a cellar's list #}
{{ marketplace_age_minimum() }}  {{ marketplace_age_notice()|trans }}

The gate is a <dialog open> with a form (POST /age, marketplace_age_gate): it works without JavaScript; style .marketplace-age-gate or override the template (its age_gate_mark block takes a logo).

Where the shop delivers

A store's sales regions (Sales\Region::$countries) say where its parcels go. Shipping::deliversTo($order, 'JP') is false when the store's enabled regions list countries and not this one; Shipping::apply() then answers shipping.error.out_of_zone, and the checkout sends the buyer to the quotation form (marketplace_quote_request, the country and the cart's products filled in). A region listing no country goes everywhere. An order made from a quote goes where its quote says.

Proposer une modification docs/selling-rules.md

Hand-overs, options, starting prices, files

What a shop that makes things - a caterer, a sign maker, a florist - adds to a plain cart. Nothing here knows the trade: which dish may travel chilled is omnibase/restaurant's business (Service\ColdChain), not the marketplace's.

Pickup: how an order is handed over

Entity\Order\Pickup (table marketplace_pickup) goes with an order, one row per order: the mode (Enum\PickupMode: pickup at the shop on a slot, delivery brought nearby by the shop itself, shipping by a carrier), the day and the slot wished ("12:00-12:30"), the time the shop gives (readyAt), who (contactName, email, phone), where (address, postcode, city, country), a note, a parcel's tracking number, and a token: /remise/{token} (marketplace_pickup) follows it without an account. Orders made together may share a token; the page shows them together.

"Fulfilment" already names this in some applications and Supply is what a supplier makes to order (Made to order): hence Pickup.

marketplace:
    pickup:
        modes: [pickup, delivery]        # default: [pickup]; "shipping" for a carrier
        zip_codes: ['67000', '67100', '674*']   # where the shop delivers itself; a prefix ends with *
        slots: { from: '11:00', to: '19:00', step: 30 }
        notice: 60                       # minutes the shop needs before the first slot
        horizon: 14                      # days ahead
        timezone: 'Europe/Paris'         # the shop's clock; null: PHP's (omnibase sets it to the visitor's)
$pickups->modes();                          // [PickupMode::PICKUP, PickupMode::DELIVERY]
$pickups->deliversTo('67400');              // true
$pickups->days();                           // the days still open
$pickups->slots($day);                      // ['11:00-11:30', '11:30-12:00', …], those gone left out

$pickup = $pickups->open($order, PickupMode::DELIVERY, $day, [
    'contactName' => 'Léa Martin', 'phone' => '06 …', 'slot' => '12:00-12:30',
    'address' => '12 rue des Lilas', 'postcode' => '67000', 'city' => 'Strasbourg',
]);                                         // CartException pickup.error.{mode,day,out_of_zone,address,placed}
$pickups->move($pickup, PickupStatus::READY);
$pickups->trackingUrl($pickup);

open() may be called again while the order is still a cart (the buyer changes their mind); once placed it refuses. An application that filters the modes itself passes check: false as the last argument.

Status (Enum\PickupStatus)
checkout the order exists, its payment has not come
received paid: set by EventListener\PickupPaidListener on OrderPaidEvent
accepted, preparing, ready the shop's steps
out_for_delivery a delivery or a parcel on its way (not a step of a pickup)
done, cancelled, refused, refunded final

Every move dispatches Event\PickupChangedEvent ($pickup, $previous): the application writes to the customer, prints a ticket. The bundle sends no mail of its own. @Marketplace/client/_pickup.html.twig shows one hand-over.

A ShippingMethod may now have no carrier: gatewayName is nullable (hasCarrier()), for "collected at the workshop" priced like any method.

Options: what the buyer chooses on a product

Entity\Product\OptionGroup and Option (tables marketplace_product_option_group, marketplace_product_option): a choice that is not another reference - a cooking, extras, a finish. A group takes one option or several (multiple), between minimum and maximum; an option may add to the price (price: smallest unit, before VAT like the product's), be preselected (default) or unavailable. Labels have translations (labels: {en: …}). A variant without groups offers its principal's.

$ramen->addOptionGroup((new OptionGroup('Cuisson des nouilles', minimum: 1))
    ->option('Fermes')->option('Normales', default: true)->option('Tendres'));
$ramen->addOptionGroup((new OptionGroup('Suppléments', multiple: true, maximum: 2))
    ->option('Œuf mollet', 150)->option('Chashu', 300, labels: ['en' => 'Braised pork']));

$selection = $options->select($ramen, [$egg->getId()]);   // Service\ProductOptions; CartException options.error.*
$selection->surcharge();                                  // 150
$cart->add($ramen, 2, [$egg->getId()]);                   // a line per product and choice

The line keeps a copy (OrderItem::getOptions(): [{group, option, group_label, label, price}], getOptionsLabel(), getOptionsSurcharge()) and its unit price is the product's plus the surcharge (OrderItem::applyOptions()), so discounts and VAT apply to the whole. The product's page shows the groups inside its "add to cart" form (@Marketplace/client/_options.html.twig, posting options[<group>]). Back office: OptionGroupCrudController (Boutique › Options).

A starting price: "dès 130 € HT"

$product->getPriceRange();      // [lowest, highest] of its variants still for sale; its own price without variants
$product->setPriceFrom(30000);  // typed by hand, for what is priced on request: shown, never charged
$product->getStartingPrice();   // priceFrom, else the lowest of the range; null when there is no price at all
$product->isPricedFrom();       // typed, or the lowest of several
{% include '@Marketplace/client/_price_from.html.twig' with {product: product, price_tax: 'excl'} %}   {# dès 130,00 € HT #}
{% include '@Marketplace/client/_price.html.twig' with {amount: 13000, currency: 'EUR', price_from: true, price_tax: 'incl'} %}

The product card shows the starting price when there is one, and "price on request" (product.price_on_request, .marketplace-price-request) when there is none: no price was ever written, or a product of its own kind has an empty getPriceRange(). getStartingPrice() is null then, and a price of zero is shown only when it was given.

A shop that quotes to businesses says "HT" after every price without passing price_tax each time: marketplace.price_tax: excl (or incl; null, the default, for no mention). The HT / VAT / TTC rows of a summary (_vat.html.twig) carry no mention after their amounts, their labels saying it, and a total paid with VAT in it (a quick order's page, the orders' list, a pickup, the carts on the stores' page) says "TTC" whatever the rule; a template says price_tax: false for no mention on one amount.

Files: with a quote request, for an order line

Entity\Attachment (table marketplace_attachment) is a file somebody gave the shop, kept outside the public directory under a random name:

marketplace:
    attachments:
        directory: '%kernel.project_dir%/var/storage/marketplace'
        max_size: 20971520      # bytes
        max_files: 5            # per request, per line
        extensions: [pdf, png, jpg, jpeg, webp, gif, svg, ai, eps, psd, tif, tiff, zip]
  • A quote request takes files (Quotes); they are listed on the quote in the back office and in the mail to the shop.
  • An order line: POST /panier/{order}/ligne/{item}/fichiers (marketplace_cart_files, the buyer's own cart, field files[]), with @Marketplace/client/_line_files.html.twig to include where a line needs it; or $attachments->attachToItem($item, $files) from the application. The order in the back office lists them.

Reading one: marketplace_attachment (/marketplace/piece-jointe/{id}) for whoever may see the shop's screens (MARKETPLACE_VIEW), or through $attachments->url($attachment, $ttl), a signed link for somebody without an account - a supplier fetching an artwork. Always a download (Content-Disposition: attachment, nosniff): an SVG is never shown in the site's origin. Keep the directory on a persistent volume, and in the backups.

Upgrading an application

New tables marketplace_pickup, marketplace_product_option_group, marketplace_product_option, marketplace_attachment; new columns priceFrom on the product, options on the order item, phone on marketplace_quote; gatewayName of the shipping method becomes nullable:

bin/console make:migration
bin/console doctrine:migrations:migrate

Nothing changes for a shop that uses none of it: Cart::add() takes its options as an optional third argument, getPriceRange() still answers [price, price] for a product without variants, the quote form keeps its address (marketplace.quotes.path: cotation).

The stylesheet's custom properties are now defaults of no specificity (:where(:root)): a site's :root { --marketplace-accent: … } wins whatever the order of the stylesheets. They were set on .marketplace, which beat :root; a value set on .marketplace or .marketplace-store-<slug> still wins.

Proposer une modification docs/pickup-and-options.md

Invoices and credit notes

Entity\Invoice is the shop's invoice and its credit note (avoir). Service\Invoices issues them, cancels them, sends them and prints them. The PDF carries its Factur-X XML.

$invoice = $invoices->issue($order);              // F-2026-000041: the order paid, frozen
$pdf = $invoices->pdf($invoice);                  // PDF/A-3, factur-x.xml attached
$invoices->send($invoice);                        // mailed to the buyer, PDF attached: "sent"
$creditNote = $invoices->credit($invoice, 'Colis retourné');   // A-2026-000003: the invoice cancelled

What an invoice is

  • Its number is the next of a sequence without a gap, one per series and year: F-2026-000041 (marketplace.invoice.prefix, digits), A-2026-000003 for credit notes (credit_prefix). The law asks for "a chronological and continuous sequence", and allows several series. Invoice\InvoiceNumbering takes the number and stores the invoice in one transaction, under a symfony/lock lock on the series (marketplace.invoice.F). Two invoices issued at the same moment never share a number, and no number is taken without its invoice. The table's unique key (series, year, sequence) holds even if the lock fails.
  • Its seller and buyer are copied onto it the day it is issued, and kept as they were then:
    • the seller is marketplace.invoice.seller (name, legal form, capital, address, SIREN, SIRET, RCS, VAT number), with the store's own VAT number taking precedence;
    • the buyer is the customer: their company name or name, the billing address, their SIREN and SIRET when their User has them (getSiren(), getSiret()), and their VAT number (VatCustomerInterface). The delivery address is copied too when it differs from the billing one.
  • Its lines and amounts come from the order, VAT included: each line's price before VAT and its VAT are read from OrderItem, never computed again.
    • The order's own charges (shipping, service, other taxes) and its order-level discount carry no VAT in the order. They are amounts the buyer paid VAT included. Each one is split between the rates of the lines, in proportion to their bases: an accessory charge follows the rate of what it accompanies.
    • The invoice's total is exactly what the order cost.
    • The VAT breakdown by rate, the total before VAT, the VAT, the total, what was paid and what remains due are all kept on the invoice.
  • Its mentions are written in the seller's language (French for a French seller):
    • the order's VAT exemption, if any (TVA non applicable, art. 293 B du CGI, Autoliquidation…, Exonération… article 262 I du CGI);
    • the nature of the operations (marketplace.invoice.operation: goods, services, mixed);
    • the option for VAT on debits (vat_on_debits);
    • the payment: "acquittée le…", or the due date (payment_days);
    • the discount for early payment (discount, "néant" by default);
    • for a business buyer, the late payment penalties and the 40 € flat indemnity (late_penalties);
    • then the seller's own mentions (mentions).
  • Its state is issued, then sent (send(), or markSent()), paid, or cancelled by a credit note. The invoice of an order already paid is born paid.

An issued invoice does not change. Doctrine refuses any update of it except its state (Invoice::MUTABLE). A correction is a credit note (credit()), which cancels the whole invoice:

  • it copies the invoice's seller, buyer, lines and amounts;
  • it takes its own number in the credit notes' series;
  • it names the invoice it cancels.

The order may then be invoiced again. Partial credit notes do not exist yet.

The mentions the law asks for, and where they come from

The invoice prints what the administration lists:

  • Its number and date.
  • The date of the sale: the order's payment.
  • The seller: name, legal form, capital, address, SIREN, RCS, VAT number.
  • The buyer: their name, address, SIREN and VAT number when they are a business, and the delivery address when it differs.
  • Each line: its designation, quantity, unit price before VAT, VAT rate, and amounts before and after VAT.
  • The charges and discounts.
  • The VAT by rate, and the totals before and after VAT.
  • Payment: the payment date or due date, the discount for early payment, and the late payment penalties and 40 € indemnity.
  • The exemption mentions.
  • Since 2026-09-01: the buyer's SIREN, the delivery address, the nature of the operations, and the option for VAT on debits.

Sources:

  • Service-public.gouv.fr, Factures : mentions obligatoires, https://entreprendre.service-public.gouv.fr/vosdroits/F31808 (page updated on 11 August 2026, read on 8 October 2026).
  • Code général des impôts, article 289, and its annex II, article 242 nonies A (the mentions, the continuous numbering).
  • Code de commerce, articles L441-9 (the invoice between professionals) and L441-10 (late payment penalties, the 40 € indemnity).

A seller with special mentions adds them in marketplace.invoice.mentions: an approved association, a craftsperson's insurance, the legal guarantee of conformity.

Factur-X

The PDF carries its data as Factur-X 1.0 with the EN 16931 profile ("COMFORT"), written by horstoeko/zugferd (Invoice\FacturX). The file is a PDF/A-3 with the UN/CEFACT CII XML attached as factur-x.xml.

Why EN 16931 rather than another profile:

  • EN 16931 is the European standard's own model, and the French reform's base set of data is defined on it.
  • MINIMUM and BASIC WL are not invoices under French law.
  • BASIC drops what a line here may say.
  • EXTENDED adds nothing a line here holds.

The XML is checked against the profile's schema (FacturX::validate(), the library's XSD validator). The Schematron rules of EN 16931 (KoSIT's validator, which needs Java) are not run here.

Today's French reform requires the reception of electronic invoices from 2026-09-01, and their emission from 2027-09-01 for small and medium companies. Sending goes through omnisense/omnibill when it is installed (below); until the shop must use an approved platform, its omnibill/email gateway sends the invoice by e-mail, its PDF attached.

Sending through omnisense/omnibill

With omnisense/omnibill installed, Invoices::send() (the invoices' screen, "Send") hands the invoice to an omnibill gateway, and keeps where it stands there beside it (Invoice\Transmission\Entity\InvoiceFlow: the gateway, its reference, the lifecycle statuses of the reform - sent, deposited, refused, paid... - with their codes 200 to 213).

# config/packages/marketplace.yaml
marketplace:
    invoice:
        gateway: email           # the default; null: this bundle's own e-mail, as without omnibill
  • email, by default, with nothing to configure: with omnibill/email installed and no omnibill gateway of that name, the invoice is e-mailed from the seller's address (marketplace.invoice.seller.email) to the buyer, with this bundle's subject and text (the signed link included) and the PDF attached. Its statuses are kept by hand, beside the invoice: "Mark paid" declares it "Encaissée" (212), the status the reform makes mandatory.
  • A site's own gateway: marketplace.invoice.gateway names one of omnibill.gateways (the OmnibillBundle registered). An email one keeps its own from, subject, body, bcc; this bundle's words fill in what it leaves out. An approved platform (omnibill/afnor) is a change of configuration, not of code - the day the shop must issue through one.
  • Where it stands: the invoices' screen shows the latest status beside the state ("sent · Encaissée (email)") and asks the gateway again ("Refresh its status"); bin/console marketplace:invoice:status [<number>...] does it for every invoice transmitted whose status is not final (a cron, for a platform without webhooks); a platform's webhook goes to POST /marketplace/omnibill/{gateway}/webhook (marketplace_invoice_webhook), its signature checked by the gateway.
  • Without the family, nothing of it exists: the folder src/Invoice/Transmission/ is not registered and its entity not mapped (a site has no marketplace_invoice_flow table, and needs none); send() mails the invoice itself. A site that installs the family creates the table: make:migration, then doctrine:migrations:migrate.
  • Not done here: receiving the suppliers' invoices (omnibase/ledger), writing a lifecycle message (CDAR) for a platform that wants one to declare a payment - the status is then kept on the shop's side only.

Configuration

# config/packages/marketplace.yaml
marketplace:
    invoice:
        auto_issue: false        # true: issued when the order is paid (OrderPaidEvent)
        prefix: F                # F-2026-000001
        credit_prefix: A         # A-2026-000001
        digits: 6
        payment_days: 30         # the due date of an invoice not paid yet
        operation: goods         # goods, services, mixed
        vat_on_debits: false
        late_penalties: '@marketplace.invoice.mention.late_penalties'   # a text or a translation key
        discount: '@marketplace.invoice.mention.no_discount'
        mentions: []
        seller:
            name: "Atelier Exemple"
            legal_form: SAS
            share_capital: "10 000 €"
            street: ["12 rue Exemple"]
            postcode: "75011"
            city: Paris
            country: FR
            siren: "123456789"
            siret: "12345678900012"
            rcs: "RCS Paris"
            vat_number: FR40123456789
            email: factures@example.org

Where it shows

  • The buyer:
    • signed in, they download their invoice at /factures/{number} (marketplace_invoice, InvoiceVoter);
    • anyone holding its signed link can open it too (Invoices::signedUrl(), the link the e-mail carries).
  • The back office:
    • the orders' screen has "Issue the invoice" for an order paid without one;
    • the invoices' screen (InvoiceCrudController) shows the PDF and lets staff send it, mark it paid, or cancel it with a credit note. Nothing is edited there.
  • The command line: bin/console marketplace:invoice:issue <reference>... issues those orders' invoices; --paid-without-invoice issues every paid order's that has none.

The PDF's look

@Marketplace/invoice/invoice.pdf.twig, laid out after La Touche Originale's invoice. A site dresses it in its own templates/bundles/MarketplaceBundle/invoice/invoice.pdf.twig, extending the bundle's one ({% extends '@!Marketplace/invoice/invoice.pdf.twig' %}) and filling its blocks: style, logo (a data: URI, |embed_base64: nothing is fetched from elsewhere), seller, footer. Everything it prints comes from the invoice itself, never from today's order or settings.

For a site

The bundle brings the entity; the site creates its table: bin/console make:migration, then doctrine:migrations:migrate. It requires dompdf/dompdf, horstoeko/zugferd and symfony/lock (composer update omnibase/marketplace installs them).

Proposer une modification docs/invoices.md

Business quotes and exports

The flow

  1. A professional asks (/cotation, marketplace_quote_request; from a product's page ?product=12, from a cart going abroad ?country=JP): who, which company - a French SIRET checked at the State's register, an EU VAT number at VIES, or just a name -, which way (TradeDirection: export, import, domestic), an Incoterm (EXW, FCA, FOB, CIF, DAP, DDP) and its place, the country, the volumes, a date, their words. marketplace.quotes.recipient is told by mail.
  2. The seller prices it in the back office (Boutique › Cotations, and the pipeline marketplace_admin_quotes): lines - a product of the catalogue or a free line (the transport, a label for the importer) - by the lot (quantity lots of lotSize units at lotPrice cents), a discount, a date; "Envoyer" mails the client their link (/cotation/<token>).
  3. The client accepts it, signed in with the address of the request: Service\QuoteToOrder makes an order at the quote's prices (each lot shared out in units when it divides to the cent, the discount in each price, a free line a one-off product in no store), delivered where the quote says, waiting to be paid like any other - a transfer (the manual gateway), a card.
  4. Paid, the quote is marked paid with the order's reference (EventListener\QuotePaidListener).

Statuses (Enum\QuoteStatus): requested, draft, sent, accepted, paid, declined.

Accepting by signing it (omnisense/omnisign)

With omnisense/omnisign installed, omnisense/omnibase's Base\Service\Signatures there, and a gateway named, accepting a priced quote is signing it in the page:

marketplace:
    quotes:
        signature: contracts          # a gateway of omnisign.gateways; absent (the default): accepting is a click
        signature_template: ~         # DocuSeal's open-source edition signs its own templates only: one's id
  1. "Signer et accepter" sends the quote's PDF (@Marketplace/quote/quote.pdf.twig, laid out as the invoice, a box "Bon pour accord" on its last page where the signature goes) to the provider, as an envelope about the quote, and takes the client to the provider's signing page. Nothing is accepted yet. Asked again while it waits, the same envelope is signed.
  2. Signed, the client comes back to /cotation/<token>/signee (marketplace_quote_signed): the provider is asked where it stands and, completed, the quote is accepted as a click accepts it - its order, its payment (Service\QuoteToOrder). The provider's webhook (/signatures/{gateway}/webhook, omnisense/omnibase) accepts it too, for its client, if they never come back.
  3. Declined, expired or cancelled: the quote stays open, its page says so, and the client may sign again (a new envelope).
  4. The signed quote and its evidence (the provider's audit trail) are kept with the envelope in the private storage; the quote's page links them for its client, the back office's quote screen for the shop (/cotation/<token>/signature/document|preuve, marketplace_quote_signature_file).

A site dresses the PDF in templates/bundles/MarketplaceBundle/quote/quote.pdf.twig, keeping the signature's box where Quote\Signature\QuoteSignatures::BOX says (points from the top left corner of the last page). With DocuSeal's open-source edition, which signs only its own templates, signature_template names one: the quote is then what the site's page shows, not the signed document.

Without the family, without the core's Signatures, or without signature, accepting is a click as it always was: nothing in src/Quote/Signature/ is registered, and nothing of the bundle implements an omnisign interface.

marketplace:
    quotes:
        enabled: true
        store: cave            # the store a quote's order is made in, when neither it nor its products name one
        validity: 30           # days a quote sent holds when no date is set
        recipient: '%env(MAILER_CONTACT)%'
        path: cotation         # the public path: "devis" on a site without omnibase/forge
        phone: true            # the form asks a phone number (optional)
        attachments: true      # and takes files
        consent: false         # true: the data-protection box must be ticked

The form

/cotation by default; marketplace.quotes.path moves the form and the quote's page together (/devis, /devis/<token>) - the route names do not change. omnibase/forge keeps /devis for a studio's quotes: leave cotation where both are installed.

The request takes a phone number (Quote::$phone) and files - a logo, a photo of the shopfront, a plan (PDF, images, AI, SVG...) - kept out of the public directory and downloaded from the back office (files); their number, weight and kinds are marketplace.attachments.*. A file refused is said on its field and nothing is kept.

Robots: the form is guarded as omnisense/omnibase guards a form - its option guard (action: quote; omnisense/omnibase's docs/20-architecture/guard.md): a trap, the time it takes (a signed stamp), the lists of base.guard.reputation, the captcha when the site has omnisense/omnishield (without it, the trap and the time alone). A robot is refused on the form; nothing is stored nor mailed. Under the form, omnisense/omnibase's data-protection notice (Base\Form\Type\PrivacyType), with its box to tick when consent is on. Form\QuoteRequestType takes the same as options: phone, attachments, privacy (true, a translation key of the site's own notice, or false), privacy_consent, privacy_parameters, and the core's guard.

The pipeline is a page of the back office outside the CRUDs: import it in the application's routes (as omnibase/agenda's calendar page):

# config/routes.yaml
marketplace_admin_controller:
    resource: "@MarketplaceBundle/src/Controller/Admin/QuotePipelineController.php"
    type: attribute

Routes: marketplace_quote_request (/cotation, or /<path>), marketplace_quote (/cotation/{token}), marketplace_quote_accept, marketplace_quote_decline (POST), marketplace_quotes (/mes-cotations), marketplace_admin_quotes (the pipeline, in the back office's nest).

Exports: no VAT

Pricing\ExportExemption (a VatExemptionInterface, priority 75: after the store's own regime, before a reverse charge) exempts an order delivered outside the EU by a seller inside it, and the order keeps the invoice's mention: "Exonération de TVA - article 262 I du CGI (livraison à l'exportation)" for a French seller, article 146 of the VAT directive otherwise. The seller's country is its store's VAT number's, else marketplace.shipping.sender.country. The customs side - the export declaration, the excise document (DAE) for alcohol - stays off the site.

Other trades: AbstractQuote

Entity\Quote\AbstractQuote is a mapped superclass: the client and company, the status, discount, validity, token, isAcceptable(), accept(), markPaid(). The marketplace's Quote adds the trade's terms and lines; omnibase/forge's Quote (hours at a rate, accepted as one HourPack) maps it on its own table, its QuoteToOrder extending this one. Quote\QuoteRepositoryTrait (used by each quote repository) numbers them (saveNumbered(): D-2026-0001 here, Q-2026-0001 at the forge), finds a client's (findForClient()) and groups them by status (pipeline()). Form\QuoteRequestType with ['trade' => false] leaves out the trade's fields.

Proposer une modification docs/quotes.md

A catalogue kept on a platform

The catalogue is kept in the back office by default. It may live on a platform instead - Stripe's Products (no extra cost when Stripe already takes the payments), a Shopify shop, a WooCommerce site: any omnisense/omnitrade gateway answering FetchProducts (see omnisense/omnitrade's docs/catalogue.md). The payments stay wherever the shop pays.

# config/packages/omnitrade.yaml
omnitrade:
    gateways:
        shopify: { factory: shopify, options: { shop: '%env(SHOPIFY_SHOP)%', access_token: '%env(SHOPIFY_ADMIN_TOKEN)%', webhook_secret: '%env(SHOPIFY_WEBHOOK_SECRET)%' } }

# config/packages/marketplace.yaml
marketplace:
    catalogue:
        source: shopify      # null: the back office
        store: cave          # the store the products are filed in
        owned_fields: [title, description, price, stock, availability, identifiers, brand, attributes]
        inventory: true      # read the stock where the platform counts it
bin/console marketplace:catalogue:sync                 # what changed since the last change read
bin/console marketplace:catalogue:sync stripe --full   # everything; what is gone there is taken off sale here
bin/console marketplace:catalogue:sync --inventory     # the stock levels only (Shopify, WooCommerce; not Stripe)
bin/console marketplace:catalogue:sync --dry-run -v

Run it from the cron container (plan 0: cron + Messenger, no Scheduler).

What is written

Catalogue\PlatformSynchronizer: one way, the platform to the site. A platform product's first variant is the Product, its other variants Variants; its vendor or brand a Brand (found by name or created); its key/value attributes the product's attributes of the same code - an adapter must exist (an AttributeSet field): "custom.appellation" (a Shopify metafield), "pa_appellation" (a WooCommerce attribute) and "appellation" (a Stripe metadata) all fill appellation, the rest is left there -; its tags Features; its SKU and barcode Identifiers; its price, stock and availability.

  • Only owned_fields are written: taxa, pairings, the age gate, the lots, the pictures chosen here are the site's.
  • A variant whose owned fields hash as last time is not written at all.
  • Nothing is deleted: a product gone (or no longer active) there is DISCONTINUED here with no stock, its link orphaned.
  • A first import links a product already sold here under the same SKU.
  • A product with the feature platform-unmanaged is never written.
  • Pictures are not fetched (the uploader stores files, not URLs): they are added in the back office.

Entity\Catalogue\PlatformLink keeps, per variant, the gateway, the product's and the variant's ids there, the counted item (a Shopify inventory item), the SKU, a fingerprint and the platform's last change.

Webhooks

The platform's product and stock webhooks go to the same address as its payment webhooks, /marketplace/{gateway}/webhook (PaymentController): the omnitrade gateway checks and reads them, and a catalogue event (Notification::isCatalogue()) is dispatched as CatalogueNotificationEvent; Catalogue\CatalogueNotificationListener brings the product in again, takes it off sale when deleted, writes the stock - when that gateway is the catalogue.source.

From the former Shopify module

src/Shopify (its ProductLink, the marketplace.shopify configuration, the marketplace:shopify:* commands, the draft-order checkout and the order push) is gone: no application used it. Shopify's payments and platform orders are omnitrade/shopify's, its catalogue this page's. An application that had the module's table keeps its links with:

INSERT INTO marketplace_platform_link (product_id, gateway, remoteProduct, remoteVariant, remoteItem, fingerprint, syncedAt, orphaned)
SELECT product_id, 'shopify', productGid, variantGid, inventoryItemId, '', NOW(), 0
FROM marketplaceShopifyProductLink WHERE product_id IS NOT NULL;
DROP TABLE marketplaceShopifyProductLink;

Proposer une modification docs/platform-catalogue.md

Plans, subscriptions, rights and credits

A shop that sells access rather than things: a pass bought once, a subscription, a pack of credits. A plan is a product; paying it grants its buyer a right (Entitlement); what the right allows is read through Service\Entitlements; units to spend later are kept in a ledger (Credit). Rights and credits are generic: any bundle hangs its own on them (a course's access, hours of work) rather than keeping tables of its own.

Installation

Nothing to enable. The update adds two columns to the products' table (kind, plan) and four tables (marketplace_subscription, marketplace_entitlement, marketplace_usage, marketplace_credit):

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

Subscriptions need omnisense/omnitrade with its subscriptions (1.x) and a provider that offers them (omnitrade/stripe); passes and credit packs need nothing more than a payment method.

# config/packages/marketplace.yaml
marketplace:
    plans:
        reminder_days: 7     # days before a right ends when its holder is told
# crontab: every night
0 3 * * *  php bin/console marketplace:subscriptions --sync

A plan is a product

use Base\Marketplace\Enum\ProductKind;
use Base\Marketplace\Model\PlanTerms;

$plan = new Product(null, $store, 4900, 'EUR');
$plan->setTitle('Formule B');
$plan->setKind(ProductKind::PLAN);
$plan->setPlan(new PlanTerms(
    billing: PlanTerms::ONE_TIME, months: 18,                 // a pass: 18 months of access (null: for good)
    grants: ['events.major' => 1, 'events.extra' => 2, 'guests' => 150, 'list' => true, 'commission' => 0.025],
    credits: ['stationery' => 2000],                          // credited when paid
    level: 2,                                                 // a higher level is an upgrade
));

$monthly = (new Product(null, $store, 1900, 'EUR'))->setKind(ProductKind::PLAN)
    ->setPlan(new PlanTerms(PlanTerms::RECURRING, 'month', grants: ['ai' => true], credits: ['ai' => 200], level: 4));

$pack = (new Product(null, $store, 900, 'EUR'))->setKind(ProductKind::CREDIT_PACK)
    ->setPlan(new PlanTerms(credits: ['ai' => 100]));
PlanTerms
billing one_time (a pass) or recurring (a subscription)
interval of a subscription: day, week, month, year
months of a pass: how long the right lasts; null: for good
grants the rights by key: a count or a ceiling (integer), a switch (boolean), a rate or any value
credits units credited at each payment, by kind
level how plans compare

A plan sold by size is a product with variants: each has its price, and its own terms are laid over its principal's (['grants' => ['guests' => 150]] on the variant "up to 150 guests"). Plans go through the cart like any product: coupons and promotions apply. A plan or a credit pack is not shippable (Product::isShippable()): checkout asks no address for it.

What happens when it is paid

EventListener\PlanPurchaseListener (on OrderPaidEvent):

  • each plan in the order becomes an Entitlement of the buyer - its code the product's slug (its principal's for a variant), its grants, its end (months from now) - and EntitlementGrantedEvent is dispatched; a quantity of 2 makes two entitlements, whose counts add up;
  • its credits are credited (they lapse with the pass);
  • each credit pack credits its kinds, times the quantity (no expiry).

An order made of one recurring plan is not paid once: the omnitrade bridge subscribes (Subscribe, the plan's interval). Paid, the listener keeps the provider's subscription as an Entity\Order\Subscription, and the entitlement lasts as long as it runs. The provider's events (customer.subscription.*) reach /marketplace/{gateway}/webhook and are applied by Service\Subscriptions::apply(): a renewal moves the period's end and credits the plan's credits again, a payment that fails makes it past_due (the right is kept while the provider retries), its end ends the right.

$subscriptions->cancel($subscription);                          // at the end of the period paid
$subscriptions->cancel($subscription, atPeriodEnd: false);      // at once
return $this->redirect($subscriptions->portalUrl($subscription, $returnUrl, 'fr'));   // card, invoices: the provider's page

marketplace:subscriptions announces, once each, the rights that ended (EntitlementEndedEvent: the application archives, offers an export) and those that end within reminder_days (EntitlementEndingEvent: it writes to their holder); --sync first reads each running subscription from its provider.

Asking what somebody may do

public function __construct(private Entitlements $rights) {}

$rights->allows($user, 'list');                    // a switch on in one active entitlement - or a count with some left
$rights->limit($user, 'guests');                   // a ceiling: the largest among them (null: none)
$rights->value($user, 'commission', 0.03);         // a value: the highest-level entitlement's
$rights->remaining($user, 'events.extra');         // a count: all of them, less what was used

$rights->consume($user, 'events.extra', 1, 'occasion', $occasion->getId());   // takes one; EntitlementException when none is left
$rights->release($user, 'events.extra', 'occasion', $occasion->getId());      // gives it back
$rights->coveredBy($user, 'events.major', 'occasion', $id);                   // the entitlement that thing took its unit from

A refusal (EntitlementException, its $key) is an answer to go on from - offer the upgrade -, not a failure: the EntityManager stays open.

A right over one thing

$rights->grant($pupil, 'course', [], null, ['type' => 'classroom_course', 'id' => $course->getId(), 'order' => $order]);
$rights->holds($pupil, 'course', 'classroom_course', $course->getId());

grant() takes the holder, a code, the grants, an expiry and a context (type / id of the resource, product, order, subscription, level, startsAt): the same call serves a gift made by hand in a back office.

Credits

public function __construct(private Credits $credits) {}

$credits->balance($user, 'ai');
$credits->grant($user, 'ai', 100, 'welcome', new \DateTimeImmutable('+1 year'));
$spent = $credits->spend($user, 'ai', 1, 'an answer', 'occasion', $id);       // CreditException when the balance is short
$credits->refund($spent, 'the answer failed');

The balance is what the grants still valid gave, less what was spent: what a lapsed grant had left is lost, and what was spent comes out of the lapsed grants first. spend() locks the holder's row while it reads the balance.

Before a payment leaves: PaymentPreparingEvent

Dispatched by the omnitrade bridge just before the provider is asked: a listener may send the money to a connected account and keep a fee (destination, applicationFee - Stripe Connect), make or change a subscription (interval, price, trialDays, customer), add metadata, set the description and the locale of the provider's page.

#[AsEventListener]
final class SellerPayout
{
    public function __invoke(PaymentPreparingEvent $event): void
    {
        $event->destination = $event->order->getStore()->getManager()?->getPayoutAccount();
        $event->applicationFee = (int) round($event->order->getNetPrice() * 0.08);
    }
}

A webhook that is neither an order's payment nor the catalogue's is dispatched as PaymentNotificationEvent (a connected account, a subscription, a payment of the application's own): whoever it concerns sets its outcome.

Proposer une modification docs/plans.md

Lists of wishes

A wedding's or a birthday's gifts, a birth list, a fund: a list (Entity\Wishlist\Wishlist) of wishes (Item) that are objects from any shop, products of this shop, or pots. Those who give either reserve an object - so nothing is given twice - and buy it themselves, or contribute money, paid straight to the list's owner (Stripe Connect): the platform never holds a gift.

Installation

The update adds five tables (marketplace_wishlist, marketplace_wishlist_item, marketplace_wishlist_reservation, marketplace_wishlist_contribution, marketplace_payout_account): a migration (doctrine:migrations:diff, migrate).

Reading a pasted address needs omnisense/omnitrade and a gateway that reads product pages (omnitrade/web, omnitrade/amazon); contributions need its Connect and omnitrade/stripe. Without them lists and reservations work, wishes are written by hand.

In a demonstration (omnisense/omnibase's demo environment) none of these gateways is reached, whatever keys they hold: an address is kept as pasted, no account is opened at the provider, a contribution is refused - unless the application names a trial gateway of its own as the payout gateway there (The "dev" gateway and the shop's keys, "Lists, payout accounts, the catalogue").

# config/packages/marketplace.yaml
marketplace:
    wishlist:
        lookup: [amazon, web]      # the omnitrade gateways asked, in order, to read an address and write its affiliate link
        payout_gateway: stripe     # whose connected accounts receive the contributions
        fee_rate: 0.03             # the platform's fee on a contribution...
        fee_fixed: 0               # ...plus this, in minor units
        minimum: 500               # the smallest contribution, in minor units
        reservation_days: 30       # a promise not confirmed lapses
        price_ttl: 86400           # seconds before a price read from a shop is read again
# crontab: every night
30 3 * * *  php bin/console marketplace:wishlist:refresh-prices

The gateways are reached through the payment bridges, so keys typed in the back office for a gateway (api.payment_method.<gateway>.<option>) apply to the lookups too.

The bundle brings no page: a list's look is the application's. It brings the model, the services and the webhook's handling.

A list and its wishes

public function __construct(private Wishlists $wishlists) {}

$list = $wishlists->create($user, 'Liste de naissance', WishlistKind::BIRTH, $occasion);   // $occasion: a WishlistHolderInterface, or null
$wishlists->addFromUrl($list, 'https://boutique.example/p/poussette');     // read from its page: title, picture, price, merchant, affiliate link
$wishlists->addProduct($list, $product);                                   // one of this shop's products
$wishlists->addFund($list, 'Voyage de noces');                             // a pot
$wishlists->addFund($list, 'Robot pâtissier', 59900, WishlistItemKind::SHARE);   // an object several pay for together
Item kind How it is given
OBJECT reserved (quantity in all), bought by the giver at getBuyUrl() - the affiliate link when the shop has a programme
SHARE contributions, up to its price times its quantity (getRemaining())
FUND contributions without ceiling; its price, when set, is a goal to show

A list belongs to its owner and, when the application says so, to a holder of its own (Model\WishlistHolderInterface: a type and an id, no relation); WishlistRepository::findForHolder($occasion) finds its lists. Its token is its public address' part. surprise (a birth list's default) tells the application to show its owner that a wish is taken, not by whom.

A price read from a shop is the page's at that time: show it with getPriceFetchedAt(). marketplace:wishlist:refresh-prices reads again those older than price_ttl.

Reservations

['reservation' => $reservation, 'token' => $token] = $reservations->reserve($item, 1, 'Tante Odile', 'odile@example.org');
// send $token to its author: $reservations->cancel($token) puts the object back
$reservations->confirm($reservation);   // bought: it no longer lapses

The item's row is locked while what is left is counted, in the database: two givers cannot both take the last one (WishlistException wishlist.error.taken, its {left}). Only the token's hash is kept. A promise not confirmed lapses after reservation_days.

Contributions

  1. The owner's account, once: PayoutAccounts::open($user, 'FR') opens an Express account, onboardingUrl() gives the provider's page where they give their identity and bank account, refresh() (on their return) or the account.updated webhook says when it isReady(). Put it on the list: $list->setPayoutAccount($account).
  2. A gift:
$contribution = $contributions->start($item, 5000, 'Tom', $returnUrl, $cancelUrl, [
    'email' => 'tom@example.org', 'message' => 'Bon voyage !', 'coverFees' => true, 'giverReference' => (string) $household->getId(),
]);
return new RedirectResponse($contribution->getRedirectUrl());

// on $returnUrl:
$contribution = $contributions->confirm($contributions->find($reference));

The payment is a destination charge: taken on the platform's Checkout page, transferred to the owner's account, the platform keeping its fee (fee_rate of the gift plus fee_fixed). With coverFees the giver pays the gift plus the fee and the owner receives the gift whole (getCharged(), getReceived()).

Event When For
ContributionPreparingEvent before the payment set $fee (a rate from the owner's plan), add $metadata, the $description of the provider's page
ContributionPaidEvent paid, once - whichever of the return page and the webhook says it first thank, note the gift, tell the owner

The provider's webhooks reach /marketplace/{gateway}/webhook (forward the Connect events too); EventListener\WishlistNotificationListener applies a contribution's payment and a payout account's update.

A contribution is not an order of the shop: it is not the platform's revenue, has no VAT, takes no coupon. The platform's fee is what its provider's reports show as application fees.

Errors

Wishlist\WishlistException's message is a key of the marketplace translations, with its parameters: wishlist.error.url, .closed, .not_reservable, .not_fund, .taken, .share_price, .no_payout, .minimum, .too_much, .payment.

Proposer une modification docs/wishlists.md

Referrals

Each member has a code; somebody who signs up through it is their referee and may get a welcome; once the referee's first order is paid and has held for the cooling-off period, the referrer is rewarded - a coupon of the shop or credits. For any store.

Installation

Three tables (marketplace_referral_code, marketplace_referral, marketplace_referral_reward): a migration.

# config/packages/marketplace.yaml
marketplace:
    referral:
        enabled: true
        cooling_days: 14          # the withdrawal period: the referee's order must hold that long
        monthly_cap: 10           # rewards a referrer may earn in a calendar month
        referee:                  # on signing up through a code
            type: coupon          # coupon, credit, or null (nothing)
            action: action-item-percentage-amount   # the shop's discount action adapter, by its code
            value: 0.2            # 20 %
            validity: '+3 months'
            prefix: BIENV
            label: 'Bienvenue'
        referrer:                 # once the referee's order has held
            type: credit
            credit_kind: stationery
            credit_quantity: 1000
# crontab: every night
45 3 * * *  php bin/console marketplace:referrals

A coupon is made on the shop's own discount adapters, found by their codes (scope, scope-user by default: the coupon is its owner's alone; action): they must exist in the database (the fixtures of a shop seed them), else no coupon is handed out and the referral goes on without. Each coupon is used once.

In the application

The bundle has no page: the link, the sign-up and the mails are the application's.

public function __construct(private Referrals $referrals) {}

// The member's link: /p/{code}
$code = $referrals->codeFor($user)->getCode();

// On /p/{code}: keep it for the sign-up.
if ($referrals->find($code)) { $session->set('referral', $code); }

// Once the new member exists:
try {
    $referral = $referrals->attribute($session->get('referral'), $newUser);
} catch (ReferralException $e) {
    // $e->getMessage(): disabled, unknown, self, email, already, customer
}

$referrals->referralsBy($user);   // those who came through their code, with their status
$referrals->rewardsOf($user);     // what they earned
$referrals->reject($referral);    // in review, before its reward leaves

The rest happens by itself: EventListener\ReferralListener (on OrderPaidEvent) qualifies a referee's first paid order, marketplace:referrals rewards the referrers whose referee's order has held cooling_days. ReferralRewardedEvent is dispatched for each reward (the referee's welcome, the referrer's thanks): the application writes to its beneficiary.

Status
pending signed up through the code, nothing bought yet
qualified first order paid; the reward waits for the cooling-off period
rewarded the referrer was rewarded
rejected refused: its rejection says why

What is refused

Rejection Why
self, email (at sign-up, a ReferralException) the same account; the same address once its +tag and a Gmail's dots are taken off
already, customer (at sign-up) a member is referred once; somebody who already bought is no referee
card the order was paid with a means the referrer also paid with
refunded the order was refunded or cancelled during the cooling-off period
cap the referrer reached monthly_cap
review, or a listener's own Referrals::reject(); ReferralQualifyingEvent::$rejection

The card is compared by the fingerprint kept in a transaction's details. The omnitrade bridge does not record one by itself: an application that wants this check reads it from its provider (Stripe: payment_method_details.card.fingerprint of the charge) and sets ReferralQualifyingEvent::$fingerprint, or keeps it in the transaction's details under fingerprint.

Proposer une modification docs/referrals.md

Made to order

What the shop sells but does not make: cards, mugs, books printed on demand, or anything a partner workshop makes. A product names its supplier; once an order is paid its made-to-order lines are handed to their suppliers, each recipient's apart, and followed until the parcel is delivered.

Fulfilment already names how an order is handed over in some shops; this is Supply.

Installation

The update adds two columns to the products' table (supplier, supplierReference), two to the order items' (recipient, personalisation) and one table (marketplace_supply_job): a migration.

# config/packages/marketplace.yaml
marketplace:
    supply:
        gelato:
            api_key: '%env(GELATO_API_KEY)%'
            webhook_secret: '%env(GELATO_WEBHOOK_SECRET)%'   # what Gelato sends as its webhook's authorization header
            catalog: cards                                   # the catalogue products() searches
        offline:
            email: 'atelier@example.org'                     # the partner workshop: briefs go there
            name: 'Atelier Dupont'
            link_ttl: 7776000                                # seconds its signed links stay valid (90 days)

Gelato's key may also be typed in the back office: the setting api.supply.gelato.api_key (declare it in a SettingsSectionInterface of the API keys page) wins over the configured one.

The application imports the bundle's client routes (it already does for the cart): they include the workshop's page (marketplace_supply_offline) and the suppliers' webhook (/marketplace/supply/{supplier}/webhook: give it to Gelato as https://<site>/marketplace/supply/gelato/webhook).

A product made to order

$card->setSupplier('gelato')->setSupplierReference('cards_pf_a5_pt_350-gsm-coated-silk_cl_4-4_ver');   // Gelato's productUid
$menu->setSupplier('offline')->setSupplierReference('menu-a5-vergé');                                  // the workshop's own name for it

A variant without a supplier is made by its principal's.

A line's recipient and files

$item = new OrderItem($card, 50);
$item->setPersonalisation(['files' => [$frontPdfUrl, $backPdfUrl], 'monogram' => 'L&H']);   // print files as public addresses; any words
$item->setRecipient(['name' => 'Léa Martin', 'street' => ['12 rue des Lilas'], 'postcode' => '37000', 'city' => 'Tours', 'country' => 'FR']);

Without a recipient of its own a line goes to the order's shipping address. Lines are grouped by supplier and by recipient: a card sent to each guest is one job per guest, a batch for the host one job.

What happens

EventListener\SupplyListener (on OrderPaidEvent) calls Service\Supply::dispatch($order): one Entity\Order\SupplyJob per group, submitted to its supplier. A supplier that refuses or does not answer does not undo the payment: its job is kept failed with the reason, and sent again with resubmit().

$supply->jobsOf($order);          // the jobs, their status, their tracking
$supply->sync($job);              // asks the supplier
$supply->resubmit($job);          // a failed one, again
$supply->dispatch($order, true);  // as drafts...
$supply->confirm($job);           // ...launched later
$supply->cancel($job);
Status
draft sent as a draft, not launched
submitted sent, not answered yet
accepted, in_production being made
shipped left: trackingNumber, trackingUrl, carrier
delivered, cancelled, failed final

SupplyJobChangedEvent is dispatched each time a job moves: the application tells the buyer. The bundle does not create a Shipment for a supplier's parcel: its tracking is the job's.

The suppliers

Supplier How
gelato Gelato's Order API v4 (https://order.gelatoapis.com, X-API-KEY): POST /v4/orders (orderType order or draft, items with productUid and files, shippingAddress), PATCH to launch a draft, GET, :cancel, /v4/orders:quote; its order_status_updated webhook. One order per address.
offline a brief by e-mail (@Marketplace/email/supply_brief.html.twig, on the application's email.html.twig) with a signed link to a page where the workshop accepts, says where it stands and gives the tracking. No account, nothing to install there.

Another supplier is a class of the application implementing Supply\SupplierInterface (name(), isConfigured(), products(), quote(), submit(), confirm(), fetch(), cancel(), notify()): autoconfigured, found by Supply\SupplierRegistry.

Gelato's calls are written from its public documentation and tested on answers shaped as it shows them; no call was made with a real key. The products() search (product.gelatoapis.com/v3/catalogs/{catalog}/products:search) and the webhook's authorization check in particular are to be confirmed on a first real exchange.

Proposer une modification docs/supply.md

Configuration reference

The shop's own keys

Key Default Read by
default_currency EUR prices without a currency of their own (a store's currency wins); a made-up code serves an in-game currency
products_per_page, orders_per_page 24, 20 a store's page, the member's orders
cart_max_quantity 99 a cart line; Product::getMaxQuantity() can lower it
default_gateway stripe the gateway checkout offers first
guest_cart false whether a visitor not signed in may fill a cart
price_tax null the mention after every price: excl ("HT"), incl ("TTC"), none
quick_order.enabled, quick_order.link_ttl true, 2592000 the quick order's form; its signed link's life in seconds
admin_role ROLE_SUPERADMIN every marketplace screen of the back office (MARKETPLACE_VIEW)
forex.targets none the ISO 4217 codes one Fixer call refreshes
gateways.<slug> none a payment method's own settings: currencies, instructions, method, webhook_url...
shipping.sender empty the sender of the parcels booked with omnisense/omnibus

The other sections are explained with what they serve: invoice in Invoices and credit notes, brand_route and the attributes in Brands, typed sheets, filters, pairings, age_gate, the lots and the zones in Lots, the age gate, where the shop delivers, quotes in Business quotes and exports, plans in Plans, subscriptions, rights and credits, wishlist in Lists of wishes, referral in Referrals, the suppliers (supply) in Made to order, attachments and pickup in Hand-overs, options, starting prices, files, catalogue in A catalogue kept on a platform.

The whole tree

Dumped with Symfony's YamlReferenceDumper from Base\Marketplace\DependencyInjection\MarketplaceConfiguration:

marketplace:

    # ISO 4217 code of prices without a currency of their own - or a made-up one for an in-game currency.
    default_currency:     EUR
    products_per_page:    24
    orders_per_page:      20

    # How many of one product a cart line may hold.
    cart_max_quantity:    99

    # The gateway checkout offers first.
    default_gateway:      stripe

    # Whether a visitor who is not signed in may fill a cart.
    guest_cart:           false

    # The mention after every price: "excl" ("HT") for a shop that quotes to businesses, "incl" ("TTC"), null for none. A total paid, VAT in it, says "TTC" whatever this is; a template may say otherwise for one amount (@Marketplace/client/_price.html.twig, price_tax).
    price_tax:            null # One of null; "excl"; "incl"
    quick_order:

        # The form of @Marketplace/client/_quick_order.html.twig takes orders.
        enabled:              true

        # Seconds the signed link of a quick order's page stays valid (30 days).
        link_ttl:             2592000

    # The role (or permission) every marketplace screen of the back office requires: the creators by default.
    admin_role:           ROLE_SUPERADMIN
    forex:

        # ISO 4217 codes, e.g. [JPY, USD]: one Fixer call refreshes them all.
        targets:              []
    gateways:

        # Prototype
        slug:                 ~
    invoice:

        # Issue the invoice of an order as soon as it is paid (OrderPaidEvent). Off: from the back office.
        auto_issue:           false

        # With glitchr/omnibill: the gateway invoices are sent through (omnibill.gateways.<name>; "email" works unconfigured with omnibill/email, from seller.email), their lifecycle statuses kept on them. Null, or without omnibill: the bundle's own e-mail.
        gateway:              email

        # The invoices' series: F-2026-000001. One sequence per series and year, without a gap.
        prefix:               F

        # The credit notes' series: A-2026-000001.
        credit_prefix:        A
        digits:               6

        # Days given to pay an invoice not paid yet (a bank transfer): its due date.
        payment_days:         30

        # The nature of the operations invoiced, a mention required since 2026-09-01: goods, services or mixed.
        operation:            goods

        # "Option pour le paiement de la taxe d'après les débits": the seller opted for it.
        vat_on_debits:        false

        # The rate of the penalties for late payment, and the flat 40 € indemnity: a text or a translation key.
        late_penalties:       '@marketplace.invoice.mention.late_penalties'

        # The terms of a discount for early payment: "Escompte pour paiement anticipé : néant".
        discount:             '@marketplace.invoice.mention.no_discount'

        # Further mentions of the seller's own: an approved association, an insurance, the legal guarantee.
        mentions:             []

        # The seller as the invoices name them, frozen on each one when it is issued.
        seller:

            # Its company name - or for a sole trader, their name.
            name:                 ''

            # SAS, SARL, EURL, EI...
            legal_form:           null

            # "10 000 €": a company's capital.
            share_capital:        null
            street:               []
            postcode:             ''
            city:                 ''
            country:              FR
            siren:                null
            siret:                null

            # "RCS Paris": where it is registered.
            rcs:                  null

            # Its intra-EU VAT number - a store's own (Store::getVatNumber()) wins.
            vat_number:           null
            email:                null
            phone:                null
    shipping:
        sender:
            name:                 ''
            company:              null
            street:               []
            postcode:             ''
            city:                 ''
            country:              FR
            email:                null
            phone:                null

    # The route of a brand's page, given its slug; null: brands have no page.
    brand_route:          null
    age_gate:
        enabled:              true

        # The age asked where nothing more precise is set.
        default:              18

        # By language ("ja") or country ("JP"): {ja: 20, JP: 20}. A country wins over a language.
        ages:

            # Prototype
            key:                  ~

        # The health notice shown with restricted products, e.g. "L'abus d'alcool est dangereux pour la santé, à consommer avec modération." (loi Évin); a translation key works too.
        notice:               null

        # Ask on every page, not only before a restricted product or taxon.
        site:                 false
    quotes:
        enabled:              true

        # Slug of the store a quote's order is made in when neither the quote nor its products name one.
        store:                null

        # Days a quote sent holds when no date is set.
        validity:             30

        # Who is told of a request (default: the base notifier's technical recipient).
        recipient:            null

        # The public path of the quote form and of a quote's page: "cotation" (omnibase/forge keeps /devis for a studio's), "devis" where no forge is installed.
        path:                 cotation

        # The form asks a phone number (optional).
        phone:                true

        # The form takes files (a logo, a photo of the shopfront): kept out of the public directory, downloaded from the back office.
        attachments:          true

        # The data-protection box must be ticked (the notice is shown either way).
        consent:              false

        # With glitchr/omnisign: the gateway (omnisign.gateways.<name>) a priced quote is signed through, in the site's page, to be accepted. Null: accepting is a click, as without omnisign.
        signature:            null

        # A template of the provider's to sign instead of the quote's PDF: DocuSeal's open-source edition signs its own templates only.
        signature_template:   null
    attachments:

        # Where the files are kept: outside the public directory.
        directory:            '%kernel.project_dir%/var/storage/marketplace'

        # Bytes a file may weigh (20 MB).
        max_size:             20971520

        # Files one request or one line may carry.
        max_files:            5

        # What a file may be, by its extension (lower case).
        extensions:

            # Defaults:
            - pdf
            - png
            - jpg
            - jpeg
            - webp
            - gif
            - svg
            - ai
            - eps
            - psd
            - tif
            - tiff
            - zip
    pickup:

        # The ways offered: pickup (at the shop, on a slot), delivery (brought nearby by the shop), shipping (a carrier).
        modes:

            # Default:
            - pickup

        # Where the shop delivers itself: postcodes, or a prefix ("67*").
        zip_codes:            []
        slots:
            from:                 '11:00'
            to:                   '19:00'

            # Minutes a slot lasts.
            step:                 30

        # Minutes the shop needs before the first slot.
        notice:               60

        # Days ahead an order may be for.
        horizon:              14

        # The shop's clock ("Europe/Paris"); null: PHP's, which omnibase sets to the visitor's.
        timezone:             null
    catalogue:

        # The omnitrade gateway the catalogue is read from (stripe, shopify, woocommerce...); null: kept in the back office.
        source:               null

        # Slug of the store the products read are filed in.
        store:                null

        # What the platform writes here; anything else (taxa, pairings, the age gate) stays the site's.
        owned_fields:

            # Defaults:
            - title
            - description
            - price
            - stock
            - availability
            - identifiers
            - brand
            - attributes

        # Read the stock from the platform when it counts it (FetchInventory).
        inventory:            true
    wishlist:

        # The omnitrade gateways asked, in order, to read a pasted address and to write its affiliate link.
        lookup:

            # Defaults:
            - amazon
            - web

        # The omnitrade gateway whose connected accounts the contributions are paid to.
        payout_gateway:       stripe

        # The platform's fee on a contribution: a share of the gift...
        fee_rate:             0.03

        # ...plus this, in minor units.
        fee_fixed:            0

        # The smallest contribution, in minor units.
        minimum:              500

        # Days a reservation holds before its object goes back on the list.
        reservation_days:     30

        # Seconds after which a price read from a shop is read again.
        price_ttl:            86400
    referral:
        enabled:              true

        # Days the referee's first order must hold (the withdrawal period) before the referrer is rewarded.
        cooling_days:         14

        # Rewards a referrer may earn in a calendar month.
        monthly_cap:          10

        # What a new member gets on signing up through a code.
        referee:

            # null: nothing.
            type:                 null # One of null; "coupon"; "credit"

            # A coupon: the code of the discount scope adapter that ties it to its owner.
            scope:                scope-user

            # A coupon: the code of the discount action adapter (a percentage, a fixed amount).
            action:               null

            # A coupon: what the action takes off (0.2 for 20 %, or minor units).
            value:                0
            validity:             '+3 months'
            prefix:               REF
            label:                Referral

            # Credits: their kind.
            credit_kind:          null
            credit_quantity:      0

        # What the referrer gets once the referee's order has held.
        referrer:

            # null: nothing.
            type:                 null # One of null; "coupon"; "credit"

            # A coupon: the code of the discount scope adapter that ties it to its owner.
            scope:                scope-user

            # A coupon: the code of the discount action adapter (a percentage, a fixed amount).
            action:               null

            # A coupon: what the action takes off (0.2 for 20 %, or minor units).
            value:                0
            validity:             '+3 months'
            prefix:               REF
            label:                Referral

            # Credits: their kind.
            credit_kind:          null
            credit_quantity:      0
    supply:
        gelato:
            api_key:              null

            # What Gelato sends as its webhook's authorization header.
            webhook_secret:       null

            # The catalogue products() searches.
            catalog:              cards
        offline:

            # The partner workshop's address: the briefs go there.
            email:                null
            name:                 null

            # Seconds the signed link of a brief stays valid (90 days).
            link_ttl:             7776000
    plans:

        # Days before a right ends when its holder is told (Event\EntitlementEndingEvent).
        reminder_days:        7

Proposer une modification docs/configuration.md

The "dev" gateway and the shop's keys

Paying in development: the dev gateway

Base\Marketplace\Payment\DevGateway pays an order at once, by nobody, so a checkout can be walked through without a Stripe key. It is registered only when the kernel runs in debug, or in the demo environment (MarketplaceExtension), and its supports() refuses anywhere else all the same: it can never show in production.

To offer it, give a payment method the gateway factory dev (in the back office, or in the fixtures):

$method = new PaymentMethod();
$method->setSlug('dev');
$method->setLabel('Essai (développement)');
$method->setGatewayFactory('dev');

The applications delete their App\Market\DevGateway: the registry keys gateways by name, and this one is dev.

Paying in a demonstration: the dev gateway, and no other

omnisense/omnibase's demo environment (its docs/20-architecture/demo.md) runs a site as production does, without debug - and no money may move there. In demo, and there only besides debug:

  • the dev gateway is registered and takes the orders: the fixtures' "dev" payment method is the one a visitor pays with;
  • the gateways of omnisense/omnitrade are not there at all (PaymentGatewayRegistry leaves them out): a method on stripe, paypal or any gateway of omnitrade.gateways is not offered at the checkout, its return page and its webhook find no gateway, and the back office does not list them for a payment method. The keys typed on the API keys page change nothing to that.

The application's own gateways (the manual one - a transfer, a payment at the counter - and whatever it tagged marketplace.payment_gateway) stay: a gateway of its own that reaches a real provider says no in demo itself (supports()).

Lists, payout accounts, the catalogue: the same rule, at the one door

The checkout is not the only thing that talks to omnisense/omnitrade. A list's contributions (Wishlist\Contributions: a destination charge to its owner's connected account), that account itself (Wishlist\PayoutAccounts: opened, completed and refreshed at the provider), an address read for a wish (Wishlist\ProductLookup) all ask Payment\Omnitrade\OmnitradeGateways directly, and the catalogue read from a platform asks omnitrade's registry (Catalogue\PlatformSynchronizer). A demonstration holding real keys would have charged a card, opened an Express account and read a shop.

In demo, no path of the bundle reaches a gateway of omnitrade.gateways:

In demo
OmnitradeGateways::get('stripe') (any configured name) null, whatever keys are typed or configured; the first time each is asked, a line in the log (Demonstration: the omnitrade gateway "stripe" was asked for and refused)
OmnitradeGateways::names() the application's trial gateways only (none by default)
Contributions::start() WishlistException('wishlist.error.payment'), nothing sent, nothing kept as paid
PayoutAccounts::isAvailable() false; open(), refresh(), onboardingUrl() throw WishlistException('wishlist.error.no_payout')
ProductLookup::lookup(), ::affiliateLink() null: a pasted address is kept as it is
PlatformSynchronizer::gateway() (catalogue:sync, the stock) LogicException: the demonstration's catalogue is its fixtures'

A trial gateway of the application's own is the one way through - for a site whose lists must take contributions in its demonstration. It writes a omnisense/omnitrade factory whose gateways move no money and marks it Base\Marketplace\Payment\Omnitrade\TrialGatewayFactoryInterface:

#[When(env: 'demo')]
final class TrialGatewayFactory implements TrialGatewayFactoryInterface
{
    public function getName(): string { return 'essai'; }

    public function create(array $options = []): GatewayInterface
    {
        // accounts ready at once, a contribution paid on the spot, by nobody
        return new Gateway('essai', 'Paiement d’essai (aucun débit)', [new TrialAction()]);
    }
}
# config/packages/marketplace.yaml
when@demo:
    marketplace:
        wishlist:
            payout_gateway: essai      # the trial factory's name

In demo, OmnitradeGateways::get('essai') gives the gateway that factory makes - built from the factory itself, never through omnitrade.gateways - and nothing else answers. The interface is a promise the marketplace cannot check: what such a gateway does is the application's. Outside demo it changes nothing: there the gateways are the configured ones, and a trial factory is not asked. A factory that is only listed under omnitrade.gateways without the interface is refused in demo like any other.

Stripe's keys on the API keys page

With omnibase/admin installed and omnisense/omnitrade's stripe gateway registered, Base\Marketplace\Settings\PaymentKeysSection puts Stripe's three keys on /admin/api-key: api.payment_method.stripe.publishable, api.payment_method.stripe.api_key, api.payment_method.stripe.webhook_secret. Typed there, they win over the configuration (OmnitradeGateways). See omnibase/admin's docs/system-pages.md for the sections.

Proposer une modification docs/dev-gateway-and-keys.md

Reference

Routes

Name Path Methods Access
marketplace_stores /boutiques any everyone
marketplace_store /boutique/{slug} any everyone
marketplace_product /boutique/{store}/{slug} any everyone
marketplace_age_gate /age POST everyone (selling rules)
marketplace_attachment /marketplace/piece-jointe/{id} GET a product's attachment (files)
marketplace_cart /panier any ROLE_USER
marketplace_cart_add /panier/ajouter/{id} POST ROLE_USER
marketplace_cart_update /panier/{order}/ligne/{item} POST ROLE_USER
marketplace_cart_files /panier/{order}/ligne/{item}/fichiers POST ROLE_USER
marketplace_cart_coupon /panier/{order}/coupon POST ROLE_USER
marketplace_cart_coupon_remove /panier/{order}/coupon/{coupon}/retirer POST ROLE_USER
marketplace_checkout /panier/{order}/commander any ROLE_USER
marketplace_payment_return /panier/{order}/paiement/{gateway} any the buyer back from the provider
marketplace_payment_webhook /marketplace/{gateway}/webhook POST the provider, checked by its omnitrade package
marketplace_orders /commandes any ROLE_USER
marketplace_order /commandes/{reference} any ROLE_USER
marketplace_quick_order /commande-express/{id} POST everyone, when quick_order.enabled
marketplace_quick_order_done /commande-express/merci/{reference} any the signed link
marketplace_pickup /remise/{token} GET the hand-over's token (hand-overs)
marketplace_invoice /factures/{number} GET the invoice's buyer (invoices)
marketplace_invoice_webhook /marketplace/omnibill/{gateway}/webhook POST the invoicing gateway
marketplace_quote_request /%marketplace.quotes.path% GET, POST everyone (quotes)
marketplace_quote /%marketplace.quotes.path%/{token} any the quote's token
marketplace_quote_accept, marketplace_quote_decline .../{token}/accepter, .../{token}/refuser POST ROLE_USER
marketplace_quote_signed, marketplace_quote_signature_file .../{token}/signee, .../{token}/signature/{kind} GET ROLE_USER
marketplace_quotes /mes-cotations any ROLE_USER
marketplace_company_lookup /api/company/{number} GET a company's registration number (9 to 20 digits)
marketplace_supply_offline /marketplace/fabrication/{reference} GET, POST a supplier without an API (made to order)
marketplace_supply_webhook /marketplace/supply/{supplier}/webhook POST the supplier
marketplace_shipping_queue /admin/commandes/a-expedier GET ROLE_ADMIN
marketplace_shipping_queue_legacy /commandes/a-expedier GET redirects to the queue
marketplace_order_ship, marketplace_order_deliver /commandes/{reference}/expedier, /commandes/{reference}/livree POST ROLE_ADMIN
marketplace_admin_quotes /admin/marketplace/quotes GET MARKETPLACE_VIEW

The back office's screens follow marketplace.admin_role (ROLE_SUPERADMIN by default) through the MARKETPLACE_VIEW voter attribute. Their own actions: the exchange rates' refresh; an invoice's send, refresh, paid and credit; an order's invoice; a quote's send.

Twig functions and filters

Name Kind Returns
marketplace_cart_count() function the items in the member's carts (quantities summed)
marketplace_price_with_vat(product, region = null, order = null) function a product's price with its VAT, in minor units
marketplace_vat_rate(product, region = null, order = null), marketplace_vat_rates(order) function the rate that applies to a product; the rates of an order
marketplace_price_tax() function marketplace.price_tax: the mention after a price
marketplace_quick_order_form(product, back) function the quick order's form
marketplace_tracking_url(method, number) function a shipment's tracking address, {number} replaced
marketplace_order_invoices(order) function an order's invoices and credit notes
marketplace_age_gate(), marketplace_age_minimum(), marketplace_age_notice() function whether to ask the visitor's age, the age, the notice
marketplace_attribute_set(product), marketplace_attributes(...) function a product's typed sheet and its values
marketplace_associations(...), marketplace_products_of(...) function pairings, a brand's or a set's products
invoice_money, invoice_rate filter an amount and a rate as an invoice prints them

Commands

Command Arguments and options Does
marketplace:stripe:webhook --url, --recreate, --dry-run creates the Stripe webhook endpoint if missing
marketplace:catalogue:sync [gateway], --full, --since, --query, --inventory, --dry-run reads a platform's catalogue into the shop (a catalogue kept on a platform)
marketplace:invoice:issue [orders...], --paid-without-invoice issues the invoices of paid orders (invoices)
marketplace:invoice:status [numbers...] asks the invoicing gateway where the invoices sent stand
marketplace:subscriptions --sync ends the rights that ran out, announces those about to (plans)
marketplace:referrals rewards the referrers whose referee's first order held (referrals)
marketplace:wishlist:refresh-prices --limit=200 reads the lists' prices again, expires lapsed reservations (lists of wishes)

Events

Event When
OrderPaidEvent an order has just been paid in full, its stock decremented
PaymentPreparingEvent an order is about to be handed to an omnitrade gateway: the last moment to say how
PaymentCancelledEvent a payment did not happen (the member turned back, the provider let it expire): the order is a cart again; a listener may set the response
PaymentNotificationEvent a provider's webhook about something else than an order or the catalogue
QuickOrderDoneEvent a quick order's page is about to be shown to the holder of its link; a listener may set the response
PickupChangedEvent a hand-over moved (received, accepted, ready, done...)
CatalogueNotificationEvent a platform said a product or a stock level changed
SubscriptionChangedEvent a subscription's state moved (renewed, past due, set to stop, ended), as the provider told
EntitlementGrantedEvent, EntitlementEndingEvent, EntitlementEndedEvent a right granted, about to end, ended
ReferralQualifyingEvent, ReferralRewardedEvent a referee's first order is paid (a listener may refuse the referral); a referrer rewarded
ContributionPreparingEvent, ContributionPaidEvent a contribution to a list about to be paid, paid
SupplyJobChangedEvent a made-to-order job moved at its supplier

All are in Base\Marketplace\Event\.

Extension points

Tag or interface For
PaymentGatewayInterface (autoconfigured) a payment gateway of the application's own
marketplace.trial_gateway_factory a trial's gateway (plans)
the VAT regimes (StoreVatRegime, ExportExemption, ReverseCharge, by priority) how a line's VAT is decided
PaymentKeysSection the payment keys on omnibase/admin's API keys page (the dev gateway and the keys)

Proposer une modification docs/reference.md

Testing

Running the suite

Inside a host application:

php vendor/bin/phpunit -c vendor/omnibase/marketplace/phpunit.xml.dist

In omnisense/omnibase-harness, mounting the checkout in place of the installed package:

docker run --rm --network none \
    -v "$PWD":/srv/demo/vendor/omnibase/marketplace:ro \
    omnisense/omnibase-harness:latest test marketplace

On this checkout, in the harness:

Tests: 164, Assertions: 1805, Skipped: 4.

The skipped tests need what the image does not have: the quote form's captcha (omnisense/omnishield and its fixed test gateway) and the invoices' transmission (omnisense/omnibill with omnibill/email).

What the tests cover

Folder Tests
Attribute OrderReferenceTest: an order's reference
Catalogue PlatformSynchronizerTest: a platform's catalogue read into the shop
Controller CrudRequiredFieldsTest: omnibase/admin's CrudFieldsTestCase on the screens
Entity availability, barcodes, lots, options, SKUs, quotes
Http the price mention, the product form and screens without script, a product's page, the quick order, the quote request, the shipping method's form without script
Model PlanTermsTest: a plan's terms
Payment the demo gateways, omnitrade's demo, the dev gateway, the omnitrade bridge, PaymentPreparingEvent
Pricing ExportExemptionTest: VAT on exports
Service the age gate, attachments, attribute filters, credits, entitlements, invoice transmission, invoices, hand-overs paid and moved, plan purchase, quote signatures, referrals, shipping zones
Supply made to order, the Gelato supplier
Translation the back office's field labels, the politeness of the catalogues
Wishlist lists of wishes

Proposer une modification docs/testing.md