payvand

package module
v1.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: MIT Imports: 27 Imported by: 0

README

Payvand

One interface for every Iranian payment gateway.

Payvand (پیوند — the link) is a Go package that puts every Iranian internet payment gateway (IPG) behind a single, strategy-pattern interface: bank acquirers, PSPs, aggregators and the buy-now-pay-later providers all answer the same five methods. Swapping Zarinpal for Mellat — or for SnappPay — is a value change, not a code change.

Go Reference

📖 Documentation wiki · 🌐 Website · 🧭 API reference · 📝 Changelog


Table of contents


Why Payvand

Zero dependencies Nothing but the Go standard library. No SOAP library, no HTTP client, no logger. Audit it in an afternoon.
One interface Purchase, Verify, Refund, Inquiry, ParseCallback. Every provider, same signatures.
Capability aware Ask gw.Capabilities() instead of hard-coding "Zarinpal cannot refund". Unsupported operations return ErrNotSupported, never a surprise.
Opt-in provider features Split settlement, fee modes, saved cards, service types — every provider extra is an option you may simply not pass.
Amount safety payvand.Toman(15_000) and payvand.Rial(150_000) are the same money; each gateway converts to what its API wants.
Callback parsing included The messy query-string / POST-form differences between the PSPs are normalised into one Callback struct.
Testable by construction Every gateway accepts WithBaseURL, and a virtual in-memory gateway runs the whole cycle offline.

Install

go get github.com/amiranmanesh/payvand
import "github.com/amiranmanesh/payvand"

Requires Go 1.26 or newer.


Quick start

package main

import (
	"context"
	"log"
	"time"

	"github.com/amiranmanesh/payvand"
)

func main() {
	// 1. Initialise once, with the settings shared by the whole application.
	pv := payvand.Init(payvand.WithTimeout(20 * time.Second))

	// 2. Build the gateway of the terminal you charge on.
	gw, err := pv.Gateway(payvand.Zarinpal, payvand.Config{
		MerchantKey: "00000000-0000-0000-0000-000000000000",
	})
	if err != nil {
		log.Fatal(err)
	}

	// 3. Create the payment.
	purchase, err := gw.Purchase(context.Background(), payvand.PurchaseRequest{
		Amount:      payvand.Toman(15_000),
		OrderID:     "1001",
		CallbackURL: "https://shop.example/payments/callback",
		Description: "Wallet top-up",
		Mobile:      "09120000000",
	})
	if err != nil {
		log.Fatal(err)
	}

	// Persist purchase.Token next to the order, then send the payer:
	//     purchase.Redirect.Send(w, r)
	_ = purchase
}

And the callback handler, written once for every provider:

func callback(w http.ResponseWriter, r *http.Request) {
	cb, err := gw.ParseCallback(r)
	if err != nil || !cb.Succeeded {
		http.Error(w, "payment canceled", http.StatusPaymentRequired)
		return
	}

	order := orders.ByToken(cb.Token) // your own records

	// The amount always comes from your database, never from the browser.
	verified, err := gw.Verify(r.Context(), cb.VerifyRequest(order.Amount))
	if err != nil {
		http.Error(w, "payment not verified", http.StatusPaymentRequired)
		return
	}

	orders.MarkPaid(order.ID, verified.ReferenceNumber, verified.CardNumber)
}

Run the offline examples:

go run ./examples/basic         # one full cycle on the in-memory gateway
go run ./examples/multigateway  # capability table + provider switching
go run ./examples/webshop       # a two-handler shop on :8080

How a payment flows

sequenceDiagram
    autonumber
    actor Payer
    participant Shop as Your server
    participant PV as payvand.Gateway
    participant Bank as PSP / Bank

    Payer->>Shop: Checkout
    Shop->>PV: Purchase(ctx, PurchaseRequest)
    PV->>Bank: create payment (REST or SOAP)
    Bank-->>PV: token
    PV-->>Shop: PurchaseResponse{Token, Redirect}
    Note over Shop: persist the token next to the order
    Shop-->>Payer: Redirect.Send(w, r) — 303 or auto-posting form
    Payer->>Bank: enters card details
    Bank-->>Payer: redirect back to CallbackURL
    Payer->>Shop: GET/POST callback
    Shop->>PV: ParseCallback(r)
    PV-->>Shop: Callback{Token, Succeeded, …}
    Shop->>PV: Verify(ctx, cb.VerifyRequest(orderAmount))
    PV->>Bank: verify (+ settle where the provider needs it)
    Bank-->>PV: reference number, masked PAN
    PV-->>Shop: VerifyResponse
    Shop-->>Payer: receipt

The verification step is not optional. Most Iranian gateways reverse a transaction that is never verified, and some (Mellat, AsanPardakht) need an extra settlement call, which Payvand makes for you inside Verify.

Verify is also the amount check. It compares what the provider says it settled against the amount you passed in, and refuses the settlement with ErrAmountMismatch when they disagree — which is what a token replayed from a cheaper payment looks like. Pass the amount from your own order record, not from the callback, and that check is doing real work for you.

State as Payvand reports it through Inquiry:

stateDiagram-v2
    [*] --> Pending: Purchase
    Pending --> Paid: payer completed the payment
    Pending --> Canceled: payer aborted
    Pending --> Failed: bank declined
    Paid --> Verified: Verify
    Paid --> Failed: verification window expired
    Verified --> Refunded: Refund
    Canceled --> [*]
    Failed --> [*]
    Refunded --> [*]

Supported gateways

All twenty-five are implemented and covered by tests.

