Skip to content
pcreative Commerce

Commerce contract — pcreative Commerce

It is what a theme can ask the store for: products, cart, checkout, account, downloads, author support… A theme does not import any backend's SDK: it calls…

Package: @pcreative/commerce-contract · version 1.3.5 · data contract 1.0 (CONTRACT_VERSION).

It is what a theme can ask the store for: products, cart, checkout, account, downloads, author support… A theme does not import any backend's SDK: it calls this client, and the adapter translates to the store's API.

Two things follow from that. The same theme works tomorrow with another backend, through another adapter. And the store is not married to themes from a single framework.

The package has no dependencies and needs Node 18 or later.


Creating the client

import { createCommerce } from "@pcreative/commerce-contract/api"

const commerce = createCommerce({
  baseUrl: process.env.NEXT_PUBLIC_COMMERCE_URL,
  publishableKey: process.env.NEXT_PUBLIC_COMMERCE_KEY,
  countryCode: "es",
  requestInit: { next: { revalidate: 300 } },
})
OptionRequiredWhat it does
baseUrlyesThe store's address. Without it, createCommerce throws.
publishableKeynoThe publishable key. Sent in the x-pcc-clave header.
countryCodenoPicks the region (and with it the currency) whose country matches. Without it, the first region.
localenoThe language. Sent in the x-pcc-locale header.
fetchnoA different fetch. Useful for testing without a store.
requestInitnoMerged into every request. That way the framework adds its cache without the adapter knowing about frameworks.

The adapter is called "pcreative" (commerce.adapter).


Contract rules

Amounts are in major units, with their currency alongside: 19.99, not 1999. A Money is { amount, currency, taxIncluded? }, with the ISO 4217 currency in lowercase. So no theme has to guess the scale, which is where half of all price bugs come from.

import { formatMoney, sumMoney, money } from "@pcreative/commerce-contract"

formatMoney({ amount: 19.99, currency: "eur" })  // "19,99 €"
sumMoney(a, b)                                    // throws if you mix currencies
money(19.99, "eur", true)                         // builds a Money

formatMoney uses es-ES unless you pass another locale.

Errors carry the HTTP status inside: err.status, and the store's message in err.detalle. A 404 for an expired cart and a 401 for a wrong key ask different things of the theme.

What does not exist returns null, not an exception: an expired cart, a product that is not there, someone else's order. Lists that do not exist return [].

checkout.complete() does not throw when payment fails. It returns { order } or { cart, error }, and in the second case the cart comes back intact with the reason.

What has no data comes as null, never as zero. On the author's page, sales and support without enough data come as null, so the theme shows "new author" instead of an off-putting 0%.

The buyer's email never goes in the URL. In support it always travels in the request body: a URL ends up in logs and in browser history.


Required methods

These are the ones assertCommerceClient checks. An adapter missing any of them does not meet the contract.

Catalogue

MethodReturns
listProducts(params?)Page<Product>: { items, count, limit, offset }. count is the total, not this page's.
getProduct(handle)Product or null.
getProductsByIds(ids)Product[] in the same order as ids, without the ones that do not exist.
listCategories()Category[]: top level only.
getCategory(handle)Category or null.
search(q, limit?)Product[]. 24 by default.
getOrder(id)Order or null. It is for the confirmation page after paying.

listProducts accepts q, category (handle), categoryId, ids, tags, type ("tema" or "extension", marketplace items only), limit (24 by default), offset and sort.

sort accepts relevance, price_asc, price_desc, newest and rating. This adapter only translates newest, price_asc and price_desc; with the other two the store uses its own order, and tags is not sent.

cart

MethodReturns
cart.get(id)Cart, or null if it no longer exists.
cart.create()A new Cart, in the countryCode region.
cart.addItem(cartId, variantId, quantity, personalizacion?, pack?)Cart. pack picks a bundle's components: { group: [variants] }.
cart.updateItem(cartId, lineId, quantity)Cart. With 0 or less, it removes the line.
cart.removeItem(cartId, lineId)Cart.

checkout

MethodReturns
checkout.setEmail(cartId, email)Cart.
checkout.setAddresses(cartId, shipping, billing?)Cart. Without billing, the shipping one is used.
checkout.listShippingOptions(cartId)ShippingOption[].
checkout.setShippingMethod(cartId, optionId)Cart.
checkout.listPaymentMethods(cartId)PaymentMethod[].
checkout.selectPaymentMethod(cartId, provider, datos?)Cart. If the method needs one more step, it arrives in cart.pago.
checkout.complete(cartId){ order } or { cart, error }.

A payment method's surcharge (surcharge) is there to be shown. The store charges it, reading it from its own settings: if it came from the storefront, anyone could ask for a zero surcharge.

cart.pago says how the buyer carries on: { tipo: "redirigir", url } to send them to the gateway, or { tipo: "confirmar_en_cliente", pasarela, datos } to confirm in the browser with the gateway's SDK. It is null for methods with no gateway.


Optional methods

An adapter may not have them. Check with typeof before using them. The "pcreative" adapter has them all.

