Odoo 19용 건물 생산 KRA eTIMS 및 Safaricom M-Pesa 통합

작성자

카테고리:

← 피드로
DEV Community · Zoo Codes · 2026-09-05 개발(SW)
Cover image for Building Production KRA eTIMS and Safaricom M-Pesa Integrations for Odoo 19

Zoo Codes

Building business software in East Africa means dealing with two hard operational facts. First, the Kenya Revenue Authority requires every business invoice to carry a digital fiscal signature and a verifiable QR code via eTIMS. Second, over 80 percent of commercial transactions settle through Safaricom M-Pesa.

If your ERP cannot sign invoices in real time or match incoming Paybill payments automatically, your accounting team spends their days doing manual data entry. If your retail POS goes offline when the fiber cuts, you cannot legally issue receipts.

To solve these problems, we built and published three production-ready modules on the official Odoo App Store. They support Odoo 17.0, 18.0, and 19.0 across both Community and Enterprise editions.

Here is the technical architecture behind how we built them, how we handle network failures, and what we learned along the way.

The Three Integrations

Module Purpose Edition & Versions JengaStack eTIMS Real-time KRA OSCU invoice signing and fiscal QR codes Community & Enterprise (17.0, 18.0, 19.0) JengaStack M-Pesa Daraja STK Push and C2B Paybill/Till ledger auto-reconciliation Community & Enterprise (17.0, 18.0, 19.0) JengaStack eTIMS VSCU Offline-first virtual control unit and batched compliance sync Community & Enterprise (17.0, 18.0, 19.0)

1. Real-Time Fiscal Signing Without ERP Worker Blocking

Jengastack etims

The standard KRA eTIMS Online Sales Control Unit (OSCU) flow requires sending invoice line items, tax classification codes, and buyer PINs to KRA over HTTPS. KRA returns control unit internal data (CU Information), an invoice sequence number, and a verification URL encoded as a QR code.

The immediate trap many developers fall into is making a synchronous HTTP call directly inside Odoo’s invoice confirmation method:

# The anti-pattern: Blocking the main thread
class AccountMove(models.Model):
    _inherit = "account.move"

    def action_post(self):
        res = super().action_post()
        for record in self:
            response = requests.post("https://etims-api.kra.go.ke/...", timeout=30)
            # If KRA takes 15 seconds, the HTTP worker is tied up.
            # If KRA drops, the entire invoice rollback fails.
        return res

Enter fullscreen mode Exit fullscreen mode

Under high traffic or during peak tax filing deadlines, KRA endpoints often take several seconds to respond. Tying up Odoo WSGI workers with blocking external calls quickly exhausts the server worker pool.

The Asynchronous Buffer and Retry Engine

In jengastack_etims, we separate invoice confirmation from fiscal transmission.

When an invoice posts:

  1. The module assigns an internal sequence and creates a pending fiscal record.
  2. If online mode is active, it attempts an immediate short-timeout call (maximum 4 seconds).
  3. If the call succeeds, the invoice receives its KRA control code and QR code right away.
  4. If the call fails or times out, the module marks the record as pending_retry without rolling back the invoice. A dedicated scheduled cron worker sweeps pending records using exponential backoff with jitter.
import logging
import random
import time

_logger = logging.getLogger(__name__)

class JengaStackEtimsQueue(models.Model):
    _name = "jengastack.etims.queue"
    _description = "eTIMS Asynchronous Transmission Queue"

    move_id = fields.Many2one("account.move", required=True, ondelete="cascade")
    state = fields.Selection([
        ("pending", "Pending"),
        ("retrying", "Retrying"),
        ("signed", "Fiscalized"),
        ("failed", "Permanent Error"),
    ], default="pending", index=True)
    retry_count = fields.Integer(default=0)
    next_retry_time = fields.Datetime(default=fields.Datetime.now, index=True)

    def process_pending_queue(self):
        records = self.search([
            ("state", "in", ["pending", "retrying"]),
            ("next_retry_time", "<=", fields.Datetime.now()),
        ], limit=50)

        for item in records:
            try:
                item.move_id._transmit_to_kra_oscu()
                item.state = "signed"
            except (requests.RequestException, TimeoutError) as err:
                item.retry_count += 1
                if item.retry_count > 8:
                    item.state = "failed"
                    _logger.error("Fiscal transmission permanently failed for %s: %s", item.move_id.name, err)
                else:
                    # Exponential backoff: 2^retry + random jitter
                    delay_seconds = (2 ** item.retry_count) * 15 + random.randint(1, 10)
                    item.next_retry_time = fields.Datetime.add(fields.Datetime.now(), seconds=delay_seconds)
                    item.state = "retrying"

