Documentation/Reference apps/Wholesale marketplace app Atelier asks for net terms before a card
Wholesale marketplace app Atelier asks for net terms before a card
Atelier is an open-source wholesale marketplace app with multi-brand orders and Net 30 payment terms at checkout. The code is on GitHub and it runs live.
- apps
- marketplace
- wholesale
- payments
- commerce
Overview
Prepaid, Net 15, Net 30 or Net 60. This open-source wholesale marketplace app is the one reference app of the 19 with net payment terms.
The last decision a buyer makes at Atelier's checkout is when to pay. The options come from a single list near the top of
schemas.py:python
NET_TERMS = ["Prepaid", "Net 15", "Net 30", "Net 60"]That list is the enum, the set of allowed values, behind
payment_terms on the order and net_terms on the buyer's profile. I searched the other 18 apps for net terms and found none. Five others enable the same stripe_checkout service. None of them offers terms. Atelier's code is in the atelier folder.One field, two routes through checkout
The checkout form has a terms dropdown, defaulting to Net 30. The submit button's label follows it: "Place order on Net 30" or "Pay with Stripe".
placeOrder in ui/app.js creates the order as pending and unpaid, then writes its lines. After that the two routes part.On net terms the order is updated to
confirmed and stays unpaid. No card is asked for. The operator's dashboard counts unpaid orders under "On terms (unpaid)", and each order has a Mark paid button for when the money arrives.On Prepaid the code on
main calls services.stripe.checkout with the order total and redirects to the hosted checkout URL, and since the success URL carries Stripe's session id, the app can call retrieveCheckoutSession with it when the buyer comes back and write pay_state: 'paid' only if Stripe itself reports the session as paid or complete. A failed checkout call leaves the order unpaid, with a message asking the buyer to try again.One more branch matters if you run a clone. When the Stripe service is absent from the environment, the code marks the order paid with
payment_provider set to 'simulated', and the confirmation page says so in a banner.An order that spans brands
The cart lives in the browser, in
localStorage under one key. Each cart line remembers its brand_name, and the cart page groups lines under a heading per brand.At checkout they become one order.
The order schema carries the counts a multi-brand order needs, next to the two fields that record how it was paid:
python
{"name": "item_count", "type": "integer"},
{"name": "brand_count", "type": "integer"},
{"name": "pay_state", "type": "string", "values": ["unpaid", "paid", "refunded"]},
{"name": "payment_provider", "type": "string"},The lines are a separate object.
OrderItem is declared with "parent_type": "order", so each line is a child of its order, and the front end reads them back with client.getScopedList('order', o.uuid, 'order_item'). Product is linked to its brand through a references block with a back-reference named products. It also keeps brand_name as a plain string, and a product card needs no second lookup. setup.py seeds enough to browse. I counted 13 brands and 59 products, one buyer profile on Net 30, and two orders. Order ATL-100412 is delivered and paid at $168.50. Order ATL-100488 is confirmed and unpaid at $256.00. Each spans three brands.In this wholesale marketplace, brands are rows and buyers own their orders
Should each brand be a tenant, with its own separate data? A comment in
config.py answers that for this app, and the answer is no. There is a single tenant for the marketplace operator. Brands are ordinary records inside it. The comment gives the reason: discovery across brands is the whole point of a marketplace.So
Brand and Product are the two public schemas and the storefront renders for a logged-out visitor. The buyer role, tenant_user, may read both. Its other three rules cover the buyer profile, the order and the order line, and each one carries the same filter, filter_field="owner_username" matched against $user.name, so a buyer's order list contains their own orders and no one else's while the operator (tenant_admin) sees every order on the marketplace. That is policy-based row scoping. The operator's rules are narrower than you might guess: delete is granted on brands and products, the two things the console has a delete button for, and on nothing else.Confirmation mail and a buying assistant
In
setup.py the order_confirmation workflow is bound to the event @create:atelier:order, so placing an order is what sends the email. The recipient is mapped from user.email, the signed-in buyer's account address. The address typed into the checkout form is stored on the order and is not where the mail goes. In the operator's console, Mark shipped and Mark delivered are plain updates to order_state.The storefront also has a curation panel. A buyer describes their shop, the code sends that description with the catalog to the
ai service, and the reply is matched back to real product rows by name. When the service is unavailable the panel shows the bestsellers and says why.All of it is 1,051 lines of React in
ui/app.js, the one front-end file you edit.Atelier is a demo of a wholesale ordering flow. The brands are seed data and nothing ships.
Place a Net 30 order, then clone it
The login page at atelier.supero.live lists a buyer and an operator account. Sign in as the buyer, add products from two brands, and check out on Net 30. Then sign in as the operator and find it in the console.
The source folder runs with:
bash
git clone https://github.com/supero-platform/supero-apps
cd supero-apps/apps/marketplace/atelier
cp .env.example .env # set SUPERO_DOMAIN and SUPERO_PASSWORD
./run.shThere is no account to create first. The storefront is running in about two minutes on
http://localhost:5661. Net-terms orders need nothing more, and the Prepaid route depends on the Stripe service. Replace the 13 brands in BRANDS with your own catalogue and open a project at supero.dev.On this page