Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,6 @@ mod-tidy: ## Tidy Go modules
# Maintenance commands
expire-checkouts: ## Expire old checkouts manually
go run ./cmd/expire-checkouts

force-delete-checkouts: ## Force delete all expired, abandoned, and old completed checkouts
go run ./cmd/expire-checkouts -force
43 changes: 32 additions & 11 deletions cmd/expire-checkouts/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"flag"
"log"

"github.com/joho/godotenv"
Expand All @@ -11,14 +12,22 @@ import (
)

func main() {
// Parse command line flags
forceDelete := flag.Bool("force", false, "Force delete all expired, abandoned, and old completed checkouts")
flag.Parse()

// Load environment variables
if err := godotenv.Load(); err != nil {
log.Println("No .env file found, using environment variables")
}

// Initialize logger
logger := logger.NewLogger()
logger.Info("Starting checkout expiry cleanup tool")
if *forceDelete {
logger.Info("Starting checkout force deletion tool")
} else {
logger.Info("Starting checkout expiry cleanup tool")
}

// Load configuration
cfg, err := config.LoadConfig()
Expand All @@ -39,15 +48,27 @@ func main() {
// Get checkout use case
checkoutUseCase := diContainer.UseCases().CheckoutUseCase()

// Expire old checkouts
result, err := checkoutUseCase.ExpireOldCheckouts()
if err != nil {
logger.Fatal("Failed to expire old checkouts: %v", err)
}
if *forceDelete {
// Force delete all expired checkouts
deleteResult, err := checkoutUseCase.ForceDeleteAllExpiredCheckouts()
if err != nil {
logger.Fatal("Failed to force delete expired checkouts: %v", err)
}

logger.Info("Force deletion completed:")
logger.Info("- Force deleted checkouts: %d", deleteResult.DeletedCount)
logger.Info("Total processed: %d", deleteResult.DeletedCount)
} else {
// Regular expire old checkouts
expireResult, err := checkoutUseCase.ExpireOldCheckouts()
if err != nil {
logger.Fatal("Failed to expire old checkouts: %v", err)
}

logger.Info("Checkout cleanup completed:")
logger.Info("- Abandoned checkouts: %d", result.AbandonedCount)
logger.Info("- Deleted checkouts: %d", result.DeletedCount)
logger.Info("- Expired checkouts: %d", result.ExpiredCount)
logger.Info("Total processed: %d", result.AbandonedCount+result.DeletedCount+result.ExpiredCount)
logger.Info("Checkout cleanup completed:")
logger.Info("- Abandoned checkouts: %d", expireResult.AbandonedCount)
logger.Info("- Deleted checkouts: %d", expireResult.DeletedCount)
logger.Info("- Expired checkouts: %d", expireResult.ExpiredCount)
logger.Info("Total processed: %d", expireResult.AbandonedCount+expireResult.DeletedCount+expireResult.ExpiredCount)
}
}
42 changes: 42 additions & 0 deletions internal/application/usecase/checkout_usecase.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,28 @@ func (uc *CheckoutUseCase) ExpireOldCheckoutsLegacy() (int, error) {
return result.AbandonedCount + result.DeletedCount + result.ExpiredCount, nil
}

// ForceDeleteAllExpiredCheckouts forcefully deletes all expired, abandoned, and old completed checkouts
func (uc *CheckoutUseCase) ForceDeleteAllExpiredCheckouts() (*CheckoutCleanupResult, error) {
result := &CheckoutCleanupResult{}

// Get all expired checkouts for deletion
checkoutsToDelete, err := uc.checkoutRepo.GetAllExpiredCheckoutsForDeletion()
if err != nil {
return result, fmt.Errorf("failed to get expired checkouts for deletion: %w", err)
}

for _, checkout := range checkoutsToDelete {
err = uc.checkoutRepo.Delete(checkout.ID)
if err != nil {
log.Printf("Failed to force delete checkout %d: %v", checkout.ID, err)
continue
}
result.DeletedCount++
}

return result, nil
}

// CreateOrderFromCheckout creates an order from a checkout
func (uc *CheckoutUseCase) CreateOrderFromCheckout(checkoutID uint) (*entity.Order, error) {
// Get checkout
Expand Down Expand Up @@ -780,6 +802,26 @@ func (uc *CheckoutUseCase) GetOrCreateCheckoutBySessionIDWithCurrency(sessionID
return checkout, nil
}

// If no active checkout found, try to get an abandoned checkout and reactivate it
abandonedCheckout, err := uc.checkoutRepo.GetAbandonedBySessionID(sessionID)
if err == nil {
// Reactivate the abandoned checkout
abandonedCheckout.Reactivate()

// If currency is specified, change currency if different
if currency != "" && abandonedCheckout.Currency != currency {
return uc.ChangeCurrency(abandonedCheckout, currency)
}

// Update the checkout in repository
err = uc.checkoutRepo.Update(abandonedCheckout)
if err != nil {
return nil, fmt.Errorf("failed to reactivate abandoned checkout: %w", err)
}

return abandonedCheckout, nil
}

// If not found, create a new one
var checkoutCurrency string
if currency != "" {
Expand Down
6 changes: 6 additions & 0 deletions internal/domain/entity/checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,12 @@ func (c *Checkout) MarkAsExpired() {
c.LastActivityAt = time.Now()
}

// Reactivate marks an abandoned checkout as active again
func (c *Checkout) Reactivate() {
c.Status = CheckoutStatusActive
c.LastActivityAt = time.Now()
}

// IsExpired checks if the checkout has expired
func (c *Checkout) IsExpired() bool {
return time.Now().After(c.ExpiresAt)
Expand Down
31 changes: 31 additions & 0 deletions internal/domain/entity/checkout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,37 @@ func TestCheckoutStatus(t *testing.T) {
assert.Equal(t, CheckoutStatus("abandoned"), CheckoutStatusAbandoned)
assert.Equal(t, CheckoutStatus("expired"), CheckoutStatusExpired)
})

t.Run("MarkAsAbandoned", func(t *testing.T) {
checkout, err := NewCheckout("session123", "USD")
require.NoError(t, err)

originalTime := checkout.LastActivityAt
time.Sleep(1 * time.Millisecond) // Ensure time difference

checkout.MarkAsAbandoned()

assert.Equal(t, CheckoutStatusAbandoned, checkout.Status)
assert.True(t, checkout.LastActivityAt.After(originalTime))
})

t.Run("Reactivate", func(t *testing.T) {
checkout, err := NewCheckout("session123", "USD")
require.NoError(t, err)

// First mark as abandoned
checkout.MarkAsAbandoned()
assert.Equal(t, CheckoutStatusAbandoned, checkout.Status)

originalTime := checkout.LastActivityAt
time.Sleep(1 * time.Millisecond) // Ensure time difference

// Then reactivate
checkout.Reactivate()

assert.Equal(t, CheckoutStatusActive, checkout.Status)
assert.True(t, checkout.LastActivityAt.After(originalTime))
})
}

func TestCheckoutDTOConversions(t *testing.T) {
Expand Down
6 changes: 6 additions & 0 deletions internal/domain/repository/checkout_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ type CheckoutRepository interface {
// GetBySessionID retrieves an active checkout by session ID
GetBySessionID(sessionID string) (*entity.Checkout, error)

// GetAbandonedBySessionID retrieves an abandoned checkout by session ID
GetAbandonedBySessionID(sessionID string) (*entity.Checkout, error)

// Update updates a checkout
Update(checkout *entity.Checkout) error

Expand Down Expand Up @@ -45,4 +48,7 @@ type CheckoutRepository interface {

// HasActiveCheckoutsWithProduct checks if a product has any active checkouts
HasActiveCheckoutsWithProduct(productID uint) (bool, error)

// GetAllExpiredCheckoutsForDeletion retrieves all expired checkouts for force deletion
GetAllExpiredCheckoutsForDeletion() ([]*entity.Checkout, error)
}
63 changes: 62 additions & 1 deletion internal/infrastructure/repository/gorm/checkout_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ func (c *CheckoutRepository) GetBySessionID(sessionID string) (*entity.Checkout,
return &checkout, nil
}

// GetAbandonedBySessionID implements repository.CheckoutRepository.
func (c *CheckoutRepository) GetAbandonedBySessionID(sessionID string) (*entity.Checkout, error) {
var checkout entity.Checkout
err := c.db.Preload("Items").Preload("Items.Product").Preload("Items.ProductVariant").
Preload("User").
Where("session_id = ? AND status = ?", sessionID, entity.CheckoutStatusAbandoned).
First(&checkout).Error
if err != nil {
return nil, err
}
return &checkout, nil
}

// GetByUserID implements repository.CheckoutRepository.
func (c *CheckoutRepository) GetByUserID(userID uint) (*entity.Checkout, error) {
var checkout entity.Checkout
Expand Down Expand Up @@ -207,7 +220,55 @@ func (c *CheckoutRepository) HasActiveCheckoutsWithProduct(productID uint) (bool

// Update implements repository.CheckoutRepository.
func (c *CheckoutRepository) Update(checkout *entity.Checkout) error {
return c.db.Session(&gorm.Session{FullSaveAssociations: true}).Save(checkout).Error
return c.db.Transaction(func(tx *gorm.DB) error {
// First, get the current items in the database
var currentItems []entity.CheckoutItem
if err := tx.Where("checkout_id = ?", checkout.ID).Find(&currentItems).Error; err != nil {
return fmt.Errorf("failed to fetch current checkout items: %w", err)
}

// Create a map of current item IDs in the checkout entity for efficient lookup
currentItemIDs := make(map[uint]bool)
for _, item := range checkout.Items {
if item.ID != 0 {
currentItemIDs[item.ID] = true
}
}

// Delete items that are no longer in the checkout
for _, dbItem := range currentItems {
if !currentItemIDs[dbItem.ID] {
if err := tx.Unscoped().Delete(&dbItem).Error; err != nil {
return fmt.Errorf("failed to delete checkout item %d: %w", dbItem.ID, err)
}
}
}

// Save the checkout with remaining items
return tx.Session(&gorm.Session{FullSaveAssociations: true}).Save(checkout).Error
})
}

// GetAllExpiredCheckoutsForDeletion implements repository.CheckoutRepository.
func (c *CheckoutRepository) GetAllExpiredCheckoutsForDeletion() ([]*entity.Checkout, error) {
var checkouts []*entity.Checkout

// Get all checkouts that are expired, abandoned, or completed (older than 30 days to be safe)
thirtyDaysAgo := time.Now().AddDate(0, 0, -30)

err := c.db.Preload("Items").Preload("Items.Product").Preload("Items.ProductVariant").
Where("status IN (?, ?, ?) OR updated_at < ?",
entity.CheckoutStatusExpired,
entity.CheckoutStatusAbandoned,
entity.CheckoutStatusCompleted,
thirtyDaysAgo).
Find(&checkouts).Error

if err != nil {
return nil, fmt.Errorf("failed to get expired checkouts for deletion: %w", err)
}

return checkouts, nil
}

// NewCheckoutRepository creates a new GORM-based CheckoutRepository
Expand Down
Loading