Skip to content

FX Quote Lifecycle Workflow

Temporal Workflow Definition Service: remittance-svc Markets: PK, BD, NP, IQ, EG Scale: 270M+ transactions, $1B+ processed


Overview

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.

Idempotency is ensured by using the quoteId as the Temporal Workflow ID.


Workflow Definition

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
}

Activities Summary

Activity Description Idempotent
FetchRates Fetches mid-market rates from multiple sources (Reuters, Open Exchange, central banks) Yes
ApplyMarkup Applies corridor and merchant-specific markup in basis points Yes
CreateQuote Persists quote record to SurrealDB with ACTIVE status Yes (quoteId)
ExecuteRemittance Creates remittance using locked quote rate Yes (quoteId as idempotency)
ExpireQuote Marks quote as EXPIRED in SurrealDB Yes

Signals

Signal Payload Valid In State Effect
confirm-quote ConfirmQuoteSignal{ConfirmedBy} ACTIVE Locks rate, transitions to LOCKED
execute-remittance ExecuteRemittanceSignal{BeneficiaryID, DeliveryMethod, Purpose, SenderID} LOCKED Creates remittance, transitions to USED

Query Handlers

Query Response Purpose
quote-status FXQuoteState{QuoteID, Status, Rates, ExpiresAt, LockedAt} Check quote state from merchant API

Timer Behaviour

Event Duration Effect
Quote validity 30 minutes (configurable per corridor) If not confirmed, transitions to EXPIRED
Rate locked but not executed Timer continues from original creation Locked quotes still expire if not executed

Lifecycle State Machine

FETCHING_RATES → ACTIVE → LOCKED → USED
                   ↓         ↓
                EXPIRED   EXPIRED

Failure Modes

Failure Behaviour
Rate provider unavailable Retried 3 times; fails workflow if all providers down
Markup calculation fails Workflow fails, no quote created
Confirm signal after expiry Ignored (state check prevents transition)
Execute signal without confirm Ignored (state check prevents transition)
Remittance execution fails Quote remains LOCKED; can be retried via signal