Code examples

Complete, minimal clients for PHP/Laravel, Node.js and Python. Each one:

  • reads every value from configuration (4.3)
  • sends X-Api-Key on every call and caches the bearer token for 14 minutes
  • logs in again and retries once on 401
  • sends amounts as integer cents and rejects anything else before calling InnBucks
  • treats 00 and 000 as success and reads the message from either field
  • normalises the code status and treats Claimed and Paid as paid
  • builds the deep link for the configured environment
  • keeps the X-Trace-Id of the last response
Language HTTP client Section
PHP / Laravel Laravel Http facade, Cache, a queued job 9.1
JavaScript Node.js 18+ built-in fetch 9.2
Python requests 9.3

Every client was run against a mock InnBucks server that checks the paths, both headers, token refresh after a 401, the exact field names and integer amounts (tools/test_examples.py).

9.1 PHP / Laravel

config/services.php:

'innbucks' => [
    'base_url'    => env('INNBUCKS_BASE_URL'),
    'api_key'     => env('INNBUCKS_API_KEY'),
    'username'    => env('INNBUCKS_USERNAME'),
    'password'    => env('INNBUCKS_PASSWORD'),
    'environment' => env('INNBUCKS_ENV', 'test'),
    'token_ttl'   => (int) env('INNBUCKS_TOKEN_TTL', 840),
],

app/Exceptions/InnBucksException.php:

<?php

namespace App\Exceptions;

use RuntimeException;

class InnBucksException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly array $response = [],
        public readonly ?string $traceId = null,
        public readonly ?int $httpStatus = null,
    ) {
        parent::__construct($message);
    }
}

app/Services/InnBucks.php:

<?php

namespace App\Services;