Cart and checkout

MethodReturns
cart.applyPromo(cartId, code)Cart. If the code is not valid, throws with status 400 and the reason.
cart.removePromo(cartId, code)Cart.
cart.consentirDigital(cartId, aceptado)Cart. Consent to receive digital content right away (EU, UK).
checkout.listCountries()string[]: the countries the store sells to, lowercase.
checkout.listProvinces(country){ code, name }[]. Empty list: that country uses no province and the field stays free.
checkout.setVatNumber(cartId, vatNumber)Cart, with vatNumber and, when it applies, taxExempt. null clears the number.
checkout.listShippingGroups(cartId){ grupos, moneda }: one group per place goods ship from (marketplace). null if there is none.
checkout.setShippingMethods(cartId, elecciones)nothing. Sets the shipping of all groups at once.

applyPromo and removePromo are in the cart type, but assertCommerceClient does not require them.

setShippingMethods takes the whole set on purpose: in the store, sending one shipping method on its own deletes the others without warning.

vatNumber is { number, valid, checked, name }. taxExempt is { reason: "intra_eu", articles } when the cart pays no VAT because it is a business in another EU country.

Account: account

MethodReturns
account.login(email, password, cartId?){ token }.
account.register({ email, password, firstName?, lastName? })Customer.
account.me(token)Customer, or null if the token is no longer valid.
account.orders(token)Order[].
account.confirmEmail(sessionToken, token){ adopted }, or null if the link is not valid.

Content: content

MethodReturns
content.listPosts(limit?)Post[]. 10 by default.
content.getPost(handle)Post or null.
content.listPages()PaginaSuelta[], without their content: it is what the footer draws.
content.getPage(handle)PaginaSuelta with its content, or null.

If the store does not answer, these four return an empty list or null instead of throwing: a broken blog does not take the page down.

A standalone page is not a blog post. A post has a date; a page has an order decided by whoever writes it.

Store and contact

MethodReturns
getStore()StoreInfo or null: name, legal name, tax ID, address, email, phone, country, how to show prices and the legal notices.
sendContact({ name?, email, subject?, message })nothing. Throws with status 400 if data is missing, 429 if there are too many messages and 503 if the store has nowhere to send it.

StoreInfo.priceDisplay says how the store wants its prices: show is "with", "without" or "both"; storedWithTax, whether the incoming price already includes tax; and defaultRate, the rate of the store's country, to work out the other amount before the customer says where they live.

After buying

MethodReturns
getDownloads(orderId)Download[]: the downloads of an order with digital content. Each url already has the store's address in front.
getLicenses(orderId)License[]: the licences of the marketplace items bought in that order, with the full key.
getCodes(orderId)ProductCode[]: the order's codes, masked. You see hint (the last characters).
revealCode(orderId, codeId)string: the full code. Who and when is recorded, and it counts as delivered.
getWithdrawal(orderId)WithdrawalState or null: whether withdrawal is possible, how many days, until when and which lines are left out.
requestWithdrawal(orderId, { lineas?, motivo? }){ solicitud, aceptadas }. Without lineas, all of them.
subirFichero(archivo){ id, nombre, tipo, bytes, firma }: uploads a file the customer attaches to a customisation.

getDownloads, getLicenses and getCodes return [] if the order does not exist; getWithdrawal, null.

Author marketplace

MethodReturns
getAuthor(handle)Author or null: the public page of whoever sells, with their items, their sales and their support.
applyAsAuthor(application){ id } of the application. The account is created when someone accepts it. If that email already applied, throws with status 400 and the reason in err.detalle.
support.open({ licenseKey, subject, message, email? }){ id, covered }.
support.get(id, email)SupportThread or null.
support.reply(id, email, message)nothing.
support.rate(id, email, solved)nothing. The only question at closing: did it help.

Opening support needs the licence key: only someone who bought can write. If the included support has expired, covered comes as false. It warns, it does not block.

In Author, sales and support come as null while there is no data. support.resolvedPct is the percentage of resolved queries, firstReplyHours the median hours to the first reply and ratings how many ratings back it. The platform calculates it; the author does not write it.

Newsletter: newsletter

MethodReturns
newsletter.subscribe(email, consent){ status: "pendiente" }.
newsletter.confirm(token)true, or false if the link is not valid.
newsletter.unsubscribe(token)true.

subscribe signs nobody up: it sends an email with a link, and only when it is clicked (confirm) is the person in. It always answers the same, so it cannot be used to find out who is subscribed.

consent carries text (the exact text the person saw next to the checkbox, which is the proof) and version, and may carry locale, source, ip and userAgent.


Writing another adapter

Implement the package's CommerceClient interface and check it:

import { assertCommerceClient } from "@pcreative/commerce-contract"

const missing = assertCommerceClient(myClient)   // [] if it complies

It returns the list of what is missing: root methods by name (getOrder) and group methods with their group in front (cart.addItem). If a whole group is missing, the group comes out (checkout).

It only looks at what is required. Everything optional may be missing: a backend may have no customer accounts, no blog and no author marketplace.


See also