factory/shape-marketplace (0.3.2)
Installation
{
"repositories": [{
"type": "composer",
"url": " "
}
]
}composer require factory/shape-marketplace:0.3.2About this package
factory/shape-marketplace
Shape F — the multi-vertical marketplace. One platform, many categories: groceries, pharmacy, parcels, taxi, bookings, home services. Sanaga is the first product built on it.
The five shapes that already exist (booking, logistics, tracking, directory, catalog) each model one transaction. This one models a platform that hosts several — which is a different problem, and the two claims below are the whole point of the module.
A vertical is a row
Adding pharmacy to a live marketplace is INSERT INTO mkt_verticals. Not a class, not a
migration, not a deploy.
Vertical::create([
'product_slug' => 'sanaga',
'slug' => 'pharmacy',
'name' => 'Pharmacie',
'order_shape' => 'regular',
'commission_bps' => 800,
]);
The row names its order shape — one of five (regular, parcel, taxi, booking,
service), each with its own lifecycle graph. A taxi ride is never delivered; a parcel is
never prepared; a booking is never collected. One graph covering all five would have to
permit all three, so there are five.
The shape is copied onto the order at intake. Re-point a vertical at a different shape next quarter and every in-flight order still runs the graph it was placed under — a customer's order does not change meaning because an operator edited a category row underneath them.
Vertical refuses an unknown shape at save time. A category the platform cannot actually run
never reaches the table.
No status is ever set by a client
There is no route on this module through which a request can name a state. Orders move by verb:
POST /api/marketplace/orders/{id}/actions/accept
POST /api/marketplace/orders/{id}/actions/collect
POST /api/marketplace/orders/{id}/actions/deliver
OrderAction maps fourteen verbs onto states; anything else is a 422 that says so. refunded
is deliberately absent from that map — treasury walks that edge, not a client holding an
order id.
Every move runs through Factory\Kernel\Fsm\HasLifecycle, which means:
- the graph decides legality, and refuses anything it does not declare;
- the guard re-reads the row
lockForUpdate()inside the transaction, so two couriers claiming the same job resolve to exactly one winner rather than two who each held a model that saidready; - the state change and its audit row commit together — a failed audit write rolls the transition back. There is no path to a state change nobody recorded.
What the client sends, and what it cannot
The client sends what it wants — a vertical, an outlet, item ids, quantities, a dropoff. It
never sends what things cost. Prices are read from mkt_catalogue_items at intake; the
validator does not even accept a price field, so a modified client's price_minor is dropped
before OrderIntake sees it. Line name and unit price are then copied onto the order line,
so a receipt keeps the price it was bought at when the shop puts the price up overnight.
Tax and currency come from the product's market via factory/jurisdiction — Cameroon pays
19.25% in XAF, Kenya 16% in KES, and nothing here hardcodes either. Commission resolves to the
vendor's negotiated rate if it has one, otherwise the vertical's: a NULL on the vendor row
means not negotiated, never free.
An unavailable line refuses the whole order. Silently dropping the fish and delivering the rice is the failure this category is named for.
Tables
All six are mkt_-prefixed. The fleet already carries a live collision exposure from shapes
that claimed generic names (reviews, services, carts), and this module does not add to it.
| table | holds |
|---|---|
mkt_verticals |
the categories — a row per one, naming its order shape |
mkt_vendors |
merchants trading in a vertical |
mkt_outlets |
a vendor's physical branches, hours, and service area |
mkt_catalogue_items |
what an outlet sells, at what price |
mkt_orders |
the order, its stamped shape, its money, its lifecycle state |
mkt_order_lines |
the basket, with name and price copied at purchase |
Routes live under /api/marketplace — /api/orders is already shape-logistics', and the fleet
has paid once already for two modules quietly claiming /api/notifications.
Migrations are additive; down() is a deliberate no-op.
Composing it
composer require factory/shape-marketplace
The service provider loads its own migrations and routes, and declares
['api', 'factory.product'] on the route group itself rather than trusting the host app to
remember — the models fail closed to 1 = 0 without a bound product, and failing closed at the
edge with a clear error beats failing closed in the query layer with an empty list.
Money — three owners, one tap (0.2.0)
OrderMoney is the seam between an order and factory/treasury. A grocery order does not
have the shape Split::of() describes: one payment collects money belonging to three parties on
three different bases, and commission is charged on the goods and never on the courier's
delivery fee. So collect() states the exact amounts and treasury checks the arithmetic:
vendor · withdrawable items − commission
order · escrow delivery # held, because there is no courier yet
platform revenue commission
tax payable tax
─────────────────
gateway clearing the order total
The fee changes holder on assignCourier() and only becomes drawable on release() — accepting
a job is not finishing one. Four standing rules, each with a test: never recompute a price the
order already fixed; the delivery fee is held, not given; paying twice moves money once
(token mkt:{id}:collect, because a retried webhook is the normal case); and withdraw() has
no fee parameter and no parameter through which one could be added (D26).
The money columns on mkt_orders are pointers, not balances — paid_at,
payment_entry_uid, courier_ref, delivery_released_at, refund_entry_uid. There is
deliberately no total-paid or earnings column: those live in ledger_accounts, and a second copy
of a balance is a copy that can disagree with the books.
Only money in has a route
POST /orders/{id}/pay is the module's one money route, and it is scoped to your own order.
withdraw, earnings, assignCourier and release are implemented and tested with no route
at all — on a client-supplied X-User-Ref, an endpoint that moves a merchant's balance is a
withdrawal API for anyone who can guess a vendor id. A test asserts those routes do not exist, so
adding one has to be a deliberate act. The real Campay / My-CoolPay callback is separate, gated
work; what the route proves today is that the split is correct and idempotent when it fires.
Known gap
The merchant and courier verbs (prepare, ready, collect, deliver) are currently
unreachable: find() scopes to the requester's own orders, because roles arrive with
factory/auth and there is still no login route in the platform. That is deliberately too
narrow rather than open to whoever knows an order id. It is booked in the ledger.
Tests
vendor/bin/phpunit — 49 tests (22 of them in OrderMoneyTest, every figure read back from ledger_accounts rather than from a response body). They boot the real kernel and infra rather than stubbing
either, and run two products in two countries on purpose (CM/XAF/19.25% and KE/KES/16%), so a
tax rate that is secretly a constant or a currency that is secretly hardcoded fails.
Dependencies
Dependencies
| ID | Version |
|---|---|
| factory/auth | ^0.3 || ^0.4 |
| factory/infra | ^0.1 || ^0.2 || ^0.3 |
| factory/kernel | ^0.5 || ^0.6 || ^0.7 || ^0.8 || ^0.9 |
| factory/treasury | ^0.2 || ^0.3 || ^0.4 || ^0.5 || ^0.6 |
| illuminate/database | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/routing | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| illuminate/support | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| php | ^8.2 |
Development dependencies
| ID | Version |
|---|---|
| orchestra/testbench | ^8.0 || ^9.0 || ^10.0 || ^11.0 |
| phpunit/phpunit | ^10.1 || ^11.0 || ^12.0 |