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+.
Clone and install dependencies
git clone git@github.com:your-org/emakola.git
cd emakola
mix deps.get
Configure secrets and set up the database
cp .env.example .env # Paystack, S3, WhatsApp & SMS keys
mix ecto.setup # create, migrate, seed demo data
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=...