Files
go-cart-actor/pkg/order/stripe.go
T
mats 1a365de071
Build and Publish / BuildAndDeployArm64 (push) Failing after 5s
Build and Publish / BuildAndDeployAmd64 (push) Has been cancelled
order sagas
2026-06-21 11:33:20 +02:00

164 lines
5.4 KiB
Go

package order
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// StripeProvider implements PaymentProvider against the Stripe REST API using
// PaymentIntents with manual capture (authorize now, capture on fulfillment),
// mirroring the order state machine. It speaks the form-encoded REST API
// directly — no SDK dependency — so it stays in step with the dependency-light
// style of the rest of the module.
//
// Flow mapping:
// - Authorize -> POST /v1/payment_intents (capture_method=manual, confirm=true)
// - Capture -> POST /v1/payment_intents/{id}/capture
// - Refund -> POST /v1/refunds (payment_intent={id})
// - Void -> POST /v1/payment_intents/{id}/cancel
//
// The authorization reference is the PaymentIntent id (pi_...), which is what
// Capture/Refund/Void operate on.
type StripeProvider struct {
secretKey string
baseURL string // defaults to https://api.stripe.com
// PaymentMethod is the payment method id/token to confirm with. For a real
// integration this comes from the client (Stripe.js); for server-side tests
// and headless flows it can be a test token like "pm_card_visa".
PaymentMethod string
client *http.Client
}
var _ PaymentProvider = (*StripeProvider)(nil)
// NewStripeProvider returns a Stripe-backed provider. baseURL may be empty to
// use the live API; tests pass a fake server URL.
func NewStripeProvider(secretKey, baseURL, paymentMethod string) *StripeProvider {
if baseURL == "" {
baseURL = "https://api.stripe.com"
}
if paymentMethod == "" {
paymentMethod = "pm_card_visa" // Stripe test token; override for prod
}
return &StripeProvider{
secretKey: secretKey,
baseURL: strings.TrimSuffix(baseURL, "/"),
PaymentMethod: paymentMethod,
client: &http.Client{Timeout: 20 * time.Second},
}
}
func (s *StripeProvider) Name() string { return "stripe" }
// post sends a form-encoded request and decodes the JSON response, returning an
// error for any non-2xx (surfacing Stripe's error message).
func (s *StripeProvider) post(ctx context.Context, path string, form url.Values) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+path, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.SetBasicAuth(s.secretKey, "")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("stripe: decode %s: %w", path, err)
}
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("stripe: %s -> %d: %s", path, resp.StatusCode, stripeErr(body))
}
return body, nil
}
func stripeErr(body map[string]any) string {
if e, ok := body["error"].(map[string]any); ok {
if m, ok := e["message"].(string); ok {
return m
}
}
return "unknown error"
}
func strField(body map[string]any, key string) string {
if v, ok := body[key].(string); ok {
return v
}
return ""
}
func (s *StripeProvider) Authorize(ctx context.Context, orderRef string, amount int64, currency string) (Authorization, error) {
form := url.Values{}
form.Set("amount", strconv.FormatInt(amount, 10))
form.Set("currency", strings.ToLower(currency))
form.Set("capture_method", "manual")
form.Set("confirm", "true")
form.Set("payment_method", s.PaymentMethod)
form.Set("description", "order "+orderRef)
// Idempotency on the order reference avoids a duplicate intent on retry.
form.Set("metadata[order_reference]", orderRef)
body, err := s.post(ctx, "/v1/payment_intents", form)
if err != nil {
return Authorization{}, err
}
id := strField(body, "id")
if id == "" {
return Authorization{}, fmt.Errorf("stripe: authorize returned no payment intent id")
}
if status := strField(body, "status"); status != "requires_capture" && status != "succeeded" {
return Authorization{}, fmt.Errorf("stripe: authorize status %q", status)
}
return Authorization{Provider: s.Name(), Reference: id, Amount: amount}, nil
}
func (s *StripeProvider) Capture(ctx context.Context, authRef string, amount int64) (Capture, error) {
if authRef == "" {
return Capture{}, fmt.Errorf("stripe: capture requires a payment intent id")
}
form := url.Values{}
if amount > 0 {
form.Set("amount_to_capture", strconv.FormatInt(amount, 10))
}
body, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/capture", form)
if err != nil {
return Capture{}, err
}
return Capture{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
}
func (s *StripeProvider) Refund(ctx context.Context, captureRef string, amount int64) (RefundResult, error) {
if captureRef == "" {
return RefundResult{}, fmt.Errorf("stripe: refund requires a payment intent id")
}
form := url.Values{}
form.Set("payment_intent", captureRef)
if amount > 0 {
form.Set("amount", strconv.FormatInt(amount, 10))
}
body, err := s.post(ctx, "/v1/refunds", form)
if err != nil {
return RefundResult{}, err
}
return RefundResult{Provider: s.Name(), Reference: strField(body, "id"), Amount: amount}, nil
}
func (s *StripeProvider) Void(ctx context.Context, authRef string) error {
if authRef == "" {
return fmt.Errorf("stripe: void requires a payment intent id")
}
_, err := s.post(ctx, "/v1/payment_intents/"+url.PathEscape(authRef)+"/cancel", url.Values{})
return err
}