use App\Exceptions\InnBucksException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class InnBucks
{
    private const TOKEN_CACHE_KEY = 'innbucks.access_token';

    public ?string $lastTraceId = null;

    // ---- authentication -------------------------------------------------

    /** A cached bearer token; logs in when there is none (or $fresh is true). */
    public function token(bool $fresh = false): string
    {
        if ($fresh) {
            Cache::forget(self::TOKEN_CACHE_KEY);
        }

        return Cache::remember(self::TOKEN_CACHE_KEY, config('services.innbucks.token_ttl', 840), function () {
            $response = $this->http()->post('/auth/third-party', [
                'username' => config('services.innbucks.username'),
                'password' => config('services.innbucks.password'),
            ]);
            $body = $this->decode($response);

            if (! self::ok($body) || empty($body['accessToken'])) {
                throw new InnBucksException('InnBucks login failed: '.self::message($body), $body, $this->lastTraceId, $response->status());
            }

            return $body['accessToken'];
        });
    }

    // ---- codes: payments and withdrawals --------------------------------

    /** Generate a PAYMENT or WITHDRAWAL code. $amountCents: 450 = 4.50. */
    public function generateCode(int $amountCents, string $currency, string $reference,
                                 string $narration, string $type = 'PAYMENT'): array
    {
        return $this->send('POST', '/api/code/generate', [
            'reference' => $reference,
            'narration' => $narration,
            'currency'  => self::currency($currency),
            'amount'    => self::cents($amountCents),
            'type'      => in_array($type, ['PAYMENT', 'WITHDRAWAL'], true) ? $type
                           : throw new \InvalidArgumentException('type must be PAYMENT or WITHDRAWAL'),
        ]);
    }

    /** Current status of a code. Call at most once every 30 seconds per code. */
    public function inquireCode(string $code, ?string $reference = null): array
    {
        return $this->send('POST', '/api/code/inquiry', array_filter([
            'reference' => $reference,
            'code'      => $code,
        ], fn ($v) => $v !== null));
    }

    // ---- agent services -------------------------------------------------

    public function linkedAccount(string $msisdn, string $currency = 'USD'): array
    {
        return $this->send('GET', '/api/v1/account/msisdn/'.rawurlencode($msisdn).'/details?currency='.self::currency($currency));
    }

    public function deposit(string $destinationAccount, int $amountCents, string $currency,
                            string $reference, string $narration, ?string $type = null): array
    {
        return $this->send('POST', '/api/transaction/deposit', array_filter([
            'reference'          => $reference,
            'currency'           => self::currency($currency),
            'amount'             => self::cents($amountCents),
            'narration'          => $narration,
            'destinationAccount' => $destinationAccount,
            'type'               => $type,
        ], fn ($v) => $v !== null));
    }

    public function depositInquiry(string $accountNumber, string $originalReference, string $inquiryReference): array
    {
        return $this->send('POST', '/bank/api/transaction/inquiry', [
            'accountNumber'                => $accountNumber,
            'participantReference'         => $inquiryReference,
            'originalParticipantReference' => $originalReference,
        ]);
    }

    /** Reverse a DEPOSIT. Amount and account must match the original exactly. */
    public function reverseDeposit(string $destinationAccount, int $amountCents, string $currency,
                                   string $originalReference, string $reversalReference): array
    {
        return $this->send('POST', '/api/transaction/reversal/v2', [
            'amount'                       => self::cents($amountCents),
            'currency'                     => self::currency($currency),
            'type'                         => 'CREDIT',
            'destinationAccount'           => $destinationAccount,
            'participantReference'         => $reversalReference,
            'originalParticipantReference' => $originalReference,
        ]);
    }

    /** Credit a cash customer's change to their wallet: US$5.00 (500 cents) or less. */
    public function bankChange(string $msisdn, int $amountCents, string $currency,
                               string $reference, string $narration): array
    {
        if (strtoupper($currency) === 'USD' && $amountCents > 500) {
            throw new \InvalidArgumentException('Bank change is limited to 500 cents (US$5.00).');
        }

        return $this->send('POST', '/api/transaction/bankChange', [
            'reference'         => $reference,
            'currency'          => self::currency($currency),
            'amount'            => self::cents($amountCents),
            'narration'         => $narration,
            'destinationMsisdn' => $msisdn,
        ]);
    }

    public function utilityPayment(string $provider, string $product, string $destinationAccount,
                                   int $amountCents, string $currency, string $reference,
                                   string $narration = '', array $additionalData = []): array
    {
        return $this->send('POST', '/api/utility/provider/payment', [
            'provider'           => $provider,
            'providerProduct'    => $product,
            'amount'             => self::cents($amountCents),
            'currency'           => self::currency($currency),
            'reference'          => $reference,
            'narration'          => $narration,
            'destinationAccount' => $destinationAccount,
            'additionalData'     => (object) $additionalData,
        ]);
    }

    /** $start / $end as 'Y-m-d H:i:s', within one calendar month. */
    public function statement(string $accountNumber, string $currency, string $start, string $end): array
    {
        return $this->send('POST', '/api/account/fullStatement', [
            'accountNumber' => $accountNumber,
            'currency'      => self::currency($currency),
            'startDateTime' => $start,
            'endDateTime'   => $end,
        ]);
    }

    // ---- helpers --------------------------------------------------------

    public static function ok(array $body): bool
    {
        return in_array($body['responseCode'] ?? null, ['00', '000'], true);
    }

    public static function message(array $body): string
    {
        return (string) ($body['responseMsg'] ?? $body['responseDescription'] ?? 'no message');
    }

    /** new | claimed | paid | expired | timed_out | null */
    public static function codeStatus(array $body): ?string
    {
        $s = strtolower(preg_replace('/[\s_-]+/', '', (string) ($body['status'] ?? '')));

        return ['new' => 'new', 'claimed' => 'claimed', 'paid' => 'paid',
                'expired' => 'expired', 'timedout' => 'timed_out'][$s] ?? null;
    }

    public static function isPaid(array $body): bool
    {
        return self::ok($body) && in_array(self::codeStatus($body), ['claimed', 'paid'], true);
    }

    public static function isFinal(array $body): bool
    {
        return self::ok($body) && in_array(self::codeStatus($body), ['claimed', 'paid', 'expired', 'timed_out'], true);
    }

    public static function deepLink(string $code): string
    {
        $scheme = config('services.innbucks.environment') === 'production'
            ? 'com.innbucks.customer'
            : 'zw.co.innbucksnova.test';

        return $scheme.'://purchase?paymentToken='.rawurlencode($code);
    }

    /** A data: URI for an <img> tag, whether or not qrCode already carries the prefix. */
    public static function qrDataUri(array $body): ?string
    {
        $qr = $body['qrCode'] ?? null;

        return $qr === null ? null : (str_starts_with($qr, 'data:') ? $qr : 'data:image/png;base64,'.$qr);
    }

    private static function cents(int $amount): int
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException('Amounts are positive integers in cents: 450 = 4.50.');
        }

        return $amount;
    }

    private static function currency(string $currency): string
    {
        $currency = strtoupper($currency);

        return in_array($currency, ['USD', 'ZWG'], true) ? $currency
            : throw new \InvalidArgumentException('currency must be USD or ZWG');
    }

    private function http(): PendingRequest
    {
        return Http::baseUrl(rtrim(config('services.innbucks.base_url'), '/'))
            ->withHeaders(['X-Api-Key' => config('services.innbucks.api_key')])
            ->acceptJson()
            ->asJson()
            ->timeout(30);
    }

    private function send(string $method, string $path, array $body = []): array
    {
        $call = fn (string $token) => $this->http()->withToken($token)
            ->send($method, $path, $method === 'GET' ? [] : ['json' => $body]);

        $response = $call($this->token());
        if ($response->status() === 401) {         // token expired: log in again, retry once
            $response = $call($this->token(fresh: true));
        }

        $decoded = $this->decode($response);
        if ($response->failed()) {
            throw new InnBucksException("InnBucks HTTP {$response->status()}: ".self::message($decoded),
                $decoded, $this->lastTraceId, $response->status());
        }

        return $decoded;
    }

    private function decode(Response $response): array
    {
        $this->lastTraceId = $response->header('X-Trace-Id') ?: null;

        return $response->json() ?? [];
    }
}