Gateway payvand.… Kind Redirect Verify Refund Inquiry Callback Split settlement
Zarinpal Zarinpal REST GET ✅ (wages)
Zibal Zibal REST GET
Vandar Vandar REST GET
PayWeb PayWeb REST GET
IDPay IDPay REST GET
Pay.ir PayIr REST (form) GET
NextPay NextPay REST GET
PayPing PayPing REST v3 GET
BitPay.ir BitPay REST (form) GET
YekPay YekPay REST GET
Sadad / Bank Melli Sadad REST + 3DES GET
Parsian Parsian SOAP GET ✅ (IBAN)
Iran Kish IranKish REST + RSA/AES POST
Mellat (Behpardakht) Mellat SOAP POST
Saman (SEP) Saman REST GET
Pasargad Pasargad REST + RSA sign GET
AsanPardakht AsanPardakht REST v1 POST
Sepehr / Saderat Sepehr REST POST
TOP (Taban Ati Pardaz) Top REST, in-app in-app
Jibit (PPG v3) Jibit OAuth REST GET
SnappPay SnappPay OAuth REST, BNPL GET
TorobPay TorobPay OAuth REST, BNPL GET
Digipay DigiPay OAuth REST, wallet/BNPL GET ✅ (split)
Tara Tara OAuth REST, club credit POST ✅ (services)
Virtual (development) Virtual in-memory GET

✅ supported · ➖ the provider offers no such API to merchants (the call returns payvand.ErrNotSupported, and Capabilities() says so up front).

Buy now, pay later

The last five providers lend rather than move money from a card, which shows up in three places and nowhere else:

  • A basket is mandatory. SnappPay, TorobPay, Digipay (credit and BNPL tickets) and Tara decide the credit from the goods, so each of them accepts a builder — snapppay.WithCartBuilder, torobpay.WithCartBuilder, digipay.WithBasketBuilder, tara.WithInvoiceBuilder. Leave it unset and Payvand sends one line covering the whole order, which is right for a top-up and wrong for a shop.

  • Settlement can be a second call. snapppay verifies and settles inside Verify; turn the second half off with snapppay.WithAutoSettle(false) when your own code settles later. SnappPay reverts a payment that is verified and never settled, so this is not optional there.

    torobpay serves the same endpoint paths but is treated here as settling on its own, and Verify makes the one call. Ask TorobPay which your contract is, and switch torobpay.WithSettle(true) on if it expects the second call: a reversal for a missing settlement only surfaces once the window closes, long after a test payment looks successful.

  • Delivery matters. Digipay only starts collecting instalments once the order is reported as shipped with digipay.Deliver.

Everything else — Purchase, Verify, Refund, ParseCallback, the redirect, the errors — is the interface every other gateway implements.


Credentials per gateway

payvand.Config is one struct for every provider; each gateway validates the fields it needs at construction time, so a misconfigured terminal fails when you wire it, not when a customer pays.

Gateway MerchantKey MerchantID TerminalID Username Password IBAN
Zarinpal merchant id
Zibal merchant
Vandar API key business name (refunds)
PayWeb bearer token
IDPay API key
Pay.ir API key
NextPay API key
PayPing bearer token
BitPay.ir API key
YekPay merchant id
Sadad base64 terminal key merchant id terminal id
Parsian login account (pin) settlement IBAN
Iran Kish acquirer RSA public key terminal id acceptor id terminal password
Mellat terminal id user name password
Saman terminal number
Pasargad RSA private key (PEM or .NET XML) merchant code terminal code
AsanPardakht merchant configuration id usr pwd
Sepehr terminal id
TOP EShop pin
Jibit API key secret key
SnappPay OAuth client secret OAuth client id merchant user merchant password
TorobPay OAuth client secret OAuth client id merchant user merchant password
Digipay OAuth client secret OAuth client id merchant user merchant password
Tara merchant user merchant password
Virtual

Anything a provider needs beyond these goes into Config.Extra, and every gateway documents its own keys in its package documentation.


The interface

type Gateway interface {
	Name() Name
	Capabilities() Capabilities

	Purchase(ctx context.Context, req PurchaseRequest) (PurchaseResponse, error)
	Verify(ctx context.Context, req VerifyRequest) (VerifyResponse, error)
	Refund(ctx context.Context, req RefundRequest) (RefundResponse, error)
	Inquiry(ctx context.Context, req InquiryRequest) (InquiryResponse, error)
	ParseCallback(r *http.Request) (Callback, error)
}

Branch on capability, not on provider name:

if gw.Capabilities().Refund {
	_, err = gw.Refund(ctx, payvand.RefundRequest{
		Token:           payment.Token,
		OrderID:         payment.OrderID,
		ReferenceNumber: payment.ReferenceNumber,
		Amount:          payment.Amount,
	})
}

Amounts: Rial and Toman

Iranian providers disagree about the unit: most want Rial, PayPing works in Toman throughout its API, and Zarinpal and NextPay terminals can be either. Payvand keeps the caller's unit explicit and converts per gateway.

payvand.Toman(15_000)          // 15,000 Toman
payvand.Rial(150_000)          // the same money
payvand.Toman(15_000).Rial()   // 150000

Never build Money from a browser-supplied value — read it from your order.


Redirecting the payer

Some banks expect a plain redirect, others an HTML form POST (Mellat, Sepehr, Iran Kish, AsanPardakht). Redirect covers both, so your handler never branches:

purchase.Redirect.Send(w, r)   // 303 for GET gateways, auto-submitting form for POST ones

If you render the page yourself:

purchase.Redirect.String()     // the full URL, query parameters already appended
purchase.Redirect.IsPost()     // whether a form is required
purchase.Redirect.HTML()       // the auto-submitting form as a string

Handling the callback

cb, err := gw.ParseCallback(r)      // query string and POST form, merged and normalised
cb.Succeeded                        // the bank's own verdict — a hint, not proof
cb.Token, cb.OrderID                // matched against your records
cb.ReferenceNumber, cb.TraceNumber  // needed by Iran Kish, Mellat, Saman
cb.Get("digitalreceipt")            // provider specific values stay reachable

verified, err := gw.Verify(ctx, cb.VerifyRequest(order.Amount))

