mgpoller

package
v1.1.2 Latest Latest
Warning

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

Go to latest
Published: Aug 31, 2026 License: AGPL-3.0 Imports: 15 Imported by: 0

Documentation

Overview

Package mgpoller drives both directions of the MoneyGram SEP-24 rail: withdrawal (cash out at an agent) and deposit (cash in, settling a loan).

MoneyGram publishes no webhooks, so status only moves when we ask. Both directions share Runner: wake on a cadence, fetch a batch, drive each record, never let one record's failure abort the batch.

Why the two directions are not mirror images

A withdrawal is something we started and MoneyGram must finish. It resolves in minutes and is polled hard on a fixed ticker until it settles.

A deposit is something the borrower must finish, and nothing on our side compels them to. The window runs to a day or more, most of it spent doing nothing. Cadence therefore lives in a repayment_next_poll_at column rather than in the ticker: in-memory timers are lost on restart, and a 30-second tick for three days is tens of thousands of pointless requests. The runner interval decides how often we ask which rows are due; the column decides which come back.

The deadline is MoneyGram's

A deposit lapses on the transaction's user_action_required_by whatever REPAYMENT_WINDOW says. It has been observed at roughly 24 hours against a default of 96, so acting on ours would keep a dead deposit live for three days and tell the borrower they still had time. The reminder lead is capped at a fraction of that real window, measured from started_at — measuring from the time remaining would make the threshold chase itself and only ever be met at expiry.

Ordering on the deposit side

The borrower is told the moment funds reach the treasury, before the vault leg is attempted: from their side the repayment is done, and the treasury-to-vault transfer is our leg. A failure there never reaches them, the row stays at funds_received, and the retry never stops — their USDC is already on the treasury, so there is no state in which abandoning it is correct. The attempt ceiling decides when a human is told, not when to give up.

Telling a borrower how to pay

Two artifacts can carry the instructions and MoneyGram issues them at different points. external_transaction_id is the reference quoted at the counter, but SEP-24 defines it as the external transaction that "started the deposit" — on a cash-in it does not exist until the borrower has already paid. more_info_url is the field the spec designates for telling a user how to start one, and is populated from the first poll.

The reference is preferred because a code a borrower can read out beats a link they must open on a feature phone; the page is the fallback, because a borrower holding neither cannot pay at all. Either way the marker is written before the send, so a failing provider is not retried every tick for the rest of the window. The cost is that a failure there is terminal until an operator clears repayment_reference_sent, which is why it alerts rather than logs.

Refunds

A refund is settled from what landed on-ledger, never from the anchor's own arithmetic: MoneyGram reports amount_refunded already net of its withdrawal fee and then restates that fee in amount_fee, so deriving the total from those fields subtracts it twice. Settlement runs in stages across ticks so every step is retried until it succeeds, and nothing depends on a single observation.

Status strings for the lending module's loan model are duplicated here as constants rather than imported: importing the lending module would invert the layering.

Package mgpoller polls MoneyGram for SEP-24 transaction status changes and drives the existing disbursement state machine accordingly.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type AlertService

type AlertService interface {
	AlertOps(subject, message string) error
}

AlertService is the same interface used by the YC refund poller — receives ops alerts when something needs human attention. Optional.

type DepositDriver added in v1.1.2

type DepositDriver struct {
	// contains filtered or unexported fields
}

DepositDriver drives the borrower repayment cash-in state machine.

func NewDepositDriver added in v1.1.2

func NewDepositDriver(deps DepositDriverDeps) (*DepositDriver, error)

NewDepositDriver validates the dependencies and returns a DepositDriver.

func (*DepositDriver) Drive added in v1.1.2

func (d *DepositDriver) Drive(ctx context.Context, rec RepaymentRecord)

Drive evaluates one repayment and applies the appropriate transition. Errors are logged but never abort the batch.

func (*DepositDriver) Start added in v1.1.2

func (d *DepositDriver) Start(ctx context.Context)

Start runs the deposit driver until ctx is cancelled.

type DepositDriverDeps added in v1.1.2

