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:
- Installation
- The shop: stores, products, promotions, shipping, payments, the quick order
- Brands, typed sheets, filters, pairings
- Lots, the age gate, where the shop delivers
- Business quotes and exports
- A catalogue kept on a platform
- The "dev" gateway and the shop's keys
- Plans, subscriptions, rights and credits
- Lists of wishes, reservations, contributions
- Referrals
- Made to order: suppliers
- Hand-overs, options, starting prices, files
- Invoices and credit notes
- Configuration reference
- Reference: routes, Twig, commands, events
- Testing
Propose a change 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.
Propose a change 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
validAtandexpiredAt, highestpriorityfirst: an event, a sale. - Coupons are codes the member types in the cart.
quotaandquotaPerCustomercap how many paid orders may use one,ownerreserves it to one member. A coupon markedindividualUsestands 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).
Propose a change 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
Propose a change 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.
Propose a change 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, fieldfiles[]), with@Marketplace/client/_line_files.html.twigto 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.
Propose a change 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-000003for credit notes (credit_prefix). The law asks for "a chronological and continuous sequence", and allows several series.Invoice\InvoiceNumberingtakes the number and stores the invoice in one transaction, under asymfony/locklock 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.
- the seller is
- 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).
- the order's VAT exemption, if any (
- Its state is issued, then sent (
send(), ormarkSent()), 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: withomnibill/emailinstalled 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.gatewaynames one ofomnibill.gateways(theOmnibillBundleregistered). Anemailone keeps its ownfrom,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 toPOST /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 nomarketplace_invoice_flowtable, and needs none);send()mails the invoice itself. A site that installs the family creates the table:make:migration, thendoctrine: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).
- signed in, they download their invoice at
- 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-invoiceissues 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).
Propose a change docs/invoices.md
Business quotes and exports
The flow
- 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), anIncoterm(EXW, FCA, FOB, CIF, DAP, DDP) and its place, the country, the volumes, a date, their words.marketplace.quotes.recipientis told by mail. - 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 (quantitylots oflotSizeunits atlotPricecents), a discount, a date; "Envoyer" mails the client their link (/cotation/<token>). - The client accepts it, signed in with the address of the request:
Service\QuoteToOrdermakes 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 (themanualgateway), a card. - 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
- "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. - 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. - Declined, expired or cancelled: the quote stays open, its page says so, and the client may sign again (a new envelope).
- 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.
Propose a change 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_fieldsare 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
DISCONTINUEDhere with no stock, its linkorphaned. - A first import links a product already sold here under the same SKU.
- A product with the feature
platform-unmanagedis 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;
Propose a change 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
Entitlementof the buyer - its code the product's slug (its principal's for a variant), its grants, its end (monthsfrom now) - andEntitlementGrantedEventis dispatched; a quantity of 2 makes two entitlements, whose counts add up; - its
creditsare 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.
Propose a change 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
- 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 theaccount.updatedwebhook says when itisReady(). Put it on the list:$list->setPayoutAccount($account). - 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.
Propose a change 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.
Propose a change 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.
Propose a change 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
Propose a change 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
devgateway 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
(
PaymentGatewayRegistryleaves them out): a method onstripe,paypalor any gateway ofomnitrade.gatewaysis 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.
Propose a change 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) |
Propose a change 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 |