app/Jobs/PollInnBucksCode.php: checks one code every 30 seconds until it is final:

<?php

namespace App\Jobs;

use App\Models\Order;
use App\Services\InnBucks;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class PollInnBucksCode implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 1;

    public function __construct(public Order $order, public int $attempt = 1) {}

    public function handle(InnBucks $innbucks): void
    {
        try {
            $res = $innbucks->inquireCode($this->order->innbucks_code);
        } catch (Throwable $e) {
            report($e);
            $res = [];                             // try again at the next check
        }

        if (InnBucks::isPaid($res)) {
            $this->order->markPaid(authNumber: $res['authNumber'] ?? null);
            return;
        }
        if (InnBucks::isFinal($res)) {             // Expired or Timed Out
            $this->order->markInnBucksCodeExpired();
            return;
        }
        if ($this->attempt >= 22) {                // ~11 minutes: the code has expired by now
            $this->order->markPaymentUnknown();    // review it; never call it failed
            return;
        }

        self::dispatch($this->order, $this->attempt + 1)->delay(now()->addSeconds(30));
    }
}

Usage in a controller:

use App\Jobs\PollInnBucksCode;
use App\Services\InnBucks;

$res = app(InnBucks::class)->generateCode(
    amountCents: $order->total_cents,            // 450 = US$4.50
    currency: 'USD',
    reference: $order->reference,
    narration: 'Order '.$order->reference,
);

if (! InnBucks::ok($res)) {
    return back()->withErrors(['payment' => InnBucks::message($res)]);
}

$order->update(['innbucks_code' => $res['code'], 'innbucks_auth_number' => $res['authNumber'] ?? null]);
PollInnBucksCode::dispatch($order)->delay(now()->addSeconds(30));