cb.VerifyRequest(amount) copies every field the provider will need — including the ones only that provider uses — and takes the amount from you rather than from the request.

The callback map travels into VerifyRequest.Extra unfiltered, because gateways need the provider specific fields out of it. Everything security relevant is decided against your own records or against what the provider says on its own API, never against a value that came back through the browser — but it does mean you should not read a settlement decision out of Extra yourself either.

A payer who refreshes that page sends the callback twice. The second Verify answers ErrAlreadyVerified on every provider that signals it, so handle that case explicitly and do not fulfil the order again:

verified, err := gw.Verify(ctx, cb.VerifyRequest(order.Amount))
switch {
case errors.Is(err, payvand.ErrAlreadyVerified):
	// already paid; answer from what you stored the first time
case err != nil:
	// not paid
}

Options

Shared options, on payvand:

Option Effect
WithTimeout(d) bounds one gateway call (default 30s)
WithHTTPClient(c) supply your own client for tracing, proxying or mocking
WithLogger(l) receive every request and response; payvand.SlogLogger{Logger: …} wraps log/slog
WithSandbox(true) switch providers that have a test environment
WithBaseURL(u) point the gateway at another host — sandboxes and tests
WithRetry(n, backoff) retry network errors and 5xx responses
WithHeader(k, v), WithUserAgent(ua) extra headers
WithSkipTLSVerify(true) last resort for a Shaparak host with an incomplete chain

Provider options live in the gateway packages and are ordinary payvand.Option values, so they compose in the same list:

import "github.com/amiranmanesh/payvand/gateway/zibal"

gw, err := pv.Gateway(payvand.Zibal, cfg,
	payvand.WithSandbox(true),
	zibal.WithFeeMode(1),
	zibal.WithMobileCardCheck(true),
	zibal.WithMultiplexing(
		zibal.Share{BankAccount: "IR…", Amount: 90_000},
		zibal.Share{SubMerchantID: "sub-1", Amount: 60_000},
	),
)

A tour of what each package offers:

Package Options
zarinpal WithCurrency, WithWages, WithDefaultDescription
zibal WithLedger, WithFeeMode, WithMobileCardCheck, WithMultiplexing, WithPercentMultiplexing, WithDefaultDescription
vandar WithPort, WithComment, WithAccessToken, WithOrderAsFactorNumber, WithDefaultDescription
payweb WithDefaultComment, WithCardRestriction
idpay WithDefaultDescription
payir WithOrderAsFactorNumber, WithDefaultDescription
nextpay WithCurrency, WithAutoVerify, WithDefaultDescription
payping WithPayerIdentity, WithReversible, WithBlockedSettlement, WithMultiplexing, WithDefaultDescription
bitpay WithDefaultDescription
yekpay WithCurrencies, WithAddress, WithDefaultDescription
sadad WithApplicationName, WithAdditionalData, WithMobileAsUserID
parsian WithMultiplexing, WithSettlementToIBAN, WithAdditionalData, WithMobileAsOriginator
irankish WithTransactionType, WithMobileAsCmsID
mellat WithAdditionalData, WithPayerID, WithoutSettle
saman WithGetMethod, WithMobile
pasargad WithAction, WithPayerDetails, WithoutTransactionCheck
asanpardakht WithServiceType, WithPaymentID, WithSettlements, WithAdditionalData, WithoutSettlement, WithCancelInsteadOfReverse
sepehr WithPayload, WithPayerDetails
top WithAdditionalInfo, WithUserID, WithSetData
jibit WithWage, WithUserIdentifier, WithPayerCardMatching, WithCancellableRefunds, WithAdditionalData, WithDefaultDescription
snapppay WithCart, WithCartBuilder, WithDefaultCategory, WithPaymentMethod, WithAutoSettle, WithScope
torobpay WithCart, WithCartBuilder, WithDefaultCategory, WithPaymentMethod, WithSettle
digipay WithTicketType, WithAgent, WithAPIVersion, WithPreferredGateway, WithBasket, WithBasketBuilder, WithSplitDetails
tara WithServiceID, WithInvoiceItems, WithInvoiceBuilder, WithDefaultGroup, WithDefaultUnit, WithClientIP
virtual WithDecline, WithRedirectURL, WithFailingVerify

Nothing here is mandatory: leave an option out and the parameter is simply not sent to the provider.


Errors

Every failure is a *payvand.Error wrapping a sentinel, so you can match broadly with errors.Is and still read the provider's own code:

verified, err := gw.Verify(ctx, req)
switch {
case errors.Is(err, payvand.ErrAlreadyVerified):
	// a refreshed callback page — the order is already paid
case errors.Is(err, payvand.ErrAmountMismatch):
	// the bank settled a different amount: stop and investigate
case errors.Is(err, payvand.ErrPaymentFailed):
	var e *payvand.Error
	errors.As(err, &e)
	log.Printf("gateway %s said %s: %s", e.Gateway, e.Code, e.Message)
}
Sentinel Meaning
ErrNotSupported the provider has no such API
ErrGatewayNotRegistered unknown gateway name
ErrInvalidConfig missing or malformed credentials
ErrInvalidRequest the request cannot be sent (zero amount, missing order id, …)
ErrPaymentFailed the provider rejected the operation
ErrPaymentCanceled the payer aborted
ErrAlreadyVerified the transaction was verified before
ErrVerificationPending the provider is still settling; call Verify again
ErrAmountMismatch the settled amount differs from the requested one
ErrUnexpectedResponse the provider answered with something unreadable

Three of them are worth handling explicitly. ErrAlreadyVerified is what a refreshed callback page looks like on every gateway whose provider signals it, so treat it as "already paid" rather than as a failure — never as a reason to fulfil the order a second time. ErrVerificationPending (PayPing) means the answer has not arrived yet: retry the verification, and do not send the payer back to the bank. ErrAmountMismatch means the payment settled for something other than what was ordered, which is the shape a replayed token takes: stop, and reconcile by hand.

