rentease/internal/driver/stripe/client.go
Ruidy 8384d85e3e
feat(stripe): add Stripe payment sync and webhook support
Introduce Stripe integration for automatic payment ingestion and refund
tracking. Adds new fields to the payment model for Stripe IDs and
status,
Stripe client driver, sync service, cron job, manual API endpoint, and
public webhook handler for real-time updates. Includes tests and
documentation. Manual cash entry remains supported.
2025-10-03 21:39:59 +02:00

166 lines
4 KiB
Go

package stripe
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
stripe "github.com/stripe/stripe-go/v79"
stripeclient "github.com/stripe/stripe-go/v79/client"
)
// Option configures a Client instance.
type Option func(*Client)
// WithAccount sets the Stripe connected account identifier used for requests.
func WithAccount(account string) Option {
return func(c *Client) {
c.account = strings.TrimSpace(account)
}
}
// 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 *stripeclient.API
account string
}
// 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 := stripeclient.New(trimmed, nil)
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))
}
if c.account != "" {
listParams.SetStripeAccount(c.account)
}
iter := c.api.PaymentIntents.List(listParams)
payments := make([]Payment, 0)
for iter.Next() {
pi := iter.PaymentIntent()
if pi == nil {
continue
}
payments = append(payments, NormalizePaymentIntent(pi))
}
if err := iter.Err(); err != nil {
return nil, fmt.Errorf("stripe payment intents iteration failed: %w", err)
}
return payments, 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
}