Skip to content

Payment Integration

Stoa provides a flexible payment architecture that separates payment methods (stored in the database) from payment processing (implemented as plugins).

Architecture

┌──────────────┐       ┌──────────────┐       ┌────────────────────┐
│  Storefront  │──────▶│  Stoa API    │──────▶│  PSP Plugin        │
│  (Checkout)  │       │  /checkout   │       │  (e.g. Stripe)     │
└──────────────┘       └──────┬───────┘       └────────┬───────────┘
                              │                        │
                     ┌────────▼────────┐      ┌────────▼───────────┐
                     │ PaymentMethod   │      │ Stripe API         │
                     │ (DB: config,    │      │ (external)         │
                     │  provider name) │      └────────────────────┘
                     └─────────────────┘
  1. A PaymentMethod record stores the provider name (e.g. "stripe") and encrypted credentials in the config field.
  2. A PSP plugin hooks into checkout, reads the decrypted config, and calls the external API.
  3. The plugin creates PaymentTransaction records to track the outcome.

Integration Checklist

StepWhatWhere
1Create plugin struct implementing sdk.Pluginplugins/<provider>/plugin.go
2Define a Config struct for provider credentialsSame file
3Hook into checkout.before to initiate paymentInit() method
4Parse PaymentMethod.Config (auto-decrypted JSON) for API keysHook handler
5Call the provider API to create a payment intent/sessionHook handler
6Create a payment_transactions record with status pendingHook handler
7Register a /api/v1/payments/<provider>/webhook endpointInit() method
8Verify webhook signature and update transaction statusWebhook handler
9Dispatch payment.after_complete or payment.after_failed hookWebhook handler
10Register the plugin in app.goRegisterPlugins()
11Create the payment method via admin API with provider credentialsAdmin API / UI

Step 1–2: Plugin Skeleton

go
package stripe

import (
    "github.com/jackc/pgx/v5/pgxpool"
    "github.com/rs/zerolog"
    "github.com/epoxx-arch/stoa/pkg/sdk"
)

const ProviderName = "stripe"

type Config struct {
    SecretKey      string `json:"secret_key"`
    WebhookSecret  string `json:"webhook_secret"`
    PublishableKey string `json:"publishable_key"`
}

type Plugin struct {
    db     *pgxpool.Pool
    logger zerolog.Logger
    hooks  *sdk.HookRegistry
}

func New() *Plugin { return &Plugin{} }

func (p *Plugin) Name() string        { return "stripe-payment" }
func (p *Plugin) Version() string     { return "1.0.0" }
func (p *Plugin) Description() string { return "Stripe payment integration" }
func (p *Plugin) Shutdown() error     { return nil }

Step 3: Hook into Checkout

go
func (p *Plugin) Init(app *sdk.AppContext) error {
    p.db = app.DB
    p.logger = app.Logger
    p.hooks = app.Hooks

    app.Hooks.On(sdk.HookBeforeCheckout, p.handleBeforeCheckout)

    app.Router.Route("/api/v1/payments/stripe", func(r chi.Router) {
        r.Post("/webhook", p.handleWebhook)
    })

    return nil
}

Step 4–5: Create a Payment Intent

go
func (p *Plugin) handleBeforeCheckout(ctx context.Context, event *sdk.HookEvent) error {
    o := event.Entity.(*order.Order)

    // Use the payment method service to get decrypted config
    method, err := p.paymentMethodSvc.GetByID(ctx, o.PaymentMethodID)
    if err != nil {
        return fmt.Errorf("stripe: %w", err)
    }
    if method.Provider != ProviderName {
        return nil // not our provider, skip
    }

    var cfg Config
    if err := json.Unmarshal(method.Config, &cfg); err != nil {
        return fmt.Errorf("stripe: invalid config: %w", err)
    }

    return p.createPaymentIntent(ctx, o, &cfg)
}

Step 6: Record the Transaction

go
func (p *Plugin) createPaymentIntent(ctx context.Context, o *order.Order, cfg *Config) error {
    // Call the Stripe API to create a PaymentIntent...
    // stripePaymentIntentID = "pi_xxx" from the response

    _, err := p.db.Exec(ctx, `
        INSERT INTO payment_transactions
            (id, order_id, payment_method_id, status, currency, amount, provider_reference, created_at)
        VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`,
        uuid.New(), o.ID, o.PaymentMethodID, "pending", o.Currency, o.Total, stripePaymentIntentID,
    )
    return err
}

Step 7–9: Webhook Handler

go
func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    // 1. Verify webhook signature using cfg.WebhookSecret
    // 2. Parse the event type

    _, err := p.db.Exec(r.Context(), `
        UPDATE payment_transactions
        SET status = $1
        WHERE provider_reference = $2`,
        "completed", providerReference,
    )
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    hookName := sdk.HookAfterPaymentFailed
    if eventType == "payment_intent.succeeded" {
        hookName = sdk.HookAfterPaymentComplete
    }
    _ = p.hooks.Dispatch(r.Context(), &sdk.HookEvent{Name: hookName, Entity: transaction})

    w.WriteHeader(http.StatusOK)
}

Step 10: Register the Plugin

go
import "github.com/epoxx-arch/stoa/plugins/stripe"

func (a *App) RegisterPlugins() error {
    appCtx := &plugin.AppContext{
        DB:     a.DB.Pool,
        Router: a.Server.Router(),
        Hooks:  a.PluginRegistry.Hooks(),
        Logger: a.Logger,
    }
    return a.PluginRegistry.Register(stripe.New(), appCtx)
}

Step 11: Create the Payment Method via API

bash
curl -X POST http://localhost:8080/api/v1/admin/payment-methods \
  -H 'Authorization: Bearer <token>' \
  -H 'Content-Type: application/json' \
  -d '{
    "provider": "stripe",
    "active": true,
    "config": {
      "secret_key": "sk_live_...",
      "publishable_key": "pk_live_...",
      "webhook_secret": "whsec_..."
    },
    "translations": [
      {"locale": "en-US", "name": "Credit Card", "description": "Pay with Visa, Mastercard, or Amex"},
      {"locale": "de-DE", "name": "Kreditkarte", "description": "Zahlen Sie mit Visa, Mastercard oder Amex"}
    ]
  }'

The config object is stored as AES-256-GCM encrypted bytes in the database and never exposed through the public store API.

Security Notes

  • Config encryption: All provider credentials are encrypted with AES-256-GCM at rest. Set STOA_PAYMENT_ENCRYPTION_KEY before starting the application.
  • Never expose secrets: The Config field is tagged json:"-" and never included in API responses.
  • Webhook verification: Always verify webhook signatures. Never trust unverified webhook payloads.
  • Scope provider access: Each payment method has its own isolated config. You can run multiple providers simultaneously.

Released under the MIT License.