mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-10 12:46:53 +00:00
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.
165 lines
5 KiB
Go
165 lines
5 KiB
Go
package booking
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/rjNemo/rentease/internal/config"
|
|
stripeclient "github.com/rjNemo/rentease/internal/driver/stripe"
|
|
)
|
|
|
|
type fakeStripeClient struct {
|
|
payments []stripeclient.Payment
|
|
err error
|
|
}
|
|
|
|
func (f *fakeStripeClient) ListPayments(ctx context.Context, params stripeclient.ListPaymentsParams) ([]stripeclient.Payment, error) {
|
|
return f.payments, f.err
|
|
}
|
|
|
|
type mockStore struct {
|
|
upserts []*Payment
|
|
err error
|
|
byStripeID map[string]*Payment
|
|
}
|
|
|
|
func (m *mockStore) record(p *Payment) (*Payment, error) {
|
|
cp := *p
|
|
m.upserts = append(m.upserts, &cp)
|
|
if cp.StripePaymentID != nil {
|
|
if m.byStripeID == nil {
|
|
m.byStripeID = make(map[string]*Payment)
|
|
}
|
|
clone := cp
|
|
m.byStripeID[*cp.StripePaymentID] = &clone
|
|
}
|
|
if m.err != nil {
|
|
return nil, m.err
|
|
}
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *mockStore) All() []*Line { return nil }
|
|
func (m *mockStore) Search(string) []*Line { return nil }
|
|
func (m *mockStore) List(time.Time, time.Time) ([]*Line, error) { return nil, nil }
|
|
func (m *mockStore) CardTotal(time.Time, time.Time) (float64, error) { return 0, nil }
|
|
func (m *mockStore) Get(int) *Booking { return nil }
|
|
func (m *mockStore) Create(*Booking) error { return nil }
|
|
func (m *mockStore) Update(*Booking) error { return nil }
|
|
func (m *mockStore) Cancel(int) error { return nil }
|
|
func (m *mockStore) CreateItem(*Item) error { return nil }
|
|
func (m *mockStore) PayItem(int) (*Item, error) { return nil, nil }
|
|
func (m *mockStore) GetItem(int) (*Item, error) { return nil, nil }
|
|
func (m *mockStore) UpdateItem(int, string, string, string, int, float64) (*Item, error) {
|
|
return nil, nil
|
|
}
|
|
func (m *mockStore) CreatePayment(*Payment) (*Payment, error) { return nil, nil }
|
|
func (m *mockStore) GetPayment(int) (*Payment, error) { return nil, nil }
|
|
func (m *mockStore) UpdatePayment(int, float64, string) (*Payment, error) { return nil, nil }
|
|
func (m *mockStore) UpsertStripePayment(p *Payment) (*Payment, error) { return m.record(p) }
|
|
func (m *mockStore) FindStripePayment(id string) (*Payment, error) {
|
|
if m.byStripeID == nil {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
if p, ok := m.byStripeID[id]; ok {
|
|
clone := *p
|
|
return &clone, nil
|
|
}
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
func TestSyncStripePayments(t *testing.T) {
|
|
bookingID := uint(42)
|
|
stripePayments := []stripeclient.Payment{
|
|
{
|
|
ID: "pi_123",
|
|
Amount: 120.50,
|
|
PaymentMethod: "card",
|
|
Status: "succeeded",
|
|
BookingID: &bookingID,
|
|
},
|
|
}
|
|
|
|
store := &mockStore{}
|
|
stripe := &fakeStripeClient{payments: stripePayments}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
svc, err := NewService(logger, store, nil, nil, stripe)
|
|
if err != nil {
|
|
t.Fatalf("NewService returned error: %v", err)
|
|
}
|
|
|
|
if err := svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now()); err != nil {
|
|
t.Fatalf("SyncStripePayments returned error: %v", err)
|
|
}
|
|
|
|
if len(store.upserts) != 1 {
|
|
t.Fatalf("expected 1 upsert, got %d", len(store.upserts))
|
|
}
|
|
|
|
upsert := store.upserts[0]
|
|
if upsert.Amount != 120.50 {
|
|
t.Errorf("unexpected amount: %v", upsert.Amount)
|
|
}
|
|
if upsert.PaymentMethod != config.PaymentMethod("Card") {
|
|
t.Errorf("unexpected payment method: %v", upsert.PaymentMethod)
|
|
}
|
|
if upsert.StripePaymentID == nil || *upsert.StripePaymentID != "pi_123" {
|
|
t.Errorf("stripe payment id not set correctly: %v", upsert.StripePaymentID)
|
|
}
|
|
}
|
|
|
|
func TestSyncStripePaymentsSkipsMissingBooking(t *testing.T) {
|
|
stripePayments := []stripeclient.Payment{
|
|
{ID: "pi_123", Amount: 10},
|
|
}
|
|
|
|
store := &mockStore{}
|
|
stripe := &fakeStripeClient{payments: stripePayments}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
svc, err := NewService(logger, store, nil, nil, stripe)
|
|
if err != nil {
|
|
t.Fatalf("NewService returned error: %v", err)
|
|
}
|
|
|
|
if err := svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now()); err != nil {
|
|
t.Fatalf("SyncStripePayments returned error: %v", err)
|
|
}
|
|
|
|
if len(store.upserts) != 0 {
|
|
t.Fatalf("expected 0 upserts, got %d", len(store.upserts))
|
|
}
|
|
}
|
|
|
|
func TestSyncStripePaymentsReturnsAggregatedError(t *testing.T) {
|
|
bookingID := uint(7)
|
|
stripePayments := []stripeclient.Payment{
|
|
{
|
|
ID: "pi_err",
|
|
Amount: 50,
|
|
PaymentMethod: "card",
|
|
Status: "succeeded",
|
|
BookingID: &bookingID,
|
|
},
|
|
}
|
|
|
|
store := &mockStore{err: errors.New("db failure")}
|
|
stripe := &fakeStripeClient{payments: stripePayments}
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
svc, err := NewService(logger, store, nil, nil, stripe)
|
|
if err != nil {
|
|
t.Fatalf("NewService returned error: %v", err)
|
|
}
|
|
|
|
err = svc.SyncStripePayments(context.Background(), time.Now().Add(-time.Hour), time.Now())
|
|
if err == nil {
|
|
t.Fatalf("expected error, got nil")
|
|
}
|
|
}
|