payment

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 18, 2026 License: Apache-2.0 Imports: 17 Imported by: 0

README

Payment Package

The payment package provides a modular and extensible payment orchestration system. It follows a gateway-based architecture, separating protocol-level concerns from domain logic, and is designed to be imported and integrated into other repositories.

Architecture Overview

The system consists of several key components:

  1. PaymentGateway: An interface for gateway-specific integrations (e.g., LankaPay, GovPay). It handles session creation, webhook parsing, and real-time validation formatting.
  2. GatewayRegistry: A pure discovery and lookup service. It manages gateway registration, configuration injection, and provides sanitized metadata for the UI.
  3. PaymentRepository: Handles persistence for PaymentTransaction records using GORM.
  4. PaymentService: The high-level orchestrator. It uses the Registry to find the correct Gateway and coordinates between the gateway logic, database, and internal events.
  5. HTTPHandler: Exposes the payment service via RESTful endpoints for both public and internal use.

Integration

This package is designed to be imported into other repositories. To use it:

import "github.com/OpenNSW/core/payment"

Getting Started

1. Implement a PaymentGateway

Each payment gateway requires a dedicated implementation of the PaymentGateway interface.

type MyGateway struct {}

// NewMyGateway is this gateway's Factory, called once by the registry at
// init time with its raw config from payment_methods.json.
func NewMyGateway(config json.RawMessage) (payment.PaymentGateway, error) {
    // Unmarshal gateway-specific settings from JSON.
    return &MyGateway{}, nil
}

func (g *MyGateway) GetFlowType() payment.InteractionType {
    return payment.FlowTypeRedirect
}

// ValidateMetadata declares what this gateway cannot operate without. It
// runs before a reference number is generated and before anything is
// persisted, so a caller that omitted a required key fails immediately
// rather than at callback time, when the configuration responsible is out
// of reach. Presence checks on the request only — no I/O, no outside state.
// A gateway with no requirements returns nil.
func (g *MyGateway) ValidateMetadata(metadata map[string]string) error {
    if strings.TrimSpace(metadata["my_merchant_id"]) == "" {
        return errors.New("my_merchant_id is required")
    }
    return nil
}

func (g *MyGateway) CreateSession(ctx context.Context, req payment.SessionRequest) (*payment.SessionResponse, error) {
    // Logic to initialize session with gateway. req.Metadata carries the
    // checkout's pass-through metadata, already past ValidateMetadata.
    return &payment.SessionResponse{...}, nil
}

// VerifyWebhook authenticates the caller — using whatever scheme this
// gateway requires (HMAC signature, bearer token, IP allowlist, a
// server-side status check, etc. — not every scheme needs to be
// cryptographic) — before ExtractReferenceNumber or ParseWebhook ever runs.
// There is no default: every gateway must implement a real check here, or
// no transaction can ever be settled through it.
func (g *MyGateway) VerifyWebhook(ctx context.Context, body []byte, headers map[string][]string) error {
    token := http.Header(headers).Get("Authorization")
    valid, err := isValidBearerToken(ctx, token)
    if err != nil {
        // An operational failure to complete the check (e.g. a timeout
        // reaching an upstream token-introspection endpoint) — NOT proof
        // the caller is invalid. Return unwrapped so it's treated as
        // transient — see "Verification error classification" below.
        return fmt.Errorf("checking bearer token: %w", err)
    }
    if !valid {
        // The check ran and determined the caller is invalid — this is
        // what maps to 401.
        return payment.NewWebhookVerificationError("invalid or missing bearer token")
    }
    return nil
}

func (g *MyGateway) ExtractReferenceNumber(ctx context.Context, reqData json.RawMessage) (string, error) {
    // Parse gateway-specific validation request to find the reference
    return "REF-123", nil
}

func (g *MyGateway) HandleValidateReference(ctx context.Context, tx *payment.ValidationTransaction, isPayable bool, reqData json.RawMessage) (*payment.ValidationResponse, error) {
    // Format the final response for the gateway
    return &payment.ValidationResponse{...}, nil
}