Enter fullscreen mode Exit fullscreen mode

The resulting QR code and Control Unit ID are rendered on both PDF QWeb reports and OWL POS thermal receipts, satisfying statutory requirements without stalling billing operations.

2. Safaricom M-Pesa Dual-Mode Architecture and Auto-Reconciliation

JengaStack M-Pesa Module Icon

Most M-Pesa plugins for Odoo treat payments as an afterthought, forcing staff to manually read SMS strings and type confirmation codes into Odoo.

We designed jengastack_mpesa with a dual-mode engine:

  1. Odoo Payment Provider connects directly to standard customer invoice portals and eCommerce checkouts.
  2. Direct POS and Backoffice Engine allows cashiers to trigger an STK Push with a single click, or allows the system to accept unsolicited C2B customer payments sent to a Paybill or Till.

The Webhook Reliability Gap

Safaricom sends validation and confirmation callbacks via HTTPS. However, network hops between Safaricom and cloud servers can occasionally drop packets. Relying solely on incoming callbacks leads to unconfirmed payments.

We solve this with a three-layer verification loop:

  1. Webhook Listener. Accepts incoming JSON payloads from Safaricom, stores the raw transaction in an immutable audit ledger, and acknowledges receipt within 200 milliseconds.
  2. Active Status Query Fallback. If an STK Push initiates and no confirmation arrives within 45 seconds, a background job queries Safaricom’s transaction status API directly using the MerchantRequestID.
  3. Idempotency Guard. Before registering any payment, the transaction code (such as QK71AA2981) is checked against an indexed unique database constraint. Duplicate callbacks never produce duplicate journal entries.

Compliance With Data Protection Regulations

Under the Kenya Data Protection Act 2019 (ODPC), customer phone numbers are personally identifiable information. Exposing plain customer MSISDNs in application logs or shared user screens creates serious regulatory risk.

Our module masks customer phone numbers in all logging and reporting layers:

def mask_msisdn(phone_number: str) -> str:
    """Mask phone numbers according to Kenyan data protection regulations.

    Example: '254712345678' becomes '2547****5678'.
    """
    if not phone_number or len(phone_number) < 8:
        return "****"
    cleaned = "".join(filter(str.isdigit, phone_number))
    if len(cleaned) < 8:
        return "****"
    return f"{cleaned[:4]}****{cleaned[-4:]}"

Enter fullscreen mode Exit fullscreen mode

When payments settle, the module finds the corresponding account.move, creates the account.payment entry, and runs Odoo’s automated reconciliation engine against the outstanding receivable balance.

3. Dealing With Spotty Internet and The VSCU Offline Bridge

JengaStack eTIMS VSCU Module Icon

Retail branches and distribution depots outside major urban centers frequently suffer from unstable internet. When connectivity drops, an online-only compliance setup stops all business operations because invoices cannot be legally cleared.

To solve this, we built jengastack_vscu.

The Virtual Sales Control Unit operates on an offline-first model:

  1. Local Sequential Monotonic Counter. Every sale generated while offline receives a strictly incrementing, tamper-evident local sequence number.
  2. Local Cryptographic Buffer. Invoices are signed using the local business security parameters and queued into a persistent on-disk buffer.
  3. Batch Transmission on Reconnection. When internet connectivity returns, the module flushes invoices in managed chunks of 20 to 50 records.
  4. Serial Reconciliation. Once KRA acknowledges the batch, the module updates the master fiscal register and stores the return tokens.

This architecture ensures retail counters can continue ringing up customers without violating tax regulations or slowing down queues during internet dropouts.

4. Multi-Version Odoo Craft for 17, 18, and 19

Odoo releases a new major version every year, introducing changes to OWL components, JavaScript framework bindings, and ORM interfaces. Between Odoo 17 and Odoo 19, significant updates were made to the Point of Sale UI and payment provider classes.

To support all three versions reliably:

  • We maintain dedicated release branches (17.0, 18.0, 19.0) with exact branch names matching the target series.
  • We standardized our testing pipeline to run 378 unit and integration tests across versions.
  • We followed strict UI craft guidelines with clean Tailwind-inspired layout cards, clear badge states, and responsive forms designed for daily warehouse and accounting operations.

Summary and Availability

All three modules are available now on the official Odoo App Store:

For deployment guides, sandbox test environments, or managed cloud hosting for your Odoo instances in East Africa, check our documentation at jengastack.app.

원문에서 계속 ↗