The FX Quote Workflow manages the complete lifecycle of a foreign exchange quote — from rate fetching across multiple providers, through markup application, to time-limited quote creation. The quote remains active for 30 minutes (configurable per corridor). Merchants confirm via signal to lock the rate, then execute the remittance against the locked rate.
package workflows
import (
"time"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// FXQuoteRequest is the input to the FX quote workflow.
type FXQuoteRequest struct {
QuoteID string
MerchantID string
CorridorID string // e.g. GBP_PKR, AED_BDT
SourceCurrency string // ISO 4217
DestCurrency string // ISO 4217
SourceAmount decimal
TraceID string
}
// FXQuoteResult is the final outcome of the quote lifecycle.
type FXQuoteResult struct {
QuoteID string
Status string // EXPIRED, USED, CANCELLED
AppliedRate decimal
DestAmount decimal
RemittanceID string // Set if quote was consumed
CompletedAt time.Time
}
// FXQuoteState tracks the current state for query handlers.
type FXQuoteState struct {
QuoteID string
Status string
MidMarketRate decimal
AppliedRate decimal
MarkupBps int
DestAmount decimal
ExpiresAt time.Time
LockedAt *time.Time
}
// ConfirmQuoteSignal locks the rate for execution.
type ConfirmQuoteSignal struct {
ConfirmedBy string // merchant_user.id
}
// ExecuteRemittanceSignal triggers remittance using the locked rate.
type ExecuteRemittanceSignal struct {
BeneficiaryID string
DeliveryMethod string // MOBILE_WALLET, BANK_DEPOSIT, CASH_PICKUP, AGENT_NETWORK
Purpose string // Purpose of remittance
SenderID string
}
// FXQuoteWorkflow manages an FX quote from creation through expiry or execution.
//
// Workflow ID: fx-quote-{quoteId} (idempotency via quoteId)
// Task Queue: remittance-worker
func FXQuoteWorkflow(ctx workflow.Context, req FXQuoteRequest) (*FXQuoteResult, error) {
logger := workflow.GetLogger(ctx)
// -----------------------------------------------------------------
// Activity options
// -----------------------------------------------------------------
fetchOpts := workflow.ActivityOptions{
StartToCloseTimeout: 15 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
InitialInterval: 1 * time.Second,
BackoffCoefficient: 2.0,
},
}
markupOpts := workflow.ActivityOptions{
StartToCloseTimeout: 5 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
},
}
createOpts := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
},
}
executeOpts := workflow.ActivityOptions{
StartToCloseTimeout: 30 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 3,
BackoffCoefficient: 2.0,
},
}
expireOpts := workflow.ActivityOptions{
StartToCloseTimeout: 10 * time.Second,
RetryPolicy: &temporal.RetryPolicy{
MaximumAttempts: 5,
},
}
// -----------------------------------------------------------------
// Workflow state
// -----------------------------------------------------------------
state := &FXQuoteState{
QuoteID: req.QuoteID,
Status: "FETCHING_RATES",
}
// Register query handler
_ = workflow.SetQueryHandler(ctx, "quote-status", func() (*FXQuoteState, error) {
return state, nil
})
// Signal channels
confirmCh := workflow.GetSignalChannel(ctx, "confirm-quote")
executeCh := workflow.GetSignalChannel(ctx, "execute-remittance")
// -----------------------------------------------------------------
// Step 1: Fetch Rates from Multiple Sources
// -----------------------------------------------------------------
fetchCtx := workflow.WithActivityOptions(ctx, fetchOpts)
var rateResult FetchRatesResult
err := workflow.ExecuteActivity(fetchCtx, FetchRates, FetchRatesInput{
CorridorID: req.CorridorID,
SourceCurrency: req.SourceCurrency,
DestCurrency: req.DestCurrency,
Sources: []string{"reuters", "open_exchange", "central_bank"},
}).Get(ctx, &rateResult)
if err != nil {
return nil, err
}
state.MidMarketRate = rateResult.MidMarketRate
// -----------------------------------------------------------------
// Step 2: Apply Merchant-Specific Markup
// -----------------------------------------------------------------
markupCtx := workflow.WithActivityOptions(ctx, markupOpts)
var markupResult ApplyMarkupResult
err = workflow.ExecuteActivity(markupCtx, ApplyMarkup, ApplyMarkupInput{
MerchantID: req.MerchantID,
CorridorID: req.CorridorID,
MidMarketRate: rateResult.MidMarketRate,
SourceAmount: req.SourceAmount,
}).Get(ctx, &markupResult)
if err != nil {
return nil, err
}
state.AppliedRate = markupResult.AppliedRate
state.MarkupBps = markupResult.MarkupBps
state.DestAmount = markupResult.DestAmount
// -----------------------------------------------------------------
// Step 3: Create Quote Record
// -----------------------------------------------------------------
quoteValidityMins := 30 // Default; overridden by corridor config
expiresAt := workflow.Now(ctx).Add(time.Duration(quoteValidityMins) * time.Minute)
state.ExpiresAt = expiresAt
createCtx := workflow.WithActivityOptions(ctx, createOpts)
err = workflow.ExecuteActivity(createCtx, CreateQuote, CreateQuoteInput{
QuoteID: req.QuoteID,
MerchantID: req.MerchantID,
CorridorID: req.CorridorID,
SourceCurrency: req.SourceCurrency,
DestCurrency: req.DestCurrency,
MidMarketRate: rateResult.MidMarketRate,
AppliedRate: markupResult.AppliedRate,
MarkupBps: markupResult.MarkupBps,
SourceAmount: req.SourceAmount,
DestAmount: markupResult.DestAmount,
RateSource: rateResult.Source,
ExpiresAt: expiresAt,
}).Get(ctx, nil)
if err != nil {
return nil, err
}
state.Status = "ACTIVE"
// -----------------------------------------------------------------
// Step 4: Wait for Signals or Timer Expiry
// -----------------------------------------------------------------
// The quote sits in ACTIVE state until one of:
// (a) ConfirmQuote signal received → locks the rate
// (b) Timer expires → quote expires
// (c) ExecuteRemittance signal (after confirm) → uses locked rate
timerCtx, cancelTimer := workflow.WithCancel(ctx)
timerFuture := workflow.NewTimer(timerCtx, time.Duration(quoteValidityMins)*time.Minute)
confirmed := false
var remittanceID string
for {
selector := workflow.NewSelector(ctx)
// Timer expiry
selector.AddFuture(timerFuture, func(f workflow.Future) {
if !confirmed {
// Quote expired without confirmation
state.Status = "EXPIRED"
}
})
// ConfirmQuote signal
selector.AddReceive(confirmCh, func(ch workflow.ReceiveChannel, more bool) {
var signal ConfirmQuoteSignal
ch.Receive(ctx, &signal)
if state.Status == "ACTIVE" {
confirmed = true
now := workflow.Now(ctx)
state.LockedAt = &now
state.Status = "LOCKED"
logger.Info("Quote confirmed",
"quoteId", req.QuoteID,
"confirmedBy", signal.ConfirmedBy,
"rate", state.AppliedRate,
)
}
})
// ExecuteRemittance signal (only valid after confirmation)
selector.AddReceive(executeCh, func(ch workflow.ReceiveChannel, more bool) {
var signal ExecuteRemittanceSignal
ch.Receive(ctx, &signal)
if state.Status == "LOCKED" {
// Execute the remittance using the locked rate
execCtx := workflow.WithActivityOptions(ctx, executeOpts)
var execResult ExecuteRemittanceResult
err := workflow.ExecuteActivity(execCtx, ExecuteRemittance, ExecuteRemittanceInput{
QuoteID: req.QuoteID,
MerchantID: req.MerchantID,
CorridorID: req.CorridorID,
SourceAmount: req.SourceAmount,
SourceCurrency: req.SourceCurrency,
DestAmount: state.DestAmount,
DestCurrency: req.DestCurrency,
AppliedRate: state.AppliedRate,
BeneficiaryID: signal.BeneficiaryID,
DeliveryMethod: signal.DeliveryMethod,
Purpose: signal.Purpose,
SenderID: signal.SenderID,
TraceID: req.TraceID,
}).Get(ctx, &execResult)
if err == nil {
remittanceID = execResult.RemittanceID
state.Status = "USED"
cancelTimer() // No need for expiry timer
}
}
})
selector.Select(ctx)
// Break on terminal states
if state.Status == "EXPIRED" || state.Status == "USED" {
break
}
}
// -----------------------------------------------------------------
// Handle Expiry
// -----------------------------------------------------------------
if state.Status == "EXPIRED" {
expireCtx := workflow.WithActivityOptions(ctx, expireOpts)
_ = workflow.ExecuteActivity(expireCtx, ExpireQuote, ExpireQuoteInput{
QuoteID: req.QuoteID,
MerchantID: req.MerchantID,
}).Get(ctx, nil)
}
return &FXQuoteResult{
QuoteID: req.QuoteID,
Status: state.Status,
AppliedRate: state.AppliedRate,
DestAmount: state.DestAmount,
RemittanceID: remittanceID,
CompletedAt: workflow.Now(ctx),
}, nil
}