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 ¶
- type AlertService
- type DepositDriver
- type DepositDriverDeps
- type Driver
- type FetchFunc
- type Fetcher
- type LoanFetcher
- type LoanRecord
- type LoanRecorder
- type PaymentVerifier
- type Poller
- type PollerConfig
- type PollerDeps
- type RefundRecord
- type RepaymentFetcher
- type RepaymentNotifier
- type RepaymentRecord
- type RepaymentRecorder
- type Runner
- type RunnerDeps
- type VaultRepayer
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type AlertService ¶
AlertService receives ops alerts when something needs human attention. Optional; see alerts.Raise.
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 FetchFunc ¶ added in v1.1.2
FetchFunc adapts an ordinary function to Fetcher, so an existing repository method can be handed to a Runner without a wrapper type.
type Fetcher ¶ added in v1.1.2
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
LoanReference 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.
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 contracts.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(ctx context.Context, loanID, reference string) error
// NotifyRepaymentMoreInfo carries MoneyGram's transaction page instead,
// for the case where no reference has been issued. See sendPayInstructions.
NotifyRepaymentMoreInfo(ctx context.Context, loanID string) error
NotifyRepaymentReceived(ctx context.Context, loanID string) error
NotifyRepaymentReminder(ctx context.Context, loanID string) error
NotifyRepaymentExpired(ctx context.Context, loanID string) error
// NotifyLoanRepaid confirms the treasury-to-vault leg confirmed and the
// loan is closed — sent once, right after MarkSettled records it.
NotifyLoanRepaid(ctx context.Context, 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
LoanReference 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
// MarkVaultOutcomeUnknown parks a repayment whose repay_for may have
// landed, taking it out of the due set until ops verify it on-chain.
MarkVaultOutcomeUnknown(ctx context.Context, loanID string) 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.
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. It also doubles as the advisory-lock key // when DB is set, so it must stay unique across every runner sharing that // database — which every runner in this package already needs regardless. Direction string Interval time.Duration MaxBatch int Fetcher Fetcher[T] Driver Driver[T] Logger *slog.Logger // DB gates each tick on a Postgres advisory lock keyed by Direction, so a // second replica of the same runner skips its work instead of racing the // first. Nil (the default) runs unguarded, which is correct today — see // poll's doc comment — and is what every existing runner construction // site keeps doing unless it opts in. DB *sql.DB // LoanID names the loan a record belongs to. When set, each record is // driven under its own root span and a context carrying loan_id, so its // trace and log lines are findable by loan. Optional. LoanID func(T) string // LoanReference names the loan's human-facing reference, attached beside // loan_id so ops alerts can show it. Optional. LoanReference func(T) string }
RunnerDeps are the collaborators and settings a Runner needs. Logger and DB are optional; everything else is required.