payment

package
v1.6.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 29, 2026 License: AGPL-3.0 Imports: 0 Imported by: 0

README

Payments Architecture

How the payment surface is structured, why it's structured that way, and what you need to know to add a new off-ramp provider or consume the contract from any channel i.e. USSD, WhatsApp, Telegram or web.

This document describes the deliberate shape of pkg/payment/. If you're making changes here, follow the principles below; if you're adding a feature that needs to break them, write down why in the PR.

Contents


Design principles

Four principles shape the package layout.

1. Channel-agnostic contract

pkg/payment/offramp owns the off-ramp interfaces. It does not know about USSD, bots, HTTP handlers, or any specific delivery channel. The same offramp.Provider is consumed by pkg/mobile/ussd/adapters today and will be consumed by future chat-bot adapters without modification.

If you find yourself wanting to import a channel package from pkg/payment/, that's a layering inversion; push the channel concern back into the channel package.

2. Capability composition over fat interface

Each distinct capability is its own narrow interface:

// pkg/payment/offramp/offramp.go

type Provider interface {
    ID() ProviderID
    Initiate(ctx context.Context, req Request) (*Result, error)
}

type StatusReader interface {
    Status(ctx context.Context, ref ProviderRef) (*Status, error)
}

type Quoter interface {
    Quote(ctx context.Context, q QuoteRequest) (*ExchangeRate, error)
}

type Directory interface {
    SupportedProviders(ctx context.Context, country string) ([]ProviderInfo, error)
}

type MobileMoneyDirectory interface {
    Networks(ctx context.Context, country string) ([]MobileMoneyNetwork, error)
}

type BalanceReporter interface {
    AvailableBalance(ctx context.Context) (float64, error)
}

Providers implement only the capabilities they actually support. Callers type-assert for what they need:

if d, ok := provider.(offramp.MobileMoneyDirectory); ok {
    networks, _ := d.Networks(ctx, "KE")
}

This is the standard library's pattern (io.Reader, io.Closer, io.Seeker — small interfaces composed where needed). Two consequences:

  • Honest implementations. A provider implements a capability only when it genuinely supports it. MoneyGram has no MoMo concept, so it does not implement MobileMoneyDirectory; provider.(MobileMoneyDirectory) fails for MG and callers handle that explicitly.
  • Cheap to extend. A new capability (e.g. Refunder for providers that expose a refund API) is a new interface in this file. Existing providers don't change.

The mandatory minimum is Provider. Every off-ramp must satisfy it.

3. Core + typed payload

Request and Result carry only cross-provider fields directly. Anything provider-specific lives in a typed payload attached to the struct:

type Request struct {
    LoanID, UserID, RecipientName, DestinationPhone, CountryCode string
    AmountUSD     float64
    AmountStroops int64
    // ... cross-cutting fields
    PayoutMethod  string
    Options       ProviderOptions   // typed extras, e.g. yellowcard.Options
}

type Result struct {
    RequestID, Status, LocalCurrency string
    AmountUSD, AmountLocal float64
    // ... cross-cutting summary fields
    Provider ProviderPayload         // typed extras, e.g. moneygram.CashPickupPayload
}

type ProviderOptions interface { ProviderID() ProviderID }
type ProviderPayload interface { ProviderID() ProviderID }

Concrete payload types live in their provider's package, not in offramp:

// pkg/payment/yellowcard/payload.go
type Options struct {
    SettlementMethod string // "direct" | "fiat"
}
func (Options) ProviderID() offramp.ProviderID { return offramp.ProviderYellowCard }

type DirectSettlementPayload struct {
    StellarAddress, StellarMemo, StellarTxHash string
}
func (DirectSettlementPayload) ProviderID() offramp.ProviderID { return offramp.ProviderYellowCard }
// pkg/payment/moneygram/payload.go
type Options struct {
    BirthDate         string
    ChildAccountIndex uint32
}
func (Options) ProviderID() offramp.ProviderID { return offramp.ProviderMoneyGram }

type CashPickupPayload struct {
    InteractiveURL, ExternalReference, MoreInfoURL string
    ChildAccountMemo                                int64
    WithdrawMemo, WithdrawMemoType                  string
}
func (CashPickupPayload) ProviderID() offramp.ProviderID { return offramp.ProviderMoneyGram }

Adding a third provider means a third payload type in that provider's package; the Request and Result shapes in offramp stay untouched.

Consumers that care about the typed payload assert:

