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 }