main.go
go run .
package main
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gofiber/fiber/v3"
)
const checkoutPath = "/api/v1/merchant/checkout/sessions"
type TenkiPayClient struct {
baseURL string
publicKey string
secretKey string
http *http.Client
}
type checkoutRequest struct {
MerchantReference string `json:"merchant_reference"`
Amount string `json:"amount"`
Currency string `json:"currency"`
Description string `json:"description"`
SuccessURL string `json:"success_url"`
CancelURL string `json:"cancel_url"`
}
type checkoutSession struct {
SessionID string `json:"session_id"`
PaymentIntentID string `json:"payment_intent_id"`
CheckoutURL string `json:"checkout_url"`
}
type apiEnvelope struct {
Data checkoutSession `json:"data"`
Message string `json:"message"`
}
func (client *TenkiPayClient) createCheckout(ctx context.Context, payload checkoutRequest, idempotencyKey string) (checkoutSession, error) {
// Marshal once because TenkiPay verifies the exact JSON bytes sent on the wire.
body, err := json.Marshal(payload)
if err != nil {
return checkoutSession{}, err
}
timestamp := time.Now().UTC().Format(time.RFC3339Nano)
canonical := strings.Join([]string{timestamp, http.MethodPost, checkoutPath, string(body)}, "\n")
mac := hmac.New(sha256.New, []byte(client.secretKey))
_, _ = mac.Write([]byte(canonical))
signature := hex.EncodeToString(mac.Sum(nil))
request, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+checkoutPath, bytes.NewReader(body))
if err != nil {
return checkoutSession{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-TenkiPay-Key", client.publicKey)
request.Header.Set("X-TenkiPay-Timestamp", timestamp)
request.Header.Set("X-TenkiPay-Signature", signature)
request.Header.Set("Idempotency-Key", idempotencyKey)
response, err := client.http.Do(request)
if err != nil {
return checkoutSession{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return checkoutSession{}, err
}
var envelope apiEnvelope
if err := json.Unmarshal(responseBody, &envelope); err != nil {
return checkoutSession{}, fmt.Errorf("decode TenkiPay response: %w", err)
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return checkoutSession{}, fmt.Errorf("TenkiPay returned %d: %s", response.StatusCode, envelope.Message)
}
return envelope.Data, nil
}
func main() {
client := &TenkiPayClient{
baseURL: strings.TrimRight(env("TENKIPAY_BASE_URL", "https://me.tenkipay.com"), "/"),
publicKey: os.Getenv("TENKIPAY_PUBLIC_KEY"),
secretKey: os.Getenv("TENKIPAY_SECRET_KEY"),
http: &http.Client{Timeout: 15 * time.Second},
}
if client.publicKey == "" || client.secretKey == "" {
panic("TenkiPay server credentials are required")
}
app := fiber.New()
app.Post("/payments/tenkipay", func(c fiber.Ctx) error {
var input struct {
OrderID string `form:"order_id" json:"order_id"`
}
if err := c.Bind().Body(&input); err != nil || input.OrderID == "" {
return fiber.NewError(fiber.StatusBadRequest, "order_id is required")
}
// Replace this placeholder with an authenticated, customer-scoped query.
order, err := loadPendingOrder(input.OrderID)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, "order not found")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
session, err := client.createCheckout(ctx, checkoutRequest{
MerchantReference: order.Reference,
Amount: order.TrustedTotal,
Currency: "SLE",
Description: "Order " + order.Reference,
SuccessURL: env("STORE_URL", "http://localhost:3000") + "/orders/" + order.ID + "/payment-return",
CancelURL: env("STORE_URL", "http://localhost:3000") + "/orders/" + order.ID,
}, "checkout:"+order.ID+":v1")
if err != nil {
return fiber.NewError(fiber.StatusBadGateway, "payment service is temporarily unavailable")
}
// Persist session IDs before redirecting so webhooks can find this order.
if err := saveCheckoutReferences(order.ID, session); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "could not save checkout")
}
return c.Redirect().Status(fiber.StatusSeeOther).To(session.CheckoutURL)
})
if err := app.Listen(":" + env("PORT", "3000")); err != nil {
panic(err)
}
}
type order struct {
ID string
Reference string
TrustedTotal string
}
func loadPendingOrder(id string) (order, error) {
if id == "" {
return order{}, errors.New("missing order")
}
return order{ID: id, Reference: "ORDER-" + id, TrustedTotal: "249.50"}, nil
}
func saveCheckoutReferences(_ string, _ checkoutSession) error { return nil }
func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}