func (g *MyGateway) ParseWebhook(ctx context.Context, body []byte, headers map[string][]string) (*payment.WebhookPayload, *payment.WebhookResponse, error) {
    // Logic to parse the gateway webhook into a domain-neutral payload,
    // plus the gateway-specific acknowledgement to relay back.
    return &payment.WebhookPayload{...}, &payment.WebhookResponse{...}, nil
}
2. Configure Payment Methods

The payment_methods.json file is the source of truth for available methods.

{
  "version": "1.0",
  "methods": [
    {
      "id": "lankapay",
      "is_active": true,
      "render_info": {
        "display_name": "Credit/Debit Card (LankaPay)",
        "description": "Pay securely using your card.",
        "display_order": 1
      },
      "config": {
        "base_url": "https://sandbox.govpay.lk"
      }
    }
  ]
}
3. Instantiate the Registry

The GatewayRegistry loads the configuration and maps each method ID to its implementation.

factories := map[string]payment.Factory{
    "lankapay": lankapay.NewGateway,
    "govpay":   govpay.NewGateway,
}

registry, err := payment.NewRegistry("configs/payment_methods.json", factories)
4. Setup the Orchestrator

The PaymentService acts as the orchestrator using the Registry as a lookup.

repo := payment.NewPaymentRepository(db)
service := payment.NewPaymentService(repo, registry)

handler := payment.NewHTTPHandler(service)

Key Flows

Checkout Initialization

The frontend calls CreateCheckoutSession. The Service generates an NSW reference, looks up the gateway implementation via the Registry, and delegates the session creation to that gateway.

Real-Time Validation

When a user enters a reference in a bank app, the gateway calls NSW.

  1. The Service looks up the Gateway via the Registry, then calls VerifyWebhook to authenticate the caller. A failure here stops the flow immediately — no reference lookup happens, and no presentment info is disclosed.
  2. The Service uses the Gateway to Extract the reference number.
  3. The Service fetches the transaction from the Database.
  4. The Service passes the record back to the Gateway to Validate and format the protocol-specific response.
Webhook Processing

Gateways notify the payment service of results. The Service looks up the gateway via the Registry, calls VerifyWebhook to authenticate the caller (again, a failure stops the flow before any parsing or settlement), delegates the parsing, and then performs domain actions: updating status, persisting metadata, and firing internal events.

Verification error classification

