🔧 Dev

OffreThe Augmented Engineering Programdès 2 500 € / mois

Tech Lead & équipe · ou MVP livré en 3 mois + recrutement

Découvrir

🧩 CH2 - Building a Modern Payment Gateway with a Rebill Scheduler - Chapter 2 – Integration & Abstractions

Sébastien Techer17/10/2025

Why you might want to build your own payment gateway — and how to do it safely with an external PCI-compliant vault.

🎯 The Goal

In Chapter 1, we defined the foundations: a modular gateway, external vault, and strict PCI boundaries.
Now it’s time to make it extensible — one core, many providers.

We’ll design a universal PSP interface in Go,
show how to plug in multiple payment providers (Stripe, Adyen, TrustPayments…),
and keep everything aligned with compliance and clean abstractions.


🧩 Designing the Universal PSP Interface

Each PSP has its own SDK, terminology, and quirks:

  • Stripe talks about “Payment Intents” and “Next Actions.”
  • Adyen uses “Payments API” with resultCode and redirect URLs.
  • TrustPayments has its own authorization + rebill flows.

To unify them, we define one common contract — the PSP interface.


// --- 3DS & SCA primitives ----------------------------------------------------


type ThreeDSVersion string
const (
    ThreeDS1 ThreeDSVersion = "1.0"
    ThreeDS2 ThreeDSVersion = "2.x"
)


// DeviceChannel per EMV 3DS
// 01 = App (SDK), 02 = Browser, 03 = 3RI (merchant-initiated)
type DeviceChannel string
const (
    DeviceApp DeviceChannel = "01"
    DeviceBrowser DeviceChannel = "02"
    Device3RI DeviceChannel = "03"
)


type ThreeDSRequest struct {
    Version ThreeDSVersion // desired or supported version
    Device DeviceChannel // app/browser/3RI
    ReturnURL string // where ACS/PSP redirects the customer (browser)
    NotificationURL string // where the provider posts results (app/3DS SDK)


    // For Browser flows (3DS1/2):
    BrowserAcceptHeader string
    BrowserUserAgent string
    ScreenColorDepth int
    ScreenWidth int
    ScreenHeight int
    Timezone int
    JavaEnabled bool
    Language string

    // For App (SDK) flows (3DS2):
    SdkAppID string
    SdkTransID string
    SdkEncData string
    SdkEphemPubKey string
    SdkMaxTimeout int
}

type ThreeDSOutcome struct {
    Version ThreeDSVersion
    ECI string // e.g. 05/06/07
    LiabilityShift bool
    AuthResult string // Y, A, N, U, R
    DSReference string // dsTransID / threeDS2TransID
    Cavv string
    Xid string // 3DS1
    Ares []byte // sanitized provider payloads (PII-free)
    Cres []byte
}

type Money struct { // smallest unit (cents)
    Currency string
    Amount int64
}


type PaymentMethodRef string // token from external vault

type InitiationMode string
const (
    CIT InitiationMode = "CIT" // customer-initiated
    MIT InitiationMode = "MIT" // merchant-initiated (3RI)
)


// NextAction guides the client on what to do to continue the flow
// (redirect to URL, run 3DS SDK challenge, display QR, etc.)
type NextAction struct {
    Type string // redirect|three_ds_challenge|fingerprint|none
    URL string // for redirects
    Payload map[string]string // key/values required by SDK/form (PII-free)
}


type PaymentIntent struct {
    ID string // provider-specific intent ID
    Status string // requires_payment_method|requires_action|processing|succeeded|canceled
    Amount Money // in smallest unit
    PSP string // provider name (Stripe, Adyen, etc.)
    PSPRef string // provider-specific reference
    PaymentRef PaymentMethodRef // tokenized ref from external vault
    Next *NextAction // if requires_action
    ThreeDS *ThreeDSOutcome
}


type PaymentRequest struct {
    TenantID string
    OrderID string
    Amount Money
    PaymentRef PaymentMethodRef // tokenized ref from external vault
    Capture bool // auth+capture or auth-only
    Mode InitiationMode // CIT/MIT (MIT may be SCA-exempt)
    ThreeDS *ThreeDSRequest // optional; provider decides 3DS1 vs 3DS2 fallback
    Metadata map[string]string
}


type PaymentResponse struct {
    Intent PaymentIntent,
    Raw []byte // minimal raw for audit/debug (PII-free)
}