type DepositDriverDeps struct {
	Client   *moneygram.Client
	Fetcher  RepaymentFetcher
	Recorder RepaymentRecorder
	Vault    VaultRepayer
	Notifier RepaymentNotifier
	Alerts   AlertService
	Config   PollerConfig
	Logger   *slog.Logger
}

DepositDriverDeps are the collaborators and settings a DepositDriver needs. Client, Fetcher, Recorder and Vault are required; Notifier, Alerts and Logger may be nil.

type DisbursementUpdater

type DisbursementUpdater interface {
	UpdateDisbursementStatus(sequenceID string, status string) error
	NotifyDisbursementComplete(sequenceID string) error
	NotifyDisbursementFailed(sequenceID string) error

	// NotifyCashPickupReady tells the borrower their cash is collectable and
	// quotes the MG reference number. Sent once, when MG reports
	// pending_user_transfer_complete.
	NotifyCashPickupReady(sequenceID string) error

	// NotifyRefundReceived tells the borrower their cash pickup was cancelled
	// and the funds returned. Distinct from NotifyDisbursementFailed because
	// the usual cause is the borrower cancelling in MoneyGram's own UI,
	// sometimes by mistake — the message has to say they can request again.
	NotifyRefundReceived(sequenceID string) error

	RepayVault(sequenceID string) error

	// RepayVaultAmount repays an explicit stroop amount rather than the loan
	// principal. Used for refunds, where MG may return less than we sent and
	// repaying the full principal would overdraw the treasury.
	RepayVaultAmount(sequenceID string, amountStroops int64) error
}

DisbursementUpdater drives terminal state transitions and user notifications. The lending module's disbursement-status adapter already implements this for the YC flow; the same impl is reused here.

type Driver added in v1.1.2

type Driver[T any] interface {
	Drive(ctx context.Context, rec T)
}

Driver applies the state transition for a single record.

type FetchFunc added in v1.1.2

type FetchFunc[T any] func(ctx context.Context, limit int) ([]T, error)

FetchFunc adapts an ordinary function to Fetcher, so an existing repository method can be handed to a Runner without a wrapper type.

func (FetchFunc[T]) Fetch added in v1.1.2

func (f FetchFunc[T]) Fetch(ctx context.Context, limit int) ([]T, error)

Fetch implements Fetcher.

type Fetcher added in v1.1.2

type Fetcher[T any] interface {
	Fetch(ctx context.Context, limit int) ([]T, error)
}

Fetcher supplies one batch of records that need driving. Implementations decide what "needs driving" means — the withdrawal side selects loans with a live MoneyGram withdrawal, the deposit side selects rows whose next poll is due.

type LoanFetcher

type LoanFetcher interface {
	GetActiveMoneyGramLoans(ctx context.Context, limit int) ([]LoanRecord, error)
}

LoanFetcher retrieves active MoneyGram cash-pickup loans for the poller to evaluate. Implementations should select loans where ramp_provider = "moneygram" AND disbursement_status NOT IN terminal-states.

type LoanRecord

type LoanRecord struct {
	LoanID               string
	SequenceID           string  // = loans.ramp_sequence_id
	MoneyGramTxID        string  // = loans.ramp_request_id
	ChildAccountIndex    uint32  // for SEP-10 memo re-derivation
	PrincipalStroops     int64   // USDC stroops to send to MG anchor
	RequestedLocalAmount float64 // KES the user typed in USSD; used for drift alerts
	HasStellarSend       bool    // true once MG.tx.stellar_transaction_id is observed
	DisbursementStatus   string
	PhoneNumber          string
	UserID               string
}

LoanRecord is the projection of a loan row the poller needs to drive state for a single MoneyGram cash-pickup transaction.

The lending module's loan repository constructs these from active rows where ramp_provider="moneygram" and disbursement_status is in the active set.

type LoanRecorder

