order sagas
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user