return view('checkout.innbucks', [
    'code'     => $res['code'],
    'qr'       => InnBucks::qrDataUri($res),
    'deepLink' => InnBucks::deepLink($res['code']),
    'expires'  => now()->addMinutes(10),
]);

markPaid, markInnBucksCodeExpired and markPaymentUnknown stand for your own order logic. Make markPaid idempotent: a second call must not fulfil the order twice.

9.2 Node.js

innbucks.js (Node.js 18+, no dependencies, ES module):

// InnBucks Merchant API client. Node.js 18+ (built-in fetch). Amounts are integer cents.
const cfg = {
  baseUrl: (process.env.INNBUCKS_BASE_URL || '').replace(/\/+$/, ''),
  apiKey: process.env.INNBUCKS_API_KEY,
  username: process.env.INNBUCKS_USERNAME,
  password: process.env.INNBUCKS_PASSWORD,
  environment: process.env.INNBUCKS_ENV || 'test',
  tokenTtlMs: Number(process.env.INNBUCKS_TOKEN_TTL || 840) * 1000,
};

let token = null;
let tokenAt = 0;
export let lastTraceId = null;

export class InnBucksError extends Error {
  constructor(message, { response = {}, traceId = null, httpStatus = null } = {}) {
    super(message);
    Object.assign(this, { response, traceId, httpStatus });
  }
}