type LoanRecorder interface {
	// RecordTransactionUpdate persists the latest fields from a polled MG
	// transaction onto the loan row: amount_out, amount_out_asset, amount_fee,
	// external_transaction_id, more_info_url. Idempotent.
	RecordTransactionUpdate(ctx context.Context, loanID string, tx *stellaranchor.Transaction) error

	// RecordSendUSDC records the Stellar tx hash from a successful USDC
	// transfer to MG's anchor account, replacing the pending claim written by
	// RecordSendAttempt.
	RecordSendUSDC(ctx context.Context, loanID string, txHash string) error

	// RecordSendAttempt claims the send *before* the payment is submitted, so
	// a crash between submission and RecordSendUSDC cannot let the next tick
	// re-send. The claim makes LoanRecord.HasStellarSend true on reload.
	RecordSendAttempt(ctx context.Context, loanID string) error

	// ClearSendAttempt releases the claim. Only called when the payment
	// definitively did not move funds, so a later tick may safely retry.
	ClearSendAttempt(ctx context.Context, loanID string) error

	// RecordRefund persists the settled refund: the Stellar hash MG returned
	// the USDC in, the net stroops received, and any shortfall against the
	// principal we originally sent. Written before the vault repay so a crash
	// mid-repay leaves evidence of what came back.
	RecordRefund(ctx context.Context, loanID string, refund RefundRecord) error
}

LoanRecorder writes back the per-tick state changes the poller derives from MG's SEP-24 transaction object.

type PaymentVerifier

type PaymentVerifier interface {
	TransactionSucceeded(ctx context.Context, txHash string) (bool, error)

	// PaymentsTo returns the payments in txHash addressed to destination in
	// the named asset. Direction and amount both come from the ledger: an
	// anchor's outbound refund and our own inbound payment to that anchor are
	// both "successful transactions", so success alone cannot tell them apart.
	PaymentsTo(ctx context.Context, txHash, destination, assetCode, assetIssuer string) ([]rpc.Payment, error)
}

PaymentVerifier confirms that a Stellar transaction an anchor claims to have made actually succeeded on-ledger.

type Poller

type Poller struct {
	// contains filtered or unexported fields
}

Poller drives the MoneyGram cash-pickup state machine.

func NewPoller

func NewPoller(deps PollerDeps) (*Poller, error)

NewPoller validates the dependencies and returns a Poller.

func (*Poller) Drive added in v1.1.2

func (p *Poller) Drive(ctx context.Context, rec LoanRecord)

Drive implements Driver[LoanRecord]. The state machine is driveLoan, in withdrawal.go; this is the name the runner calls it by.

func (*Poller) Start

func (p *Poller) Start(ctx context.Context)

Start runs the withdrawal poller until ctx is cancelled.

type PollerConfig

type PollerConfig struct {
	// PollInterval is how often the ticker fires. Default 30s.
	PollInterval time.Duration

	// MaxBatch caps the number of loans evaluated per tick. Default 100.
	MaxBatch int

	// PayoutDriftAlertPct triggers an ops alert when the locked
	// amount_out diverges from RequestedLocalAmount by more than this
	// fraction (e.g. 0.02 = 2 %). Default 0.02. Set to 0 to disable.
	PayoutDriftAlertPct float64

	// RefundSettleMaxAttempts caps how many ticks a loan may sit in
	// refund_pending waiting for MoneyGram to publish the SEP-24 refunds
	// object before ops are alerted. Observed MG behaviour is that it may
	// never arrive, so without a ceiling the loan polls silently forever.
	// Default 20 (10 minutes at the default interval).
	RefundSettleMaxAttempts int

	// RefundDestination is the account MoneyGram returns funds to — the
	// wallet we withdraw from. Defaults to the client's SEP-10 account.
	RefundDestination string

	// RefundAssetIssuer is the USDC issuer a refund payment must carry.
	// Defaults to the anchor client's configured issuer.
	RefundAssetIssuer string

	// DepositPollInterval is how often the deposit runner asks for rows whose
	// next poll is due. Default 60s. This is not how often any one deposit is
	// polled — that is the backoff below.
	DepositPollInterval time.Duration

	// DepositMaxBatch caps repayments evaluated per tick. Default 100.
	DepositMaxBatch int

	// DepositActiveBackoff is the gap between polls once the borrower has
	// engaged — committed in the webview, or the transaction is moving through
	// MoneyGram. Default 2m.
	DepositActiveBackoff time.Duration

	// DepositIdleBackoff is the gap between polls while the borrower has not
	// opened the link. This is most of the window, and the reason cadence is
	// column-driven at all. Default 30m.
	DepositIdleBackoff time.Duration

	// DepositReminderBefore is how long before expiry the single reminder SMS
	// goes out. Default 24h. Zero disables the reminder.
	DepositReminderBefore time.Duration

	// DepositVaultMaxAttempts is how many times the treasury-to-vault leg may
	// fail before ops are alerted. Default 10. The borrower's USDC is already
	// on the treasury by this point, so the retry never stops — the ceiling
	// decides when a human is told, not when to give up.
	DepositVaultMaxAttempts int

	// DepositVaultRetryBackoff is the gap between vault-leg retries once the
	// ceiling has been passed. Default 1h. Slower than DepositActiveBackoff
	// because past the ceiling the cause is unlikely to clear on its own, and
	// hammering a broken RPC every two minutes helps nobody.
	DepositVaultRetryBackoff time.Duration
}