VerifyWebhook implementations must distinguish two different kinds of failure:

  • The caller is not genuinely this gateway (an invalid or expired token, a signature that doesn't match): wrap payment.ErrWebhookVerificationFailed (via %w) when returning the error. HTTPHandler maps this to 401 Unauthorized.
  • Verification could not be completed for an operational reason (a timeout reaching an upstream introspection/JWKS endpoint, a missing local configuration, a cancelled context): return any other error, unwrapped. This is NOT proof the caller is invalid, and is treated like any other unclassified error — a transient 500, so the gateway's own retry can re-drive it.

Conflating the two means an operational blip on your own side (not an attack) can permanently drop a legitimate webhook, since most providers treat a 401 as "credentials are bad, stop retrying" rather than something to retry.

Extending verification beyond body and headers

VerifyWebhook(ctx, body, headers) was deliberately kept to plain params rather than *http.Request, to keep gateways transport-agnostic and unit-testable without httptest. This covers header-based schemes (an OAuth2 bearer token) and body-based schemes (an HMAC signature) directly — but some schemes need more: a signature computed over the HTTP method and request path, a check against query parameters, an IP allowlist against the remote address, or an mTLS client-certificate check against the TLS connection state.

Rather than changing VerifyWebhook's signature every time a scheme needs a different dimension of the request — which would mean a coordinated breaking change across every implementer, every time — that data is available via context: HTTPHandler attaches the full inbound *http.Request to the context (via payment.ContextWithRequest) before ever calling into PaymentService, on every request, so a gateway's VerifyWebhook can retrieve it with payment.RequestFromContext(ctx) and read whatever dimension its own scheme requires, without core/payment ever having to anticipate which one that is:

func (g *MyGateway) VerifyWebhook(ctx context.Context, body []byte, headers map[string][]string) error {
    req := payment.RequestFromContext(ctx)
    if req == nil {
        // Not available — e.g. PaymentService was invoked directly,
        // bypassing HTTPHandler (a unit test, say). Fall back to whatever
        // the explicit body/headers params allow, or fail closed if this
        // scheme can't verify without the request.
        return errors.New("request context unavailable: cannot verify without the inbound request")
    }

    // Pick whatever dimension(s) this scheme actually needs:
    query := req.URL.Query()                 // signature/timestamp in query params
    method, path := req.Method, req.URL.Path // method+path-bound signatures
    tlsState := req.TLS                      // mTLS client-certificate checks
    remoteAddr := req.RemoteAddr             // source-IP allowlisting

    valid, err := isValid(ctx, query, method, path, tlsState, remoteAddr)
    if err != nil {
        // An operational failure to complete the check — NOT proof the
        // caller is invalid. Return unwrapped so it's treated as transient.
        return fmt.Errorf("verifying request: %w", err)
    }
    if !valid {
        return payment.NewWebhookVerificationError("invalid signature")
    }
    return nil
}

req.Body has already been drained by HTTPHandler by the time VerifyWebhook runs (to produce the body parameter above) — use the explicit body parameter for the payload, not req.Body. Treat a nil return from RequestFromContext as "not available" rather than a zero-value request, and never mutate the returned request — HTTPHandler is still using it to serve the response.

If your scheme needs TLS state or the real client IP specifically: confirm with your infrastructure team where TLS actually terminates first. If a WAF/LB/ingress terminates TLS ahead of this process, req.TLS here is nil regardless of any code change — real mTLS requires the edge to verify the client certificate and forward the result via a trusted header, safe to trust only if network policy guarantees the edge is the sole path in. The same caveat applies to source-IP allowlisting via X-Forwarded-For vs. req.RemoteAddr. This is a deployment-topology decision, not something this package can resolve on its own.

Exported Types and Functions

Core Interfaces
  • PaymentGateway: Interface for gateway implementations
  • PaymentService: Main orchestrator service
  • PaymentRepository: Database persistence layer
Data Types
  • SessionRequest: Checkout session initialization
  • SessionResponse: Session response with checkout details
  • WebhookPayload: Incoming webhook data
  • ValidationTransaction: Transaction details for validation
  • ValidationResponse: Validation response format
  • InteractionType: Enum for flow types (REDIRECT, INSTRUCTION)
  • WebhookStatus: Canonical webhook status (PENDING, SUCCESS, FAILED)
Constructor Functions
  • NewRegistry(configPath string, factories map[string]Factory): Create a gateway registry
  • NewPaymentService(repo PaymentRepository, registry GatewayRegistry): Create payment service
  • NewPaymentRepository(db *gorm.DB): Create payment repository
  • NewHTTPHandler(service PaymentService): Create HTTP handler
Context Helpers
  • ContextWithRequest(ctx context.Context, r *http.Request) context.Context: Attach the inbound request to a context — called by HTTPHandler
  • RequestFromContext(ctx context.Context) *http.Request: Retrieve it — see "Extending verification beyond body and headers" above
Error Types
  • ErrUnsupportedWebhookStatus: Gateway status cannot be normalized
  • ErrTransactionNotFound: Payment transaction not found
  • ErrAmountMismatch: Payment amount or currency mismatch
  • ErrWebhookVerificationFailed: Caller could not be verified — see "Verification error classification" above for when a gateway should (and should not) use this
  • NewWebhookVerificationError(reason string) error: Optional helper that builds a correctly-wrapped ErrWebhookVerificationFailed rejection

Integration Example

In your consuming repository:

package main

import (
    "github.com/OpenNSW/core/payment"
)

func setupPayments(db *gorm.DB) *payment.HTTPHandler {
    // Wire your gateway Factories
    factories := map[string]payment.Factory{
        "your-gateway": yourgateway.NewGateway,
    }
    
    // Initialize registry
    registry, err := payment.NewRegistry("path/to/config.json", factories)
    if err != nil {
        panic(err)
    }
    
    // Setup service
    repo := payment.NewPaymentRepository(db)
    service := payment.NewPaymentService(repo, registry)
    
    // Return handler for HTTP endpoints
    return payment.NewHTTPHandler(service)
}

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrAmountMismatch = errors.New("webhook amount/currency mismatch")

ErrAmountMismatch indicates a successful-payment webhook reported an amount or currency that doesn't match the recorded transaction. Permanent and suspicious, so it is never marked paid and the gateway should not retry.

View Source
var ErrTransactionNotFound = errors.New("payment transaction not found")

ErrTransactionNotFound indicates no payment transaction matches a given reference. This is a permanent condition, so callers (e.g. the webhook handler) should not ask the gateway to retry.

View Source
var ErrUnsupportedWebhookStatus = errors.New("unsupported webhook status")

ErrUnsupportedWebhookStatus indicates a gateway status that could not be normalized into a WebhookStatus. It is a permanent condition (retrying the same payload won't help), so callers should not signal the gateway to retry.

View Source
var ErrWebhookVerificationFailed = errors.New("webhook verification failed")

ErrWebhookVerificationFailed indicates a caller — either a real-time validation request or an asynchronous webhook notification — could not be verified as genuinely originating from the gateway it claims to be. No transaction may be settled, and no presentment information may be disclosed, on the strength of an unverified caller, so this must be checked (and satisfied) before any gateway-specific parsing of the request runs.

Functions

func ContextWithRequest added in v0.2.0

func ContextWithRequest(ctx context.Context, r *http.Request) context.Context

ContextWithRequest returns a new context with the given inbound HTTP request attached. HTTPHandler calls this — for every request, on both HandleValidateReference and HandleWebhook — before invoking PaymentService, so that by the time a gateway's VerifyWebhook runs, anything about the request beyond the explicit body and headers parameters (method, URL, query parameters, TLS connection state, remote address, cookies, trailers, or any other field of *http.Request) is reachable via RequestFromContext. core/payment never has to anticipate which specific dimension a given verification scheme needs; a gateway pulls out whatever it requires.

By the time this is called, HTTPHandler has already drained r.Body via io.ReadAll to produce the explicit body []byte parameter passed to VerifyWebhook/ParseWebhook/etc. — gateways must use that parameter for the payload; r.Body here will read as empty, not the original payload. This mechanism is for everything else about the request, not for re-reading the body.

r itself is stored as given — its own r.Context() at this point is whatever it was before this call (e.g. net/http's server-assigned context), not the context this function returns. RequestFromContext reconciles that on retrieval: see its doc comment.

func NewWebhookVerificationError added in v0.2.0

func NewWebhookVerificationError(reason string) error

NewWebhookVerificationError builds the error a VerifyWebhook implementation should return once it has positively determined the caller is NOT genuinely this gateway (see VerifyWebhook's error-classification contract below). It wraps ErrWebhookVerificationFailed via %w, so errors.Is(err, ErrWebhookVerificationFailed) succeeds and HTTPHandler maps the response to 401.

Using this instead of hand-rolling fmt.Errorf("%s: %w", reason, ErrWebhookVerificationFailed) is entirely optional — any error that already wraps ErrWebhookVerificationFailed some other way is equally valid — but it gives gateway authors an easy, hard-to-get-wrong default, reducing the risk of forgetting the %w and misclassifying a genuine rejection as an operational failure.

func RequestFromContext added in v0.2.0

func RequestFromContext(ctx context.Context) *http.Request

RequestFromContext extracts the inbound *http.Request previously attached via ContextWithRequest, or nil if none was ever attached — e.g. when PaymentService is invoked directly, bypassing HTTPHandler (as most unit tests do). Callers (typically a gateway's VerifyWebhook) must treat a nil return as "not available" and fall back to whatever the explicit body/headers parameters allow, rather than treating nil as a zero-value request. The returned *http.Request must not be mutated: HTTPHandler is still using the underlying request to serve the response after PaymentService returns, and other callers may hold their own copy.

The returned request is rewrapped via r.WithContext(ctx) using the exact ctx this function is called with, so its own .Context() is that same ctx — not just whatever context ContextWithRequest happened to return earlier. This stays consistent even if ctx is wrapped further after ContextWithRequest ran, since context.Value lookups delegate to parent contexts regardless of how many layers were added afterward. One consequence: repeated calls to RequestFromContext, even with the same ctx, return distinct *http.Request instances (shallow copies sharing the same underlying Header/URL/Body/etc.), not the exact same pointer every time.

Types

type CreateCheckoutRequest

type CreateCheckoutRequest struct {
	GatewayID          string            `json:"gateway_id"`
	Amount             decimal.Decimal   `json:"amount"`
	Currency           string            `json:"currency"`
	SuccessRedirectURL string            `json:"success_redirect_url,omitempty"` // Optional: User redirect on success
	CancelRedirectURL  string            `json:"cancel_redirect_url,omitempty"`  // Optional: User redirect on cancel
	ExpiresAt          time.Time         `json:"expires_at"`                     // Aligned with Task TTL
	Metadata           map[string]string `json:"metadata"`                       // Pass-through data (e.g., TaskID)
}

CreateCheckoutRequest is the payload sent to initialize a session.

type CreateCheckoutResponse

type CreateCheckoutResponse struct {
	ReferenceNumber string          `json:"reference_number"` // The generated NSW reference
	SessionID       string          `json:"session_id"`
	Type            InteractionType `json:"type"`
	CheckoutURL     string          `json:"checkout_url,omitempty"` // The hosted URL to redirect the user to
	Instructions    string          `json:"instructions,omitempty"`
	ExpiresIn       int             `json:"expires_in_seconds"`
}

CreateCheckoutResponse is the expected reply from LankaPay.

type EventData

type EventData struct {
	TaskID               string          `json:"task_id"`
	ReferenceNumber      string          `json:"reference_number"`
	GatewayTransactionID string          `json:"gateway_transaction_id"`
	Status               PaymentStatus   `json:"status"`
	AmountPaid           decimal.Decimal `json:"amount_paid"`
	Currency             string          `json:"currency"`
	ConfirmedAt          string          `json:"confirmed_at"`
}

type Factory

type Factory func(config json.RawMessage) (PaymentGateway, error)

Factory constructs a configured, ready-to-use gateway from its raw config. One factory per gateway type; the registry calls it once at init so gateways are immutable after construction (no post-init config mutation).

type GatewayInfo

type GatewayInfo struct {
	ID         string          `json:"id"`
	IsActive   bool            `json:"is_active"`
	RenderInfo RenderInfo      `json:"render_info"`
	Config     json.RawMessage `json:"config,omitempty"`
}

GatewayInfo is the aggregate DTO used for gateway discovery.

type GatewayRegistry

type GatewayRegistry interface {
	// Get retrieves a gateway implementation by its ID.
	Get(id string) (PaymentGateway, error)

	// ListInfo returns the aggregated metadata for all supported gateways.
	ListInfo() []GatewayInfo
}

GatewayRegistry manages the discovery and lookup of payment gateways.

func NewRegistry

func NewRegistry(configPath string, factories map[string]Factory) (GatewayRegistry, error)

NewRegistry initializes a new registry by loading configuration from a file. For each configured gateway it invokes the matching factory to construct a fully configured implementation, so gateways are immutable after init.

type HTTPHandler

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

HTTPHandler handles public HTTP requests for the Payment Service.

func NewHTTPHandler

func NewHTTPHandler(service PaymentService) *HTTPHandler

NewHTTPHandler creates a new handler.

func (*HTTPHandler) HandleValidateReference

func (h *HTTPHandler) HandleValidateReference(w http.ResponseWriter, r *http.Request)

HandleValidateReference handles POST /api/v1/payments/:gatewayId/validate Called by gateways to query if a reference number is valid and payable.

func (*HTTPHandler) HandleWebhook

func (h *HTTPHandler) HandleWebhook(w http.ResponseWriter, r *http.Request)

HandleWebhook handles POST /api/v1/payments/:gatewayID/webhook Called by payment gateways to notify about payment successes and failures.

type InteractionType

type InteractionType string
const (
	FlowTypeRedirect    InteractionType = "REDIRECT"
	FlowTypeInstruction InteractionType = "INSTRUCTION"
)

type InternalPaymentEvent

type InternalPaymentEvent struct {
	EventType string    `json:"event_type"`
	Data      EventData `json:"data"`
}

InternalPaymentEvent represents the internal event the Payment Service fires for the Task Engine.

type PaymentGateway

type PaymentGateway interface {
	// GetFlowType returns the flow type of the gateway (REDIRECT or INSTRUCTION).
	GetFlowType() InteractionType

	// ValidateMetadata checks that a checkout request's pass-through metadata
	// carries whatever this gateway cannot operate without.
	//
	// It is called once per checkout, before a reference number is generated
	// and before anything is persisted, so a caller that omitted a required
	// key fails immediately — while the configuration responsible is still
	// nameable in the error — instead of surfacing much later as an opaque
	// callback failure against a transaction nobody can trace back.
	//
	// This is a presence/well-formedness check on the request only; it must
	// not perform I/O and must not depend on any state outside metadata.
	// Deciding whether the declared values are the *correct* ones for a given
	// transaction belongs on the callback paths, not here.
	//
	// A gateway with no metadata requirements returns nil. The method is
	// required rather than optional so that every gateway author has to make
	// that choice deliberately instead of silently forgetting to opt in.
	ValidateMetadata(metadata map[string]string) error

	// CreateSession initializes a payment session with the gateway.
	CreateSession(ctx context.Context, req SessionRequest) (*SessionResponse, error)

	// VerifyWebhook authenticates an inbound request — a real-time
	// validation request or an asynchronous webhook notification — as
	// genuinely originating from this gateway, using whatever scheme the
	// gateway requires (e.g. an HMAC signature over body, a bearer token
	// extracted from headers, an IP allowlist, or a server-side status
	// check against the gateway's own API — not every scheme needs to be
	// cryptographic). It is called before ExtractReferenceNumber and
	// ParseWebhook, and a non-nil return blocks both: no reference lookup, no
	// settlement, and no presentment info may reach an unverified caller.
	// There is no default/no-op — every implementation must perform a real
	// check.
	//
	// Error contract: return ErrWebhookVerificationFailed (wrapped via %w)
	// only when verification has positively determined the caller is NOT
	// genuinely this gateway (e.g. an invalid signature, an expired or
	// unrecognized token). HTTPHandler maps that sentinel to 401. Return any
	// other error for a failure to complete verification for an operational
	// reason (a timeout reaching an upstream introspection/JWKS endpoint, a
	// missing local configuration, a cancelled context) — those are NOT proof
	// the caller is invalid and must not use this sentinel; they are treated
	// as transient (mapped to 500, so the gateway's retry can re-drive it),
	// exactly like an unclassified error from any of this interface's other
	// methods. Forgetting to wrap the sentinel for a genuine rejection has a
	// concrete cost: HTTPHandler responds 500 instead of 401, so the caller
	// burns its retry budget retrying a request that can never succeed, and
	// monitoring records it as a transient/internal failure rather than an
	// auth rejection. See NewWebhookVerificationError for a helper that
	// builds a correctly-wrapped rejection.
	//
	// For a scheme needing anything beyond body/headers — e.g. query
	// parameters, HTTP method, request path, TLS connection state, or remote
	// address — see RequestFromContext, which HTTPHandler populates with the
	// full inbound *http.Request before this is invoked.
	VerifyWebhook(ctx context.Context, body []byte, headers map[string][]string) error

	// ExtractReferenceNumber parses the gateway-specific validation request to extract the reference number.
	ExtractReferenceNumber(ctx context.Context, reqData json.RawMessage) (string, error)

	// HandleValidateReference formats the gateway-specific validation response.
	// tx is nil when no matching transaction exists (unknown reference or a
	// mismatched gateway); isPayable is the domain decision (exists, owned by
	// this gateway, pending, and not expired) the gateway should reflect back.
	HandleValidateReference(ctx context.Context, tx *ValidationTransaction, isPayable bool, reqData json.RawMessage) (*ValidationResponse, error)

	// ParseWebhook processes raw gateway notifications into a domain-neutral
	// payload (for the service to act on) together with the gateway-specific
	// acknowledgement to relay back once the notification has been accepted.
	ParseWebhook(ctx context.Context, body []byte, headers map[string][]string) (*WebhookPayload, *WebhookResponse, error)
}

PaymentGateway defines the interface for external payment gateway integration.

type PaymentRepository

type PaymentRepository interface {
	Create(ctx context.Context, tx *PaymentTransaction) error
	GetByReferenceNumber(ctx context.Context, referenceNumber string) (*PaymentTransaction, error)
	// GetByReferenceNumberForUpdate reads a transaction while holding a row-level
	// write lock (SELECT ... FOR UPDATE). Must be called inside RunInTransaction.
	GetByReferenceNumberForUpdate(ctx context.Context, referenceNumber string) (*PaymentTransaction, error)
	GetByTaskID(ctx context.Context, taskID string) (*PaymentTransaction, error)
	Update(ctx context.Context, tx *PaymentTransaction) error
	UpdateStatus(ctx context.Context, referenceNumber string, status PaymentStatus) error
	// RunInTransaction runs fn inside a DB transaction, passing a repository bound
	// to that transaction. The transaction commits when fn returns nil and rolls
	// back on error.
	RunInTransaction(ctx context.Context, fn func(repo PaymentRepository) error) error
	WithTx(tx *gorm.DB) PaymentRepository
}

PaymentRepository defines the interface for managing PaymentTransactions.

func NewPaymentRepository

func NewPaymentRepository(db *gorm.DB) PaymentRepository

NewPaymentRepository creates a new instance of PaymentRepository.

type PaymentService

type PaymentService interface {
	// ListAvailableMethods returns the rendering information for all active payment gateways.
	ListAvailableMethods(ctx context.Context) ([]GatewayInfo, error)

	// CreateCheckoutSession initializes a payment session and generates a ReferenceNumber.
	CreateCheckoutSession(ctx context.Context, req CreateCheckoutRequest) (*CreateCheckoutResponse, error)

	// ValidateReference is used for real-time validation requests from gateways.
	ValidateReference(ctx context.Context, gatewayID string, rawBody json.RawMessage, headers map[string][]string) (*ValidationResponse, error)

	// ProcessWebhook handles asynchronous notifications from payment gateways and
	// returns the gateway-specific acknowledgement to relay back to the gateway.
	ProcessWebhook(ctx context.Context, gatewayID string, body []byte, headers map[string][]string) (*WebhookResponse, error)

	// SetTaskCompleter injects the dependency used to advance the workflow when
	// a payment settles. Wired post-construction to avoid an import cycle with taskv2.
	SetTaskCompleter(completer TaskCompleter)
}

PaymentService defines the high-level orchestration for payments.

func NewPaymentService

func NewPaymentService(repo PaymentRepository, registry GatewayRegistry) PaymentService

NewPaymentService initializes a new payment service.

type PaymentStatus

type PaymentStatus string
const (
	PaymentStatusPending PaymentStatus = "PENDING"
	PaymentStatusSuccess PaymentStatus = "SUCCESS"
	PaymentStatusFailed  PaymentStatus = "FAILED"
)

type PaymentTransaction

type PaymentTransaction struct {
	ID              string            `json:"id" gorm:"type:text;not null;primaryKey"`
	ReferenceNumber string            `json:"reference_number" gorm:"uniqueIndex"` // Generated by Payment Service
	TaskID          string            `json:"task_id" gorm:"index"`                // Links back to the FSM Task Node
	GatewayID       string            `json:"gateway_id" gorm:"index"`             // e.g., "lankapay"
	SessionID       string            `json:"session_id"`                          // Gateway-specific session identifier
	Amount          decimal.Decimal   `json:"amount"`
	Currency        string            `json:"currency"`       // "LKR" or foreign currency
	Status          PaymentStatus     `json:"status"`         // PENDING, SUCCESS, FAILED, EXPIRED
	PaymentMethod   string            `json:"payment_method"` // CC, BANK_TRANSFER (populated on webhook)
	ExpiryDate      time.Time         `json:"expiry_date"`
	GatewayMetadata map[string]string `json:"gateway_metadata" gorm:"serializer:json"`
	CreatedAt       time.Time         `json:"created_at"`
	UpdatedAt       time.Time         `json:"updated_at"`
}

PaymentTransaction represents the internal state of a payment

type RenderInfo

type RenderInfo struct {
	DisplayName  string `json:"display_name"`
	Description  string `json:"description"`
	LogoURL      string `json:"logo_url"`
	DisplayOrder int    `json:"display_order"`
	PrimaryColor string `json:"primary_color,omitempty"`
}

RenderInfo contains UI-specific metadata for displaying a payment method.

type SessionRequest

type SessionRequest struct {
	Amount             decimal.Decimal `json:"amount"`
	Currency           string          `json:"currency"`
	SuccessRedirectURL string          `json:"success_redirect_url"`
	CancelRedirectURL  string          `json:"cancel_redirect_url"`

	// Metadata is the checkout request's pass-through metadata, forwarded
	// verbatim so a gateway can read whatever it declared as required in
	// ValidateMetadata. It is the same map persisted on the transaction as
	// GatewayMetadata, so anything readable here is recoverable later by
	// reference number.
	Metadata map[string]string `json:"metadata,omitempty"`
}

type SessionResponse

type SessionResponse struct {
	SessionID    string          `json:"session_id"`
	Type         InteractionType `json:"type"`
	CheckoutURL  string          `json:"checkout_url,omitempty"`
	Instructions string          `json:"instructions,omitempty"`
}

type TaskCompleter

type TaskCompleter interface {
	CompleteTaskStep(ctx context.Context, taskID string, payload map[string]any) error
}

TaskCompleter resumes a suspended workflow step once a payment reaches a terminal outcome. It is satisfied by the taskv2 TaskManager.

type ValidateReferenceRequest

type ValidateReferenceRequest struct {
	PaymentReference string `json:"paymentReference"` // Maps to our ReferenceNumber
	ServiceType      string `json:"serviceType"`      // e.g., NSW_IMPORT_PERMIT_CD
}

ValidateReferenceRequest is the payload GovPay sends when a user enters a reference in their bank app.

type ValidateReferenceResponse

type ValidateReferenceResponse struct {
	Amount     decimal.Decimal `json:"amount"`
	Currency   string          `json:"currency"`
	TraderName string          `json:"traderName"`
	OGAName    string          `json:"ogaName"`
	ExpiryDate string          `json:"expiryDate"` // ISO8601 format string
	IsPayable  bool            `json:"isPayable"`  // false if already paid or expired
	Remarks    string          `json:"remarks,omitempty"`
}

ValidateReferenceResponse is the payload we return to GovPay to auto-populate the user's screen.

type ValidationResponse

type ValidationResponse struct {
	Payload    json.RawMessage
	HTTPStatus int
}

ValidationResponse represents a structured response for a validation request.

type ValidationTransaction

type ValidationTransaction struct {
	ReferenceNumber string            `json:"reference_number"`
	Amount          decimal.Decimal   `json:"amount"`
	Currency        string            `json:"currency"`
	Status          string            `json:"status"`
	ExpiryDate      time.Time         `json:"expiry_date"`
	Metadata        map[string]string `json:"metadata"`
}

ValidationTransaction represents a minimal view of a payment transaction for validation purposes.

type WebhookPayload

type WebhookPayload struct {
	ReferenceNumber      string            `json:"reference_number"`
	SessionID            string            `json:"session_id"`
	GatewayTransactionID string            `json:"gateway_transaction_id"`
	Status               WebhookStatus     `json:"status"`
	Amount               decimal.Decimal   `json:"amount"`
	Currency             string            `json:"currency"`
	PaymentMethod        string            `json:"payment_method"`
	Timestamp            string            `json:"timestamp"`
	Metadata             map[string]string `json:"metadata"`
}

WebhookPayload represents the external callback from LankaPay to the Payment Service.

type WebhookResponse

type WebhookResponse struct {
	Payload    json.RawMessage
	HTTPStatus int
}

WebhookResponse is the gateway-specific acknowledgement returned to the gateway after a webhook (payment-completion) notification has been processed. For GovPay+ this carries the UpdateResponse (paymentData receipt).

type WebhookStatus

type WebhookStatus string

WebhookStatus is the canonical, gateway-neutral outcome a gateway must normalize its own status vocabulary into when parsing a webhook.

const (
	WebhookStatusPending WebhookStatus = "PENDING"
	WebhookStatusSuccess WebhookStatus = "SUCCESS"
	WebhookStatusFailed  WebhookStatus = "FAILED"
)

Jump to

Keyboard shortcuts

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