From 087f523291d66db79ded8620e261d2378679909c Mon Sep 17 00:00:00 2001 From: Gero Posmyk-Leinemann Date: Wed, 15 Oct 2025 08:14:33 +0000 Subject: [PATCH] Add Stripe subscription cancellation scripts - Script 1: extract-active-subscriptions.go - Extracts active subscription IDs from database - Script 2: cancel-subscriptions.go - Cancels subscriptions and finalizes invoices - Supports organization exclusion, dry-run mode, and error handling - Includes comprehensive README with usage instructions Related to CLC-1867 Co-authored-by: Ona --- scripts/README.md | 194 ++++++++++++++++++++++++ scripts/cancel-subscriptions.go | 164 ++++++++++++++++++++ scripts/extract-active-subscriptions.go | 143 +++++++++++++++++ scripts/go.mod | 15 ++ scripts/go.sum | 31 ++++ 5 files changed, 547 insertions(+) create mode 100644 scripts/README.md create mode 100644 scripts/cancel-subscriptions.go create mode 100644 scripts/extract-active-subscriptions.go create mode 100644 scripts/go.mod create mode 100644 scripts/go.sum diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000000000..be0ecd14de6fff --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,194 @@ +# Stripe Subscription Cancellation Scripts + +Two Go scripts for bulk cancellation of Stripe subscriptions: + +1. **extract-active-subscriptions.go** - Extracts active subscription IDs from database +2. **cancel-subscriptions.go** - Cancels subscriptions and finalizes invoices + +## Prerequisites + +- Go 1.19 or later +- Access to Gitpod database +- Stripe secret key with write permissions + +## Installation + +Install required dependencies: + +```bash +cd scripts +go mod init stripe-scripts +go get github.com/stripe/stripe-go/v72 +go get gorm.io/gorm +go get gorm.io/driver/mysql +``` + +## Script 1: Extract Active Subscriptions + +Queries the database for Stripe customers and extracts their active subscription IDs. + +### Usage + +```bash +go run extract-active-subscriptions.go \ + --db-dsn "user:password@tcp(host:3306)/gitpod" \ + --stripe-key "sk_live_..." \ + --exclude-orgs "org-id-1,org-id-2" \ + --output "active-subscriptions.txt" +``` + +### Parameters + +- `--db-dsn` (required): MySQL database connection string + - Format: `user:password@tcp(host:port)/database` + - Example: `gitpod:password@tcp(db.example.com:3306)/gitpod` + +- `--stripe-key` (required): Stripe secret key + - Format: `sk_live_...` or `sk_test_...` + +- `--exclude-orgs` (optional): Comma-separated organization IDs to exclude + - Example: `a7dcf253-f05e-4dcf-9a47-cf8fccc74717,b8edf364-g16f-5edg-0b58-dg9gddd85828` + +- `--output` (optional): Output file path (default: `active-subscriptions.txt`) + +### Output + +Creates a text file with one subscription ID per line: + +``` +sub_1MlPf9LkdIwHu7ixB6VIYRyX +sub_2NqRg0MldJxIv8jyC7WJZSaY +sub_3OrSh1NmeKyJw9kzD8XKaTbZ +``` + +### Example Output + +``` +Excluding 2 organization(s) +Found 150 Stripe customers in database + +Extraction complete! +Summary: +- Total customers processed: 150 +- Excluded (by organization): 5 +- Active subscriptions found: 145 +- Errors: 0 +- Output written to: active-subscriptions.txt +``` + +## Script 2: Cancel Subscriptions + +Reads subscription IDs from file and cancels them in Stripe. + +### Usage + +```bash +# Dry run (recommended first) +go run cancel-subscriptions.go \ + --stripe-key "sk_live_..." \ + --input "active-subscriptions.txt" \ + --dry-run + +# Actual cancellation +go run cancel-subscriptions.go \ + --stripe-key "sk_live_..." \ + --input "active-subscriptions.txt" +``` + +### Parameters + +- `--stripe-key` (required): Stripe secret key + - Format: `sk_live_...` or `sk_test_...` + +- `--input` (optional): Input file with subscription IDs (default: `active-subscriptions.txt`) + +- `--dry-run` (optional): Preview actions without making changes + +### Behavior + +For each subscription: +1. Retrieves subscription from Stripe +2. If already canceled → skips silently +3. If active: + - Finalizes any draft invoice for current period + - Cancels subscription immediately +4. Continues on errors + +### Example Output + +``` +=== DRY RUN MODE === +Processing 145 subscription(s)... + +[1/145] Processing sub_1MlPf9LkdIwHu7ixB6VIYRyX... would finalize invoice in_1MlPf9LkdIwHu7ixEo6hdgCw, would cancel subscription +[2/145] Processing sub_2NqRg0MldJxIv8jyC7WJZSaY... already canceled (skipped) +[3/145] Processing sub_3OrSh1NmeKyJw9kzD8XKaTbZ... would cancel subscription +... + +=== DRY RUN SUMMARY === +Total processed: 145 +Cancelled: 142 +Already cancelled (skipped): 3 +Errors: 0 +``` + +## Recommended Workflow + +1. **Extract subscriptions** with exclusions: + ```bash + go run extract-active-subscriptions.go \ + --db-dsn "user:pass@tcp(host:3306)/gitpod" \ + --stripe-key "sk_live_..." \ + --exclude-orgs "org-to-keep-1,org-to-keep-2" + ``` + +2. **Review the output file** (`active-subscriptions.txt`) + +3. **Dry run** to preview changes: + ```bash + go run cancel-subscriptions.go \ + --stripe-key "sk_live_..." \ + --dry-run + ``` + +4. **Execute cancellation**: + ```bash + go run cancel-subscriptions.go \ + --stripe-key "sk_live_..." + ``` + +## Safety Features + +- **Dry run mode**: Preview all actions before execution +- **No database writes**: Scripts never modify the Gitpod database +- **Error resilience**: Continues processing on errors +- **Silent skip**: Already-canceled subscriptions are skipped without logging +- **Summary reporting**: Clear counts of success/skip/error + +## Notes + +- Subscriptions are canceled **immediately** (not at period end) +- Draft invoices are finalized before cancellation to capture current usage +- The scripts do NOT update Gitpod's cost center or billing strategy +- Organization IDs are extracted from attribution IDs (format: `team:`) + +## Troubleshooting + +### Database Connection Issues + +Ensure your DSN includes all required parameters: +``` +user:password@tcp(host:port)/database?parseTime=true&loc=UTC +``` + +### Stripe API Errors + +- Verify your API key has write permissions +- Check rate limits if processing many subscriptions +- Use test mode (`sk_test_...`) for testing + +### Empty Output File + +- Verify database contains Stripe customers +- Check that customers have active subscriptions +- Ensure excluded organizations aren't filtering all results diff --git a/scripts/cancel-subscriptions.go b/scripts/cancel-subscriptions.go new file mode 100644 index 00000000000000..bf24fdd4ee15a8 --- /dev/null +++ b/scripts/cancel-subscriptions.go @@ -0,0 +1,164 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "os" + "strings" + + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/client" +) + +func main() { + var ( + stripeKey = flag.String("stripe-key", "", "Stripe secret key") + inputFile = flag.String("input", "active-subscriptions.txt", "Input file with subscription IDs") + dryRun = flag.Bool("dry-run", false, "Perform dry run without making changes") + ) + flag.Parse() + + if *stripeKey == "" { + fmt.Fprintf(os.Stderr, "Error: --stripe-key is required\n") + flag.Usage() + os.Exit(1) + } + + // Initialize Stripe client + sc := &client.API{} + sc.Init(*stripeKey, nil) + + // Read subscription IDs from file + subscriptionIDs, err := readSubscriptionIDs(*inputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to read input file: %v\n", err) + os.Exit(1) + } + + if len(subscriptionIDs) == 0 { + fmt.Println("No subscription IDs found in input file") + os.Exit(0) + } + + if *dryRun { + fmt.Printf("=== DRY RUN MODE ===\n") + } + fmt.Printf("Processing %d subscription(s)...\n\n", len(subscriptionIDs)) + + ctx := context.Background() + var ( + cancelled = 0 + alreadyCanceled = 0 + errors = 0 + ) + + // Process each subscription + for i, subID := range subscriptionIDs { + subID = strings.TrimSpace(subID) + if subID == "" { + continue + } + + fmt.Printf("[%d/%d] Processing %s... ", i+1, len(subscriptionIDs), subID) + + // Retrieve subscription with latest invoice expanded + sub, err := sc.Subscriptions.Get(subID, &stripe.SubscriptionParams{ + Params: stripe.Params{ + Context: ctx, + Expand: []*string{stripe.String("latest_invoice")}, + }, + }) + if err != nil { + fmt.Printf("ERROR: %v\n", err) + errors++ + continue + } + + // Check if already canceled + if sub.Status == stripe.SubscriptionStatusCanceled { + alreadyCanceled++ + fmt.Printf("already canceled (skipped)\n") + continue + } + + // Finalize draft invoice if exists + if sub.LatestInvoice != nil && sub.LatestInvoice.Status == stripe.InvoiceStatusDraft { + invoiceID := sub.LatestInvoice.ID + if *dryRun { + fmt.Printf("would finalize invoice %s, ", invoiceID) + } else { + _, err := sc.Invoices.FinalizeInvoice(invoiceID, &stripe.InvoiceFinalizeParams{ + Params: stripe.Params{ + Context: ctx, + }, + }) + if err != nil { + fmt.Printf("ERROR finalizing invoice %s: %v\n", invoiceID, err) + errors++ + continue + } + fmt.Printf("finalized invoice %s, ", invoiceID) + } + } + + // Cancel subscription + if *dryRun { + fmt.Printf("would cancel subscription\n") + cancelled++ + } else { + _, err := sc.Subscriptions.Cancel(subID, &stripe.SubscriptionCancelParams{ + Params: stripe.Params{ + Context: ctx, + }, + }) + if err != nil { + fmt.Printf("ERROR canceling: %v\n", err) + errors++ + continue + } + fmt.Printf("canceled\n") + cancelled++ + } + } + + // Print summary + fmt.Printf("\n") + if *dryRun { + fmt.Printf("=== DRY RUN SUMMARY ===\n") + } else { + fmt.Printf("=== SUMMARY ===\n") + } + fmt.Printf("Total processed: %d\n", len(subscriptionIDs)) + fmt.Printf("Cancelled: %d\n", cancelled) + fmt.Printf("Already cancelled (skipped): %d\n", alreadyCanceled) + fmt.Printf("Errors: %d\n", errors) +} + +func readSubscriptionIDs(filename string) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var ids []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" { + ids = append(ids, line) + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return ids, nil +} diff --git a/scripts/extract-active-subscriptions.go b/scripts/extract-active-subscriptions.go new file mode 100644 index 00000000000000..d705cabb50d459 --- /dev/null +++ b/scripts/extract-active-subscriptions.go @@ -0,0 +1,143 @@ +// Copyright (c) 2024 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License.AGPL.txt in the project root for license information. + +package main + +import ( + "context" + "flag" + "fmt" + "os" + "strings" + + "github.com/stripe/stripe-go/v72" + "github.com/stripe/stripe-go/v72/client" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type StripeCustomer struct { + StripeCustomerID string `gorm:"column:stripeCustomerId;type:char;size:255;"` + AttributionID string `gorm:"column:attributionId;type:varchar;size:255;"` +} + +func (StripeCustomer) TableName() string { + return "d_b_stripe_customer" +} + +func main() { + var ( + dbDSN = flag.String("db-dsn", "", "Database DSN (e.g., user:pass@tcp(host:port)/dbname)") + stripeKey = flag.String("stripe-key", "", "Stripe secret key") + excludeOrgs = flag.String("exclude-orgs", "", "Comma-separated list of organization IDs to exclude") + outputFile = flag.String("output", "active-subscriptions.txt", "Output file path") + ) + flag.Parse() + + if *dbDSN == "" { + fmt.Fprintf(os.Stderr, "Error: --db-dsn is required\n") + flag.Usage() + os.Exit(1) + } + if *stripeKey == "" { + fmt.Fprintf(os.Stderr, "Error: --stripe-key is required\n") + flag.Usage() + os.Exit(1) + } + + // Parse excluded organization IDs + excludedOrgs := make(map[string]bool) + if *excludeOrgs != "" { + for _, orgID := range strings.Split(*excludeOrgs, ",") { + orgID = strings.TrimSpace(orgID) + if orgID != "" { + excludedOrgs[orgID] = true + } + } + fmt.Printf("Excluding %d organization(s)\n", len(excludedOrgs)) + } + + // Connect to database + db, err := gorm.Open(mysql.Open(*dbDSN), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to connect to database: %v\n", err) + os.Exit(1) + } + + // Initialize Stripe client + sc := &client.API{} + sc.Init(*stripeKey, nil) + + // Query all Stripe customers + var customers []StripeCustomer + if err := db.Find(&customers).Error; err != nil { + fmt.Fprintf(os.Stderr, "Failed to query customers: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Found %d Stripe customers in database\n", len(customers)) + + // Open output file + f, err := os.Create(*outputFile) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create output file: %v\n", err) + os.Exit(1) + } + defer f.Close() + + ctx := context.Background() + totalSubscriptions := 0 + excludedCount := 0 + errorCount := 0 + + // Process each customer + for _, customer := range customers { + // Parse attribution ID (format: "team:") + parts := strings.Split(customer.AttributionID, ":") + if len(parts) != 2 || parts[0] != "team" { + continue + } + orgID := parts[1] + + // Check if organization should be excluded + if excludedOrgs[orgID] { + excludedCount++ + continue + } + + // Fetch customer from Stripe with subscriptions expanded + stripeCustomer, err := sc.Customers.Get(customer.StripeCustomerID, &stripe.CustomerParams{ + Params: stripe.Params{ + Context: ctx, + Expand: []*string{stripe.String("subscriptions")}, + }, + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error fetching customer %s: %v\n", customer.StripeCustomerID, err) + errorCount++ + continue + } + + // Extract active subscriptions + if stripeCustomer.Subscriptions != nil { + for _, sub := range stripeCustomer.Subscriptions.Data { + if sub.Status != stripe.SubscriptionStatusCanceled { + fmt.Fprintf(f, "%s\n", sub.ID) + totalSubscriptions++ + } + } + } + } + + fmt.Printf("\nExtraction complete!\n") + fmt.Printf("Summary:\n") + fmt.Printf("- Total customers processed: %d\n", len(customers)) + fmt.Printf("- Excluded (by organization): %d\n", excludedCount) + fmt.Printf("- Active subscriptions found: %d\n", totalSubscriptions) + fmt.Printf("- Errors: %d\n", errorCount) + fmt.Printf("- Output written to: %s\n", *outputFile) +} diff --git a/scripts/go.mod b/scripts/go.mod new file mode 100644 index 00000000000000..f137d5c53c7eca --- /dev/null +++ b/scripts/go.mod @@ -0,0 +1,15 @@ +module stripe-scripts + +go 1.21 + +require ( + github.com/stripe/stripe-go/v72 v72.122.0 + gorm.io/driver/mysql v1.5.2 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/go-sql-driver/mysql v1.7.1 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect +) diff --git a/scripts/go.sum b/scripts/go.sum new file mode 100644 index 00000000000000..748771d7cb2985 --- /dev/null +++ b/scripts/go.sum @@ -0,0 +1,31 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= +github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stripe/stripe-go/v72 v72.122.0 h1:eRXWqnEwGny6dneQ5BsxGzUCED5n180u8n665JHlut8= +github.com/stripe/stripe-go/v72 v72.122.0/go.mod h1:QwqJQtduHubZht9mek5sds9CtQcKFdsykV9ZepRWwo0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs= +gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8= +gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=