PollerConfig configures cadence and drift detection.

func DefaultConfig

func DefaultConfig() PollerConfig

DefaultConfig returns a sensible PollerConfig.

type PollerDeps added in v1.1.2

type PollerDeps struct {
	Client       *moneygram.Client
	Fetcher      LoanFetcher
	Recorder     LoanRecorder
	Disbursement DisbursementUpdater
	Treasury     offramp.TreasuryTransfer
	Verifier     PaymentVerifier
	Alerts       AlertService
	Config       PollerConfig
	Logger       *slog.Logger
}

PollerDeps are the collaborators and settings a withdrawal Poller needs. Client, Fetcher, Recorder, Disbursement and Treasury are required; Verifier, Alerts and Logger may be nil.

type RefundRecord

type RefundRecord struct {
	// TxHash is the Stellar transaction the refund arrived in. When MG splits
	// a refund across several payments this is the last one; the amount is
	// always the total.
	TxHash string

	// NetStroops is what actually landed, summed from the refund payments as
	// they appear on-ledger rather than from the anchor's reported amounts.
	NetStroops int64

	// ShortfallStroops is PrincipalStroops - NetStroops when MG returned less
	// than we sent, otherwise zero. Non-zero means the treasury absorbed the
	// difference and the loan needs a human to settle it.
	ShortfallStroops int64
}

RefundRecord is the settled outcome of a MoneyGram refund.

type RepaymentFetcher added in v1.1.2

type RepaymentFetcher interface {
	GetDueRepayments(ctx context.Context, limit int) ([]RepaymentRecord, error)
}

RepaymentFetcher returns the repayments due for evaluation this tick. Implementations select loans whose repayment_status is initiated or funds_received and whose repayment_next_poll_at has passed.

type RepaymentNotifier added in v1.1.2

type RepaymentNotifier interface {
	// NotifyRepaymentReference carries MoneyGram's deposit reference once the
	// borrower has committed in the webview. Without it they reach the counter
	// with nothing to quote.
	NotifyRepaymentReference(loanID, reference string) error

	// NotifyRepaymentMoreInfo carries MoneyGram's transaction page instead,
	// for the case where no reference has been issued. See sendPayInstructions.
	NotifyRepaymentMoreInfo(loanID string) error

	NotifyRepaymentReceived(loanID string) error
	NotifyRepaymentReminder(loanID string) error
	NotifyRepaymentExpired(loanID string) error
}

RepaymentNotifier sends the borrower-facing messages for the cash-in rail. Optional: a nil notifier logs instead, which keeps the state machine testable without a notification stack.

type RepaymentRecord added in v1.1.2

type RepaymentRecord struct {
	LoanID        string
	SequenceID    string // = loans.ramp_sequence_id; the handle notifications use
	MoneyGramTxID string // = loans.repayment_mg_tx_id

	// ChildAccountIndex re-derives the SEP-10 memo. The deposit was created
	// under a JWT scoped to this borrower, which is what attributes the
	// MoneyGram transaction to them — not any field on the transaction.
	ChildAccountIndex uint32

	// BorrowerAddress is the child account's Stellar address, carried onto the
	// vault's Repaid event by repay_for. Attribution only: the treasury is the
	// payer and the borrower authorizes nothing.
	BorrowerAddress string

	// PayoffStroops is the payoff quoted and locked at initiation. Borrow-index
	// movement since then does not change what the borrower owes.
	PayoffStroops int64

	// VaultAttempts is how many times the treasury-to-vault leg has already
	// failed for this loan. Durable, so a restart does not reset the ceiling.
	VaultAttempts int

	// ReferenceSent marks the one SMS carrying MoneyGram's deposit reference,
	// which is what the borrower quotes at the counter to pay in.
	ReferenceSent bool

	RepaymentStatus string
	ExpiresAt       time.Time
	ReminderSent    bool
	PhoneNumber     string
	UserID          string
}