Gateways that publish a code table expose it as a function — mellat.Message, irankish.Message, saman.Message, nextpay.Message, bitpay.Message — with English texts.


Testing your integration

In your unit tests, use the virtual gateway; it runs the whole cycle in memory:

gw, _ := payvand.New(payvand.Virtual, payvand.Config{})

purchase, _ := gw.Purchase(ctx, req)
cb, _ := gw.ParseCallback(httptest.NewRequest("GET", purchase.Redirect.String(), nil))
verified, err := gw.Verify(ctx, cb.VerifyRequest(req.Amount))

Failure paths are options: virtual.WithDecline(true), virtual.WithFailingVerify(true).

Against a fake provider, point any gateway at an httptest.Server:

server := httptest.NewServer(handler)
gw, _ := payvand.New(payvand.Zibal, cfg, payvand.WithBaseURL(server.URL))

That is exactly how Payvand's own twenty test packages work — no network, no credentials:

make test        # go test -race ./...
make cover       # coverage summary

Against the provider's own sandbox, pass payvand.WithSandbox(true). The providers disagree about what a sandbox even is, so the option only does something where the switch is a public, fixed one — a test host or a published test credential. Where the provider issues a personal test credential instead, there is nothing for the option to switch: use the credential as your Config and leave the option out.

