English | پارسی
payment is a provider-neutral payment package for Go. It provides a small common API for purchasing, verifying, and optionally refunding payments while keeping provider configuration and protocol details in dedicated packages.
Each client is bound to one gateway. There is no global registry or mutable default, so applications remain in control of provider selection, persistence, retries, reconciliation, and business rules.
- Go 1.24 or newer
go get github.com/codenaline/payment@latestImport the root package and only the provider packages your application uses:
import (
"github.com/codenaline/payment"
"github.com/codenaline/payment/zarinpal"
)| Provider | Purchase | Verify | Refund | Currencies | Sandbox |
|---|---|---|---|---|---|
| ZarinPal | ✅ | ✅ | ❌ | IRR | ✅ |
| NextPay | ✅ | ✅ | ✅ | IRR, IRT | ❌ |
| SEP | ✅ | ✅ | ❌ | IRR | ❌ |
Refunding is an optional capability. Calling Client.Refund with a gateway that does not implement payment.Refunder returns payment.ErrUnsupported.
Create a gateway, bind it to a client, and initiate a payment:
package main
import (
"context"
"fmt"
"github.com/codenaline/payment"
"github.com/codenaline/payment/zarinpal"
)
func main() {
gateway, err := zarinpal.New(zarinpal.Config{
MerchantID: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
Sandbox: true,
})
if err != nil {
panic(err)
}
client := payment.NewClient(gateway)
result, err := client.Purchase(context.Background(), payment.PurchaseRequest{
OrderID: "order-1234",
Amount: payment.Money{
Amount: 100_000,
Currency: payment.CurrencyIRR,
},
CallbackURL: "https://example.com/payments/callback",
Description: "Order #1234",
})
if err != nil {
panic(err)
}
// Store the transaction before redirecting the customer.
fmt.Println(result.Transaction.ID)
fmt.Println(result.RedirectURL)
}Persist the transaction ID, order ID, amount, currency, and current status before redirecting the customer to PurchaseResponse.RedirectURL.
Money.Amount is an integer expressed in the selected currency unit. The package does not convert currencies or units; pass the unit expected by the configured provider.
The browser callback is not proof of payment. After the provider redirects the customer, verify the transaction from a trusted server using the stored transaction ID and original amount:
transaction, err := client.Verify(ctx, payment.VerifyRequest{
TransactionID: storedTransactionID,
Amount: payment.Money{
Amount: 100_000,
Currency: payment.CurrencyIRR,
},
})
if err != nil {
return err
}
if transaction.Status == payment.StatusPaid {
// Persist the paid state before fulfilling the order.
}Make callback processing idempotent. Store the verified state before fulfillment and return the existing successful result when the same paid callback is received again.
ZarinPal response codes 100 (verified) and 101 (already verified) both return a paid transaction without an error.
SEP returns a new RefNum to the callback after payment. For SEP, validate the callback fields and pass that RefNum as VerifyRequest.TransactionID; do not pass the purchase token. Prevent a RefNum from being used for more than one order.
gateway, err := zarinpal.New(zarinpal.Config{
MerchantID: "your-merchant-id",
Sandbox: true, // Optional; false by default.
HTTPClient: httpClient, // Optional.
})ZarinPal accepts IRR. Purchases require a positive amount, an absolute callback URL, and a non-empty description. If HTTPClient is nil, the provider uses an HTTP client with a 30-second timeout.
gateway, err := nextpay.New(nextpay.Config{
APIKey: "your-api-key",
HTTPClient: httpClient, // Optional.
})NextPay accepts IRR and IRT. Purchases require a positive amount, a non-empty OrderID, and an absolute callback URL. If HTTPClient is nil, the provider uses an HTTP client with a 30-second timeout.
NextPay forwards these optional PurchaseRequest.Metadata keys when present: customer_phone, payer_name, and allowed_card.
gateway, err := sep.New(sep.Config{
TerminalID: 12345678,
HTTPClient: httpClient, // Optional.
})SEP accepts IRR. Purchases require a positive amount, a non-empty OrderID, and an absolute callback URL. If HTTPClient is nil, the provider uses an HTTP client with a 30-second timeout.
SEP requires the merchant server's public IP to be registered. After a successful callback, verify its RefNum within 30 minutes and compare it with the stored order and amount. SEP does not publish a general sandbox endpoint.
Create one client for each configured gateway and keep provider selection in the application:
zarinpalGateway, err := zarinpal.New(zarinpal.Config{
MerchantID: "your-merchant-id",
})
if err != nil {
return err
}
nextpayGateway, err := nextpay.New(nextpay.Config{
APIKey: "your-api-key",
})
if err != nil {
return err
}
zarinpalClient := payment.NewClient(zarinpalGateway)
nextpayClient := payment.NewClient(nextpayGateway)
// Select a client using application-owned business rules.
_ = zarinpalClient
_ = nextpayClientpayment.NewClient panics when passed a nil gateway. A client cannot switch gateways after construction.
NextPay implements the optional payment.Refunder capability. ZarinPal and SEP currently do not.
refund, err := client.Refund(ctx, payment.RefundRequest{
TransactionID: transactionID,
Amount: amount,
Reason: "customer request",
})
if errors.Is(err, payment.ErrUnsupported) {
// Use a provider-specific or manual refund process.
}Applications should persist the refund result and reconcile it according to their own policies.
The root package exposes portable sentinel errors. Use errors.Is for provider-independent decisions:
switch {
case errors.Is(err, payment.ErrInvalidRequest):
// Fix the request; retrying it unchanged will not help.
case errors.Is(err, payment.ErrNetwork):
// Apply the application's retry and reconciliation policy.
case errors.Is(err, payment.ErrDeclined):
// Ask the customer to use another payment method.
case errors.Is(err, payment.ErrTransactionNotFound):
// Reconcile the stored transaction details.
case errors.Is(err, payment.ErrCanceled):
// Record the canceled payment.
case errors.Is(err, payment.ErrProvider):
// Handle an unclassified provider failure.
}Provider packages expose typed errors for diagnostics. Use errors.As only when provider-specific details are needed:
var providerError *zarinpal.Error
if errors.As(err, &providerError) {
fmt.Printf("ZarinPal %s failed with code %d: %s\n",
providerError.Operation,
providerError.Code,
providerError.Message,
)
}Avoid showing raw provider errors to customers because they may contain operational details.
Implement payment.Gateway to add a provider without global registration:
type CustomGateway struct{}
func (*CustomGateway) Purchase(
ctx context.Context,
request payment.PurchaseRequest,
) (payment.PurchaseResponse, error) {
return payment.PurchaseResponse{}, nil
}
func (*CustomGateway) Verify(
ctx context.Context,
request payment.VerifyRequest,
) (payment.Transaction, error) {
return payment.Transaction{}, nil
}
client := payment.NewClient(&CustomGateway{})Implement payment.Refunder when the provider supports refunds. Custom providers should wrap the root sentinel errors and expose a provider-specific error type when callers need more detail.
This package intentionally does not manage:
- Gateway selection or routing
- Transaction, order, and refund persistence
- Callback authentication and idempotency
- Retry, timeout, and reconciliation policies
- Logging, metrics, or tracing
- Fulfillment and other business rules
Never commit credentials or include merchant IDs, API keys, customer information, or complete callback data in logs and public bug reports.
Contributions are welcome. Read CONTRIBUTING.md before opening a pull request. Use GitHub Discussions for usage questions and follow SECURITY.md to report vulnerabilities privately.
Released under the MIT License.