rentease/internal/driver/stripe/client.go
Ruidy 508de01116
Some checks failed
CI / checks (push) Has been cancelled
feat(stripe): add booking_id metadata to payment links
Refactored payment link creation to ensure booking_id is set in both the
PaymentLink and PaymentIntent metadata. Extracted parameter building
logic
into a helper for improved testability. Added a unit test to verify
metadata propagation.
2025-11-10 20:23:33 +01:00

231 lines
6 KiB
Go

package stripe
import (
"context"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/stripe/stripe-go/v83"
)
// Option configures a Client instance.
type Option func(*Client)
// Client wraps Stripe's SDK to expose the subset of functionality needed by the
// application while keeping the rest of the codebase decoupled from the SDK.
type Client struct {
api *stripe.Client
}
// New constructs a Client using the provided secret key. The key must not be empty.
func New(secretKey string, opts ...Option) (*Client, error) {
trimmed := strings.TrimSpace(secretKey)
if trimmed == "" {
return nil, errors.New("stripe secret key is required")
}
api := stripe.NewClient(trimmed)
client := &Client{api: api}
for _, opt := range opts {
opt(client)
}
return client, nil
}
// Payment represents the subset of payment intent data consumed by the booking service.
type Payment struct {
ID string
Amount float64
Currency string
Status string
PaymentMethod string
BookingID *uint
Created time.Time
}
// NormalizePaymentIntent converts a Stripe payment intent into the simplified Payment structure used
// by the application. Fields that are absent default to their zero values.
func NormalizePaymentIntent(pi *stripe.PaymentIntent) Payment {
if pi == nil {
return Payment{}
}
amount := float64(pi.AmountReceived) / 100.0
if amount == 0 {
amount = float64(pi.Amount) / 100.0
}
return Payment{
ID: pi.ID,
Amount: amount,
Currency: strings.ToUpper(string(pi.Currency)),
Status: string(pi.Status),
PaymentMethod: deriveMethod(pi),
BookingID: extractBookingID(pi.Metadata),
Created: time.Unix(int64(pi.Created), 0),
}
}
// ListPaymentsParams defines the time boundaries used when fetching Stripe payments.
type ListPaymentsParams struct {
From time.Time
To time.Time
}
// ListPayments fetches payment intents created within the provided time range. The
// results are normalised into Payment structs suitable for downstream processing.
func (c *Client) ListPayments(ctx context.Context, params ListPaymentsParams) ([]Payment, error) {
listParams := &stripe.PaymentIntentListParams{}
listParams.Context = ctx
listParams.AddExpand("data.latest_charge")
listParams.AddExpand("data.payment_method")
if !params.From.IsZero() {
listParams.Filters.AddFilter("created", "gte", strconv.FormatInt(params.From.Unix(), 10))
}
if !params.To.IsZero() {
listParams.Filters.AddFilter("created", "lte", strconv.FormatInt(params.To.Unix(), 10))
}
seq := c.api.V1PaymentIntents.List(ctx, listParams)
payments := make([]Payment, 0)
var listErr error
seq(func(pi *stripe.PaymentIntent, err error) bool {
if err != nil {
listErr = err
return false
}
if pi == nil {
return true
}
payments = append(payments, NormalizePaymentIntent(pi))
return true
})
if listErr != nil {
return nil, fmt.Errorf("stripe payment intents iteration failed: %w", listErr)
}
return payments, nil
}
// CreatePaymentLinkParams encapsulates the data required to generate a Stripe payment link.
type CreatePaymentLinkParams struct {
Amount float64
Currency string
BookingID uint
Description string
PaymentMethodTypes []string
}
// CreatePaymentLink creates a payment link for the provided booking metadata and amount.
func (c *Client) CreatePaymentLink(ctx context.Context, params CreatePaymentLinkParams) (string, error) {
linkParams, err := buildPaymentLinkCreateParams(params)
if err != nil {
return "", err
}
linkParams.Context = ctx
pl, err := c.api.V1PaymentLinks.Create(ctx, linkParams)
if err != nil {
return "", fmt.Errorf("failed to create payment link: %w", err)
}
return pl.URL, nil
}
func buildPaymentLinkCreateParams(params CreatePaymentLinkParams) (*stripe.PaymentLinkCreateParams, error) {
if params.Amount <= 0 {
return nil, errors.New("amount must be greater than zero")
}
currency := strings.ToLower(strings.TrimSpace(params.Currency))
if currency == "" {
return nil, errors.New("currency is required")
}
if params.BookingID == 0 {
return nil, errors.New("booking id is required")
}
amountCents := int64(math.Round(params.Amount * 100))
metadata := map[string]string{
"booking_id": strconv.FormatUint(uint64(params.BookingID), 10),
}
linkParams := &stripe.PaymentLinkCreateParams{
LineItems: []*stripe.PaymentLinkCreateLineItemParams{
{
PriceData: &stripe.PaymentLinkCreateLineItemPriceDataParams{
Currency: stripe.String(currency),
ProductData: &stripe.PaymentLinkCreateLineItemPriceDataProductDataParams{
Name: stripe.String(strings.TrimSpace(params.Description)),
},
UnitAmount: stripe.Int64(amountCents),
},
Quantity: stripe.Int64(1),
},
},
Metadata: metadata,
PaymentIntentData: &stripe.PaymentLinkCreatePaymentIntentDataParams{
Metadata: metadata,
},
}
if linkParams.LineItems[0].PriceData.ProductData.Name == nil || *linkParams.LineItems[0].PriceData.ProductData.Name == "" {
linkParams.LineItems[0].PriceData.ProductData.Name = stripe.String(fmt.Sprintf("Booking %d", params.BookingID))
}
if len(params.PaymentMethodTypes) > 0 {
linkParams.PaymentMethodTypes = stripe.StringSlice(params.PaymentMethodTypes)
}
return linkParams, nil
}
func deriveMethod(pi *stripe.PaymentIntent) string {
if pi == nil {
return ""
}
if pi.LatestCharge != nil && pi.LatestCharge.PaymentMethodDetails != nil {
typ := pi.LatestCharge.PaymentMethodDetails.Type
if typ != "" {
return string(typ)
}
}
if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" {
return string(pi.PaymentMethod.Type)
}
if len(pi.PaymentMethodTypes) > 0 {
return pi.PaymentMethodTypes[0]
}
return ""
}
func extractBookingID(metadata map[string]string) *uint {
if len(metadata) == 0 {
return nil
}
keys := []string{"booking_id", "bookingId", "bookingID"}
for _, key := range keys {
if raw, ok := metadata[key]; ok {
if raw == "" {
continue
}
if id, err := strconv.ParseUint(raw, 10, 32); err == nil {
value := uint(id)
return &value
}
}
}
return nil
}