Gateway WithSandbox(true) does Notes
Zibal sends the merchant id zibal ignores your own merchant id
Pay.ir sends the api key test
BitPay sends BitPay's published demo key
IDPay adds the X-SANDBOX: 1 header keeps your api key
Zarinpal switches to sandbox.zarinpal.com
Digipay switches to Digipay's sandbox host credentials still issued by Digipay
YekPay switches to api.ypsapi.com and its /api/sandbox/* paths same merchant id; the test page lets you choose success or failure

Providers whose sandbox is a credential you request, on the same endpoints — WithSandbox is a no-op, so just build the gateway with the test credential:

Gateway How to get into the test flow
PayPing issue a test token in the developer console and pass it as MerchantKey. Test tokens cap the amount (error 104) and the number of transactions (error 112)
Vandar ask Vandar support to add you to the "سند باکس" business and pass the test api_key as MerchantKey

Every other gateway — the bank acquirers (Mellat, Saman, Parsian, Pasargad, Sadad, Sepehr, AsanPardakht, Iran Kish), the BNPL providers (SnappPay, TorobPay, Tara), TOP, Jibit, NextPay and PayWeb — publishes no sandbox at all. Test those against the virtual gateway or an httptest.Server; a bank test terminal, where one exists, is issued per merchant and is reached with WithBaseURL.


Adding your own gateway

An in-house or not-yet-supported provider becomes a first class citizen by implementing the interface and registering a factory:

package acme

const Name core.Name = "acme"

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

type Gateway struct {
	core.Unsupported // answers Refund/Inquiry/ParseCallback with ErrNotSupported
	// …
}

Embed core.Unsupported and override only what your provider actually does. After a blank import of your package, payvand.New("acme", cfg) works like any built-in gateway.


Migrating an existing service

If your service already has an internal IPG layer with a GetToken / Confirm / Reverse interface (the shape most Iranian Go services grew), the move is mechanical:

Your old code Payvand
NewIPG(cfg, db, gateway, terminalInfo) pv.Gateway(name, payvand.Config{…})
GetToken(ctx, GetTokenReq{Amount, CallbackUrl, OrderID, Mobile}) Purchase(ctx, payvand.PurchaseRequest{…})
res.PaymentToken, res.URL res.Token, res.Redirect
Confirm(ctx, ConfirmReq{PaymentToken, Amount, ReferenceNumber, TraceNumber}) Verify(ctx, payvand.VerifyRequest{…})
res.FinalReferenceNumber, res.CardNumber res.ReferenceNumber, res.CardNumber
Reverse(ctx, ReverseReq{…}) Refund(ctx, payvand.RefundRequest{…})
hand-parsed callback query/form ParseCallback(r) + Callback.VerifyRequest(amount)
amount int64 in Rial payvand.Rial(amount)
a switch over gateway constants the registry: the name is data

A thin adapter keeps your existing call sites untouched while you migrate:

type ipgAdapter struct{ gw payvand.Gateway }

func (a ipgAdapter) GetToken(ctx context.Context, req dto.GetTokenReq) (dto.GetTokenRes, error) {
	res, err := a.gw.Purchase(ctx, payvand.PurchaseRequest{
		Amount:      payvand.Rial(req.Amount),
		OrderID:     req.OrderID,
		CallbackURL: req.CallbackUrl,
		Mobile:      req.Mobile,
		NationalID:  req.NationalId,
	})
	if err != nil {
		return dto.GetTokenRes{}, err
	}
	return dto.GetTokenRes{PaymentToken: res.Token, URL: res.Redirect.String()}, nil
}

Project layout

payvand/
├── payvand.go            # facade: names, aliases, Init/New, shared options
├── core/                 # the contracts: Gateway, DTOs, Money, errors, registry, Client
├── gateway/              # one package per provider, three files each
│   ├── zarinpal/  zibal/   vandar/  payweb/  idpay/    payir/
│   ├── nextpay/   payping/ bitpay/  yekpay/  sadad/    parsian/
│   ├── irankish/  top/     mellat/  saman/   pasargad/
│   ├── jibit/     snapppay/ torobpay/ digipay/ tara/
│   └── asanpardakht/ sepehr/ virtual/
├── internal/
│   ├── transport/        # net/http plumbing: retry, timeout, logging
│   ├── soap/             # SOAP 1.1 envelopes on encoding/xml
│   ├── cryptox/          # 3DES-ECB, AES-CBC, RSA sign/encrypt, PKCS#7, .NET XML keys
│   ├── tokenauth/        # bearer token cache, renew-and-replay on 401
│   ├── gwopt/            # per-gateway option storage
│   └── testutil/         # the fake provider the tests are written against
├── examples/             # basic, multigateway, webshop
├── Makefile
└── .github/workflows/ci.yml

Roadmap

Done
  • Provider independent Gateway interface with capability reporting
  • 24 real gateways plus an in-memory virtual one
  • Buy-now-pay-later providers behind the same interface: SnappPay, TorobPay, Digipay, Tara
  • Jibit's proxy payment gateway, with reversal and partial refunds
  • Purchase, verify, refund, inquiry and callback parsing
  • Multi-step settlement handled inside Verify (Mellat, AsanPardakht, Vandar, Pasargad, SnappPay)
  • OAuth bearer tokens cached and renewed transparently
  • Split settlement for Zarinpal, Zibal, Parsian and AsanPardakht
  • SOAP client, 3DES/AES/RSA envelopes and .NET XML key support, all on the standard library
  • Rial/Toman handling per provider
  • GET and POST redirects, including the auto-submitting form
  • Functional options per gateway, everything opt-in
  • Retry, timeout, logging and a pluggable HTTP client
  • Tests for every gateway, plus consumer level tests and runnable examples
  • Makefile and CI enforcing the "standard library only" rule
Next
  • Zarinpal refunds through the merchant OAuth token
  • Sadad and Iran Kish reversal endpoints, once the contracts are confirmed
  • IDPay and Zibal panel-level refunds
  • Azki, Shepa, Rayanpay and Sepal gateways
  • Bill payment and instalment transaction types where the provider offers them
  • A recovery helper that reconciles lost callbacks through Inquiry
  • Idempotency keys for retried purchases
  • Persian translations of the provider response codes, next to the English ones
  • Benchmarks and a fuzz corpus for the callback parsers

Development

make help          # list the targets
make build         # compile everything
make test          # go test -race ./...
make lint          # gofmt check + go vet
make deps-check    # fail if a third party dependency appears in go.mod
make cover-html    # coverage report in the browser
make examples      # run the offline examples

Contributions are welcome. A new gateway is expected to bring:

  1. gateway/<name>/{<name>.go,dto.go,options.go} following the existing shape,
  2. a test package driven by internal/testutil, covering purchase, verify and callback at minimum,
  3. its row in the tables above, and
  4. no new dependency.

License

MIT — see LICENSE.

Documentation

Overview

Package payvand is a dependency-free Go client for the Iranian internet payment gateways (IPG).

Every provider — bank acquirers and PSPs alike — is reached through one interface, Gateway, so the call sites of an application never change when the provider does. Choosing a provider is choosing a value:

pv := payvand.Init(payvand.WithTimeout(20 * time.Second))

gw, err := pv.Gateway(payvand.Zarinpal, payvand.Config{MerchantKey: merchantID})
if err != nil {
    return err
}

purchase, err := gw.Purchase(ctx, payvand.PurchaseRequest{
    Amount:      payvand.Toman(15_000),
    OrderID:     "10245",
    CallbackURL: "https://shop.example/payments/callback",
})
if err != nil {
    return err
}
purchase.Redirect.Send(w, r) // GET redirect or auto-posting form

After the payer returns, the callback is parsed and the payment verified:

callback, _ := gw.ParseCallback(r)
verified, err := gw.Verify(ctx, callback.VerifyRequest(payvand.Toman(15_000)))

Swapping Zarinpal for Mellat, Parsian or the in-memory payvand.Virtual gateway changes the first line and nothing else.

Provider specific behaviour is opt-in through options declared by the gateway packages, and composes with the shared ones:

gw, err := pv.Gateway(payvand.Zibal, cfg,
    payvand.WithSandbox(true),
    zibal.WithFeeMode(1),
    zibal.WithMultiplexing(zibal.Share{BankAccount: iban, Amount: 50_000}),
)

The package imports nothing outside the Go standard library.

Example

The virtual gateway keeps the examples runnable without a merchant account; swapping it for a real name is the only change a production program needs.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"github.com/amiranmanesh/payvand"
)

func main() {
	pv := payvand.Init(payvand.WithTimeout(20 * time.Second))

	gw, err := pv.Gateway(payvand.Virtual, payvand.Config{MerchantKey: "merchant-key"})
	if err != nil {
		log.Fatal(err)
	}

	purchase, err := gw.Purchase(context.Background(), payvand.PurchaseRequest{
		Amount:      payvand.Toman(15_000),
		OrderID:     "1001",
		CallbackURL: "https://shop.example/payments/callback",
		Description: "Wallet top-up",
	})
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("gateway:", gw.Name())
	fmt.Println("amount:", purchase.Amount.Rial(), "Rial")
	fmt.Println("redirect method:", purchase.Redirect.Method)
}
Output:
gateway: virtual
amount: 150000 Rial
redirect method: GET

Index

Examples

Constants

View Source
const (
	// AsanPardakht is the AsanPardakht PSP (REST v1).
	AsanPardakht = asanpardakht.Name
	// BitPay is the BitPay.ir aggregator.
	BitPay = bitpay.Name
	// DigiPay is the Digipay wallet, credit and BNPL gateway.
	DigiPay = digipay.Name
	// IDPay is the IDPay PSP.
	IDPay = idpay.Name
	// IranKish is the Iran Kish acquirer (Bank Kar Afarin group).
	IranKish = irankish.Name
	// Jibit is the Jibit proxy payment gateway.
	Jibit = jibit.Name
	// Mellat is the Behpardakht Mellat acquirer.
	Mellat = mellat.Name
	// NextPay is the NextPay PSP.
	NextPay = nextpay.Name
	// Parsian is the Parsian Bank acquirer.
	Parsian = parsian.Name
	// Pasargad is the Bank Pasargad acquirer.
	Pasargad = pasargad.Name
	// PayIr is the Pay.ir PSP.
	PayIr = payir.Name
	// PayPing is the PayPing PSP.
	PayPing = payping.Name
	// PayWeb is the PayWeb PSP.
	PayWeb = payweb.Name
	// Sadad is the Sadad / Bank Melli acquirer.
	Sadad = sadad.Name
	// Saman is the Saman Bank (SEP) acquirer.
	Saman = saman.Name
	// Sepehr is the Sepehr / Bank Saderat (Mabna) acquirer.
	Sepehr = sepehr.Name
	// SnappPay is the SnappPay online instalment (BNPL) gateway.
	SnappPay = snapppay.Name
	// Tara is the Tara club credit gateway.
	Tara = tara.Name
	// Top is the TOP (Taban Ati Pardaz) in-app gateway.
	Top = top.Name
	// TorobPay is the TorobPay online credit (BNPL) gateway.
	TorobPay = torobpay.Name
	// Vandar is the Vandar PSP.
	Vandar = vandar.Name
	// Virtual is the in-memory gateway used for development and tests.
	Virtual = virtual.Name
	// YekPay is the YekPay multi-currency PSP.
	YekPay = yekpay.Name
	// Zarinpal is the Zarinpal PSP.
	Zarinpal = zarinpal.Name
	// Zibal is the Zibal PSP.
	Zibal = zibal.Name
)

Names of the supported gateways. Pass one to [Client.Gateway] or New.

View Source
const (
	// IRR is the Iranian Rial.
	IRR = core.IRR
	// IRT is the Iranian Toman.
	IRT = core.IRT
)

Currency values.

View Source
const (
	// StatusUnknown means the provider reported no mappable state.
	StatusUnknown = core.StatusUnknown
	// StatusPending means the payer has not finished yet.
	StatusPending = core.StatusPending
	// StatusPaid means the money was taken but not settled.
	StatusPaid = core.StatusPaid
	// StatusVerified means the payment is settled.
	StatusVerified = core.StatusVerified
	// StatusFailed means the payment failed.
	StatusFailed = core.StatusFailed
	// StatusCanceled means the payer aborted.
	StatusCanceled = core.StatusCanceled
	// StatusRefunded means the payment was returned.
	StatusRefunded = core.StatusRefunded
)

Transaction statuses.

Variables

View Source
var (
	// ErrNotSupported is returned by an operation the provider lacks.
	ErrNotSupported = core.ErrNotSupported
	// ErrGatewayNotRegistered is returned for an unknown gateway name.
	ErrGatewayNotRegistered = core.ErrGatewayNotRegistered
	// ErrInvalidConfig is returned when credentials are missing.
	ErrInvalidConfig = core.ErrInvalidConfig
	// ErrInvalidRequest is returned for an unusable request.
	ErrInvalidRequest = core.ErrInvalidRequest
	// ErrPaymentFailed is returned when the provider rejected the payment.
	ErrPaymentFailed = core.ErrPaymentFailed
	// ErrPaymentCanceled is returned when the payer aborted.
	ErrPaymentCanceled = core.ErrPaymentCanceled
	// ErrAlreadyVerified is returned for a repeated verification.
	ErrAlreadyVerified = core.ErrAlreadyVerified
	// ErrVerificationPending is returned when the provider is still settling
	// the payment and Verify must be called again.
	ErrVerificationPending = core.ErrVerificationPending
	// ErrAmountMismatch is returned when the settled amount differs.
	ErrAmountMismatch = core.ErrAmountMismatch
	// ErrUnexpectedResponse is returned for an unreadable provider answer.
	ErrUnexpectedResponse = core.ErrUnexpectedResponse
)

Sentinel errors, comparable with errors.Is.

Functions

func IsRegistered

func IsRegistered(name Name) bool

IsRegistered reports whether a gateway is available.

func Register

func Register(name Name, factory core.Factory)

Register adds a gateway of your own to the registry, so an in-house provider is reachable by name exactly like the built-in ones.

Types

type Callback

type Callback = core.Callback

Callback is the parsed return request of a gateway.

type Capabilities

type Capabilities = core.Capabilities

Capabilities describes what a gateway supports.

type Client

type Client = core.Client

Client is the initialised entry point holding shared options.

func Init

func Init(opts ...Option) *Client

Init creates the Client every gateway is built from, carrying the options shared by the whole application.

type Config

type Config = core.Config

Config carries the credentials of one terminal.

type Currency

type Currency = core.Currency

Currency is the unit of a Money.

type Doer

type Doer = core.Doer

Doer is the HTTP client contract.

type Error

type Error = core.Error

Error is the rich error returned by every gateway.

type Factory

type Factory = core.Factory

Factory builds a gateway; supply one to Register.

type Gateway

type Gateway = core.Gateway

Gateway is the interface every provider implements.

Example (ParseCallback)

A callback handler is written once and works for every provider: the parsed callback carries the token, and the amount always comes from the merchant's own records.

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/amiranmanesh/payvand"
)

func main() {
	gw, err := payvand.New(payvand.Virtual, payvand.Config{})
	if err != nil {
		log.Fatal(err)
	}

	handler := func(w http.ResponseWriter, r *http.Request) {
		callback, err := gw.ParseCallback(r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		if !callback.Succeeded {
			http.Error(w, "the payer canceled the payment", http.StatusPaymentRequired)
			return
		}

		// The amount is read from the order, never from the query string.
		verified, err := gw.Verify(r.Context(), callback.VerifyRequest(payvand.Toman(15_000)))
		if err != nil {
			http.Error(w, err.Error(), http.StatusPaymentRequired)
			return
		}
		fmt.Fprintln(w, "reference number:", verified.ReferenceNumber)
	}

	_ = handler
	fmt.Println("ready")
}
Output:
ready
Example (Refund)

Operations a provider does not offer report payvand.ErrNotSupported, so a caller can branch on capability instead of on provider name.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"

	"github.com/amiranmanesh/payvand"
)

func main() {
	gw, err := payvand.New(payvand.Zarinpal, payvand.Config{MerchantKey: "merchant-id"})
	if err != nil {
		log.Fatal(err)
	}

	if !gw.Capabilities().Refund {
		fmt.Println("refunds must be issued from the provider panel")
	}

	_, err = gw.Refund(context.Background(), payvand.RefundRequest{Token: "A1"})
	fmt.Println(errors.Is(err, payvand.ErrNotSupported))
}
Output:
refunds must be issued from the provider panel
true

func MustNew

func MustNew(name Name, cfg Config, opts ...Option) Gateway

MustNew is New for the wiring phase of a program: it panics instead of returning an error.

func New

func New(name Name, cfg Config, opts ...Option) (Gateway, error)

New builds a gateway directly, without a Client. Use it for one-off jobs; long lived applications are better served by Init.

type InquiryRequest

type InquiryRequest = core.InquiryRequest

InquiryRequest is the input of [Gateway.Inquiry].

type InquiryResponse

type InquiryResponse = core.InquiryResponse

InquiryResponse is the output of [Gateway.Inquiry].

type Logger

type Logger = core.Logger

Logger receives request and response events.

type Money

type Money = core.Money

Money is an amount plus the unit it is expressed in.

func Rial

func Rial(amount int64) Money

Rial builds an amount expressed in Iranian Rial.

func SettledAmount added in v1.2.0

func SettledAmount(gateway Name, requested, reported Money) (Money, error)

SettledAmount reconciles the amount a provider reports for a payment with the amount that was ordered, returning an error wrapping ErrAmountMismatch when they disagree. Gateways apply it inside [Gateway.Verify]; it is exported for the same check against an InquiryResponse.

func Toman

func Toman(amount int64) Money

Toman builds an amount expressed in Iranian Toman.

type Name

type Name = core.Name

Name identifies a gateway in the registry.

func Registered

func Registered() []Name

Registered returns the sorted names of the linked gateways.

type NopLogger

type NopLogger = core.NopLogger

NopLogger is the logger that drops everything, used by default.

type Option

type Option = core.Option

Option configures a gateway.

func WithBaseURL

func WithBaseURL(baseURL string) Option

WithBaseURL overrides the provider host, for sandboxes and tests.

func WithHTTPClient

func WithHTTPClient(client Doer) Option

WithHTTPClient sets the HTTP client used for every call.

func WithHeader

func WithHeader(key, value string) Option

WithHeader adds a header sent with every request.

func WithLogger

func WithLogger(l Logger) Option

WithLogger installs a logger.

func WithRetry

func WithRetry(maxAttempts int, backoff time.Duration) Option

WithRetry enables transport level retrying.

func WithSandbox

func WithSandbox(enabled bool) Option

WithSandbox switches gateways that have a test environment to it.

func WithSkipTLSVerify

func WithSkipTLSVerify(skip bool) Option

WithSkipTLSVerify disables TLS certificate verification. Only reach for it when a Shaparak host serves a chain your trust store cannot complete.

func WithTimeout

func WithTimeout(d time.Duration) Option

WithTimeout bounds a single gateway call.

func WithUserAgent

func WithUserAgent(ua string) Option

WithUserAgent overrides the User-Agent header.

type Options

type Options = core.Options

Options is the resolved option set handed to a gateway.

type PurchaseRequest

type PurchaseRequest = core.PurchaseRequest

PurchaseRequest is the input of [Gateway.Purchase].

type PurchaseResponse

type PurchaseResponse = core.PurchaseResponse

PurchaseResponse is the output of [Gateway.Purchase].

type Redirect

type Redirect = core.Redirect

Redirect describes how to send the payer to the bank.

type RefundRequest

type RefundRequest = core.RefundRequest

RefundRequest is the input of [Gateway.Refund].

type RefundResponse

type RefundResponse = core.RefundResponse

RefundResponse is the output of [Gateway.Refund].

type SlogLogger

type SlogLogger = core.SlogLogger

SlogLogger adapts a standard library slog logger.

type Status

type Status = core.Status

Status is the normalised state of a transaction.

type VerifyRequest

type VerifyRequest = core.VerifyRequest

VerifyRequest is the input of [Gateway.Verify].

type VerifyResponse

type VerifyResponse = core.VerifyResponse

VerifyResponse is the output of [Gateway.Verify].

Directories

Path Synopsis
examples
basic command
Command basic runs one full payment cycle — purchase, callback, verify, refund — against the in-memory virtual gateway, so it works offline.
Command basic runs one full payment cycle — purchase, callback, verify, refund — against the in-memory virtual gateway, so it works offline.
multigateway command
Command multigateway shows the point of the package: a table of terminals read from configuration, every one of them driven by the same code.
Command multigateway shows the point of the package: a table of terminals read from configuration, every one of them driven by the same code.
webshop command
Command webshop is a miniature shop showing the two HTTP handlers a payment integration needs: one that starts a payment and one that finishes it.
Command webshop is a miniature shop showing the two HTTP handlers a payment integration needs: one that starts a payment and one that finishes it.
gateway
asanpardakht
Package asanpardakht implements the AsanPardakht IPG (REST v1, ipgrest.asanpardakht.ir).
Package asanpardakht implements the AsanPardakht IPG (REST v1, ipgrest.asanpardakht.ir).
bitpay
Package bitpay implements the BitPay.ir gateway (REST, bitpay.ir).
Package bitpay implements the BitPay.ir gateway (REST, bitpay.ir).
digipay
Package digipay implements the Digipay universal payment gateway (UPG, REST, api.mydigipay.com).
Package digipay implements the Digipay universal payment gateway (UPG, REST, api.mydigipay.com).
idpay
Package idpay implements the IDPay gateway (REST, api.idpay.ir).
Package idpay implements the IDPay gateway (REST, api.idpay.ir).
irankish
Package irankish implements the Iran Kish IPG (REST + RSA/AES envelope, ikc.shaparak.ir).
Package irankish implements the Iran Kish IPG (REST + RSA/AES envelope, ikc.shaparak.ir).
jibit
Package jibit implements the Jibit Proxy Payment Gateway (PPG v3, REST, napi.jibit.ir).
Package jibit implements the Jibit Proxy Payment Gateway (PPG v3, REST, napi.jibit.ir).
mellat
Package mellat implements the Behpardakht Mellat IPG (SOAP, bpm.shaparak.ir).
Package mellat implements the Behpardakht Mellat IPG (SOAP, bpm.shaparak.ir).
nextpay
Package nextpay implements the NextPay gateway (REST, nextpay.org).
Package nextpay implements the NextPay gateway (REST, nextpay.org).
parsian
Package parsian implements the Parsian Bank IPG (SOAP, pec.shaparak.ir).
Package parsian implements the Parsian Bank IPG (SOAP, pec.shaparak.ir).
pasargad
Package pasargad implements the Bank Pasargad IPG (REST with RSA signed bodies, pep.shaparak.ir).
Package pasargad implements the Bank Pasargad IPG (REST with RSA signed bodies, pep.shaparak.ir).
payir
Package payir implements the Pay.ir gateway (REST, pay.ir).
Package payir implements the Pay.ir gateway (REST, pay.ir).
payping
Package payping implements the PayPing gateway (REST v3, api.payping.ir).
Package payping implements the PayPing gateway (REST v3, api.payping.ir).
payweb
Package payweb implements the PayWeb IPG (REST, ipg.payweb.ir).
Package payweb implements the PayWeb IPG (REST, ipg.payweb.ir).
sadad
Package sadad implements the Sadad / Bank Melli IPG (REST + 3DES signature, sadad.shaparak.ir).
Package sadad implements the Sadad / Bank Melli IPG (REST + 3DES signature, sadad.shaparak.ir).
saman
Package saman implements the Saman Bank (SEP) IPG (REST, sep.shaparak.ir).
Package saman implements the Saman Bank (SEP) IPG (REST, sep.shaparak.ir).
sepehr
Package sepehr implements the Sepehr / Bank Saderat (Mabna) IPG (REST, sepehr.shaparak.ir).
Package sepehr implements the Sepehr / Bank Saderat (Mabna) IPG (REST, sepehr.shaparak.ir).
snapppay
Package snapppay implements the SnappPay online instalment gateway (REST, api.snapppay.ir).
Package snapppay implements the SnappPay online instalment gateway (REST, api.snapppay.ir).
tara
Package tara implements the Tara club credit gateway (REST, pay.tara360.ir).
Package tara implements the Tara club credit gateway (REST, pay.tara360.ir).
top
Package top implements the TOP (Taban Ati Pardaz) in-app gateway (REST, merchantapi.top.ir).
Package top implements the TOP (Taban Ati Pardaz) in-app gateway (REST, merchantapi.top.ir).
torobpay
Package torobpay implements the TorobPay online credit gateway (REST, api.torobpay.com).
Package torobpay implements the TorobPay online credit gateway (REST, api.torobpay.com).
vandar
Package vandar implements the Vandar IPG (REST, ipg.vandar.io).
Package vandar implements the Vandar IPG (REST, ipg.vandar.io).
virtual
Package virtual implements an in-memory gateway for development and tests.
Package virtual implements an in-memory gateway for development and tests.
yekpay
Package yekpay implements the YekPay gateway (REST, gate.yekpay.com).
Package yekpay implements the YekPay gateway (REST, gate.yekpay.com).
zarinpal
Package zarinpal implements the Zarinpal payment gateway (REST, api.zarinpal.com).
Package zarinpal implements the Zarinpal payment gateway (REST, api.zarinpal.com).
zibal
Package zibal implements the Zibal payment gateway (REST, gateway.zibal.ir).
Package zibal implements the Zibal payment gateway (REST, gateway.zibal.ir).
internal
cryptox
Package cryptox holds the cryptographic primitives the Iranian PSPs ask for: 3DES-ECB signatures (Sadad), AES-CBC plus RSA envelopes (IranKish) and RSA signatures over the request body (Pasargad).
Package cryptox holds the cryptographic primitives the Iranian PSPs ask for: 3DES-ECB signatures (Sadad), AES-CBC plus RSA envelopes (IranKish) and RSA signatures over the request body (Pasargad).
gwopt
Package gwopt carries the gateway specific option state that lives inside core.Options, so every gateway package expresses its own options with the shared core.Option type instead of inventing a parallel one.
Package gwopt carries the gateway specific option state that lives inside core.Options, so every gateway package expresses its own options with the shared core.Option type instead of inventing a parallel one.
soap
Package soap is the minimal SOAP 1.1 client used by the bank gateways that still expose a webservice (Parsian, Mellat).
Package soap is the minimal SOAP 1.1 client used by the bank gateways that still expose a webservice (Parsian, Mellat).
testutil
Package testutil holds the fake gateway server the package's own tests are written against.
Package testutil holds the fake gateway server the package's own tests are written against.
tokenauth
Package tokenauth caches the short lived bearer tokens the OAuth style gateways hand out, so a gateway value can be built once at start-up and then shared by every request without re-authenticating on each call.
Package tokenauth caches the short lived bearer tokens the OAuth style gateways hand out, so a gateway value can be built once at start-up and then shared by every request without re-authenticating on each call.
transport
Package transport is the HTTP plumbing shared by every gateway: JSON, form and raw calls with timeout, retry, logging and header handling.
Package transport is the HTTP plumbing shared by every gateway: JSON, form and raw calls with timeout, retry, logging and header handling.

Jump to

Keyboard shortcuts

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