type PSP interface {
  CreateIntent(ctx context.Context, req PaymentRequest) (PaymentResponse, error)
  ContinueIntent(ctx context.Context, intentID string, payload map[string]string) (PaymentResponse, error)
  Capture(ctx context.Context, intentID string, amount *Money) (PaymentResponse, error)
  Refund(ctx context.Context, intentID string, amount *Money) (PaymentResponse, error)
  Cancel(ctx context.Context, intentID string) error
  VerifyWebhook(ctx context.Context, signature string, payload []byte) (WebhookEvent, error)
}

This interface represents a complete lifecycle, independent of any SDK. Each adapter implements it, mapping between your internal types and the provider’s native objects.

⚙️ Example: Stripe Adapter

Let’s implement CreateIntent for Stripe.

func (s *StripePSP) CreateIntent(ctx context.Context, req ports.PaymentRequest) (ports.PaymentResponse, error) {
  params := &stripe.PaymentIntentParams{
    Amount:   stripe.Int64(req.Amount.Amount),
    Currency: stripe.String(req.Amount.Currency),
    Confirm:  stripe.Bool(req.Capture),
    Metadata: req.Metadata,
  }

  // 3DS flow (CIT vs MIT)
  if req.Mode == ports.CIT && req.ThreeDS != nil {
    params.PaymentMethodOptions = &stripe.PaymentIntentPaymentMethodOptionsParams{
      Card: &stripe.PaymentIntentPaymentMethodOptionsCardParams{
        RequestThreeDSecure: stripe.String("automatic"),
      },
    }
  }

  pi, err := paymentintent.New(params)
  if err != nil {
    return ports.PaymentResponse{}, err
  }

  return ports.PaymentResponse{
    Intent: ports.PaymentIntent{
      ID:         pi.ID,
      Status:     string(pi.Status),
      Amount:     ports.Money{Currency: string(pi.Currency), Amount: pi.Amount},
      PSP:        "stripe",
      PSPRef:     pi.ID,
      PaymentRef: req.PaymentRef,
      Next:       nextActionFromStripe(pi.NextAction),
      ThreeDS:    extractThreeDSOutcome(pi),
    },
  }, nil
}

By returning a PaymentIntent and a potential NextAction, your gateway can react uniformly whether it’s Stripe, Adyen, or TrustPayments.

🧠 Abstraction Patterns

Each PSP adapter lives under internal/psp/<provider> and implements ports.PSP. You can organize them like this:

internal/
  psp/
    stripe/
      stripe.go
      mapper.go       # convert internal ↔ external structs
      webhook.go
    adyen/
      adyen.go
      mapper.go
      webhook.go
    trustpayments/
      trustpayments.go
      mapper.go
      webhook.go

The mapper files handle conversion logic (e.g. PaymentRequest → API params). This ensures your domain layer never depends on SDK types.

Example mapping helper:

func nextActionFromStripe(na *stripe.PaymentIntentNextAction) *ports.NextAction {
  if na == nil {
    return &ports.NextAction{Type: "none"}
  }
  if na.RedirectToURL != nil {
    return &ports.NextAction{Type: "redirect", URL: na.RedirectToURL.URL}
  }
  return &ports.NextAction{Type: "three_ds_challenge"}
}

🔐 Staying PCI-Compliant with External Vaults

As in Chapter 1, your backend never sees card numbers. The vault (e.g. Checkout.com Vault, TokenEx, or Spreedly) handles all sensitive data.

The gateway only manipulates tokens:

Frontend calls the vault → receives a token.

Token sent to your backend.

Backend uses the vault’s API to resolve that token to a PaymentMethodRef.

That ref is sent to the PSP adapter (e.g. Stripe pm_xxx).

This allows you to integrate multiple PSPs with zero PCI impact.

🪄 Managing Provider Differences

Here’s how to keep it clean when PSPs diverge:

ConcernStrategy
SDKs differUse mappers and interface contracts.
Auth models differ (3DS1/3DS2)Normalize through ThreeDSRequest and ThreeDSOutcome.
Webhook formats differImplement VerifyWebhook() for each provider; return a standard WebhookEvent.
Statuses differMap everything to common intent statuses: requires_action, processing, succeeded, failed, canceled.
Retry semantics differHandle at the gateway level with an idempotent store.

🚀 Coming Next

In Chapter 3, we’ll focus on the payment lifecycle:

  • How to manage authorize → capture → refund → cancel flows.

  • How to build a robust idempotency engine.

  • How to make your gateway the single source of truth for payments.

Your gateway is now modular, extensible, and multi-PSP ready. Next, we make it reliable under real transaction pressure.


Respect de votre vie privée

Nous utilisons des cookies pour améliorer votre expérience, analyser le trafic et personnaliser le contenu. Vous pouvez choisir quels cookies accepter.