async function request(method, path, body, bearer) {
  const headers = { 'X-Api-Key': cfg.apiKey, Accept: 'application/json' };
  if (bearer) headers.Authorization = `Bearer ${bearer}`;
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  const res = await fetch(cfg.baseUrl + path, {
    method,
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  lastTraceId = res.headers.get('x-trace-id');
  const text = await res.text();
  let json = {};
  try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
  return { status: res.status, json };
}

export async function login(fresh = false) {
  if (!fresh && token && Date.now() - tokenAt < cfg.tokenTtlMs) return token;
  const { status, json } = await request('POST', '/auth/third-party',
    { username: cfg.username, password: cfg.password });
  if (!ok(json) || !json.accessToken) {
    throw new InnBucksError(`InnBucks login failed: ${message(json)}`,
      { response: json, traceId: lastTraceId, httpStatus: status });
  }
  token = json.accessToken;
  tokenAt = Date.now();
  return token;
}

async function send(method, path, body) {
  let res = await request(method, path, body, await login());
  if (res.status === 401) res = await request(method, path, body, await login(true)); // retry once
  if (res.status >= 400) {
    throw new InnBucksError(`InnBucks HTTP ${res.status}: ${message(res.json)}`,
      { response: res.json, traceId: lastTraceId, httpStatus: res.status });
  }
  return res.json;
}

function cents(amount) {
  if (!Number.isInteger(amount) || amount <= 0) {
    throw new TypeError('Amounts are positive integers in cents: 450 = 4.50');
  }
  return amount;
}

function currency(c) {
  const up = String(c).toUpperCase();
  if (!['USD', 'ZWG'].includes(up)) throw new TypeError('currency must be USD or ZWG');
  return up;
}

// ---- codes ---------------------------------------------------------------

export function generateCode({ amount, currency: cur = 'USD', reference, narration, type = 'PAYMENT' }) {
  if (!['PAYMENT', 'WITHDRAWAL'].includes(type)) throw new TypeError('type must be PAYMENT or WITHDRAWAL');
  return send('POST', '/api/code/generate',
    { reference, narration, currency: currency(cur), amount: cents(amount), type });
}

export function inquireCode(code, reference) {
  return send('POST', '/api/code/inquiry', reference ? { reference, code } : { code });
}

// ---- agent services ----------------------------------------------------

export function linkedAccount(msisdn, cur = 'USD') {
  return send('GET', `/api/v1/account/msisdn/${encodeURIComponent(msisdn)}/details?currency=${currency(cur)}`);
}

export function deposit({ destinationAccount, amount, currency: cur, reference, narration, type }) {
  const body = { reference, currency: currency(cur), amount: cents(amount), narration, destinationAccount };
  if (type) body.type = type;
  return send('POST', '/api/transaction/deposit', body);
}

export function depositInquiry({ accountNumber, originalReference, inquiryReference }) {
  return send('POST', '/bank/api/transaction/inquiry', {
    accountNumber,
    participantReference: inquiryReference,
    originalParticipantReference: originalReference,
  });
}

export function reverseDeposit({ destinationAccount, amount, currency: cur, originalReference, reversalReference }) {
  return send('POST', '/api/transaction/reversal/v2', {
    amount: cents(amount),
    currency: currency(cur),
    type: 'CREDIT',
    destinationAccount,
    participantReference: reversalReference,
    originalParticipantReference: originalReference,
  });
}

export function bankChange({ msisdn, amount, currency: cur = 'USD', reference, narration }) {
  if (currency(cur) === 'USD' && cents(amount) > 500) throw new RangeError('Bank change is limited to 500 cents (US$5.00)');
  return send('POST', '/api/transaction/bankChange',
    { reference, currency: currency(cur), amount, narration, destinationMsisdn: msisdn });
}

export function utilityPayment({ provider, product, destinationAccount, amount, currency: cur, reference, narration = '', additionalData = {} }) {
  return send('POST', '/api/utility/provider/payment', {
    provider, providerProduct: product, amount: cents(amount), currency: currency(cur),
    reference, narration, destinationAccount, additionalData,
  });
}

export function statement({ accountNumber, currency: cur, start, end }) {
  return send('POST', '/api/account/fullStatement',
    { accountNumber, currency: currency(cur), startDateTime: start, endDateTime: end });
}

// ---- helpers ---------------------------------------------------------------

export function ok(body) {
  return ['00', '000'].includes(body?.responseCode);
}

export function message(body) {
  return body?.responseMsg ?? body?.responseDescription ?? 'no message';
}

/** 'new' | 'claimed' | 'paid' | 'expired' | 'timed_out' | null */
export function codeStatus(body) {
  const s = String(body?.status ?? '').toLowerCase().replace(/[\s_-]+/g, '');
  return { new: 'new', claimed: 'claimed', paid: 'paid', expired: 'expired', timedout: 'timed_out' }[s] ?? null;
}

export const isPaid = (body) => ok(body) && ['claimed', 'paid'].includes(codeStatus(body));
export const isFinal = (body) => ok(body) && ['claimed', 'paid', 'expired', 'timed_out'].includes(codeStatus(body));

export function deepLink(code) {
  const scheme = cfg.environment === 'production' ? 'com.innbucks.customer' : 'zw.co.innbucksnova.test';
  return `${scheme}://purchase?paymentToken=${encodeURIComponent(code)}`;
}

export function qrDataUri(body) {
  const qr = body?.qrCode;
  if (!qr) return null;
  return qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`;
}

/** Check a code every intervalMs (30 s by default) until final, or give up after maxMs. */
export async function waitForCode(code, { intervalMs = 30_000, maxMs = 11 * 60_000 } = {}) {
  const started = Date.now();
  for (;;) {
    await new Promise((r) => setTimeout(r, intervalMs));
    let body = {};
    try { body = await inquireCode(code); } catch { /* try again at the next check */ }
    if (isFinal(body)) return body;
    if (Date.now() - started >= maxMs) return { ...body, status: body.status ?? null, unknown: true };
  }
}

Usage:

import { generateCode, ok, message, deepLink, qrDataUri, waitForCode, isPaid } from './innbucks.js';

const res = await generateCode({ amount: 450, reference: 'ORD-10045', narration: 'Order ORD-10045' });
if (!ok(res)) throw new Error(message(res));
// save res.code on the order, then show: res.code, qrDataUri(res), deepLink(res.code)

const final = await waitForCode(res.code);        // in a worker, not in the web request
console.log(isPaid(final) ? 'paid' : final.unknown ? 'unknown - review' : 'expired');

9.3 Python

innbucks.py (Python 3.9+, pip install requests):

"""InnBucks Merchant API client. Amounts are integer cents: 450 == 4.50."""
import os
import re
import time
from urllib.parse import quote

import requests

BASE_URL = os.environ.get("INNBUCKS_BASE_URL", "").rstrip("/")
API_KEY = os.environ.get("INNBUCKS_API_KEY")
USERNAME = os.environ.get("INNBUCKS_USERNAME")
PASSWORD = os.environ.get("INNBUCKS_PASSWORD")
ENVIRONMENT = os.environ.get("INNBUCKS_ENV", "test")
TOKEN_TTL = int(os.environ.get("INNBUCKS_TOKEN_TTL", "840"))

_session = requests.Session()
_token = None
_token_at = 0.0
last_trace_id = None


class InnBucksError(Exception):
    def __init__(self, message, response=None, trace_id=None, http_status=None):
        super().__init__(message)
        self.response = response or {}
        self.trace_id = trace_id
        self.http_status = http_status


def _request(method, path, body=None, bearer=None):
    global last_trace_id
    headers = {"X-Api-Key": API_KEY, "Accept": "application/json"}
    if bearer:
        headers["Authorization"] = "Bearer " + bearer
    res = _session.request(method, BASE_URL + path, json=body, headers=headers, timeout=30)
    last_trace_id = res.headers.get("X-Trace-Id")
    try:
        data = res.json() if res.content else {}
    except ValueError:
        data = {"raw": res.text}
    return res.status_code, data


def login(fresh=False):
    global _token, _token_at
    if not fresh and _token and time.time() - _token_at < TOKEN_TTL:
        return _token
    status, data = _request("POST", "/auth/third-party", {"username": USERNAME, "password": PASSWORD})
    if not ok(data) or not data.get("accessToken"):
        raise InnBucksError("InnBucks login failed: " + message(data), data, last_trace_id, status)
    _token, _token_at = data["accessToken"], time.time()
    return _token


def _send(method, path, body=None):
    status, data = _request(method, path, body, login())
    if status == 401:  # token expired: log in again, retry once
        status, data = _request(method, path, body, login(fresh=True))
    if status >= 400:
        raise InnBucksError("InnBucks HTTP %d: %s" % (status, message(data)), data, last_trace_id, status)
    return data


def _cents(amount):
    if not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0:
        raise ValueError("Amounts are positive integers in cents: 450 == 4.50")
    return amount


def _currency(currency):
    if currency.upper() not in ("USD", "ZWG"):
        raise ValueError("currency must be USD or ZWG")
    return currency.upper()


# ---- codes ----------------------------------------------------------------

def generate_code(amount, currency, reference, narration, type="PAYMENT"):
    if type not in ("PAYMENT", "WITHDRAWAL"):
        raise ValueError("type must be PAYMENT or WITHDRAWAL")
    return _send("POST", "/api/code/generate", {
        "reference": reference, "narration": narration,
        "currency": _currency(currency), "amount": _cents(amount), "type": type})


def inquire_code(code, reference=None):
    body = {"reference": reference, "code": code} if reference else {"code": code}
    return _send("POST", "/api/code/inquiry", body)


# ---- agent services -------------------------------------------------------

def linked_account(msisdn, currency="USD"):
    return _send("GET", "/api/v1/account/msisdn/%s/details?currency=%s" % (quote(msisdn), _currency(currency)))


def deposit(destination_account, amount, currency, reference, narration, type=None):
    body = {"reference": reference, "currency": _currency(currency), "amount": _cents(amount),
            "narration": narration, "destinationAccount": destination_account}
    if type:
        body["type"] = type
    return _send("POST", "/api/transaction/deposit", body)


def deposit_inquiry(account_number, original_reference, inquiry_reference):
    return _send("POST", "/bank/api/transaction/inquiry", {
        "accountNumber": account_number,
        "participantReference": inquiry_reference,
        "originalParticipantReference": original_reference})


def reverse_deposit(destination_account, amount, currency, original_reference, reversal_reference):
    return _send("POST", "/api/transaction/reversal/v2", {
        "amount": _cents(amount), "currency": _currency(currency), "type": "CREDIT",
        "destinationAccount": destination_account,
        "participantReference": reversal_reference,
        "originalParticipantReference": original_reference})


def bank_change(msisdn, amount, currency, reference, narration):
    if _currency(currency) == "USD" and _cents(amount) > 500:
        raise ValueError("Bank change is limited to 500 cents (US$5.00)")
    return _send("POST", "/api/transaction/bankChange", {
        "reference": reference, "currency": _currency(currency), "amount": _cents(amount),
        "narration": narration, "destinationMsisdn": msisdn})


def utility_payment(provider, product, destination_account, amount, currency, reference,
                    narration="", additional_data=None):
    return _send("POST", "/api/utility/provider/payment", {
        "provider": provider, "providerProduct": product, "amount": _cents(amount),
        "currency": _currency(currency), "reference": reference, "narration": narration,
        "destinationAccount": destination_account, "additionalData": additional_data or {}})


def statement(account_number, currency, start, end):
    """start / end as 'YYYY-MM-DD HH:MM:SS', within one calendar month."""
    return _send("POST", "/api/account/fullStatement", {
        "accountNumber": account_number, "currency": _currency(currency),
        "startDateTime": start, "endDateTime": end})


# ---- helpers --------------------------------------------------------------

def ok(body):
    return (body or {}).get("responseCode") in ("00", "000")


def message(body):
    body = body or {}
    return str(body.get("responseMsg") or body.get("responseDescription") or "no message")


def code_status(body):
    """'new' | 'claimed' | 'paid' | 'expired' | 'timed_out' | None"""
    s = re.sub(r"[\s_-]+", "", str((body or {}).get("status") or "")).lower()
    return {"new": "new", "claimed": "claimed", "paid": "paid",
            "expired": "expired", "timedout": "timed_out"}.get(s)


def is_paid(body):
    return ok(body) and code_status(body) in ("claimed", "paid")


def is_final(body):
    return ok(body) and code_status(body) in ("claimed", "paid", "expired", "timed_out")


def deep_link(code):
    scheme = "com.innbucks.customer" if ENVIRONMENT == "production" else "zw.co.innbucksnova.test"
    return "%s://purchase?paymentToken=%s" % (scheme, quote(str(code)))


def qr_data_uri(body):
    qr = (body or {}).get("qrCode")
    if not qr:
        return None
    return qr if qr.startswith("data:") else "data:image/png;base64," + qr


def wait_for_code(code, interval=30, max_seconds=660):
    """Check a code every `interval` seconds until final; after `max_seconds` return it marked unknown."""
    started = time.time()
    while True:
        time.sleep(interval)
        try:
            body = inquire_code(code)
        except (InnBucksError, requests.RequestException):
            body = {}  # try again at the next check
        if is_final(body):
            return body
        if time.time() - started >= max_seconds:
            return dict(body, unknown=True)

Usage:

import innbucks

res = innbucks.generate_code(450, "USD", "ORD-10045", "Order ORD-10045")
if not innbucks.ok(res):
    raise RuntimeError(innbucks.message(res))
# save res["code"] on the order, then show res["code"], innbucks.qr_data_uri(res),
# innbucks.deep_link(res["code"])

final = innbucks.wait_for_code(res["code"])   # in a worker (Celery, RQ), not the web request
print("paid" if innbucks.is_paid(final) else "unknown" if final.get("unknown") else "expired")

This page is generated from section 9 of the README in README.md. Spotted something wrong? Open an issue.


Back to top

MIT licensed. Written and maintained by John Mugabe under 67even. Independent and community-maintained - not affiliated with or endorsed by InnBucks MicroBank Limited. "InnBucks" and the InnBucks logo belong to their owner.