res, _ := provider.Initiate(ctx, req)
if pl, ok := res.Provider.(moneygram.CashPickupPayload); ok {
    persistInteractiveURL(pl.InteractiveURL)
}

Consumers that only render res.AmountLocal and res.Status ignore the payload entirely.

4. Registry-based routing

A registry resolves the right provider for each incoming request:

// pkg/payment/offramp/registry.go

type Registry struct { /* providers, aliases */ }

func (r *Registry) Register(p Provider) error
func (r *Registry) Alias(payoutMethod string, id ProviderID) error
func (r *Registry) Resolve(req Request) (Provider, error)
func (r *Registry) Get(id ProviderID) (Provider, bool)
func (r *Registry) All() []Provider

Resolution order:

  1. If req.Options is set, use req.Options.ProviderID(). The caller has pinned a specific provider by attaching its typed options.
  2. Otherwise, look up req.PayoutMethod in the alias table. Empty PayoutMethod defaults to PayoutMethodMobileMoney.

A new provider is registered at boot:

reg := offramp.NewRegistry()
_ = reg.Register(ycAdapter) // ID() returns offramp.ProviderYellowCard
_ = reg.Register(mgAdapter) // ID() returns offramp.ProviderMoneyGram
_ = reg.Alias(offramp.PayoutMethodMobileMoney, offramp.ProviderYellowCard)
_ = reg.Alias(offramp.PayoutMethodCashPickup,  offramp.ProviderMoneyGram)

No glue file to edit, no enum to extend.


Package layout

pkg/payment/
├── offramp/                # the channel-agnostic contract
│   ├── offramp.go          # Provider + capability interfaces + Request/Result
│   ├── registry.go         # Registry: Register, Alias, Resolve, Get, All
│   └── noop.go             # NoOpProvider for tests
│
├── stellaranchor/          # reusable Stellar-anchor primitives
│   ├── client.go           # composes Auth + Anchor + JWTCache
│   ├── sep1.go             # TOML fetch + Validate
│   ├── sep9.go             # SEP-9 Customer + SplitFullName
│   ├── sep10.go            # challenge/cosign/JWT submit
│   ├── sep24.go            # withdraw/initiate + transaction + Status enum
│   ├── jwt_cache.go        # per-memo JWT cache
│   ├── memo.go             # ChildAccountMemo derivation
│   ├── iso.go              # ISO-2 to ISO-3 mapping
│   └── errors.go           # Protocol and custom errors
│
├── moneygram/              # MG-flavoured anchor: stellaranchor + REST FX
│   ├── client.go           # embeds *stellaranchor.Client; adds OAuth + FXRate
│   ├── payload.go          # Options, CashPickupPayload
│   ├── oauth.go            # MG REST OAuth client_credentials
│   ├── fxrate.go           # GET /fx-rate/v1/rates (corridor cache)
│   ├── fx.go               # FXOrchestrator (cascading rate source)
│   └── errors.go           # Moneygram-specific errors
│
├── yellowcard/             # YC mobile-money adapter
│   ├── yellowcard.go       # HTTP client + HMAC signing
│   ├── payload.go          # Options, DirectSettlementPayload, FiatPayload
│   ├── types.go            # YC API request/response shapes
│   └── errors.go           # YellowCard-specific errors
│
└── fonbnk/                 # placeholder — auth transport only
    └── fonbnk.go

Glue (the adapter that translates between USSD-shaped data and an offramp.Provider) lives in pkg/mobile/ussd/adapters/. Bots will get their own adapter packages alongside it.


Adding a new off-ramp provider

The minimum viable implementation:

1. Create pkg/payment/<provider>/ and implement the provider's protocol client (HTTP, gRPC, whatever it speaks).

2. Define your Options and result Payload types in that package:

package fooramp

import "github.com/Shamba-Records-Limited/microvault/pkg/payment/offramp"

const ProviderID offramp.ProviderID = "fooramp"

type Options struct {
    SomeFooSpecificFlag string
}
func (Options) ProviderID() offramp.ProviderID { return ProviderID }

type Payload struct {
    FooReceiptID string
}
func (Payload) ProviderID() offramp.ProviderID { return ProviderID }

Add the constant to pkg/payment/offramp/offramp.go if you want auto-completion to surface it. It's just a ProviderID(string) — no enum machinery.

3. Build an adapter that implements offramp.Provider:

// pkg/mobile/ussd/adapters/offramp_fooramp.go (or wherever the channel glue lives)

