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 DisbursementUpdater
- 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 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 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
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 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.
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.