164 lines
5.4 KiB
Go
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
|
|
}
|