type FooRampAdapter struct { /* client, logger, … */ }

func (a *FooRampAdapter) ID() offramp.ProviderID { return fooramp.ProviderID }

func (a *FooRampAdapter) Initiate(ctx context.Context, req offramp.Request) (*offramp.Result, error) {
    opts, err := readFooOptions(req.Options) // type-assert helper
    if err != nil {
        return nil, err
    }
    // ... talk to the provider, then:
    return &offramp.Result{
        RequestID:        "...",
        Status:           "pending",
        AmountUSD:        req.AmountUSD,
        SettlementMethod: "foo_settlement",
        Provider:         fooramp.Payload{FooReceiptID: "..."},
    }, nil
}

4. Implement any optional capabilities the provider actually supports (no fake stubs; if Foo has no MoMo concept, don't implement MobileMoneyDirectory):

var (
    _ offramp.Provider     = (*FooRampAdapter)(nil)
    _ offramp.StatusReader = (*FooRampAdapter)(nil)
    _ offramp.Quoter       = (*FooRampAdapter)(nil)
    // intentionally not implementing MobileMoneyDirectory or BalanceReporter
)

5. Register at boot:

reg := offramp.NewRegistry()
_ = reg.Register(fooAdapter)
_ = reg.Alias("foo_method", fooramp.ProviderID)

That's the whole loop. No edits to offramp.Request or offramp.Result.


Adding a new Stellar anchor

Multiple Stellar anchors (today: MoneyGram; tomorrow: Settle, Unilink, whatever) share the SEP-1/9/10/24 + JWT cache + memo machinery in pkg/payment/stellaranchor. To wire a new one:

1. Create pkg/payment/<anchor>/ (sibling of moneygram/).

2. Embed *stellaranchor.Client for the protocol layer:

package settle

import "github.com/Shamba-Records-Limited/microvault/pkg/payment/stellaranchor"

type Client struct {
    *stellaranchor.Client
    // settle-specific extras (their REST API, custom auth, etc.)
}

func New(cfg Config) (*Client, error) {
    anchor, err := stellaranchor.New(stellaranchor.Config{
        HomeDomain:        cfg.HomeDomain,
        WebAuthEndpoint:   cfg.WebAuthEndpoint,
        TransferServerURL: cfg.TransferServerURL,
        ServerSigningKey:  cfg.ServerSigningKey,
        NetworkPassphrase: cfg.NetworkPassphrase,
        USDCIssuer:        cfg.USDCIssuer,
        TreasurySecret:    cfg.TreasurySecret,
    })
    if err != nil { return nil, err }
    return &Client{Client: anchor /*, …*/ }, nil
}

Now Client.Token, Client.InitiateWithdrawal, Client.GetTransaction, Client.TreasuryAddress, Client.USDCIssuer work via embedding.

3. Layer your anchor-specific concerns (custom REST APIs, OAuth flows, FX rate sources) on top.

4. Build your offramp.Provider adapter as in the previous section.

The split is: protocol stays in stellaranchor; business logic stays in the anchor package. If you find yourself copying SEP-* code, push the generalisation into stellaranchor instead.


Consuming the contract

From USSD

The USSD loan service adapter depends on the narrow capabilities it actually uses:

// credit module: internal/credit/adapters/loan_service_adapter.go

type LoanServiceAdapter struct {
    offRampSvc offramp.Provider // just Initiate
    // …
}

result, err := a.offRampSvc.Initiate(ctx, offramp.Request{
    LoanID:           loanID,
    AmountUSD:        amountUSD,
    DestinationPhone: req.PhoneNumber,
    CountryCode:      req.CountryCode,
    Options: yellowcard.Options{
        SettlementMethod: yellowcard.SettlementMethodDirect,
    },
})

The rate service adapter depends only on Quoter:

type RateServiceAdapter struct {
    quoter offramp.Quoter
}

rate, err := a.quoter.Quote(ctx, offramp.QuoteRequest{Currency: "KES"})

Both adapters get the same YC concrete adapter at the call site in cmd/main.go and the YC adapter satisfies both interfaces.

From a future delivery channel

The recipe is identical: depend on offramp.Provider (and any capabilities you need), accept a concrete provider via constructor injection, call Initiate(ctx, req). The delivery channel adapter package doesn't import any USSD code.

Reading typed result payloads

When you need provider-specific output (e.g. MoneyGram's interactive URL), type-assert against the payload defined in that provider's package:

result, err := provider.Initiate(ctx, req)
if err != nil { /* … */ }

switch pl := result.Provider.(type) {
case yellowcard.DirectSettlementPayload:
    persistStellarTxHash(pl.StellarTxHash)
case yellowcard.FiatPayload:
    // YC disbursed from its balance — no chain artifacts to persist
case moneygram.CashPickupPayload:
    smsInteractiveURL(pl.InteractiveURL)
}

A code path that only renders cross-provider summary data (result.AmountLocal, result.Status) ignores result.Provider entirely. The point is most consumers don't need to know which provider ran.

Polling provider-specific state

Pollers (e.g. pkg/services/mgpoller) get the concrete provider client, not the generic Provider. They need provider-specific behaviour (MG's SEP-24 state machine, YC's refund lookup) that doesn't generalise. Don't try to abstract a poller across providers; let each provider's lifecycle drive its own poller.


What lives where, and why

If you're changing… …edit this package
The shape of Request / Result / Status pkg/payment/offramp
A new capability interface pkg/payment/offramp
How providers are resolved at runtime pkg/payment/offramp/registry.go
SEP-1/9/10/24 protocol details pkg/payment/stellaranchor
MoneyGram REST FX or OAuth pkg/payment/moneygram
YellowCard HTTP / HMAC / channel data pkg/payment/yellowcard
USSD-specific request shaping pkg/mobile/ussd/adapters

If a change spans two columns, you're probably building the wrong thing. Examples:

  • "I want to add a YC-specific field to offramp.Request." No, put it in yellowcard.Options and let the YC adapter read it.
  • "I want a generic BalanceReporter that works for every provider." No, MG is funded per-transaction. Don't force it. Adapters that have a balance implement the capability; adapters that don't, don't.
  • "I want the registry to know about USSD." No, the registry knows about ProviderID and PayoutMethod. The USSD layer maps its menu choices to those strings.

When in doubt, ask: would a future delivery channel need to import this from this layer? If the answer is no, you're putting it in the wrong layer.

Documentation

Overview

Package payment is the umbrella for the platform's off-ramp surface. It is an organizational namespace with no Go code of its own; the actual behaviour lives in its sub-packages.

offramp holds the channel-agnostic contract (Provider, capability interfaces, Registry). stellaranchor holds the reusable SEP-1/9/10/24 protocol primitives plus JWT cache and ChildAccountMemo derivation. moneygram, yellowcard and fonbnk hold one provider implementation each.

Directories

Path Synopsis
Package airtel is a client for Airtel Africa's Open API, Kenya op-co.
Package airtel is a client for Airtel Africa's Open API, Kenya op-co.
airtelstub
Package airtelstub is an in-process Airtel Open API, Kenya op-co.
Package airtelstub is an in-process Airtel Open API, Kenya op-co.
Package cashin is the collection-side counterpart to offramp.
Package cashin is the collection-side counterpart to offramp.
Package fonbnk is the client for Fonbnk's server-to-server Merchant API, covering both on-ramp (fiat in, crypto out) and off-ramp (crypto in, fiat out).
Package fonbnk is the client for Fonbnk's server-to-server Merchant API, covering both on-ramp (fiat in, crypto out) and off-ramp (crypto in, fiat out).
Package moneygram is the MoneyGram-flavoured wrapper around the generic stellaranchor SDK.
Package moneygram is the MoneyGram-flavoured wrapper around the generic stellaranchor SDK.
Package mpesa is a client for Safaricom's Daraja API gateway.
Package mpesa is a client for Safaricom's Daraja API gateway.
darajastub
Package darajastub is an in-process Daraja.
Package darajastub is an in-process Daraja.
Package offramp defines the off-ramp Provider contract that every provider in the platform satisfies.
Package offramp defines the off-ramp Provider contract that every provider in the platform satisfies.
Package relay routes one transaction to whichever ramp provider offers the best price for it.
Package relay routes one transaction to whichever ramp provider offers the best price for it.
Package stellaranchor provides the reusable Stellar-anchor primitives that any SEP-24-compliant anchor implementation can compose on top of.
Package stellaranchor provides the reusable Stellar-anchor primitives that any SEP-24-compliant anchor implementation can compose on top of.
Package yellowcard is the low-level HTTP client for the YellowCard mobile-money API, in both directions.
Package yellowcard is the low-level HTTP client for the YellowCard mobile-money API, in both directions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL