diff --git a/Makefile b/Makefile index 59fcd66..491e5a7 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/expire-checkouts/main.go b/cmd/expire-checkouts/main.go index 3b43882..9d09a53 100644 --- a/cmd/expire-checkouts/main.go +++ b/cmd/expire-checkouts/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "log" "github.com/joho/godotenv" @@ -11,6 +12,10 @@ 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") @@ -18,7 +23,11 @@ func main() { // 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() @@ -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) + } } diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index a610603..0796cf8 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -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 @@ -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 != "" { diff --git a/internal/domain/entity/checkout.go b/internal/domain/entity/checkout.go index 0192d7f..b5c75e4 100644 --- a/internal/domain/entity/checkout.go +++ b/internal/domain/entity/checkout.go @@ -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) diff --git a/internal/domain/entity/checkout_test.go b/internal/domain/entity/checkout_test.go index 15817cb..e42915d 100644 --- a/internal/domain/entity/checkout_test.go +++ b/internal/domain/entity/checkout_test.go @@ -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) { diff --git a/internal/domain/repository/checkout_repository.go b/internal/domain/repository/checkout_repository.go index d582016..a5f816b 100644 --- a/internal/domain/repository/checkout_repository.go +++ b/internal/domain/repository/checkout_repository.go @@ -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 @@ -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) } diff --git a/internal/infrastructure/repository/gorm/checkout_repository.go b/internal/infrastructure/repository/gorm/checkout_repository.go index 07d88ca..f5ad986 100644 --- a/internal/infrastructure/repository/gorm/checkout_repository.go +++ b/internal/infrastructure/repository/gorm/checkout_repository.go @@ -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 @@ -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(¤tItems).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 diff --git a/internal/infrastructure/repository/gorm/checkout_repository_test.go b/internal/infrastructure/repository/gorm/checkout_repository_test.go new file mode 100644 index 0000000..28f3b9e --- /dev/null +++ b/internal/infrastructure/repository/gorm/checkout_repository_test.go @@ -0,0 +1,219 @@ +package gorm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/testutil" +) + +func TestCheckoutRepository_Update_RemoveItems(t *testing.T) { + db := testutil.SetupTestDB(t) + checkoutRepo := NewCheckoutRepository(db) + + t.Run("Remove item should delete from database", func(t *testing.T) { + // Create a checkout with items + checkout, err := entity.NewCheckout("session123", "USD") + require.NoError(t, err) + + // Add multiple items + err = checkout.AddItem(1, 1, 2, 1000, 1.5, "Product 1", "Variant 1", "SKU-001") + require.NoError(t, err) + err = checkout.AddItem(2, 2, 1, 2000, 2.0, "Product 2", "Variant 2", "SKU-002") + require.NoError(t, err) + err = checkout.AddItem(3, 3, 3, 3000, 3.0, "Product 3", "Variant 3", "SKU-003") + require.NoError(t, err) + + // Create checkout in database + err = checkoutRepo.Create(checkout) + require.NoError(t, err) + require.NotZero(t, checkout.ID) + + // Verify all items were created + retrievedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, retrievedCheckout.Items, 3) + + // Store item IDs for verification + var itemIDs []uint + for _, item := range retrievedCheckout.Items { + itemIDs = append(itemIDs, item.ID) + } + + // Remove one item from the checkout entity + err = retrievedCheckout.RemoveItem(2, 2) // Remove Product 2 + require.NoError(t, err) + require.Len(t, retrievedCheckout.Items, 2) + + // Update checkout in database + err = checkoutRepo.Update(retrievedCheckout) + require.NoError(t, err) + + // Verify the checkout now has only 2 items + updatedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, updatedCheckout.Items, 2) + + // Verify the correct item was removed (Product 2 should be gone) + productIDs := make(map[uint]bool) + for _, item := range updatedCheckout.Items { + productIDs[item.ProductID] = true + } + assert.True(t, productIDs[1], "Product 1 should still exist") + assert.False(t, productIDs[2], "Product 2 should be removed") + assert.True(t, productIDs[3], "Product 3 should still exist") + + // Verify the item was actually deleted from the database + var itemCount int64 + err = db.Model(&entity.CheckoutItem{}).Where("checkout_id = ?", checkout.ID).Count(&itemCount).Error + require.NoError(t, err) + assert.Equal(t, int64(2), itemCount, "Should have exactly 2 items in database") + + // Verify the specific item with Product ID 2 is deleted + var deletedItemCount int64 + err = db.Model(&entity.CheckoutItem{}).Where("checkout_id = ? AND product_id = ?", checkout.ID, 2).Count(&deletedItemCount).Error + require.NoError(t, err) + assert.Equal(t, int64(0), deletedItemCount, "Product 2 item should be deleted from database") + }) + + t.Run("Remove multiple items should delete all from database", func(t *testing.T) { + // Create a checkout with items + checkout, err := entity.NewCheckout("session456", "USD") + require.NoError(t, err) + + // Add multiple items + err = checkout.AddItem(1, 1, 2, 1000, 1.5, "Product 1", "Variant 1", "SKU-001") + require.NoError(t, err) + err = checkout.AddItem(2, 2, 1, 2000, 2.0, "Product 2", "Variant 2", "SKU-002") + require.NoError(t, err) + err = checkout.AddItem(3, 3, 3, 3000, 3.0, "Product 3", "Variant 3", "SKU-003") + require.NoError(t, err) + + // Create checkout in database + err = checkoutRepo.Create(checkout) + require.NoError(t, err) + require.NotZero(t, checkout.ID) + + // Get the checkout and remove multiple items + retrievedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, retrievedCheckout.Items, 3) + + // Remove two items + err = retrievedCheckout.RemoveItem(1, 1) // Remove Product 1 + require.NoError(t, err) + err = retrievedCheckout.RemoveItem(3, 3) // Remove Product 3 + require.NoError(t, err) + require.Len(t, retrievedCheckout.Items, 1) + + // Update checkout in database + err = checkoutRepo.Update(retrievedCheckout) + require.NoError(t, err) + + // Verify the checkout now has only 1 item + updatedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, updatedCheckout.Items, 1) + + // Verify only Product 2 remains + assert.Equal(t, uint(2), updatedCheckout.Items[0].ProductID) + + // Verify the correct count in database + var itemCount int64 + err = db.Model(&entity.CheckoutItem{}).Where("checkout_id = ?", checkout.ID).Count(&itemCount).Error + require.NoError(t, err) + assert.Equal(t, int64(1), itemCount, "Should have exactly 1 item in database") + }) + + t.Run("Remove all items should clear database", func(t *testing.T) { + // Create a checkout with items + checkout, err := entity.NewCheckout("session789", "USD") + require.NoError(t, err) + + // Add items + err = checkout.AddItem(1, 1, 2, 1000, 1.5, "Product 1", "Variant 1", "SKU-001") + require.NoError(t, err) + err = checkout.AddItem(2, 2, 1, 2000, 2.0, "Product 2", "Variant 2", "SKU-002") + require.NoError(t, err) + + // Create checkout in database + err = checkoutRepo.Create(checkout) + require.NoError(t, err) + + // Get the checkout and clear all items + retrievedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + + // Clear all items using the Clear method + retrievedCheckout.Clear() + require.Len(t, retrievedCheckout.Items, 0) + + // Update checkout in database + err = checkoutRepo.Update(retrievedCheckout) + require.NoError(t, err) + + // Verify the checkout has no items + updatedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, updatedCheckout.Items, 0) + + // Verify no items exist in database for this checkout + var itemCount int64 + err = db.Model(&entity.CheckoutItem{}).Where("checkout_id = ?", checkout.ID).Count(&itemCount).Error + require.NoError(t, err) + assert.Equal(t, int64(0), itemCount, "Should have no items in database") + }) + + t.Run("Update existing items without removal should work", func(t *testing.T) { + // Create a checkout with items + checkout, err := entity.NewCheckout("session999", "USD") + require.NoError(t, err) + + // Add items + err = checkout.AddItem(1, 1, 2, 1000, 1.5, "Product 1", "Variant 1", "SKU-001") + require.NoError(t, err) + err = checkout.AddItem(2, 2, 1, 2000, 2.0, "Product 2", "Variant 2", "SKU-002") + require.NoError(t, err) + + // Create checkout in database + err = checkoutRepo.Create(checkout) + require.NoError(t, err) + + // Get the checkout and update an item quantity + retrievedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + + // Update item quantity + err = retrievedCheckout.UpdateItem(1, 1, 5) // Change quantity from 2 to 5 + require.NoError(t, err) + + // Update checkout in database + err = checkoutRepo.Update(retrievedCheckout) + require.NoError(t, err) + + // Verify the checkout still has 2 items with updated quantity + updatedCheckout, err := checkoutRepo.GetByID(checkout.ID) + require.NoError(t, err) + require.Len(t, updatedCheckout.Items, 2) + + // Find the updated item + var updatedItem *entity.CheckoutItem + for _, item := range updatedCheckout.Items { + if item.ProductID == 1 { + updatedItem = &item + break + } + } + require.NotNil(t, updatedItem) + assert.Equal(t, 5, updatedItem.Quantity) + + // Verify count in database remains the same + var itemCount int64 + err = db.Model(&entity.CheckoutItem{}).Where("checkout_id = ?", checkout.ID).Count(&itemCount).Error + require.NoError(t, err) + assert.Equal(t, int64(2), itemCount, "Should still have 2 items in database") + }) +} diff --git a/readme.md b/readme.md index 9472d1a..0efc7cf 100644 --- a/readme.md +++ b/readme.md @@ -481,3 +481,63 @@ Use Stripe's test cards for development: - `4000 0000 0000 9995` - Payment declined For more test card numbers, visit [Stripe's testing documentation](https://stripe.com/docs/testing). + +## Maintenance Commands + +The project includes useful maintenance commands for database cleanup and optimization: + +### Checkout Cleanup + +The system provides two modes for managing expired and old checkouts: + +```bash +# Regular cleanup (recommended for scheduled runs) +make expire-checkouts +# or +go run ./cmd/expire-checkouts +``` + +This command performs the following operations: + +- Marks checkouts with customer/shipping info as **abandoned** after 15 minutes of inactivity +- **Deletes** empty checkouts older than 24 hours +- **Deletes** abandoned checkouts older than 7 days +- Marks remaining expired checkouts as **expired** (legacy support) + +```bash +# Force deletion (use with caution) +make force-delete-checkouts +# or +go run ./cmd/expire-checkouts -force +``` + +This command performs aggressive cleanup: + +- **Force deletes** all expired, abandoned, and completed checkouts +- **Force deletes** checkouts older than 30 days regardless of status +- Should be used carefully as it permanently removes checkout data + +### Usage Examples + +```bash +# Show help and available options +./bin/expire-checkouts --help + +# Regular cleanup (safe for automation) +./bin/expire-checkouts + +# Force delete all expired checkouts +./bin/expire-checkouts -force +``` + +### Scheduling Maintenance + +For production environments, consider scheduling regular cleanup: + +```bash +# Example crontab entry (runs every hour) +0 * * * * /path/to/commercify/bin/expire-checkouts + +# Example crontab entry for weekly force cleanup (use with caution) +0 2 * * 0 /path/to/commercify/bin/expire-checkouts -force +```