Offers & Pricing
The offers catalog lets an organisation define what it sells — products, services, and subscriptions, each with a price and a billing model. It is the data foundation for invoicing automation at the end of the Sales pipeline, and it powers a public pricing page without any hand-rolled HTML pricing logic: a site fetches the org’s active offers through an API-key-gated read API.
The domain lives in @abeauvois/platform-billing (Offer / IOfferRepository / OfferService),
co-located with the billing module that will own invoicing but decoupled from the credit ledger.
The model
Section titled “The model”Each Offer is owner- and org-scoped and carries:
| Field | Notes |
|---|---|
offerType | product | service | subscription |
billingModel | one_off | recurring | custom_quote |
priceAmountCents | Price in minor units (cents). null for custom_quote. |
currency | ISO 4217, e.g. EUR (default). |
recurrenceInterval | month | year — required when billingModel is recurring. |
features | Bullet points for a pricing card. |
slug | URL-safe, unique per owner — the public read key. Derived from name if omitted. |
status | draft | active | archived — only active offers are served publicly. |
Invariants are enforced server-side: a custom_quote offer never has a price; a recurring offer
must name an interval.
Admin: managing offers (session-scoped)
Section titled “Admin: managing offers (session-scoped)”Manage offers from the dashboard at /offers, or via the SDK with an authenticated session:
import { PlatformApiClient } from '@abeauvois/platform-sdk';
const client = new PlatformApiClient({ baseUrl, sessionToken });
const studio = await client.offers.create({ name: 'viite studio', offerType: 'product', billingModel: 'one_off', priceAmountCents: 9900, // 99.00 EUR features: ['Brand kit', 'Landing page'],});
// Publish it to the public pricing API.await client.offers.update(studio.id, { status: 'active' });
const all = await client.offers.list({ status: 'active' });Endpoints: GET/POST /api/offers, GET/PATCH/DELETE /api/offers/:id. Reads are scoped to the
caller — another user’s offer reads as 404. Creating an offer requires an active organization.
Public: rendering a pricing page (API-key gated)
Section titled “Public: rendering a pricing page (API-key gated)”A consumer website (e.g. viite.ai) renders its pricing page from the owner’s active offers. Mint an API key for the owner account and pass it as a bearer token — no user session required:
const site = new PlatformApiClient({ baseUrl, getToken: () => SITE_API_KEY });
const offers = await site.offers.listPublic(); // GET /api/offers/public/offersconst studio = await site.offers.getPublicBySlug('viite-studio');The public response is a projected DTO — slug, name, description, offerType, billingModel,
priceAmountCents, currency, recurrenceInterval, features, sortOrder. Internal fields
(userId, orgId, status, ids) never leak. The key resolves to its owner, so a site only ever
sees that owner’s active offers.
Live consumer: viite.ai
Section titled “Live consumer: viite.ai”viite.ai dogfoods this end-to-end. Its corporate-website funnel no longer hardcodes prices — it
reads the catalog from /api/offers/public/offers at build time (SSG) and bakes the result into
the page, using the owner’s API key (kept off the browser, never PUBLIC_-prefixed). The catalog is
managed in the dashboard /offers; editing an offer there changes the funnel’s pricing on the next
site rebuild. The funnel keeps a built-in fallback so a degraded build still renders sensible
numbers. Slugs follow a per-consumer convention (e.g. expertise-sprint, module-core,
module-core-setup, managed-pro).
