Documentation

Build on Makola

Everything you need to launch a storefront — multi-tenant stores, mobile money payments, WhatsApp order alerts, and the merchant mobile API.

01 — Setup

Getting Started

Get Makola running locally in a few minutes. You'll need Elixir 1.18+, Erlang OTP 27+, and PostgreSQL 15+.

1

Clone and install dependencies

git clone git@github.com:your-org/emakola.git
cd emakola
mix deps.get
2

Configure secrets and set up the database

cp .env.example .env          # Paystack, S3, WhatsApp & SMS keys
mix ecto.setup                # create, migrate, seed demo data
3

Start the server

mix phx.server

# Visit http://localhost:4000
# A demo store is seeded at /s/demo-store

02 — Architecture

Multi-Tenancy

Every store is an isolated tenant. Makola uses Ash attribute-based multitenancy keyed on store_id — store data never leaks across tenants.

Tenant-scoped resources

Every tenant-scoped resource declares its multitenancy strategy. Queries must always carry a tenant.

defmodule Emakola.Catalog.Product do
  use Ash.Resource,
    domain: Emakola.Catalog,
    data_layer: AshPostgres.DataLayer

  multitenancy do
    strategy :attribute
    attribute :store_id
  end
end

Reading with a tenant

Pass the store as the tenant on every read. Without it, the query raises rather than silently returning another store's data.

Emakola.Catalog.Product
|> Ash.Query.for_read(:read)
|> Ash.read!(tenant: store.id)

03 — Commerce

Payments

Mobile money first. Makola routes payments through a Gateway behaviour with Paystack and Hubtel implementations — covering MTN MoMo, Vodafone Cash, and AirtelTigo.

The Gateway behaviour

Every provider implements the same contract, so checkout code is provider-agnostic.

defmodule Emakola.Payments.Gateway do
  @callback initiate_payment(map()) :: {:ok, map()} | {:error, term()}
  @callback verify_payment(String.t()) :: {:ok, map()} | {:error, term()}
  @callback process_refund(String.t(), integer()) :: {:ok, map()} | {:error, term()}
end

Initiating a payment

Amounts are passed in minor units (see Money & Currency). The gateway returns an authorization URL or mobile-money prompt reference.

Emakola.Payments.Gateways.Paystack.initiate_payment(%{
  amount_pesewas: 50_000,
  currency: "GHS",
  email: customer.email,
  reference: order.number
})

Webhooks

Provider callbacks are verified and processed by an idempotent Oban worker, which confirms the payment and advances the order.

# POST /webhooks/paystack
# Signature verified, then handed to:
Emakola.Workers.WebhookWorker

# Order transitions to :paid once verify_payment/1 succeeds.

04 — Commerce

Money & Currency

All monetary amounts are stored as integers in minor units — pesewas for GHS, kobo for NGN. Never use floats for money.

Minor units

Store the amount and currency code together; format only in the presentation layer.

# 1 GHS = 100 pesewas, 1 NGN = 100 kobo
%{amount_pesewas: 50_000, currency: "GHS"}  # = GHS 500.00

Emakola.Money.format(50_000, "GHS")
# => "GHS 500.00"

Supported currencies

GHS (Ghana Cedi) is the default for Ghana stores, with NGN for the Nigeria expansion and USD for future international payments.

# GHS — Ghana Cedi   (default)
# NGN — Nigerian Naira
# USD — international (future)

05 — Engagement

Notifications

Merchants and customers get order updates over WhatsApp and SMS. Delivery runs through idempotent Oban workers so a retry never double-sends.

Sending an alert

Enqueue a notification job; the worker picks the channel and provider.

%{order_id: order.id, channel: :whatsapp}
|> Emakola.Workers.NotificationWorker.new()
|> Oban.insert()

Provider configuration

WhatsApp Business API and the SMS gateway are configured via environment variables.

WHATSAPP_API_TOKEN=...
WHATSAPP_PHONE_NUMBER_ID=...
SMS_API_KEY=...
SMS_SENDER_ID=Makola

06 — Integration

Mobile API

A JSON:API surface (via ash_json_api ) powers the merchant mobile app. Bearer auth, per-store scoping, and an OpenAPI contract are built in.

Authentication

Sign in for a 15-minute access token plus a 30-day rotating, single-use refresh token.

POST /api/v1/auth/sign_in
{ "email": "merchant@example.com", "password": "..." }

# => { "access_token": "...", "refresh_token": "..." }

Per-store requests

Every request carries the bearer token and an X-Store-ID header, validated against the merchant's store memberships.

GET  /api/v1/stores              # the merchant's stores
GET  /api/v1/orders              # list orders
GET  /api/v1/orders/:id          # order detail
POST /api/v1/orders/:id/transition
GET  /api/v1/open_api            # OpenAPI specification

07 — Ship it

Deployment

Makola ships with a production Dockerfile and Fly.io config. Deploy in a single command.

Fly.io

The fastest path to production.

fly launch --name emakola
fly secrets set SECRET_KEY_BASE=$(mix phx.gen.secret)
fly secrets set PAYSTACK_SECRET_KEY=sk_live_...
fly deploy

Environment variables

All secrets are read from the environment at runtime.

DATABASE_URL=postgres://user:pass@host/db
SECRET_KEY_BASE=super-secret-64-char-key
PHX_HOST=emakola.com

PAYSTACK_SECRET_KEY=sk_live_...
HUBTEL_CLIENT_ID=...
AWS_S3_BUCKET=emakola-uploads
WHATSAPP_API_TOKEN=...