RepaymentRecord is the projection of a loan row with a borrower repayment in flight. The lending module builds these from rows whose repayment_status is one of the live states and whose repayment_next_poll_at is due.

type RepaymentRecorder added in v1.1.2

type RepaymentRecorder interface {
	// RecordDepositUpdate persists the latest fields from a polled deposit
	// transaction. Idempotent.
	RecordDepositUpdate(ctx context.Context, loanID string, tx *stellaranchor.Transaction) error

	// MarkFundsReceived records the borrower's cash reaching the treasury as
	// USDC. loans.status deliberately does not move until the vault leg lands.
	MarkFundsReceived(ctx context.Context, loanID string, tx *stellaranchor.Transaction) error

	// MarkSettled records the confirmed treasury-to-vault leg. This is the
	// transition that flips loans.status to repaid.
	MarkSettled(ctx context.Context, loanID string, vaultTxHash string) error

	// MarkExpired releases the quote lock after the cash-in window elapsed
	// without the borrower paying, returning the loan to its prior status.
	MarkExpired(ctx context.Context, loanID string) error

	// MarkFailed ends the rail before any funds moved. Never used for a failed
	// vault leg: money already on the treasury stays at funds_received so
	// reconciliation keeps retrying.
	MarkFailed(ctx context.Context, loanID string, reason string) error

	// MarkReminderSent is written before the SMS, so a failed send is not
	// retried on every tick. It records a notification, not a movement of
	// money, which is why it needs its own marker.
	MarkReminderSent(ctx context.Context, loanID string) error

	// ScheduleNextPoll sets when this repayment should next be looked at.
	ScheduleNextPoll(ctx context.Context, loanID string, at time.Time) error

	// MarkReferenceSent stamps the deposit-reference SMS before it is sent, so
	// a failing provider is not retried on every tick for the rest of the
	// window. Same reasoning as MarkReminderSent.
	MarkReferenceSent(ctx context.Context, loanID string) error

	// RecordVaultAttempt increments the failed-vault-leg counter. Written
	// after each failure so the count survives a restart, which is what makes
	// the escalation ceiling meaningful.
	RecordVaultAttempt(ctx context.Context, loanID string, attempts int) error
}

RepaymentRecorder writes the deposit driver's state transitions.

Each method is the whole of one transition, so a crash between two of them leaves the loan in a state the next tick can resume from rather than a half-applied one.

type Runner added in v1.1.2

type Runner[T any] struct {
	// contains filtered or unexported fields
}

Runner ticks a Fetcher/Driver pair until its context is cancelled.

func NewRunner added in v1.1.2

func NewRunner[T any](deps RunnerDeps[T]) *Runner[T]

NewRunner pairs a fetcher and a driver on one cadence.

func (*Runner[T]) Start added in v1.1.2

func (r *Runner[T]) Start(ctx context.Context)

Start runs the runner until ctx is cancelled. Mirrors RefundPoller.Start.

type RunnerDeps added in v1.1.2

type RunnerDeps[T any] struct {
	// Direction names the runner in its logs, which is what tells two runners
	// in the same process apart.
	Direction string
	Interval  time.Duration
	MaxBatch  int
	Fetcher   Fetcher[T]
	Driver    Driver[T]
	Logger    *slog.Logger
}

RunnerDeps are the collaborators and settings a Runner needs. Logger is optional; everything else is required.

type VaultRepayer added in v1.1.2

type VaultRepayer interface {
	RepayForBorrower(ctx context.Context, loanID, borrowerAddress string, amountStroops int64) (txHash string, err error)
}

VaultRepayer settles the on-chain leg, treasury to vault, attributed to the borrower.

Jump to

Keyboard shortcuts

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