Skip to content

Extending

Amir Iranmanesh edited this page Jul 31, 2026 · 1 revision

Extending

A provider Payvand does not support — or one that is in-house, or under NDA — becomes a first class gateway by implementing the interface and registering a factory. Nothing has to be upstreamed.

The skeleton

package acme

import (
	"context"
	"net/http"
	"strings"

	"github.com/amiranmanesh/payvand/core"
)

// Name is the registry name of this gateway.
const Name core.Name = "acme"

func init() {
	core.Register(Name, func(cfg core.Config, opts ...core.Option) (core.Gateway, error) {
		return New(cfg, opts...)
	})
}

// Gateway is the Acme implementation of core.Gateway.
type Gateway struct {
	// Unsupported answers Refund, Inquiry and ParseCallback with
	// ErrNotSupported; override only what Acme really offers.
	core.Unsupported

	cfg     core.Config
	opts    *core.Options
	client  *http.Client
	baseURL string
}

func New(cfg core.Config, opts ...core.Option) (*Gateway, error) {
	options := core.NewOptions(opts...)
	if strings.TrimSpace(cfg.MerchantKey) == "" {
		return nil, core.NewError(Name, "new", core.ErrInvalidConfig).
			WithMessage("MerchantKey is required")
	}

	baseURL := "https://api.acme.example"
	if options.BaseURL != "" { // keeps the gateway testable
		baseURL = options.BaseURL
	}

	return &Gateway{
		Unsupported: core.Unsupported{GatewayName: Name},
		cfg:         cfg,
		opts:        options,
		client:      &http.Client{Timeout: options.Timeout},
		baseURL:     baseURL,
	}, nil
}

func (g *Gateway) Name() core.Name { return Name }

func (g *Gateway) Capabilities() core.Capabilities {
	return core.Capabilities{
		Verify:         true,
		Callback:       true,
		RedirectMethod: http.MethodGet,
		Currencies:     []core.Currency{core.IRR},
	}
}

func (g *Gateway) Purchase(ctx context.Context, req core.PurchaseRequest) (core.PurchaseResponse, error) {
	// … call the provider, then:
	return core.PurchaseResponse{
		Token:   token,
		OrderID: req.OrderID,
		Amount:  req.Amount,
		Redirect: core.Redirect{
			Method: http.MethodGet,
			URL:    g.baseURL + "/pay/" + token,
		},
	}, nil
}

func (g *Gateway) Verify(ctx context.Context, req core.VerifyRequest) (core.VerifyResponse, error) {
	// … then:
	return core.VerifyResponse{
		ReferenceNumber: rrn,
		OrderID:         req.OrderID,
		Amount:          req.Amount,
	}, nil
}

func (g *Gateway) ParseCallback(r *http.Request) (core.Callback, error) {
	values, err := core.CallbackValues(r)
	if err != nil {
		return core.Callback{}, core.NewError(Name, "callback", err)
	}
	return core.Callback{
		Gateway:   Name,
		Succeeded: core.FirstValue(values, "status") == "OK",
		Token:     core.FirstValue(values, "token", "Token"),
		Values:    values,
	}, nil
}

Then, anywhere in your program:

import _ "yourmodule/gateway/acme"

gw, err := payvand.New("acme", payvand.Config{MerchantKey: key})

Provider options

Use the same mechanism the built-in gateways use, so your options compose with the shared ones:

type config struct {
	description string
}

func settings(o *core.Options) *config {
	if existing, ok := o.Extra(string(Name)).(*config); ok {
		return existing
	}
	created := &config{}
	o.SetExtra(string(Name), created)
	return created
}

// WithDefaultDescription sets the description used when a request carries none.
func WithDefaultDescription(description string) core.Option {
	return func(o *core.Options) { settings(o).description = description }
}

(Inside the Payvand repository, internal/gwopt.From[config] does the same in one line.)

Conventions worth following

Convention Why
Honour Options.BaseURL everywhere it is what makes the gateway testable without the provider
Validate credentials in New fail at wiring time, not at payment time
Tell the truth in Capabilities callers branch on it instead of on the provider name
Convert with Money.Rial() / Money.Toman() never assume the caller's unit
Wrap errors with core.NewError(...).WithCode(...).WithMessage(...) keeps errors.Is working and the provider code visible
Put the raw body in Raw support tickets need it
Expose Message(code) if the provider publishes a table turns numbers into sentences

Wrapping an existing gateway

Decoration works because everything is an interface. A gateway that records metrics:

type instrumented struct {
	payvand.Gateway
	metrics *Metrics
}

func (i instrumented) Purchase(ctx context.Context, req payvand.PurchaseRequest) (payvand.PurchaseResponse, error) {
	start := time.Now()
	res, err := i.Gateway.Purchase(ctx, req)
	i.metrics.Observe(string(i.Name()), "purchase", time.Since(start), err)
	return res, err
}

The same shape covers retries with your own policy, circuit breaking, or routing a payment to a second terminal when the first one is down.

Next: Testing · Contributing

Clone this wiki locally