diff --git a/src/admintools/adminsubscription.go b/src/admintools/adminsubscription.go new file mode 100644 index 00000000..c85e2c93 --- /dev/null +++ b/src/admintools/adminsubscription.go @@ -0,0 +1,1366 @@ +package admintools + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "git.handmade.network/hmn/hmn/src/auth" + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/website" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stripe/stripe-go/v84" +) + +type achTestSetup struct { + userID int + customerID string + paymentMethodID string + testClockID string +} + +const ( + subjectThankYou = "[Handmade Software Foundation] Thank you!" + subjectPaymentFailed = "[Handmade Software Foundation] Payment failed" + subjectACHVerificationGrace = "[Handmade Software Foundation] Verify your bank account" + subjectGracePeriodEnded = "[Handmade Software Foundation] Grace period ended" +) + +func expectScenarioEmailSubjects(ctx context.Context, expected []string) error { + mailpit := membershipMailpitFromContext(ctx) + if mailpit == nil { + return nil + } + return mailpit.WaitForSubjects(expected, 10*time.Second) +} + +func runCardOrACHScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) { + sctx := newScenarioCtx(scenario.Name, 6) + + username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8]) + var userID int + var emailAddress string + if err := sctx.step(fmt.Sprintf("Creating test user: %s", username), func() error { + userID, emailAddress = createSubscriptionTestUser(ctx, pool, username) + sctx.printf("user_id=%d email=%s\n", userID, emailAddress) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + var customer *stripe.Customer + if err := sctx.step("Creating Stripe customer", func() error { + customerParams := &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + if testClockID := config.Config.Stripe.TestClockID; testClockID != "" { + customerParams.TestClock = stripe.String(testClockID) + } + var err error + customer, err = sc.V1Customers.Create(ctx, customerParams) + if err != nil { + return err + } + sctx.printf("customer_id=%s\n", customer.ID) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + var paymentMethod *stripe.PaymentMethod + if err := sctx.step(fmt.Sprintf("Creating payment method (%s)", scenario.Name), func() error { + var err error + paymentMethod, err = scenario.CreatePaymentMethod(ctx, sc) + if err != nil { + return err + } + sctx.printf("payment_method_id=%s\n", paymentMethod.ID) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + var attachErr error + if err := sctx.step("Attaching payment method and creating membership", func() error { + _, attachErr = sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if attachErr != nil && isExpectedACHVerificationPending(attachErr) { + sctx.printf("ACH verification is pending; membership will complete after verification.\n") + if updateErr := persistPendingVerificationState(ctx, pool, userID, customer.ID); updateErr != nil { + return updateErr + } + printSubscriptionDataSummary(ctx, pool, userID) + return nil + } + return attachErr + }); err != nil { + return subscriptionTestResultPass, err + } + if attachErr != nil { + if isExpectedACHVerificationPending(attachErr) { + return subscriptionTestResultPending, nil + } + } + + result, err := completeSubscriptionE2E(ctx, pool, sc, userID, customer.ID, paymentMethod.ID) + if err != nil { + return result, err + } + if err := expectScenarioEmailSubjects(ctx, []string{subjectThankYou}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify thank-you email: %w", err) + } + return result, nil +} + +func completeSubscriptionE2E(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) { + subscriptionParams := &stripe.SubscriptionCreateParams{ + Customer: stripe.String(customerID), + DefaultPaymentMethod: stripe.String(paymentMethodID), + CollectionMethod: stripe.String("charge_automatically"), + PaymentBehavior: stripe.String("allow_incomplete"), + Items: []*stripe.SubscriptionCreateItemParams{ + {Price: stripe.String(config.Config.Stripe.PriceID)}, + }, + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + subscriptionParams.AddExpand("latest_invoice") + + subscription, err := sc.V1Subscriptions.Create(ctx, subscriptionParams) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status) + + fmt.Printf("[5/6] Processing membership webhooks\n") + if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.created", subscription); err != nil { + return subscriptionTestResultPass, err + } + + var invoice *stripe.Invoice + if subscription.LatestInvoice != nil && subscription.LatestInvoice.ID != "" { + invoice, err = sc.V1Invoices.Retrieve(ctx, subscription.LatestInvoice.ID, nil) + if err != nil { + return subscriptionTestResultPass, err + } + } + if invoice != nil && invoice.Status == stripe.InvoiceStatusPaid { + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", invoice); err != nil { + return subscriptionTestResultPass, err + } + } + if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[6/6] Verifying and printing stored membership data\n") + if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil { + return subscriptionTestResultPass, err + } + printSubscriptionDataSummary(ctx, pool, userID) + return subscriptionTestResultPass, nil +} + +func runDeclinedCardScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) { + username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8]) + fmt.Printf("[1/6] Creating test user: %s\n", username) + userID, emailAddress := createSubscriptionTestUser(ctx, pool, username) + fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) + + fmt.Printf("[2/6] Creating Stripe customer\n") + customerParams := &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + if testClockID := config.Config.Stripe.TestClockID; testClockID != "" { + customerParams.TestClock = stripe.String(testClockID) + } + customer, err := sc.V1Customers.Create(ctx, customerParams) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" customer_id=%s\n", customer.ID) + + fmt.Printf("[3/6] Creating failing card payment method (tok_chargeCustomerFail)\n") + paymentMethod, err := sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCreateCardParams{ + Token: stripe.String("tok_chargeCustomerFail"), + }, + }) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID) + + fmt.Printf("[4/6] Attaching payment method and creating membership\n") + _, attachErr := sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if attachErr != nil && !isStripeCardDeclined(attachErr) { + return subscriptionTestResultPass, attachErr + } + if attachErr != nil { + return subscriptionTestResultPass, fmt.Errorf("unexpected decline during payment method attach: %w", attachErr) + } + + stripeStatus := "incomplete" + + if attachErr == nil { + subscriptionParams := &stripe.SubscriptionCreateParams{ + Customer: stripe.String(customer.ID), + DefaultPaymentMethod: stripe.String(paymentMethod.ID), + CollectionMethod: stripe.String("charge_automatically"), + PaymentBehavior: stripe.String("allow_incomplete"), + Items: []*stripe.SubscriptionCreateItemParams{ + {Price: stripe.String(config.Config.Stripe.PriceID)}, + }, + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + subscriptionParams.AddExpand("latest_invoice") + + subscription, createErr := sc.V1Subscriptions.Create(ctx, subscriptionParams) + if createErr != nil { + return subscriptionTestResultPass, createErr + } + fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status) + if subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription to fail payment, got status=%s", subscription.Status) + } + stripeStatus = string(subscription.Status) + + fmt.Printf("[5/6] Dispatching decline webhooks end-to-end\n") + if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.created", subscription); err != nil { + return subscriptionTestResultPass, err + } + if invoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription); err != nil { + return subscriptionTestResultPass, err + } else if invoice != nil { + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.payment_failed", invoice); err != nil { + return subscriptionTestResultPass, err + } + } + if refreshedSub, err := sc.V1Subscriptions.Retrieve(ctx, subscription.ID, nil); err != nil { + return subscriptionTestResultPass, err + } else if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", refreshedSub); err != nil { + return subscriptionTestResultPass, err + } + } + + fmt.Printf("[6/6] Verifying stored membership data after decline\n") + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + return subscriptionTestResultPass, err + } + if user.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=false after card decline") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus != stripeStatus { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s, got %s", stripeStatus, stringOrEmpty(user.SubscriptionStatus)) + } + if !user.GraceAvailable { + return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after card decline (grace not consumed)") + } + if user.GracePeriodStartedAt != nil || user.GracePeriodEndsAt != nil { + return subscriptionTestResultPass, fmt.Errorf("expected no grace period after card decline") + } + + payments, err := db.Query[models.UserPayment](ctx, pool, ` + SELECT $columns FROM user_payment WHERE user_id = $1 + `, userID) + if err != nil { + return subscriptionTestResultPass, err + } + if len(payments) > 0 { + return subscriptionTestResultPass, fmt.Errorf("expected no paid invoices after card decline, got %d payment rows", len(payments)) + } + + printSubscriptionDataSummary(ctx, pool, userID) + return subscriptionTestResultPass, nil +} + +func runEuroCardChargeScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) { + sctx := newScenarioCtx("Credit card one-time charge (EUR)", 5) + + username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8]) + var userID int + var emailAddress string + if err := sctx.step(fmt.Sprintf("Creating test user for EUR charge: %s", username), func() error { + userID, emailAddress = createSubscriptionTestUser(ctx, pool, username) + sctx.printf("user_id=%d email=%s\n", userID, emailAddress) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + var customer *stripe.Customer + if err := sctx.step("Creating Stripe customer", func() error { + customerParams := &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + if testClockID := config.Config.Stripe.TestClockID; testClockID != "" { + customerParams.TestClock = stripe.String(testClockID) + } + var err error + customer, err = sc.V1Customers.Create(ctx, customerParams) + if err != nil { + return err + } + sctx.printf("customer_id=%s\n", customer.ID) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + var pm *stripe.PaymentMethod + if err := sctx.step("Creating and attaching tok_visa payment method", func() error { + var err error + pm, err = createCardPaymentMethod(ctx, sc, "tok_visa") + if err != nil { + return err + } + _, err = sc.V1PaymentMethods.Attach(ctx, pm.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + return err + }); err != nil { + return subscriptionTestResultPass, err + } + + var pi *stripe.PaymentIntent + if err := sctx.step("Creating one-time EUR card charge", func() error { + var err error + pi, err = sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{ + Amount: stripe.Int64(500), // EUR 5.00 + Currency: stripe.String("eur"), + Customer: stripe.String(customer.ID), + PaymentMethod: stripe.String(pm.ID), + PaymentMethodTypes: []*string{ + stripe.String("card"), + }, + Confirm: stripe.Bool(true), + Description: stripe.String("HMN admin membership test EUR card charge"), + }) + if err != nil { + return err + } + sctx.printf("payment_intent_id=%s status=%s amount=%d currency=%s\n", pi.ID, pi.Status, pi.Amount, pi.Currency) + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + if err := sctx.step("Verifying one-time EUR charge success", func() error { + if pi.Currency != stripe.CurrencyEUR { + return fmt.Errorf("expected currency=eur, got %s", pi.Currency) + } + if pi.Status != stripe.PaymentIntentStatusSucceeded { + return fmt.Errorf("expected payment_intent status=succeeded, got %s", pi.Status) + } + return nil + }); err != nil { + return subscriptionTestResultPass, err + } + + return subscriptionTestResultPass, nil +} + +func runACHGraceExpiryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) { + defer website.ClearSubscriptionNowForTests() + + fmt.Printf("[1/7] Creating Stripe test clock\n") + testClock, err := createTestClock(ctx, sc, "ach-grace-expiry") + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339)) + defer deleteTestClock(ctx, sc, testClock.ID) + + setup, err := setupACHPendingOnClock(ctx, pool, sc, testClock.ID) + if err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[5/7] Processing pending ACH checkout webhook (starts grace period)\n") + syncSubscriptionNowToTestClock(ctx, sc, testClock.ID) + + pi, err := sc.V1PaymentIntents.Create(ctx, &stripe.PaymentIntentCreateParams{ + Amount: stripe.Int64(500), + Currency: stripe.String("usd"), + Customer: stripe.String(setup.customerID), + PaymentMethod: stripe.String(setup.paymentMethodID), + PaymentMethodTypes: []*string{ + stripe.String("us_bank_account"), + }, + Confirm: stripe.Bool(true), + MandateData: &stripe.PaymentIntentCreateMandateDataParams{ + CustomerAcceptance: &stripe.PaymentIntentCreateMandateDataCustomerAcceptanceParams{ + Type: stripe.String("online"), + Online: &stripe.PaymentIntentCreateMandateDataCustomerAcceptanceOnlineParams{ + IPAddress: stripe.String("127.0.0.1"), + UserAgent: stripe.String("HMN Admin Subscription Test"), + }, + }, + }, + }) + if err != nil { + return subscriptionTestResultPass, fmt.Errorf("create pending ACH payment intent for grace scenario: %w", err) + } + + session := &stripe.CheckoutSession{ + ClientReferenceID: strconv.Itoa(setup.userID), + PaymentStatus: stripe.CheckoutSessionPaymentStatusUnpaid, + Customer: &stripe.Customer{ID: setup.customerID}, + Subscription: &stripe.Subscription{ID: fmt.Sprintf("sub_pending_grace_%d", setup.userID)}, + PaymentIntent: &stripe.PaymentIntent{ID: pi.ID}, + } + if err := dispatchMembershipWebhook(ctx, pool, sc, "checkout.session.completed", session); err != nil { + return subscriptionTestResultPass, fmt.Errorf("dispatch checkout.session.completed for grace scenario: %w", err) + } + + userAfterGraceStart, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID) + if err != nil { + return subscriptionTestResultPass, err + } + if userAfterGraceStart.SubscriptionStatus == nil || *userAfterGraceStart.SubscriptionStatus != website.SubscriptionStatusGracePeriod { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s after pending ACH checkout, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(userAfterGraceStart.SubscriptionStatus)) + } + if !userAfterGraceStart.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after pending ACH checkout grace start") + } + if userAfterGraceStart.GracePeriodStartedAt == nil || userAfterGraceStart.GracePeriodEndsAt == nil { + return subscriptionTestResultPass, fmt.Errorf("expected grace period dates after pending ACH checkout") + } + + if userAfterGraceStart.StripeCustomerID == nil || *userAfterGraceStart.StripeCustomerID != setup.customerID { + return subscriptionTestResultPass, fmt.Errorf("expected stripe_customer_id to remain linked after pending ACH checkout") + } + if userAfterGraceStart.StripeSubscriptionID == nil || *userAfterGraceStart.StripeSubscriptionID == "" { + return subscriptionTestResultPass, fmt.Errorf("expected stripe_subscription_id to be linked after pending ACH checkout") + } + + if err := expectScenarioEmailSubjects(ctx, []string{subjectACHVerificationGrace}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify ACH verification grace email: %w", err) + } + + fmt.Printf("[6/7] Advancing test clock by 14 days (past 7-day grace period)\n") + clockTime, err := advanceTestClockBy(ctx, sc, testClock.ID, 14*24*time.Hour) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" clock frozen_time=%s\n", clockTime.UTC().Format(time.RFC3339)) + website.SetSubscriptionNowForTests(clockTime) + + fmt.Printf("[7/7] Expiring due grace periods and verifying final state\n") + expiredCount, err := website.ExpireSubscriptionGracePeriods(ctx, pool) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" expired grace periods: %d\n", expiredCount) + if err := expectScenarioEmailSubjects(ctx, []string{subjectACHVerificationGrace, subjectGracePeriodEnded}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify grace period ended email: %w", err) + } + + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID) + if err != nil { + return subscriptionTestResultPass, err + } + if user.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=false after grace expiry") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus != website.SubscriptionStatusGraceFailed { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s, got %s", website.SubscriptionStatusGraceFailed, stringOrEmpty(user.SubscriptionStatus)) + } + if user.GraceAvailable { + return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace expiry") + } + + printSubscriptionDataSummary(ctx, pool, setup.userID) + return subscriptionTestResultPass, nil +} + +func runACHVerificationAfterAdvanceScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) { + defer website.ClearSubscriptionNowForTests() + + fmt.Printf("[1/8] Creating Stripe test clock\n") + testClock, err := createTestClock(ctx, sc, "ach-verify-after-advance") + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339)) + defer deleteTestClock(ctx, sc, testClock.ID) + + setup, err := setupACHPendingOnClock(ctx, pool, sc, testClock.ID) + if err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[5/8] Advancing test clock by 2 days (simulating microdeposit wait)\n") + clockTime, err := advanceTestClockBy(ctx, sc, testClock.ID, 2*24*time.Hour) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" clock frozen_time=%s\n", clockTime.UTC().Format(time.RFC3339)) + website.SetSubscriptionNowForTests(clockTime) + + fmt.Printf("[6/8] Triggering ACH verification via SetupIntent\n") + if err := verifyACHPaymentMethod(ctx, sc, setup.customerID, setup.paymentMethodID); err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[7/8] Attaching verified payment method and creating membership\n") + _, err = sc.V1PaymentMethods.Attach(ctx, setup.paymentMethodID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(setup.customerID), + }) + if err != nil { + return subscriptionTestResultPass, fmt.Errorf("attach verified ACH payment method: %w", err) + } + + result, err := completeSubscriptionE2E(ctx, pool, sc, setup.userID, setup.customerID, setup.paymentMethodID) + if err != nil { + return result, err + } + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID) + if err != nil { + return subscriptionTestResultPass, err + } + if user.StripeSubscriptionID != nil { + if paidInvoice, err := waitForLatestSubscriptionInvoiceStatus(ctx, sc, *user.StripeSubscriptionID, stripe.InvoiceStatusPaid, 20*time.Second); err != nil { + return subscriptionTestResultPass, err + } else if paidInvoice != nil { + _, err = pool.Exec(ctx, `UPDATE hmn_user SET thank_you_email_sent = false WHERE id = $1`, setup.userID) + if err != nil { + return subscriptionTestResultPass, err + } + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", paidInvoice); err != nil { + return subscriptionTestResultPass, fmt.Errorf("dispatch invoice.paid after ACH verification: %w", err) + } + } + } + if err := expectScenarioEmailSubjects(ctx, []string{subjectThankYou}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify thank-you email after ACH verification: %w", err) + } + + fmt.Printf("[8/8] Verifying membership is active after ACH verification\n") + user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", setup.userID) + if err != nil { + return subscriptionTestResultPass, err + } + if !user.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after ACH verification") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=active, got %s", stringOrEmpty(user.SubscriptionStatus)) + } + + return result, nil +} + +func runCardRenewalFailureGraceRecoveryScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client) (subscriptionTestResult, error) { + defer website.ClearSubscriptionNowForTests() + + fmt.Printf("[1/10] Creating Stripe test clock\n") + testClock, err := createTestClock(ctx, sc, "card-renewal-grace-recovery") + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" test_clock_id=%s frozen_time=%s\n", testClock.ID, time.Unix(testClock.FrozenTime, 0).UTC().Format(time.RFC3339)) + defer deleteTestClock(ctx, sc, testClock.ID) + + username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8]) + fmt.Printf("[2/10] Creating test user: %s\n", username) + userID, emailAddress := createSubscriptionTestUser(ctx, pool, username) + fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) + + fmt.Printf("[3/10] Creating Stripe customer on test clock\n") + customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + TestClock: stripe.String(testClock.ID), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + }) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" customer_id=%s\n", customer.ID) + + fmt.Printf("[4/10] Creating membership with tok_visa\n") + visaPM, err := createCardPaymentMethod(ctx, sc, "tok_visa") + if err != nil { + return subscriptionTestResultPass, err + } + _, err = sc.V1PaymentMethods.Attach(ctx, visaPM.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if err != nil { + return subscriptionTestResultPass, err + } + _, err = sc.V1Customers.Update(ctx, customer.ID, &stripe.CustomerUpdateParams{ + InvoiceSettings: &stripe.CustomerUpdateInvoiceSettingsParams{ + DefaultPaymentMethod: stripe.String(visaPM.ID), + }, + }) + if err != nil { + return subscriptionTestResultPass, err + } + + result, err := completeSubscription(ctx, pool, sc, userID, customer.ID, visaPM.ID) + if err != nil { + return result, err + } + + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + return subscriptionTestResultPass, err + } + if !user.IsSubscribed || user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" { + return subscriptionTestResultPass, fmt.Errorf("expected active membership before renewal, got is_subscribed=%v status=%s", user.IsSubscribed, stringOrEmpty(user.SubscriptionStatus)) + } + subscriptionID := *user.StripeSubscriptionID + + subParams := &stripe.SubscriptionRetrieveParams{} + subParams.AddExpand("items") + subscription, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, subParams) + if err != nil { + return subscriptionTestResultPass, err + } + if subscription.Items == nil || len(subscription.Items.Data) == 0 { + return subscriptionTestResultPass, fmt.Errorf("membership subscription has no items") + } + periodEnd := subscription.Items.Data[0].CurrentPeriodEnd + + fmt.Printf("[5/10] Swapping default payment method to tok_chargeCustomerFail (fails on charge)\n") + failPM, err := createCardPaymentMethod(ctx, sc, "tok_chargeCustomerFail") + if err != nil { + return subscriptionTestResultPass, err + } + _, err = sc.V1PaymentMethods.Attach(ctx, failPM.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if err != nil { + return subscriptionTestResultPass, fmt.Errorf("attach failing card: %w", err) + } + if err := setDefaultPaymentMethod(ctx, sc, customer.ID, subscriptionID, failPM.ID); err != nil { + return subscriptionTestResultPass, err + } + + fmt.Printf("[6/10] Advancing test clock past billing period end\n") + targetTime := time.Unix(periodEnd, 0).Add(time.Hour) + clockTime, err := advanceTestClockTo(ctx, sc, testClock.ID, targetTime) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" clock frozen_time=%s (period_end was %s)\n", + clockTime.UTC().Format(time.RFC3339), + time.Unix(periodEnd, 0).UTC().Format(time.RFC3339)) + website.SetSubscriptionNowForTests(clockTime) + + fmt.Printf("[7/10] Waiting for renewal payment attempt to fail\n") + subscription, err = waitForSubscriptionStatus(ctx, sc, subscriptionID, "past_due", "unpaid", "incomplete") + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" membership subscription status=%s\n", subscription.Status) + + invParams := &stripe.InvoiceRetrieveParams{} + invParams.AddExpand("payments.data.payment.payment_intent") + failedInvoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription) + if err != nil { + return subscriptionTestResultPass, err + } + if failedInvoice == nil { + return subscriptionTestResultPass, fmt.Errorf("expected open renewal invoice after failed payment") + } + failedInvoice, err = sc.V1Invoices.Retrieve(ctx, failedInvoice.ID, invParams) + if err != nil { + return subscriptionTestResultPass, err + } + failedInvoiceID := failedInvoice.ID + fmt.Printf(" renewal invoice_id=%s status=%s\n", failedInvoice.ID, failedInvoice.Status) + + fmt.Printf("[8/10] Processing renewal failure webhooks\n") + // Stripe test clock may deliver real webhooks if `stripe listen` is running; reset to the + // expected pre-grace subscriber state so this scenario exercises our handlers. + _, err = pool.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = true, + subscription_status = 'active', + grace_available = true, + grace_period_started_at = NULL, + grace_period_ends_at = NULL + WHERE id = $1 + `, userID) + if err != nil { + return subscriptionTestResultPass, err + } + + failedInvoice, err = sc.V1Invoices.Retrieve(ctx, failedInvoice.ID, invParams) + if err != nil { + return subscriptionTestResultPass, err + } + + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.payment_failed", failedInvoice); err != nil { + return subscriptionTestResultPass, err + } + if pi, _, err := website.InvoicePaymentIntentForTests(ctx, sc, failedInvoice); err != nil { + return subscriptionTestResultPass, err + } else if pi != nil { + if err := dispatchMembershipWebhook(ctx, pool, sc, "payment_intent.payment_failed", pi); err != nil { + return subscriptionTestResultPass, err + } + } + if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil { + return subscriptionTestResultPass, err + } + if failedInvoiceID != "" { + if recoveredInvoice, err := sc.V1Invoices.Retrieve(ctx, failedInvoiceID, nil); err == nil && recoveredInvoice.Status == stripe.InvoiceStatusPaid { + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", recoveredInvoice); err != nil { + return subscriptionTestResultPass, err + } + } + } + if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify payment failed email: %w", err) + } + + user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + return subscriptionTestResultPass, err + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus != website.SubscriptionStatusGracePeriod { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=%s after renewal failure, got %s", website.SubscriptionStatusGracePeriod, stringOrEmpty(user.SubscriptionStatus)) + } + if !user.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true during grace period") + } + if user.GraceAvailable { + return subscriptionTestResultPass, fmt.Errorf("expected grace_available=false after grace started") + } + if user.GracePeriodStartedAt == nil || user.GracePeriodEndsAt == nil { + return subscriptionTestResultPass, fmt.Errorf("expected grace period dates to be set") + } + fmt.Printf(" grace period started, ends %s\n", user.GracePeriodEndsAt.UTC().Format(time.RFC3339)) + + fmt.Printf("[9/10] Updating payment method to tok_visa and retrying payment\n") + recoveryPM, err := createCardPaymentMethod(ctx, sc, "tok_visa") + if err != nil { + return subscriptionTestResultPass, err + } + _, err = sc.V1PaymentMethods.Attach(ctx, recoveryPM.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if err != nil { + return subscriptionTestResultPass, err + } + if err := setDefaultPaymentMethod(ctx, sc, customer.ID, subscriptionID, recoveryPM.ID); err != nil { + return subscriptionTestResultPass, err + } + + recoveryPMObj, err := sc.V1PaymentMethods.Retrieve(ctx, recoveryPM.ID, nil) + if err != nil { + return subscriptionTestResultPass, err + } + if err := dispatchMembershipWebhook(ctx, pool, sc, "payment_method.attached", recoveryPMObj); err != nil { + return subscriptionTestResultPass, err + } + + subscription, err = waitForSubscriptionStatus(ctx, sc, subscriptionID, "active", "trialing") + if err != nil { + // Retry may have paid the invoice without flipping status yet; process invoice.paid if present. + fmt.Printf(" membership not active yet (%v); checking for paid invoice\n", err) + } + + paidInvoice, err := retrieveLatestSubscriptionInvoice(ctx, sc, subscription) + if err != nil { + return subscriptionTestResultPass, err + } + if paidInvoice != nil && paidInvoice.Status == stripe.InvoiceStatusPaid { + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", paidInvoice); err != nil { + return subscriptionTestResultPass, err + } + } + if subscription != nil { + if err := dispatchMembershipWebhook(ctx, pool, sc, "customer.subscription.updated", subscription); err != nil { + return subscriptionTestResultPass, err + } + } + _, err = pool.Exec(ctx, `UPDATE hmn_user SET thank_you_email_sent = false WHERE id = $1`, userID) + if err != nil { + return subscriptionTestResultPass, err + } + if failedInvoiceID != "" { + if recoveredInvoice, err := sc.V1Invoices.Retrieve(ctx, failedInvoiceID, nil); err == nil && recoveredInvoice.Status == stripe.InvoiceStatusPaid { + if err := dispatchMembershipWebhook(ctx, pool, sc, "invoice.paid", recoveredInvoice); err != nil { + return subscriptionTestResultPass, err + } + } + } + if err := expectScenarioEmailSubjects(ctx, []string{subjectPaymentFailed, subjectThankYou}); err != nil { + return subscriptionTestResultPass, fmt.Errorf("verify payment-recovery emails: %w", err) + } + + fmt.Printf("[10/10] Verifying membership reinstated\n") + user, err = db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + return subscriptionTestResultPass, err + } + if !user.IsSubscribed { + return subscriptionTestResultPass, fmt.Errorf("expected is_subscribed=true after payment method update") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus != "active" { + return subscriptionTestResultPass, fmt.Errorf("expected membership subscription_status=active after recovery, got %s", stringOrEmpty(user.SubscriptionStatus)) + } + if user.GracePeriodStartedAt != nil || user.GracePeriodEndsAt != nil { + return subscriptionTestResultPass, fmt.Errorf("expected grace period cleared after successful payment") + } + if !user.GraceAvailable { + return subscriptionTestResultPass, fmt.Errorf("expected grace_available=true after grace consumed and cleared") + } + + printSubscriptionDataSummary(ctx, pool, userID) + return subscriptionTestResultPass, nil +} + +func createCardPaymentMethod(ctx context.Context, sc *stripe.Client, token string) (*stripe.PaymentMethod, error) { + pm, err := sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCreateCardParams{ + Token: stripe.String(token), + }, + }) + if err != nil { + return nil, err + } + fmt.Printf(" payment_method_id=%s token=%s\n", pm.ID, token) + return pm, nil +} + +func setDefaultPaymentMethod(ctx context.Context, sc *stripe.Client, customerID, subscriptionID, paymentMethodID string) error { + _, err := sc.V1Customers.Update(ctx, customerID, &stripe.CustomerUpdateParams{ + InvoiceSettings: &stripe.CustomerUpdateInvoiceSettingsParams{ + DefaultPaymentMethod: stripe.String(paymentMethodID), + }, + }) + if err != nil { + return fmt.Errorf("update customer default payment method: %w", err) + } + _, err = sc.V1Subscriptions.Update(ctx, subscriptionID, &stripe.SubscriptionUpdateParams{ + DefaultPaymentMethod: stripe.String(paymentMethodID), + }) + if err != nil { + return fmt.Errorf("update membership default payment method: %w", err) + } + return nil +} + +func advanceTestClockTo(ctx context.Context, sc *stripe.Client, testClockID string, target time.Time) (time.Time, error) { + _, err := sc.V1TestHelpersTestClocks.Advance(ctx, testClockID, &stripe.TestHelpersTestClockAdvanceParams{ + FrozenTime: stripe.Int64(target.Unix()), + }) + if err != nil { + return time.Time{}, err + } + clock, err := waitForTestClockReady(ctx, sc, testClockID) + if err != nil { + return time.Time{}, err + } + return time.Unix(clock.FrozenTime, 0), nil +} + +func waitForSubscriptionStatus(ctx context.Context, sc *stripe.Client, subscriptionID string, statuses ...string) (*stripe.Subscription, error) { + want := make(map[string]struct{}, len(statuses)) + for _, s := range statuses { + want[s] = struct{}{} + } + + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil) + if err != nil { + return nil, err + } + if _, ok := want[string(sub.Status)]; ok { + return sub, nil + } + time.Sleep(2 * time.Second) + } + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil) + if err != nil { + return nil, err + } + return nil, fmt.Errorf("membership subscription %s did not reach status %v within timeout (last status=%s)", subscriptionID, statuses, sub.Status) +} + +func waitForLatestSubscriptionInvoiceStatus(ctx context.Context, sc *stripe.Client, subscriptionID string, status stripe.InvoiceStatus, timeout time.Duration) (*stripe.Invoice, error) { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil) + if err != nil { + return nil, err + } + inv, err := retrieveLatestSubscriptionInvoice(ctx, sc, sub) + if err != nil { + return nil, err + } + if inv != nil && inv.Status == status { + return inv, nil + } + time.Sleep(500 * time.Millisecond) + } + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, nil) + if err != nil { + return nil, err + } + inv, err := retrieveLatestSubscriptionInvoice(ctx, sc, sub) + if err != nil { + return nil, err + } + if inv == nil { + return nil, fmt.Errorf("subscription %s has no latest invoice after waiting for status=%s", subscriptionID, status) + } + return nil, fmt.Errorf("latest invoice %s did not reach status=%s within timeout (last status=%s)", inv.ID, status, inv.Status) +} + +func retrieveLatestSubscriptionInvoice(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) (*stripe.Invoice, error) { + if sub == nil { + return nil, fmt.Errorf("membership subscription is nil") + } + subParams := &stripe.SubscriptionRetrieveParams{} + subParams.AddExpand("latest_invoice") + fresh, err := sc.V1Subscriptions.Retrieve(ctx, sub.ID, subParams) + if err != nil { + return nil, err + } + if fresh.LatestInvoice != nil && fresh.LatestInvoice.ID != "" { + return sc.V1Invoices.Retrieve(ctx, fresh.LatestInvoice.ID, nil) + } + return nil, nil +} + +func dispatchMembershipWebhook(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, eventType string, obj any) error { + event, err := website.StripeEventFromObject(stripe.EventType(eventType), obj) + if err != nil { + return fmt.Errorf("build stripe event %s: %w", eventType, err) + } + website.ProcessMembershipStripeWebhookForTests(ctx, pool, sc, event) + return nil +} + +func setupACHPendingOnClock(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, testClockID string) (*achTestSetup, error) { + username := fmt.Sprintf("subtest_%s", uuid.NewString()[:8]) + fmt.Printf("[2/7] Creating test user: %s\n", username) + userID, emailAddress := createSubscriptionTestUser(ctx, pool, username) + fmt.Printf(" user_id=%d email=%s\n", userID, emailAddress) + + fmt.Printf("[3/7] Creating Stripe customer on test clock\n") + customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{ + Email: stripe.String(emailAddress), + Name: stripe.String(username), + TestClock: stripe.String(testClockID), + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + }) + if err != nil { + return nil, err + } + fmt.Printf(" customer_id=%s\n", customer.ID) + + fmt.Printf("[4/7] Creating ACH payment method and reaching pending verification\n") + paymentMethod, err := createACHPaymentMethod(ctx, sc) + if err != nil { + return nil, err + } + fmt.Printf(" payment_method_id=%s\n", paymentMethod.ID) + + _, err = sc.V1PaymentMethods.Attach(ctx, paymentMethod.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(customer.ID), + }) + if err == nil { + return nil, fmt.Errorf("expected ACH attach to require verification") + } + if !isExpectedACHVerificationPending(err) { + return nil, fmt.Errorf("attach ACH payment method: %w", err) + } + fmt.Printf(" ACH verification is pending; membership will complete after verification.\n") + if err := persistPendingVerificationState(ctx, pool, userID, customer.ID); err != nil { + return nil, err + } + printSubscriptionDataSummary(ctx, pool, userID) + + return &achTestSetup{ + userID: userID, + customerID: customer.ID, + paymentMethodID: paymentMethod.ID, + testClockID: testClockID, + }, nil +} + +func createACHPaymentMethod(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) { + return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("us_bank_account"), + USBankAccount: &stripe.PaymentMethodCreateUSBankAccountParams{ + AccountHolderType: stripe.String("individual"), + AccountType: stripe.String("checking"), + RoutingNumber: stripe.String("110000000"), + AccountNumber: stripe.String("000123456789"), + }, + BillingDetails: &stripe.PaymentMethodCreateBillingDetailsParams{ + Name: stripe.String("HMN ACH Test User"), + }, + }) +} + +func verifyACHPaymentMethod(ctx context.Context, sc *stripe.Client, customerID, paymentMethodID string) error { + setupIntent, err := sc.V1SetupIntents.Create(ctx, &stripe.SetupIntentCreateParams{ + Customer: stripe.String(customerID), + PaymentMethod: stripe.String(paymentMethodID), + PaymentMethodTypes: []*string{stripe.String("us_bank_account")}, + Confirm: stripe.Bool(true), + MandateData: &stripe.SetupIntentCreateMandateDataParams{ + CustomerAcceptance: &stripe.SetupIntentCreateMandateDataCustomerAcceptanceParams{ + Type: stripe.String("online"), + Online: &stripe.SetupIntentCreateMandateDataCustomerAcceptanceOnlineParams{ + IPAddress: stripe.String("127.0.0.1"), + UserAgent: stripe.String("HMN Admin Subscription Test"), + }, + }, + }, + }) + if err != nil { + return fmt.Errorf("confirm setup intent for ACH verification: %w", err) + } + fmt.Printf(" setup_intent_id=%s status=%s\n", setupIntent.ID, setupIntent.Status) + + if setupIntent.Status == stripe.SetupIntentStatusRequiresAction { + setupIntent, err = sc.V1SetupIntents.VerifyMicrodeposits(ctx, setupIntent.ID, &stripe.SetupIntentVerifyMicrodepositsParams{ + Amounts: []*int64{stripe.Int64(32), stripe.Int64(45)}, + }) + if err != nil { + setupIntent, err = sc.V1SetupIntents.VerifyMicrodeposits(ctx, setupIntent.ID, &stripe.SetupIntentVerifyMicrodepositsParams{ + DescriptorCode: stripe.String("SM11AA"), + }) + if err != nil { + return fmt.Errorf("verify ACH microdeposits: %w", err) + } + } + fmt.Printf(" setup_intent_id=%s status=%s after verification\n", setupIntent.ID, setupIntent.Status) + } + + if setupIntent.Status != stripe.SetupIntentStatusSucceeded { + return fmt.Errorf("setup intent did not succeed: status=%s", setupIntent.Status) + } + return nil +} + +func completeSubscription(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, userID int, customerID, paymentMethodID string) (subscriptionTestResult, error) { + subscriptionParams := &stripe.SubscriptionCreateParams{ + Customer: stripe.String(customerID), + DefaultPaymentMethod: stripe.String(paymentMethodID), + CollectionMethod: stripe.String("charge_automatically"), + PaymentBehavior: stripe.String("allow_incomplete"), + Items: []*stripe.SubscriptionCreateItemParams{ + {Price: stripe.String(config.Config.Stripe.PriceID)}, + }, + Metadata: map[string]string{ + "user_id": strconv.Itoa(userID), + }, + } + subscriptionParams.AddExpand("latest_invoice") + + subscription, err := sc.V1Subscriptions.Create(ctx, subscriptionParams) + if err != nil { + return subscriptionTestResultPass, err + } + fmt.Printf(" membership_subscription_id=%s status=%s\n", subscription.ID, subscription.Status) + + fmt.Printf("[5/6] Writing membership state to database\n") + renewalDate := getSubscriptionPeriodEndFromStripe(subscription) + isSubscribed := subscription.Status == stripe.SubscriptionStatusActive || subscription.Status == stripe.SubscriptionStatusTrialing + _, err = pool.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = $1, + stripe_customer_id = $2, + stripe_subscription_id = $3, + subscription_status = $4, + current_period_end = $5, + cancel_at_period_end = $6 + WHERE id = $7 + `, isSubscribed, customerID, subscription.ID, subscription.Status, renewalDate, subscription.CancelAtPeriodEnd, userID) + if err != nil { + return subscriptionTestResultPass, err + } + + var invoice *stripe.Invoice + if subscription.LatestInvoice != nil && subscription.LatestInvoice.ID != "" { + invoice, err = sc.V1Invoices.Retrieve(ctx, subscription.LatestInvoice.ID, nil) + if err != nil { + return subscriptionTestResultPass, err + } + } + if invoice != nil && invoice.StatusTransitions != nil && invoice.StatusTransitions.PaidAt > 0 { + paidAt := time.Unix(invoice.StatusTransitions.PaidAt, 0) + _, err = pool.Exec(ctx, ` + INSERT INTO user_payment (user_id, stripe_invoice_id, amount_cents, currency, paid_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (stripe_invoice_id) DO UPDATE SET + amount_cents = EXCLUDED.amount_cents, + currency = EXCLUDED.currency, + paid_at = EXCLUDED.paid_at + `, userID, invoice.ID, invoice.AmountPaid, string(invoice.Currency), paidAt) + if err != nil { + return subscriptionTestResultPass, err + } + } + + fmt.Printf("[6/6] Verifying and printing stored membership data\n") + if err := validateStoredSubscriptionData(ctx, pool, userID, customerID, subscription.ID); err != nil { + return subscriptionTestResultPass, err + } + printSubscriptionDataSummary(ctx, pool, userID) + return subscriptionTestResultPass, nil +} + +func createTestClock(ctx context.Context, sc *stripe.Client, name string) (*stripe.TestHelpersTestClock, error) { + return sc.V1TestHelpersTestClocks.Create(ctx, &stripe.TestHelpersTestClockCreateParams{ + FrozenTime: stripe.Int64(time.Now().Unix()), + Name: stripe.String(name), + }) +} + +func advanceTestClockBy(ctx context.Context, sc *stripe.Client, testClockID string, duration time.Duration) (time.Time, error) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + return time.Time{}, err + } + + target := time.Unix(clock.FrozenTime, 0).Add(duration) + _, err = sc.V1TestHelpersTestClocks.Advance(ctx, testClockID, &stripe.TestHelpersTestClockAdvanceParams{ + FrozenTime: stripe.Int64(target.Unix()), + }) + if err != nil { + return time.Time{}, err + } + + clock, err = waitForTestClockReady(ctx, sc, testClockID) + if err != nil { + return time.Time{}, err + } + return time.Unix(clock.FrozenTime, 0), nil +} + +func waitForTestClockReady(ctx context.Context, sc *stripe.Client, testClockID string) (*stripe.TestHelpersTestClock, error) { + deadline := time.Now().Add(2 * time.Minute) + for time.Now().Before(deadline) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + return nil, err + } + if clock.Status == stripe.TestHelpersTestClockStatusReady { + return clock, nil + } + time.Sleep(1 * time.Second) + } + return nil, fmt.Errorf("test clock %s did not become ready", testClockID) +} + +func syncSubscriptionNowToTestClock(ctx context.Context, sc *stripe.Client, testClockID string) { + clock, err := sc.V1TestHelpersTestClocks.Retrieve(ctx, testClockID, nil) + if err != nil { + panic(err) + } + website.SetSubscriptionNowForTests(time.Unix(clock.FrozenTime, 0)) +} + +func deleteTestClock(ctx context.Context, sc *stripe.Client, testClockID string) { + _, err := sc.V1TestHelpersTestClocks.Delete(ctx, testClockID, nil) + if err != nil { + fmt.Printf(" warning: failed to delete test clock %s: %v\n", testClockID, err) + } +} + +func validateStoredSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int, customerID string, subscriptionID string) error { + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + return err + } + if user.StripeCustomerID == nil || *user.StripeCustomerID != customerID { + return fmt.Errorf("stored stripe_customer_id mismatch") + } + if user.StripeSubscriptionID == nil || *user.StripeSubscriptionID != subscriptionID { + return fmt.Errorf("stored stripe_subscription_id mismatch") + } + if user.SubscriptionStatus == nil || *user.SubscriptionStatus == "" { + return fmt.Errorf("stored subscription_status is empty") + } + return nil +} + +func createSubscriptionTestUser(ctx context.Context, pool *pgxpool.Pool, username string) (int, string) { + emailAddress := uuid.New().String() + "@example.com" + hashedPassword := auth.HashPassword("password") + + var userID int + err := pool.QueryRow(ctx, ` + INSERT INTO hmn_user (username, email, password, date_joined, registration_ip, status) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + `, username, emailAddress, hashedPassword.String(), time.Now(), net.ParseIP("127.0.0.1"), models.UserStatusConfirmed).Scan(&userID) + if err != nil { + panic(err) + } + + return userID, emailAddress +} + +func getSubscriptionPeriodEndFromStripe(sub *stripe.Subscription) *time.Time { + if sub == nil || sub.Items == nil || len(sub.Items.Data) == 0 { + return nil + } + + t := time.Unix(sub.Items.Data[0].CurrentPeriodEnd, 0) + return &t +} + +func printSubscriptionData(ctx context.Context, pool *pgxpool.Pool, userID int) { + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + panic(err) + } + + fmt.Printf("\nStored user membership data:\n") + fmt.Printf(" user_id: %d\n", user.ID) + fmt.Printf(" username: %s\n", user.Username) + fmt.Printf(" is_subscribed: %v\n", user.IsSubscribed) + fmt.Printf(" stripe_customer_id: %s\n", stringOrEmpty(user.StripeCustomerID)) + fmt.Printf(" stripe_subscription_id: %s\n", stringOrEmpty(user.StripeSubscriptionID)) + fmt.Printf(" subscription_status: %s\n", stringOrEmpty(user.SubscriptionStatus)) + if user.CurrentPeriodEnd != nil { + fmt.Printf(" current_period_end: %s\n", user.CurrentPeriodEnd.UTC().Format(time.RFC3339)) + } else { + fmt.Printf(" current_period_end: \n") + } + fmt.Printf(" cancel_at_period_end: %v\n", user.CancelAtPeriodEnd) + if user.GracePeriodStartedAt != nil { + fmt.Printf(" grace_period_started_at: %s\n", user.GracePeriodStartedAt.UTC().Format(time.RFC3339)) + } + if user.GracePeriodEndsAt != nil { + fmt.Printf(" grace_period_ends_at: %s\n", user.GracePeriodEndsAt.UTC().Format(time.RFC3339)) + } + fmt.Printf(" grace_available: %v\n", user.GraceAvailable) + + payments, err := db.Query[models.UserPayment](ctx, pool, ` + SELECT $columns + FROM user_payment + WHERE user_id = $1 + ORDER BY paid_at DESC + `, userID) + if err != nil { + panic(err) + } + + fmt.Printf("\nStored payment rows: %d\n", len(payments)) + for i, payment := range payments { + fmt.Printf(" [%d] invoice=%s amount_cents=%d currency=%s paid_at=%s\n", + i, + stringOrEmpty(payment.StripeInvoiceID), + payment.AmountCents, + payment.Currency, + payment.PaidAt.UTC().Format(time.RFC3339), + ) + } +} + +func printSubscriptionDataSummary(ctx context.Context, pool *pgxpool.Pool, userID int) { + user, err := db.QueryOne[models.User](ctx, pool, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + panic(err) + } + + paymentCount, err := db.QueryOneScalar[int](ctx, pool, ` + SELECT COUNT(*) + FROM user_payment + WHERE user_id = $1 + `, userID) + if err != nil { + panic(err) + } + + fmt.Printf(" DB: status=%s subscribed=%v grace_available=%v payments=%d\n", + stringOrEmpty(user.SubscriptionStatus), + user.IsSubscribed, + user.GraceAvailable, + paymentCount, + ) +} + +func stringOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} + +func isExpectedACHVerificationPending(err error) bool { + var stripeErr *stripe.Error + if errors.As(err, &stripeErr) { + return strings.Contains(stripeErr.Msg, "must be verified before they can be attached to a customer") + } + return strings.Contains(err.Error(), "must be verified before they can be attached to a customer") +} + +func isStripeCardDeclined(err error) bool { + if err == nil { + return false + } + if strings.Contains(err.Error(), "card_declined") { + return true + } + var stripeErr *stripe.Error + if errors.As(err, &stripeErr) { + return stripeErr.Code == stripe.ErrorCodeCardDeclined || + stripeErr.Type == stripe.ErrorTypeCard || + stripeErr.DeclineCode == stripe.DeclineCodeGenericDecline + } + return false +} + +func persistPendingVerificationState(ctx context.Context, pool *pgxpool.Pool, userID int, customerID string) error { + _, err := pool.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = false, + stripe_customer_id = $1, + subscription_status = 'pending_verification', + current_period_end = NULL, + cancel_at_period_end = false + WHERE id = $2 + `, customerID, userID) + return err +} diff --git a/src/admintools/adminsubscription_cmd.go b/src/admintools/adminsubscription_cmd.go new file mode 100644 index 00000000..05297826 --- /dev/null +++ b/src/admintools/adminsubscription_cmd.go @@ -0,0 +1,232 @@ +package admintools + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "github.com/spf13/cobra" + "github.com/stripe/stripe-go/v84" +) + +func addSubscriptionCommands(adminCommand *cobra.Command) { + cmd := &cobra.Command{ + Use: "membership", + Short: "Admin commands for membership testing", + } + adminCommand.AddCommand(cmd) + + legacyCmd := &cobra.Command{ + Use: "subscription", + Short: "Alias for membership commands", + Hidden: true, + } + adminCommand.AddCommand(legacyCmd) + + addSubscriptionTestCommand(cmd) + addSubscriptionTestCommand(legacyCmd) + addSubscriptionInspectCommand(cmd) + addSubscriptionInspectCommand(legacyCmd) +} + +func addSubscriptionTestCommand(subscriptionCommand *cobra.Command) { + var scenarioFilter string + var openMailpit bool + + cmd := &cobra.Command{ + Use: "test", + Short: "Run membership test scenarios and print stored DB results", + Run: func(cmd *cobra.Command, _ []string) { + if config.Config.Stripe.SecretKey == "" || config.Config.Stripe.PriceID == "" { + fmt.Fprintf(os.Stderr, "Stripe.SecretKey and Stripe.PriceID must be set in config.\n") + os.Exit(1) + } + + ctx := context.Background() + pool := db.NewConnPool() + defer pool.Close() + + originalEmailConfig := config.Config.Email + defer func() { + config.Config.Email = originalEmailConfig + }() + + mailpit, mailpitInstalled, err := startMembershipMailpit() + if err != nil { + fmt.Printf("WARNING: failed to start Mailpit, email checks disabled: %v\n", err) + } + if !mailpitInstalled { + fmt.Printf("Mailpit binary not found; skipping email checks.\n") + } + if mailpit != nil { + fmt.Printf("Mailpit started: HTTP=%s SMTP=%s\n", mailpit.httpBaseURL, mailpit.smtpAddr) + if openMailpit { + if err := openURLInBrowser(mailpit.httpBaseURL); err != nil { + fmt.Printf("WARNING: failed to open Mailpit UI: %v\n", err) + } + } + defer func() { + if stopErr := mailpit.Stop(); stopErr != nil { + fmt.Printf("WARNING: failed to stop Mailpit: %v\n", stopErr) + } + }() + ctx = withMembershipMailpit(ctx, mailpit) + } + + if override := config.Config.Stripe.SubscriptionNowOverride; override != "" { + fmt.Printf("Using membership time override: %s\n", override) + } + if testClockID := config.Config.Stripe.TestClockID; testClockID != "" { + fmt.Printf("Using Stripe test clock: %s\n", testClockID) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + scenarios := membershipScenarios() + if scenarioFilter != "" { + selected, err := selectMembershipScenarios(scenarios, scenarioFilter) + if err != nil { + fmt.Fprintf(os.Stderr, "Invalid --scenario value %q: %v\n", scenarioFilter, err) + os.Exit(1) + } + scenarios = selected + } + + failed := false + passCount := 0 + pendingCount := 0 + failCount := 0 + var failedScenarioNames []string + for i, scenario := range scenarios { + if mailpit != nil { + if err := mailpit.ClearMessages(); err != nil { + fmt.Printf("WARNING: failed to clear Mailpit mailbox before scenario: %v\n", err) + } + } + + fmt.Printf("\n========== Scenario %d/%d: %s ==========\n", i+1, len(scenarios), scenario.Name) + result, err := runSubscriptionScenario(ctx, pool, sc, scenario) + + if mailpit != nil { + subjects, subjErr := mailpit.messageSubjects() + if subjErr != nil { + fmt.Printf("EMAILS: unable to list received messages (%v)\n", subjErr) + } else if len(subjects) == 0 { + fmt.Printf("EMAILS: none received\n") + } else { + fmt.Printf("EMAILS: received %d\n", len(subjects)) + for _, subject := range subjects { + fmt.Printf(" - %s\n", subject) + } + } + } + + if err != nil { + failed = true + failCount++ + failedScenarioNames = append(failedScenarioNames, scenario.Name) + fmt.Printf("RESULT: FAIL\n") + fmt.Printf("ERROR: %v\n", err) + } else if result == subscriptionTestResultPending { + pendingCount++ + fmt.Printf("RESULT: PENDING (expected for ACH verification)\n") + } else { + passCount++ + fmt.Printf("RESULT: PASS\n") + } + } + + fmt.Printf("\n========== Membership Test Summary ==========\n") + fmt.Printf("Total scenarios: %d\n", len(scenarios)) + fmt.Printf("PASS: %d\n", passCount) + fmt.Printf("PENDING: %d\n", pendingCount) + fmt.Printf("FAIL: %d\n", failCount) + if len(failedScenarioNames) > 0 { + fmt.Printf("Failed scenarios:\n") + for _, name := range failedScenarioNames { + fmt.Printf(" - %s\n", name) + } + } + + if failed { + os.Exit(1) + } + }, + } + cmd.Flags().StringVar(&scenarioFilter, "scenario", "", "Run a single scenario by 1-based index or exact name") + cmd.Flags().BoolVar(&openMailpit, "open-mailpit", false, "Open Mailpit web UI in the default browser when available") + + subscriptionCommand.AddCommand(cmd) +} + +func selectMembershipScenarios(scenarios []subscriptionTestScenario, filter string) ([]subscriptionTestScenario, error) { + if idx, err := strconv.Atoi(filter); err == nil { + if idx < 1 || idx > len(scenarios) { + return nil, fmt.Errorf("index out of range (1-%d)", len(scenarios)) + } + return []subscriptionTestScenario{scenarios[idx-1]}, nil + } + + needle := strings.TrimSpace(filter) + if needle == "" { + return nil, errors.New("scenario name is blank") + } + for _, scenario := range scenarios { + if strings.EqualFold(scenario.Name, needle) { + return []subscriptionTestScenario{scenario}, nil + } + } + + return nil, fmt.Errorf("not found; use 1-%d or one of the scenario names", len(scenarios)) +} + +func openURLInBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func addSubscriptionInspectCommand(subscriptionCommand *cobra.Command) { + cmd := &cobra.Command{ + Use: "inspect ", + Short: "Print membership/payment debug info for a user", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + username := args[0] + + ctx := context.Background() + pool := db.NewConnPool() + defer pool.Close() + + userID, err := db.QueryOneScalar[int](ctx, pool, ` + SELECT id + FROM hmn_user + WHERE LOWER(username) = LOWER($1) + `, username) + if err != nil { + if errors.Is(err, db.NotFound) { + fmt.Printf("User not found: %s\n", username) + os.Exit(1) + } + panic(err) + } + + printSubscriptionData(ctx, pool, userID) + }, + } + + subscriptionCommand.AddCommand(cmd) +} diff --git a/src/admintools/adminsubscription_helpers.go b/src/admintools/adminsubscription_helpers.go new file mode 100644 index 00000000..1a5c9d93 --- /dev/null +++ b/src/admintools/adminsubscription_helpers.go @@ -0,0 +1,28 @@ +package admintools + +import ( + "fmt" +) + +type scenarioCtx struct { + name string + steps int + index int +} + +func newScenarioCtx(name string, steps int) *scenarioCtx { + return &scenarioCtx{ + name: name, + steps: steps, + } +} + +func (s *scenarioCtx) step(msg string, fn func() error) error { + s.index++ + fmt.Printf("[%d/%d] %s\n", s.index, s.steps, msg) + return fn() +} + +func (s *scenarioCtx) printf(format string, args ...any) { + fmt.Printf(" "+format, args...) +} diff --git a/src/admintools/adminsubscription_mailpit.go b/src/admintools/adminsubscription_mailpit.go new file mode 100644 index 00000000..1c67c982 --- /dev/null +++ b/src/admintools/adminsubscription_mailpit.go @@ -0,0 +1,204 @@ +package admintools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os/exec" + "time" + + "git.handmade.network/hmn/hmn/src/config" +) + +type membershipMailpitContextKey struct{} + +func withMembershipMailpit(ctx context.Context, m *membershipMailpit) context.Context { + return context.WithValue(ctx, membershipMailpitContextKey{}, m) +} + +func membershipMailpitFromContext(ctx context.Context) *membershipMailpit { + if ctx == nil { + return nil + } + if m, ok := ctx.Value(membershipMailpitContextKey{}).(*membershipMailpit); ok { + return m + } + return nil +} + +type membershipMailpit struct { + httpBaseURL string + smtpAddr string + cmd *exec.Cmd + client *http.Client +} + +func startMembershipMailpit() (*membershipMailpit, bool, error) { + _, err := exec.LookPath("mailpit") + if err != nil { + return nil, false, nil + } + + smtpPort, err := reserveTCPPort() + if err != nil { + return nil, true, fmt.Errorf("reserve smtp port: %w", err) + } + httpPort, err := reserveTCPPort() + if err != nil { + return nil, true, fmt.Errorf("reserve http port: %w", err) + } + + m := &membershipMailpit{ + httpBaseURL: fmt.Sprintf("http://127.0.0.1:%d", httpPort), + smtpAddr: fmt.Sprintf("127.0.0.1:%d", smtpPort), + client: &http.Client{ + Timeout: 2 * time.Second, + }, + } + m.cmd = exec.Command("mailpit", + "--smtp", fmt.Sprintf("127.0.0.1:%d", smtpPort), + "--listen", fmt.Sprintf("127.0.0.1:%d", httpPort), + ) + if err := m.cmd.Start(); err != nil { + return nil, true, fmt.Errorf("start mailpit: %w", err) + } + + if err := m.waitReady(10 * time.Second); err != nil { + _ = m.Stop() + return nil, true, err + } + + config.Config.Email.ServerAddress = "127.0.0.1" + config.Config.Email.ServerPort = smtpPort + config.Config.Email.MailerUsername = "" + config.Config.Email.MailerPassword = "" + config.Config.Email.ForceToAddress = "" + + if err := m.ClearMessages(); err != nil { + _ = m.Stop() + return nil, true, err + } + + return m, true, nil +} + +func (m *membershipMailpit) Stop() error { + if m == nil || m.cmd == nil || m.cmd.Process == nil { + return nil + } + _ = m.cmd.Process.Kill() + _, _ = m.cmd.Process.Wait() + return nil +} + +func (m *membershipMailpit) waitReady(timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + req, err := http.NewRequest(http.MethodGet, m.httpBaseURL+"/api/v1/info", nil) + if err == nil { + resp, err := m.client.Do(req) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + } + time.Sleep(200 * time.Millisecond) + } + return fmt.Errorf("mailpit did not become ready at %s", m.httpBaseURL) +} + +func (m *membershipMailpit) ClearMessages() error { + req, err := http.NewRequest(http.MethodDelete, m.httpBaseURL+"/api/v1/messages", nil) + if err != nil { + return err + } + resp, err := m.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("mailpit clear messages returned %d", resp.StatusCode) + } + return nil +} + +func (m *membershipMailpit) messageSubjects() ([]string, error) { + req, err := http.NewRequest(http.MethodGet, m.httpBaseURL+"/api/v1/messages?start=0&limit=200", nil) + if err != nil { + return nil, err + } + resp, err := m.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("mailpit list messages returned %d", resp.StatusCode) + } + + var payload struct { + Messages []struct { + Subject string `json:"Subject"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, err + } + + // Mailpit returns newest-first; reverse so assertions read chronologically. + subjects := make([]string, 0, len(payload.Messages)) + for i := len(payload.Messages) - 1; i >= 0; i-- { + subjects = append(subjects, payload.Messages[i].Subject) + } + return subjects, nil +} + +func (m *membershipMailpit) WaitForSubjects(expected []string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + subjects, err := m.messageSubjects() + if err == nil { + if err := assertSubjectsEqual(subjects, expected); err == nil { + return nil + } + } + time.Sleep(200 * time.Millisecond) + } + subjects, err := m.messageSubjects() + if err != nil { + return err + } + return assertSubjectsEqual(subjects, expected) +} + +func assertSubjectsEqual(actual, expected []string) error { + if len(actual) != len(expected) { + return fmt.Errorf("email subject count mismatch: got %d expected %d (actual: %v)", len(actual), len(expected), actual) + } + for i := range expected { + if actual[i] != expected[i] { + return fmt.Errorf("email subject mismatch at index %d: got %q expected %q (actual: %v)", i, actual[i], expected[i], actual) + } + } + return nil +} + +func reserveTCPPort() (int, error) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer ln.Close() + + addr, ok := ln.Addr().(*net.TCPAddr) + if !ok { + return 0, errors.New("listener is not TCP") + } + return addr.Port, nil +} diff --git a/src/admintools/adminsubscription_scenarios.go b/src/admintools/adminsubscription_scenarios.go new file mode 100644 index 00000000..b9df2577 --- /dev/null +++ b/src/admintools/adminsubscription_scenarios.go @@ -0,0 +1,64 @@ +package admintools + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stripe/stripe-go/v84" +) + +type subscriptionTestScenario struct { + Name string + CreatePaymentMethod func(context.Context, *stripe.Client) (*stripe.PaymentMethod, error) + Run func(context.Context, *pgxpool.Pool, *stripe.Client) (subscriptionTestResult, error) +} + +type subscriptionTestResult int + +const ( + subscriptionTestResultPass subscriptionTestResult = iota + subscriptionTestResultPending +) + +func membershipScenarios() []subscriptionTestScenario { + return []subscriptionTestScenario{ + { + Name: "Credit card (tok_visa)", + CreatePaymentMethod: func(ctx context.Context, sc *stripe.Client) (*stripe.PaymentMethod, error) { + return sc.V1PaymentMethods.Create(ctx, &stripe.PaymentMethodCreateParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCreateCardParams{ + Token: stripe.String("tok_visa"), + }, + }) + }, + }, + { + Name: "Credit card one-time charge (EUR)", + Run: runEuroCardChargeScenario, + }, + { + Name: "Credit card declined (tok_chargeDeclined)", + Run: runDeclinedCardScenario, + }, + { + Name: "ACH grace expires after 2 week clock advance", + Run: runACHGraceExpiryScenario, + }, + { + Name: "ACH verification after 2 day clock advance", + Run: runACHVerificationAfterAdvanceScenario, + }, + { + Name: "Card renewal failure → grace → payment method update", + Run: runCardRenewalFailureGraceRecoveryScenario, + }, + } +} + +func runSubscriptionScenario(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, scenario subscriptionTestScenario) (subscriptionTestResult, error) { + if scenario.Run != nil { + return scenario.Run(ctx, pool, sc) + } + return runCardOrACHScenario(ctx, pool, sc, scenario) +} diff --git a/src/admintools/admintools.go b/src/admintools/admintools.go index 7bb90234..4dacf884 100644 --- a/src/admintools/admintools.go +++ b/src/admintools/admintools.go @@ -645,4 +645,5 @@ func init() { addProjectCommands(adminCommand) addPostCommands(adminCommand) addEventCommands(adminCommand) + addSubscriptionCommands(adminCommand) } diff --git a/src/config/config.go.example b/src/config/config.go.example index 47566729..8bb245bf 100644 --- a/src/config/config.go.example +++ b/src/config/config.go.example @@ -68,6 +68,7 @@ var Config = HMNConfig{ GuildID: "", MemberRoleID: "", + SupporterRoleID: "", HMHReplayRoleID: "", ShowcaseChannelID: "", JamChannelID: "", @@ -103,6 +104,15 @@ var Config = HMNConfig{ }, Stripe: StripeConfig{ SecretKey: "", + PublishableKey: "", WebhookSecret: "", + // Default USD membership price (price_... from Stripe Dashboard / API). + PriceID: "price_...", + // Optional other membership prices, e.g. EUR. First entry powers the EUR option on /foundation/membership. + MembershipAlternatePriceIDs: []string{ + // "price_...", + }, + // TestClockID: "clock_...", + // SubscriptionNowOverride: "2026-05-24T12:00:00Z", }, } diff --git a/src/config/types.go b/src/config/types.go index 7f0cd0c1..5c40e6fd 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -85,6 +85,7 @@ type DiscordConfig struct { GuildID string MemberRoleID string + SupporterRoleID string HMHReplayRoleID string ShowcaseChannelID string JamChannelID string @@ -135,8 +136,24 @@ type PostmarkConfig struct { } type StripeConfig struct { - SecretKey string - WebhookSecret string + SecretKey string + PublishableKey string + WebhookSecret string + + // PriceID is the default subscription price used when creating a membership Checkout Session. + PriceID string + + // MembershipAlternatePriceIDs lists any other Stripe price IDs used for membership (e.g. a + // second currency). The webhook dispatcher treats PriceID ∪ MembershipAlternatePriceIDs as + // belonging to the subscription flow. + MembershipAlternatePriceIDs []string + + // TestClockID attaches new Stripe customers created by subscription test tooling to this clock. + TestClockID string + + // SubscriptionNowOverride is an RFC3339 timestamp used instead of time.Now() for subscription + // grace-period logic in dev/test. Leave empty in production. + SubscriptionNowOverride string } func init() { diff --git a/src/discord/cmd/cmd.go b/src/discord/cmd/cmd.go index 5304a5b2..4be19114 100644 --- a/src/discord/cmd/cmd.go +++ b/src/discord/cmd/cmd.go @@ -80,4 +80,40 @@ func init() { }, } rootCommand.AddCommand(makeSnippetCommand) + + syncSupporterRolesCommand := &cobra.Command{ + Use: "sync-supporter-roles", + Short: "Sync supporter Discord roles for subscribed members", + Long: "Grants SupporterRoleID to subscribed users with linked Discord accounts, and removes it from others.", + Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + conn := db.NewConnPool() + defer conn.Close() + + dryRun, _ := cmd.Flags().GetBool("dry-run") + + userIDPtrs, err := db.Query[int](ctx, conn, ` + SELECT hmn_user.id + FROM hmn_user + INNER JOIN discord_user ON discord_user.hmn_user_id = hmn_user.id + WHERE hmn_user.is_subscribed = true + `) + if err != nil { + logging.Error().Err(err).Msg("failed to list subscribed users with Discord") + os.Exit(1) + } + + if dryRun { + logging.Info().Int("count", len(userIDPtrs)).Msg("dry run: would sync supporter Discord roles") + return + } + + for _, userID := range userIDPtrs { + website.SyncSupporterDiscordRole(ctx, conn, *userID) + } + logging.Info().Int("count", len(userIDPtrs)).Msg("synced supporter Discord roles") + }, + } + syncSupporterRolesCommand.Flags().Bool("dry-run", false, "log how many users would be synced without calling Discord") + rootCommand.AddCommand(syncSupporterRolesCommand) } diff --git a/src/email/email.go b/src/email/email.go index bee14c48..3f50b028 100644 --- a/src/email/email.go +++ b/src/email/email.go @@ -167,6 +167,230 @@ func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername strin return nil } +type ThankYouEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string + RenewalDate string + Amount string +} + +func SendThankYouEmail( + toAddress string, + toName string, + renewalDate *time.Time, + amount string, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Thank you email").End() + + renewalDateStr := "" + if renewalDate != nil { + renewalDateStr = renewalDate.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_thank_you.html", ThankYouEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + RenewalDate: renewalDateStr, + Amount: amount, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Thank you!", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +type SubscriptionCancelledEmailData struct { + Name string + HomepageUrl string + ExpirationDate string +} + +func SendSubscriptionCancelledEmail( + toAddress string, + toName string, + expirationDate *time.Time, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Subscription cancelled email").End() + + expirationDateStr := "" + if expirationDate != nil && !expirationDate.IsZero() { + expirationDateStr = expirationDate.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_subscription_cancelled.html", SubscriptionCancelledEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ExpirationDate: expirationDateStr, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Membership cancelled", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +type PaymentFailedEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string + Amount string + NextAttemptDate string + GracePeriodEnd string +} + +type ACHVerificationGraceEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string + GracePeriodEnd string +} + +type GracePeriodEndedEmailData struct { + Name string + HomepageUrl string + ManageSubscriptionUrl string +} + +func SendPaymentFailedEmail( + toAddress string, + toName string, + amount string, + nextAttemptDate *time.Time, + gracePeriodEnd *time.Time, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Payment failed email").End() + + nextAttemptDateStr := "" + if nextAttemptDate != nil && !nextAttemptDate.IsZero() { + nextAttemptDateStr = nextAttemptDate.Format("January 2, 2006") + } + + gracePeriodEndStr := "" + if gracePeriodEnd != nil && !gracePeriodEnd.IsZero() { + gracePeriodEndStr = gracePeriodEnd.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_payment_failed.html", PaymentFailedEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + Amount: amount, + NextAttemptDate: nextAttemptDateStr, + GracePeriodEnd: gracePeriodEndStr, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Payment failed", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +func SendACHVerificationGraceEmail( + toAddress string, + toName string, + gracePeriodEnd *time.Time, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "ACH verification grace email").End() + + gracePeriodEndStr := "" + if gracePeriodEnd != nil && !gracePeriodEnd.IsZero() { + gracePeriodEndStr = gracePeriodEnd.Format("January 2, 2006") + } + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_ach_verification_grace.html", ACHVerificationGraceEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + GracePeriodEnd: gracePeriodEndStr, + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Verify your bank account", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + +func SendGracePeriodEndedEmail( + toAddress string, + toName string, + perf *perf.RequestPerf, +) error { + defer perf.StartBlock("EMAIL", "Grace period ended email").End() + + b1 := perf.StartBlock("EMAIL", "Rendering template") + defer b1.End() + contents, err := renderTemplate("email_grace_period_ended.html", GracePeriodEndedEmailData{ + Name: toName, + HomepageUrl: hmnurl.BuildHomepage(), + ManageSubscriptionUrl: hmnurl.BuildSubscriptionManage(), + }) + if err != nil { + return err + } + b1.End() + + b2 := perf.StartBlock("EMAIL", "Sending email") + defer b2.End() + err = sendMail(toAddress, toName, "[Handmade Software Foundation] Grace period ended", contents) + if err != nil { + return oops.New(err, "Failed to send email") + } + b2.End() + + return nil +} + func SendExpoTicketPurchaseEmail(toAddress string, toName string, ticket *models.Ticket) error { event, ok := hmndata.FindTicketEventBySlug(ticket.EventSlug) if !ok { @@ -236,9 +460,13 @@ func sendMail(toAddress, toName, subject string, contentHTML []byte) error { subject, processedHTML, ) + var auth smtp.Auth + if config.Config.Email.MailerUsername != "" || config.Config.Email.MailerPassword != "" { + auth = smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress) + } return smtp.SendMail( fmt.Sprintf("%s:%d", config.Config.Email.ServerAddress, config.Config.Email.ServerPort), - smtp.PlainAuth("", config.Config.Email.MailerUsername, config.Config.Email.MailerPassword, config.Config.Email.ServerAddress), + auth, config.Config.Email.FromAddress, []string{toAddress}, contents, diff --git a/src/hmnurl/hmnurl_test.go b/src/hmnurl/hmnurl_test.go index da5519dc..336520d4 100644 --- a/src/hmnurl/hmnurl_test.go +++ b/src/hmnurl/hmnurl_test.go @@ -457,6 +457,10 @@ func TestDiscordUnlink(t *testing.T) { AssertRegexMatch(t, BuildDiscordUnlink(), RegexDiscordUnlink, nil) } +func TestDismissMembershipDiscordLinkBanner(t *testing.T) { + AssertRegexMatch(t, BuildDismissMembershipDiscordLinkBanner(), RegexDismissMembershipDiscordLinkBanner, nil) +} + func TestDiscordShowcaseBacklog(t *testing.T) { AssertRegexMatch(t, BuildDiscordShowcaseBacklog(), RegexDiscordShowcaseBacklog, nil) } @@ -538,6 +542,21 @@ func AssertRegexNoMatch(t *testing.T, fullUrl string, regex *regexp.Regexp) { assert.Nilf(t, match, "Url matched regex: [%s] vs [%s]", requestPath, regex.String()) } +func TestFoundationSubscriptionBuildUrls(t *testing.T) { + defer func() { + SetGlobalBaseUrl(config.Config.BaseUrl) + }() + SetGlobalBaseUrl("http://handmade.test") + isTest = true + + AssertRegexMatch(t, BuildHSFMembership(), RegexHSFMembership, nil) + AssertRegexMatch(t, BuildSubscriptionManage(), RegexHSFMembership, nil) + AssertRegexMatch(t, BuildSubscriptionSubscribe(), RegexSubscriptionSubscribe, nil) + AssertRegexMatch(t, BuildSubscriptionCancel(), RegexSubscriptionCancel, nil) + AssertRegexMatch(t, BuildSubscriptionResume(), RegexSubscriptionResume, nil) + AssertRegexMatch(t, BuildSubscriptionUpdatePaymentMethod(), RegexSubscriptionUpdatePaymentMethod, nil) +} + func TestThingsThatDontNeedCoverage(t *testing.T) { // look the other way ಠ_ಠ BuildPodcastEpisodeFile("foo") diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 49301b53..64e8bc5f 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -999,6 +999,12 @@ func BuildDiscordUnlink() string { return Url("/_discord_unlink", nil) } +var RegexDismissMembershipDiscordLinkBanner = regexp.MustCompile("^/_dismiss_membership_discord_link_banner$") + +func BuildDismissMembershipDiscordLinkBanner() string { + return Url("/_dismiss_membership_discord_link_banner", nil) +} + var RegexDiscordShowcaseBacklog = regexp.MustCompile("^/discord_showcase_backlog$") func BuildDiscordShowcaseBacklog() string { @@ -1083,6 +1089,40 @@ func BuildHSFMembership() string { return Url("/foundation/membership", nil) } +func BuildHSFMembershipPaymentMethodReturn() string { + return Url("/foundation/membership", []Q{{Name: "payment_method_updated", Value: "1"}}) +} + +var RegexSubscriptionManage = regexp.MustCompile(`^/foundation/membership/manage$`) + +func BuildSubscriptionManage() string { + return BuildHSFMembership() +} + +var RegexSubscriptionSubscribe = regexp.MustCompile(`^/foundation/membership/subscribe$`) + +func BuildSubscriptionSubscribe() string { + return Url("/foundation/membership/subscribe", nil) +} + +var RegexSubscriptionCancel = regexp.MustCompile(`^/foundation/membership/cancel$`) + +func BuildSubscriptionCancel() string { + return Url("/foundation/membership/cancel", nil) +} + +var RegexSubscriptionResume = regexp.MustCompile(`^/foundation/membership/resume$`) + +func BuildSubscriptionResume() string { + return Url("/foundation/membership/resume", nil) +} + +var RegexSubscriptionUpdatePaymentMethod = regexp.MustCompile(`^/foundation/membership/update-payment-method$`) + +func BuildSubscriptionUpdatePaymentMethod() string { + return Url("/foundation/membership/update-payment-method", nil) +} + /* * Perf */ diff --git a/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go b/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go new file mode 100644 index 00000000..7dc9fdde --- /dev/null +++ b/src/migration/migrations/2026-03-25T155929Z_AddIsSubscribedToUser.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddIsSubscribedToUser{}) +} + +type AddIsSubscribedToUser struct{} + +func (m AddIsSubscribedToUser) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 15, 59, 29, 0, time.UTC)) +} + +func (m AddIsSubscribedToUser) Name() string { + return "AddIsSubscribedToUser" +} + +func (m AddIsSubscribedToUser) Description() string { + return "Add is_subscribed column to hmn_user table" +} + +func (m AddIsSubscribedToUser) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE hmn_user + ADD COLUMN is_subscribed BOOLEAN NOT NULL DEFAULT false; + `, + ) + return err +} + +func (m AddIsSubscribedToUser) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, + ` + ALTER TABLE hmn_user + DROP COLUMN is_subscribed; + `, + ) + return err +} diff --git a/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go b/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go new file mode 100644 index 00000000..b8060105 --- /dev/null +++ b/src/migration/migrations/2026-03-25T160033Z_AddStripeToUser.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddStripeToUser{}) +} + +type AddStripeToUser struct{} + +func (m AddStripeToUser) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 0, 33, 0, time.UTC)) +} + +func (m AddStripeToUser) Name() string { + return "AddStripeToUser" +} + +func (m AddStripeToUser) Description() string { + return "Add Stripe customer and subscription IDs to user table" +} + +func (m AddStripeToUser) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN stripe_customer_id TEXT, + ADD COLUMN stripe_subscription_id TEXT + `) + return err +} + +func (m AddStripeToUser) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN stripe_customer_id, + DROP COLUMN stripe_subscription_id + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go b/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go new file mode 100644 index 00000000..108143bd --- /dev/null +++ b/src/migration/migrations/2026-03-25T160121Z_AddDetailedSubscriptionInfo.go @@ -0,0 +1,63 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddDetailedSubscriptionInfo{}) +} + +type AddDetailedSubscriptionInfo struct{} + +func (m AddDetailedSubscriptionInfo) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 1, 21, 0, time.UTC)) +} + +func (m AddDetailedSubscriptionInfo) Name() string { + return "AddDetailedSubscripitionInfo" +} + +func (m AddDetailedSubscriptionInfo) Description() string { + return "Add detailed subscription fields to user table and create user_payment table" +} + +func (m AddDetailedSubscriptionInfo) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN subscription_status TEXT, + ADD COLUMN current_period_end TIMESTAMP WITH TIME ZONE, + ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT false; + + CREATE TABLE user_payment ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES hmn_user(id) ON DELETE CASCADE, + stripe_invoice_id TEXT UNIQUE, + amount_cents INTEGER NOT NULL, + currency TEXT NOT NULL, + payment_method_type TEXT, + card_last4 TEXT, + card_brand TEXT, + paid_at TIMESTAMP WITH TIME ZONE NOT NULL + ); + + CREATE INDEX idx_user_payment_user_id ON user_payment(user_id); + `) + return err +} + +func (m AddDetailedSubscriptionInfo) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + DROP TABLE user_payment; + + ALTER TABLE hmn_user + DROP COLUMN subscription_status, + DROP COLUMN current_period_end, + DROP COLUMN cancel_at_period_end; + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go b/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go new file mode 100644 index 00000000..646ef474 --- /dev/null +++ b/src/migration/migrations/2026-03-25T160441Z_AddFeeInfoToUserPayment.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddFeeInfoToUserPayment{}) +} + +type AddFeeInfoToUserPayment struct{} + +func (m AddFeeInfoToUserPayment) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 4, 41, 0, time.UTC)) +} + +func (m AddFeeInfoToUserPayment) Name() string { + return "AddFeeInfoToUserPayment" +} + +func (m AddFeeInfoToUserPayment) Description() string { + return "Add stripe_fee_cents and net_amount_cents to user_payment table" +} + +func (m AddFeeInfoToUserPayment) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE user_payment + ADD COLUMN stripe_fee_cents INTEGER, + ADD COLUMN net_amount_cents INTEGER; + `) + return err +} + +func (m AddFeeInfoToUserPayment) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE user_payment + DROP COLUMN stripe_fee_cents, + DROP COLUMN net_amount_cents; + `) + return err +} diff --git a/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go b/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go new file mode 100644 index 00000000..e89e22bc --- /dev/null +++ b/src/migration/migrations/2026-03-25T162427Z_AddThankYouEmailSent.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddThankYouEmailSent{}) +} + +type AddThankYouEmailSent struct{} + +func (m AddThankYouEmailSent) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 3, 25, 16, 24, 27, 0, time.UTC)) +} + +func (m AddThankYouEmailSent) Name() string { + return "AddThankYouEmailSent" +} + +func (m AddThankYouEmailSent) Description() string { + return "Add thank_you_email_sent column to hmn_user table" +} + +func (m AddThankYouEmailSent) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN thank_you_email_sent BOOLEAN NOT NULL DEFAULT false; + `) + return err +} + +func (m AddThankYouEmailSent) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN thank_you_email_sent; + `) + return err +} diff --git a/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go b/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go new file mode 100644 index 00000000..aa601d35 --- /dev/null +++ b/src/migration/migrations/2026-05-24T170000Z_AddSubscriptionGracePeriod.go @@ -0,0 +1,47 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddSubscriptionGracePeriod{}) +} + +type AddSubscriptionGracePeriod struct{} + +func (m AddSubscriptionGracePeriod) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 5, 24, 17, 0, 0, 0, time.UTC)) +} + +func (m AddSubscriptionGracePeriod) Name() string { + return "AddSubscriptionGracePeriod" +} + +func (m AddSubscriptionGracePeriod) Description() string { + return "Add grace period tracking columns to hmn_user" +} + +func (m AddSubscriptionGracePeriod) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN grace_period_started_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN grace_period_ends_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN grace_available BOOLEAN NOT NULL DEFAULT true; + `) + return err +} + +func (m AddSubscriptionGracePeriod) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN grace_period_started_at, + DROP COLUMN grace_period_ends_at, + DROP COLUMN grace_available; + `) + return err +} diff --git a/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go b/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go new file mode 100644 index 00000000..46adfa0c --- /dev/null +++ b/src/migration/migrations/2026-05-24T180000Z_AddDismissedMembershipDiscordLinkBanner.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddDismissedMembershipDiscordLinkBanner{}) +} + +type AddDismissedMembershipDiscordLinkBanner struct{} + +func (m AddDismissedMembershipDiscordLinkBanner) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 5, 24, 18, 0, 0, 0, time.UTC)) +} + +func (m AddDismissedMembershipDiscordLinkBanner) Name() string { + return "AddDismissedMembershipDiscordLinkBanner" +} + +func (m AddDismissedMembershipDiscordLinkBanner) Description() string { + return "Track dismissal of the membership Discord link banner on hmn_user" +} + +func (m AddDismissedMembershipDiscordLinkBanner) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + ADD COLUMN dismissed_membership_discord_link_banner BOOLEAN NOT NULL DEFAULT false; + `) + return err +} + +func (m AddDismissedMembershipDiscordLinkBanner) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE hmn_user + DROP COLUMN dismissed_membership_discord_link_banner; + `) + return err +} diff --git a/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go new file mode 100644 index 00000000..d497683d --- /dev/null +++ b/src/migration/migrations/2026-06-01T055600Z_AddStripeWebhookEventLedger.go @@ -0,0 +1,49 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddStripeWebhookEventLedger{}) +} + +type AddStripeWebhookEventLedger struct{} + +func (m AddStripeWebhookEventLedger) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 6, 1, 5, 56, 0, 0, time.UTC)) +} + +func (m AddStripeWebhookEventLedger) Name() string { + return "AddStripeWebhookEventLedger" +} + +func (m AddStripeWebhookEventLedger) Description() string { + return "Add Stripe webhook event idempotency ledger table" +} + +func (m AddStripeWebhookEventLedger) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + CREATE TABLE stripe_webhook_event ( + event_id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'processing', + last_error TEXT, + received_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + processed_at TIMESTAMP WITH TIME ZONE + ) + `) + return err +} + +func (m AddStripeWebhookEventLedger) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + DROP TABLE stripe_webhook_event + `) + return err +} diff --git a/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go b/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go new file mode 100644 index 00000000..7986d159 --- /dev/null +++ b/src/migration/migrations/2026-06-02T093500Z_AddStripeMembershipEventCursor.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "github.com/jackc/pgx/v5" +) + +func init() { + registerMigration(AddStripeMembershipEventCursor{}) +} + +type AddStripeMembershipEventCursor struct{} + +func (m AddStripeMembershipEventCursor) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2026, 6, 2, 9, 35, 0, 0, time.UTC)) +} + +func (m AddStripeMembershipEventCursor) Name() string { + return "AddStripeMembershipEventCursor" +} + +func (m AddStripeMembershipEventCursor) Description() string { + return "Add per-customer Stripe membership event ordering cursor" +} + +func (m AddStripeMembershipEventCursor) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + CREATE TABLE stripe_membership_event_cursor ( + customer_id TEXT PRIMARY KEY, + last_event_created BIGINT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ) + `) + return err +} + +func (m AddStripeMembershipEventCursor) Down(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + DROP TABLE stripe_membership_event_cursor + `) + return err +} diff --git a/src/models/payment.go b/src/models/payment.go new file mode 100644 index 00000000..b43cbdb1 --- /dev/null +++ b/src/models/payment.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type UserPayment struct { + ID int `db:"id"` + UserID int `db:"user_id"` + StripeInvoiceID *string `db:"stripe_invoice_id"` + AmountCents int `db:"amount_cents"` + Currency string `db:"currency"` + PaymentMethodType *string `db:"payment_method_type"` + CardLast4 *string `db:"card_last4"` + CardBrand *string `db:"card_brand"` + PaidAt time.Time `db:"paid_at"` + StripeFeeCents *int `db:"stripe_fee_cents"` + NetAmountCents *int `db:"net_amount_cents"` +} diff --git a/src/models/user.go b/src/models/user.go index 4b55915c..d53f5c5f 100644 --- a/src/models/user.go +++ b/src/models/user.go @@ -46,6 +46,22 @@ type User struct { DiscordSaveShowcase bool `db:"discord_save_showcase"` DiscordDeleteSnippetOnMessageDelete bool `db:"discord_delete_snippet_on_message_delete"` + IsSubscribed bool `db:"is_subscribed"` + + StripeCustomerID *string `db:"stripe_customer_id"` + StripeSubscriptionID *string `db:"stripe_subscription_id"` + + SubscriptionStatus *string `db:"subscription_status"` + CurrentPeriodEnd *time.Time `db:"current_period_end"` + CancelAtPeriodEnd bool `db:"cancel_at_period_end"` + ThankYouEmailSent bool `db:"thank_you_email_sent"` + + GracePeriodStartedAt *time.Time `db:"grace_period_started_at"` + GracePeriodEndsAt *time.Time `db:"grace_period_ends_at"` + GraceAvailable bool `db:"grace_available"` + + DismissedMembershipDiscordLinkBanner bool `db:"dismissed_membership_discord_link_banner"` + MarkedAllReadAt time.Time `db:"marked_all_read_at"` // Non-db fields, to be filled in by fetch helpers diff --git a/src/templates/img/heart.svg b/src/templates/img/heart.svg new file mode 100644 index 00000000..af022f4e --- /dev/null +++ b/src/templates/img/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/templates/mapping.go b/src/templates/mapping.go index c5e06741..6b45f70a 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -262,6 +262,9 @@ func UserToTemplate(u *models.User) User { DiscordSaveShowcase: u.DiscordSaveShowcase, DiscordDeleteSnippetOnMessageDelete: u.DiscordDeleteSnippetOnMessageDelete, + IsSubscribed: u.IsSubscribed, + CancelAtPeriodEnd: u.CancelAtPeriodEnd, + IsEduTester: u.CanSeeUnpublishedEducationContent(), IsEduAuthor: u.CanAuthorEducation(), diff --git a/src/templates/src/email_ach_verification_grace.html b/src/templates/src/email_ach_verification_grace.html new file mode 100644 index 00000000..87457082 --- /dev/null +++ b/src/templates/src/email_ach_verification_grace.html @@ -0,0 +1,19 @@ +

+ Hello {{ .Name }}, +

+

+ Thank you for supporting the Handmade Network. +

+

+ Your membership has started, and we are waiting for your bank account verification to complete. +

+

+ Please verify your bank account within 7 days by visiting {{ .ManageSubscriptionUrl }}. +

+{{ if .GracePeriodEnd }} +

+ Your grace period ends on {{ .GracePeriodEnd }}. If verification is not completed by then, your membership access will end until payment is completed. +

+{{ end }} +

Thanks,
+ The Handmade Network staff.

diff --git a/src/templates/src/email_grace_period_ended.html b/src/templates/src/email_grace_period_ended.html new file mode 100644 index 00000000..f087b2e9 --- /dev/null +++ b/src/templates/src/email_grace_period_ended.html @@ -0,0 +1,14 @@ +

+ Hello {{ .Name }}, +

+

+ Your temporary membership grace period for the Handmade Network has ended. +

+

+ Membership benefits are now paused until payment is completed. +

+

+ You can update your payment details and restart membership at {{ .ManageSubscriptionUrl }}. +

+

Thanks,
+ The Handmade Network staff.

diff --git a/src/templates/src/email_payment_failed.html b/src/templates/src/email_payment_failed.html new file mode 100644 index 00000000..da3c050b --- /dev/null +++ b/src/templates/src/email_payment_failed.html @@ -0,0 +1,34 @@ +

+ Hello {{ .Name }}, +

+

+ We were unable to process your recurring donation payment to Handmade + Network. +

+{{ if .Amount }} +

+ The charge of {{ .Amount }} to your payment method on file was declined. +

+{{ end }} +

+ Please update your payment details to keep your membership active. You can manage your membership at {{ .ManageSubscriptionUrl }}. +

+{{ if .GracePeriodEnd }} +

+ Your membership benefits will remain active until {{ .GracePeriodEnd }} while we wait for payment. After that + date, access will end if payment is still unresolved. +

+{{ end }} +{{ if .NextAttemptDate }} +

+ Stripe will automatically retry the charge on {{ .NextAttemptDate }}. If the payment continues to fail, your + membership may be cancelled. +

+{{ else }} +

+ If the payment issue is not resolved, your membership may be cancelled. +

+{{ end }} +

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/email_subscription_cancelled.html b/src/templates/src/email_subscription_cancelled.html new file mode 100644 index 00000000..3ebf6a23 --- /dev/null +++ b/src/templates/src/email_subscription_cancelled.html @@ -0,0 +1,19 @@ +

+ Hello {{ .Name }}, +

+

+ Your recurring donation to Handmade Network has been cancelled. +

+{{ if .ExpirationDate }} +

+ You will continue to have access to your membership benefits until {{ .ExpirationDate }}. +

+{{ end }} +

+ We're sorry to see you go, but thank you for your support! +

+

+ You can always find us at {{ .HomepageUrl }}. +

+

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/email_thank_you.html b/src/templates/src/email_thank_you.html new file mode 100644 index 00000000..2a140ed8 --- /dev/null +++ b/src/templates/src/email_thank_you.html @@ -0,0 +1,27 @@ +

+ Hello {{ .Name }}, +

+

+ Thank you for supporting Handmade Network with your recurring donation! +

+ +{{ if and .RenewalDate .Amount }} +

+ Your payment method on file will automatically be charged again for {{ .Amount }} on {{ .RenewalDate }}. +

+{{ else if .RenewalDate }} +

+ Your payment method on file will automatically be charged again on {{ .RenewalDate }}. +

+{{ else if .Amount }} +

+ Your donation amount is {{ .Amount }}. +

+{{ end }} + +

+ You can cancel or manage your membership any time at {{ + .ManageSubscriptionUrl }}. +

+

Thanks,
+ The Handmade Network staff.

\ No newline at end of file diff --git a/src/templates/src/hsf_membership.html b/src/templates/src/hsf_membership.html index 0069fbb2..88a8fdd8 100644 --- a/src/templates/src/hsf_membership.html +++ b/src/templates/src/hsf_membership.html @@ -1,21 +1,85 @@ {{ template "base-2024.html" . }} {{ define "content" }} -
-

The Handmade Software Foundation is funded by its members. You can support our mission by becoming a member today.

- -

Ongoing membership

-

You can become a member of the Handmade Software Foundation by paying a monthly membership fee, Patreon-style. We currently have just one tier of membership.

-

Member benefits include:

- -
- Memberships are not yet available. We look forward to launching them in the near future. -
+
+ {{ if not (and .User .User.IsSubscribed) }} +

The Handmade Software Foundation is funded by its members. You can support our mission by becoming a member today.

+ +

Ongoing membership

+

You can become a member of the Handmade Software Foundation by paying a monthly membership fee, Patreon-style. We currently have just one tier of membership.

+ {{ end }} + +
+ {{ if .ShowPostCheckoutPendingInfo }} +
+

+ Thanks! Your membership signup is processing. If your bank account still needs verification, please complete that step as soon as possible to keep your membership benefits active. +

+
+ {{ end }} + + {{ if and .User .User.IsSubscribed }} +

Thank you for being a supporter! Your recurring donation helps us maintain the site, host events, and + advocate for better software.

+ {{ end }} + + {{ if not .ShowPostCheckoutPendingInfo }} + {{ template "supporter_card" . }} + {{ end }} + + {{ if and .User .User.IsSubscribed }} + {{ if .IsInGracePeriod }} +
+

+ {{ if .NeedsBankVerification }} + You haven't verified your bank account yet. Please verify before {{ .GracePeriodEnd }} to keep your membership benefits. + {{ else if .IsPaymentPending }} + Your latest payment is still processing. Your membership benefits remain active until {{ .GracePeriodEnd }}. + {{ else }} + We couldn't process your latest payment. Your membership benefits remain active until {{ .GracePeriodEnd }}. + Please update your payment method to avoid losing access. + {{ end }} +

+
+ {{ else }} +
+

+ {{ if .User.CancelAtPeriodEnd }} + Your membership will end at the end of the current billing period on {{ .CurrentPeriodEnd }}. + {{ else }} + {{ if and .LastPaymentAmount .LastPaymentMethod }} + Your payment method on file ({{ .LastPaymentMethod }}) will be automatically charged for {{ + .LastPaymentAmount }} on {{ .CurrentPeriodEnd }}. + {{ else if .LastPaymentAmount }} + Your payment method on file will be automatically charged for {{ .LastPaymentAmount }} on {{ + .CurrentPeriodEnd }}. + {{ else }} + Your payment method on file will be automatically charged on {{ .CurrentPeriodEnd }}. + {{ end }} + {{ end }} +

+
+ {{ end }} + + {{ if .PaymentHistory }} +
+ Payment + History +
    + {{ range .PaymentHistory }} +
  • +
    ·
    +
    +
    {{ .Date }}
    +
    {{ .Amount }}{{ if .CardInfo }} · {{ .CardInfo }}{{ end }}
    +
    +
  • + {{ end }} +
+
+ {{ end }} + {{ end }} +
{{ end }} @@ -23,3 +87,135 @@

Ongoing membership

{{ end }} +{{ define "supporter_card" }} +
+

Supporter Benefits

+
    +
  • +
    {{ svg "discord" }}
    +
    +
    Discord Badge & Role
    +
    Show off your support with a special role and badge on our Discord.
    +
    +
  • +
  • +
    {{ svg "heart" }}
    +
    +
    Support Handmade
    +
    Help fund our mission to put the software industry back on the right track.
    +
    +
  • +
  • +
    {{ svg "arrow-right-up" }}
    +
    +
    And More
    +
    Get access to private channels and future supporter-only features.
    +
    +
  • +
+
+ + {{ if .User }} +
+ + {{ if .User.IsSubscribed }} + {{ .CurrentCurrencySymbol }} + {{ else }} + + {{ .CurrentCurrencySymbol }} +
+
+ $ USD +
+ {{ if .EurMembershipPriceID }} +
+ € EUR +
+ {{ end }} +
+
+ {{ end }} + {{ if .CurrentAmount }}{{ .CurrentAmount }}{{ else }}5.00{{ end }}/mo +
+
+ {{ if .User.IsSubscribed }} +
+ {{ csrftoken .Session }} + +
+ {{ end }} + {{ if and .User.IsSubscribed .User.CancelAtPeriodEnd }} +
+ {{ csrftoken .Session }} + +
+ {{ else }} +
+ {{ csrftoken .Session }} + + +
+ {{ end }} +
+
+ {{ else }} +

+ Log in to support Handmade +

+ {{ end }} +
+ + +{{ end }} diff --git a/src/templates/src/include/header-2024.html b/src/templates/src/include/header-2024.html index 4f302009..72826e2e 100644 --- a/src/templates/src/include/header-2024.html +++ b/src/templates/src/include/header-2024.html @@ -1,274 +1,358 @@ - - -{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }} - -{{ else if and .Header.BannerEvent (not .Header.SuppressBanners) }} - {{ with .Header.BannerEvent }} - {{ template "event banner" . }} - {{ end }} -{{ end }} - - - -{{ define "event banner" }} -{{ if eq .Slug "Essentials2026" }} - - {{ if gt .DaysUntilEnd 0 }} - {{ if eq .DaysUntilStart 0 }} - The Handmade Essentials Jam is currently underway! - {{ else }} - Join us for the Handmade Essentials Jam, April 13-19, 2026. - {{ end }} - More info
{{ svg "arrow-right" }}
- {{ else }} - The Handmade Essentials Jam just concluded. - See the results
{{ svg "arrow-right" }}
- {{ end }} -
-{{ end }} -{{ if eq .Slug "WRJ2025" }} - - {{ if gt .DaysUntilEnd 0 }} - {{ if eq .DaysUntilStart 0 }} - The 2025 Wheel Reinvention Jam is happening now. - {{ else if eq .DaysUntilStart 1 }} - Starting tomorrow. - {{ else }} - The 2025 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days. - {{ end }} - September 22-28, 2025. - More info
{{ svg "arrow-right" }}
- {{ else }} - The 2025 Wheel Reinvention Jam just concluded. See the results. - {{ end }} -
-{{ end }} -{{ if eq .Slug "XRay2025" }} - - X-Ray Jam. June 9-15, 2025. - {{ if gt .DaysUntilEnd 0 }} - {{ if eq .DaysUntilStart 0 }} - Happening now. - {{ else if eq .DaysUntilStart 1 }} - Tomorrow. - {{ else }} - In {{ .DaysUntilStart }} days. - {{ end }} - {{ else }} - See the results. - {{ end }} - -{{ end }} -{{ if eq .Slug "VJ2024" }} - - Visibility Jam. July 19-21, 2024. - {{ if gt .DaysUntilEnd 0 }} - {{ if eq .DaysUntilStart 0 }} - Happening now. - {{ else if eq .DaysUntilStart 1 }} - Starting tomorrow. - {{ else }} - In {{ .DaysUntilStart }} days. - {{ end }} - {{ else }} - See the results. - {{ end }} - -{{ end }} -{{ if eq .Slug "WRJ2024" }} - - {{ if gt .DaysUntilEnd 0 }} - {{ if eq .DaysUntilStart 0 }} - The 2024 Wheel Reinvention Jam is happening now. - {{ else if eq .DaysUntilStart 1 }} - Starting tomorrow. - {{ else }} - The 2024 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days. - {{ end }} - September 23-29, 2024. - More info
{{ svg "arrow-right" }}
- {{ else }} - The 2024 Wheel Reinvention Jam just concluded. See the results. - {{ end }} -
-{{ end }} + + +{{ if and .Header.ShowMembershipVerificationBanner (not .Header.SuppressBanners) }} + + Bank account verification pending{{ if gt .Header.MembershipGraceDaysRemaining 0 }} — membership benefits remain active for {{ .Header.MembershipGraceDaysRemaining }} more {{ if eq .Header.MembershipGraceDaysRemaining 1 }}day{{ else }}days{{ end }}{{ end }}. + Verify bank account
{{ svg "arrow-right" }}
+
+ +{{ end }} + +{{ if not .User }} + +{{ end }} + +{{ if and .Header.ShowMembershipDiscordLinkBanner (not .Header.SuppressBanners) }} + + +{{ end }} + +{{ if and (or .Header.Breadcrumbs .Header.Actions) (not .Header.SuppressBreadcrumbs) }} + +{{ else if and .Header.BannerEvent (not .Header.SuppressBanners) }} + {{ with .Header.BannerEvent }} + {{ template "event banner" . }} + {{ end }} +{{ end }} + + + +{{ define "event banner" }} +{{ if eq .Slug "Essentials2026" }} + + {{ if gt .DaysUntilEnd 0 }} + {{ if eq .DaysUntilStart 0 }} + The Handmade Essentials Jam is currently underway! + {{ else }} + Join us for the Handmade Essentials Jam, April 13-19, 2026. + {{ end }} + More info
{{ svg "arrow-right" }}
+ {{ else }} + The Handmade Essentials Jam just concluded. + See the results
{{ svg "arrow-right" }}
+ {{ end }} +
+{{ end }} +{{ if eq .Slug "WRJ2025" }} + + {{ if gt .DaysUntilEnd 0 }} + {{ if eq .DaysUntilStart 0 }} + The 2025 Wheel Reinvention Jam is happening now. + {{ else if eq .DaysUntilStart 1 }} + Starting tomorrow. + {{ else }} + The 2025 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days. + {{ end }} + September 22-28, 2025. + More info
{{ svg "arrow-right" }}
+ {{ else }} + The 2025 Wheel Reinvention Jam just concluded. See the results. + {{ end }} +
+{{ end }} +{{ if eq .Slug "XRay2025" }} + + X-Ray Jam. June 9-15, 2025. + {{ if gt .DaysUntilEnd 0 }} + {{ if eq .DaysUntilStart 0 }} + Happening now. + {{ else if eq .DaysUntilStart 1 }} + Tomorrow. + {{ else }} + In {{ .DaysUntilStart }} days. + {{ end }} + {{ else }} + See the results. + {{ end }} + +{{ end }} +{{ if eq .Slug "VJ2024" }} + + Visibility Jam. July 19-21, 2024. + {{ if gt .DaysUntilEnd 0 }} + {{ if eq .DaysUntilStart 0 }} + Happening now. + {{ else if eq .DaysUntilStart 1 }} + Starting tomorrow. + {{ else }} + In {{ .DaysUntilStart }} days. + {{ end }} + {{ else }} + See the results. + {{ end }} + +{{ end }} +{{ if eq .Slug "WRJ2024" }} + + {{ if gt .DaysUntilEnd 0 }} + {{ if eq .DaysUntilStart 0 }} + The 2024 Wheel Reinvention Jam is happening now. + {{ else if eq .DaysUntilStart 1 }} + Starting tomorrow. + {{ else }} + The 2024 Wheel Reinvention Jam is in {{ .DaysUntilStart }} days. + {{ end }} + September 23-29, 2024. + More info
{{ svg "arrow-right" }}
+ {{ else }} + The 2024 Wheel Reinvention Jam just concluded. See the results. + {{ end }} +
+{{ end }} {{ end }} \ No newline at end of file diff --git a/src/templates/types.go b/src/templates/types.go index 976d15e5..9e6fd43d 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -80,6 +80,14 @@ type Header struct { BannerEvent *BannerEvent SuppressBanners bool + + ShowMembershipVerificationBanner bool + MembershipVerificationUrl string + MembershipGraceDaysRemaining int + + ShowMembershipDiscordLinkBanner bool + MembershipDiscordLinkUrl string + MembershipDiscordLinkDismissUrl string } type BannerEvent struct { @@ -270,6 +278,9 @@ type User struct { DiscordSaveShowcase bool DiscordDeleteSnippetOnMessageDelete bool + IsSubscribed bool + CancelAtPeriodEnd bool + IsEduTester bool IsEduAuthor bool diff --git a/src/website/base_data.go b/src/website/base_data.go index 6036e775..c666db80 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -5,11 +5,13 @@ import ( "git.handmade.network/hmn/hmn/src/buildcss" "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/discord" "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" + "github.com/stripe/stripe-go/v84" ) // NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary. @@ -131,6 +133,41 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc if c.CurrentUser != nil { baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username) + showMembershipVerificationBanner := false + isPostCheckoutPendingView := c.Req != nil && + c.Req.URL != nil && + c.Req.URL.Query().Get("session_id") != "" && + !c.CurrentUser.IsSubscribed + bankVerificationJustCompleted := c.Req != nil && c.Req.URL != nil && c.Req.URL.Query().Get("bank_verified") == "1" + if userNeedsBankVerificationReminder(c.CurrentUser) && !bankVerificationJustCompleted && !isPostCheckoutPendingView { + bannerURL := hmnurl.BuildHSFMembership() + hasHostedVerification := false + if c.CurrentUser.StripeSubscriptionID != nil && config.Config.Stripe.SecretKey != "" { + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + if hostedURL := hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID); hostedURL != "" { + bannerURL = hostedURL + hasHostedVerification = true + } + } + + status := stringOrEmpty(c.CurrentUser.SubscriptionStatus) + statusImpliesVerificationPending := status == SubscriptionStatusPendingVerification || status == "incomplete" + if statusImpliesVerificationPending || hasHostedVerification { + showMembershipVerificationBanner = true + baseData.Header.ShowMembershipVerificationBanner = true + baseData.Header.MembershipVerificationUrl = bannerURL + baseData.Header.MembershipGraceDaysRemaining = gracePeriodDaysRemaining(c.CurrentUser, SubscriptionNow()) + } + } + if !showMembershipVerificationBanner && userNeedsDiscordLinkReminder(c.CurrentUser) { + baseData.Header.ShowMembershipDiscordLinkBanner = true + baseData.Header.MembershipDiscordLinkDismissUrl = hmnurl.BuildDismissMembershipDiscordLinkBanner() + if c.CurrentSession != nil { + baseData.Header.MembershipDiscordLinkUrl = discord.GetAuthorizeUrl(c.CurrentSession.CSRFToken, false) + } else { + baseData.Header.MembershipDiscordLinkUrl = hmnurl.BuildUserSettings("discord") + } + } } if !project.IsHMN() { @@ -155,6 +192,20 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc return baseData } +func stringOrEmpty(s *string) string { + if s == nil { + return "" + } + return *s +} + +func timeOrEmpty(t *time.Time) string { + if t == nil { + return "" + } + return t.UTC().Format(time.RFC3339) +} + func buildDefaultOpenGraphItems(project *models.Project, projectLogoUrl string, title string) []templates.OpenGraphItem { if title == "" { title = "Handmade Network" diff --git a/src/website/discord.go b/src/website/discord.go index 04393709..afc4046e 100644 --- a/src/website/discord.go +++ b/src/website/discord.go @@ -317,6 +317,10 @@ func DiscordOAuthCallback(c *RequestContext) ResponseData { } } + if hmnUser.IsSubscribed { + SyncSupporterDiscordRole(c, c.Conn, hmnUser.ID) + } + // We only expect direct URLs to HMN pages, or values that were sanitized on their way into // pending_login, but defense in depth is not a bad thing. safeDest := hmnurl.SafeRedirectUrl(destinationUrl) @@ -372,6 +376,12 @@ func DiscordUnlink(c *RequestContext) ResponseData { if err != nil { c.Logger.Warn().Err(err).Msg("failed to remove member role on unlink") } + if config.Config.Discord.SupporterRoleID != "" { + err = discord.RemoveGuildMemberRole(c, discordUser.UserID, config.Config.Discord.SupporterRoleID) + if err != nil { + c.Logger.Warn().Err(err).Msg("failed to remove supporter role on unlink") + } + } return c.Redirect(hmnurl.BuildUserSettings("discord"), http.StatusSeeOther) } diff --git a/src/website/discord_membership.go b/src/website/discord_membership.go new file mode 100644 index 00000000..958f3b77 --- /dev/null +++ b/src/website/discord_membership.go @@ -0,0 +1,112 @@ +package website + +import ( + "context" + "errors" + "net/http" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/discord" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" +) + +func userEligibleForSupporterDiscordRole(user *models.User) bool { + return user != nil && user.IsSubscribed +} + +func userNeedsDiscordLinkReminder(user *models.User) bool { + return user != nil && + user.IsSubscribed && + user.DiscordUser == nil && + !user.DismissedMembershipDiscordLinkBanner +} + +func SyncSupporterDiscordRole(ctx context.Context, conn db.ConnOrTx, userID int) { + roleID := config.Config.Discord.SupporterRoleID + if roleID == "" { + return + } + + user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + if err != db.NotFound { + logging.Warn().Err(err).Int("userID", userID).Msg("failed to load user for supporter Discord role sync") + } + return + } + + discordUser, err := db.QueryOne[models.DiscordUser](ctx, conn, + "SELECT $columns FROM discord_user WHERE hmn_user_id = $1", + userID, + ) + if err == db.NotFound { + return + } + if err != nil { + logging.Warn().Err(err).Int("userID", userID).Msg("failed to load Discord user for supporter role sync") + return + } + + syncSupporterDiscordRoleForUser(ctx, user, discordUser.UserID, roleID) +} + +func SyncSupporterDiscordRoleForCustomer(ctx context.Context, conn db.ConnOrTx, stripeCustomerID string) { + if config.Config.Discord.SupporterRoleID == "" { + return + } + + user, err := db.QueryOne[models.User](ctx, conn, + "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", + stripeCustomerID, + ) + if err != nil { + if err != db.NotFound { + logging.Warn().Err(err).Str("customerID", stripeCustomerID).Msg("failed to load user for supporter Discord role sync") + } + return + } + + SyncSupporterDiscordRole(ctx, conn, user.ID) +} + +func syncSupporterDiscordRoleForUser(ctx context.Context, user *models.User, discordUserID, roleID string) { + var err error + if userEligibleForSupporterDiscordRole(user) { + err = discord.AddGuildMemberRole(ctx, discordUserID, roleID) + } else { + err = discord.RemoveGuildMemberRole(ctx, discordUserID, roleID) + } + + if err == nil { + return + } + if errors.Is(err, discord.NotFound) { + logging.Warn(). + Int("userID", user.ID). + Str("discordUserID", discordUserID). + Bool("grant", userEligibleForSupporterDiscordRole(user)). + Msg("Discord user not in guild; skipped supporter role sync") + return + } + logging.Warn(). + Err(err). + Int("userID", user.ID). + Str("discordUserID", discordUserID). + Bool("grant", userEligibleForSupporterDiscordRole(user)). + Msg("failed to sync supporter Discord role") +} + +func DismissMembershipDiscordLinkBanner(c *RequestContext) ResponseData { + _, err := c.Conn.Exec(c, + `UPDATE hmn_user SET dismissed_membership_discord_link_banner = true WHERE id = $1`, + c.CurrentUser.ID, + ) + if err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to dismiss membership Discord link banner")) + } + + return c.JSONResponse(http.StatusOK, map[string]any{"success": true}) +} diff --git a/src/website/discord_membership_test.go b/src/website/discord_membership_test.go new file mode 100644 index 00000000..2f4c20ed --- /dev/null +++ b/src/website/discord_membership_test.go @@ -0,0 +1,40 @@ +package website + +import ( + "testing" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/models" + "github.com/stretchr/testify/assert" +) + +func TestUserEligibleForSupporterDiscordRole(t *testing.T) { + assert.False(t, userEligibleForSupporterDiscordRole(nil)) + assert.False(t, userEligibleForSupporterDiscordRole(&models.User{IsSubscribed: false})) + assert.True(t, userEligibleForSupporterDiscordRole(&models.User{IsSubscribed: true})) +} + +func TestUserNeedsDiscordLinkReminder(t *testing.T) { + assert.False(t, userNeedsDiscordLinkReminder(nil)) + assert.False(t, userNeedsDiscordLinkReminder(&models.User{IsSubscribed: false})) + assert.False(t, userNeedsDiscordLinkReminder(&models.User{ + IsSubscribed: true, + DiscordUser: &models.DiscordUser{}, + })) + assert.True(t, userNeedsDiscordLinkReminder(&models.User{IsSubscribed: true})) + assert.False(t, userNeedsDiscordLinkReminder(&models.User{ + IsSubscribed: true, + DismissedMembershipDiscordLinkBanner: true, + })) +} + +func TestSyncSupporterDiscordRoleNoOpsWithoutConfig(t *testing.T) { + original := config.Config.Discord.SupporterRoleID + config.Config.Discord.SupporterRoleID = "" + defer func() { + config.Config.Discord.SupporterRoleID = original + }() + + // Should return without panicking when role ID is unset. + SyncSupporterDiscordRole(t.Context(), nil, 1) +} diff --git a/src/website/hsf.go b/src/website/hsf.go index 78e61c8e..52715c91 100644 --- a/src/website/hsf.go +++ b/src/website/hsf.go @@ -1,8 +1,12 @@ package website import ( + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/templates" + "github.com/stripe/stripe-go/v84" ) func HSFLanding(c *RequestContext) ResponseData { @@ -23,6 +27,16 @@ func HSFDetails(c *RequestContext) ResponseData { } func HSFMembership(c *RequestContext) ResponseData { + if c.Req.URL.Query().Get("payment_method_updated") == "1" && c.CurrentUser != nil { + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, c.CurrentUser); err != nil { + c.Logger.Warn().Err(err).Msg("failed to retry subscription payment after billing portal return") + } + if user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", c.CurrentUser.ID); err == nil { + c.CurrentUser = user + } + } + breadcrumbs := []templates.Breadcrumb{ hsfBaseBreadcrumb, {Name: "Membership", Url: hmnurl.BuildHSFMembership()}, @@ -32,7 +46,7 @@ func HSFMembership(c *RequestContext) ResponseData { baseData.HideMembershipCTA = true var res ResponseData - res.MustWriteTemplate("hsf_membership.html", baseData, c.Perf) + res.MustWriteTemplate("hsf_membership.html", buildMembershipPageData(c, baseData), c.Perf) return res } diff --git a/src/website/routes.go b/src/website/routes.go index 75afde16..798e11a2 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -151,6 +151,11 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexHSFLanding, HSFLanding) hmnOnly.GET(hmnurl.RegexHSFDetails, HSFDetails) hmnOnly.GET(hmnurl.RegexHSFMembership, HSFMembership) + hmnOnly.GET(hmnurl.RegexSubscriptionManage, SubscriptionManageRedirect) + hmnOnly.POST(hmnurl.RegexSubscriptionSubscribe, needsAuth(csrfMiddleware(SubscriptionSubscribe))) + hmnOnly.POST(hmnurl.RegexSubscriptionCancel, needsAuth(csrfMiddleware(SubscriptionCancel))) + hmnOnly.POST(hmnurl.RegexSubscriptionResume, needsAuth(csrfMiddleware(SubscriptionResume))) + hmnOnly.POST(hmnurl.RegexSubscriptionUpdatePaymentMethod, needsAuth(csrfMiddleware(SubscriptionUpdatePaymentMethod))) hmnOnly.GET(hmnurl.RegexTimeMachine, TimeMachine) hmnOnly.GET(hmnurl.RegexTimeMachineSubmissions, TimeMachineSubmissions) @@ -204,6 +209,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, DiscordOAuthCallback) hmnOnly.POST(hmnurl.RegexDiscordUnlink, needsAuth(csrfMiddleware(DiscordUnlink))) + hmnOnly.POST(hmnurl.RegexDismissMembershipDiscordLinkBanner, needsAuth(csrfMiddleware(DismissMembershipDiscordLinkBanner))) hmnOnly.POST(hmnurl.RegexDiscordShowcaseBacklog, needsAuth(csrfMiddleware(DiscordShowcaseBacklog))) hmnOnly.GET(hmnurl.RegexDiscordBotDebugPage, adminsOnly(DiscordBotDebugPage)) diff --git a/src/website/stripe.go b/src/website/stripe.go index 65d0728c..57d08c6b 100644 --- a/src/website/stripe.go +++ b/src/website/stripe.go @@ -1,23 +1,196 @@ package website import ( + "context" "encoding/json" "io" "net/http" "git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/db" - "git.handmade.network/hmn/hmn/src/hmndata" "git.handmade.network/hmn/hmn/src/oops" "github.com/stripe/stripe-go/v84" "github.com/stripe/stripe-go/v84/webhook" ) func init() { - // Use the global client stripe.Key = config.Config.Stripe.SecretKey } +const ( + stripeWebhookStatusProcessing = "processing" + stripeWebhookStatusProcessed = "processed" + stripeWebhookStatusFailed = "failed" + + stripeEventPaymentIntentProcessing = "payment_intent.processing" + stripeEventPaymentIntentRequiresAction = "payment_intent.requires_action" + stripeEventPaymentIntentPaymentFailed = "payment_intent.payment_failed" +) + +func beginStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) { + tag, err := conn.Exec(ctx, ` + INSERT INTO stripe_webhook_event (event_id, event_type, status, last_error, updated_at, processed_at) + VALUES ($1, $2, $3, NULL, NOW(), NULL) + ON CONFLICT (event_id) DO NOTHING + `, event.ID, string(event.Type), stripeWebhookStatusProcessing) + if err != nil { + return false, oops.New(err, "failed to insert stripe webhook event id") + } + if tag.RowsAffected() == 1 { + // First claimant for this event ID. + return true, nil + } + + status, err := db.QueryOneScalar[string](ctx, conn, ` + SELECT status + FROM stripe_webhook_event + WHERE event_id = $1 + `, event.ID) + if err != nil { + return false, oops.New(err, "failed to read Stripe webhook event state") + } + switch status { + case stripeWebhookStatusProcessed, stripeWebhookStatusProcessing: + return false, nil + case stripeWebhookStatusFailed: + tag, err = conn.Exec(ctx, ` + UPDATE stripe_webhook_event + SET + event_type = $2, + status = $3, + last_error = NULL, + updated_at = NOW(), + processed_at = NULL + WHERE event_id = $1 + AND status = $4 + `, event.ID, string(event.Type), stripeWebhookStatusProcessing, stripeWebhookStatusFailed) + if err != nil { + return false, oops.New(err, "failed to mark stripe webhook event as processing") + } + return tag.RowsAffected() == 1, nil + default: + return false, nil + } +} + +func shouldProcessMembershipEventOrder(ctx context.Context, conn db.ConnOrTx, event *stripe.Event) (bool, error) { + customerID := membershipEventCustomerID(event) + if customerID == "" { + return true, nil + } + + createdAt := event.Created + if createdAt <= 0 { + return true, nil + } + + tag, err := conn.Exec(ctx, ` + INSERT INTO stripe_membership_event_cursor (customer_id, last_event_created, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (customer_id) DO UPDATE + SET + last_event_created = EXCLUDED.last_event_created, + updated_at = NOW() + WHERE stripe_membership_event_cursor.last_event_created <= EXCLUDED.last_event_created + `, customerID, createdAt) + if err != nil { + return false, oops.New(err, "failed to update membership event cursor") + } + return tag.RowsAffected() == 1, nil +} + +func membershipEventCustomerID(event *stripe.Event) string { + if event == nil { + return "" + } + switch event.Type { + case stripeEventPaymentIntentProcessing, + stripeEventPaymentIntentRequiresAction, + stripeEventPaymentIntentPaymentFailed: + var pi stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { + return "" + } + if pi.Customer == nil { + return "" + } + return pi.Customer.ID + case "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return "" + } + if sub.Customer == nil { + return "" + } + return sub.Customer.ID + case "invoice.paid", "invoice.payment_failed": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return "" + } + if inv.Customer == nil { + return "" + } + return inv.Customer.ID + case "checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return "" + } + if session.Customer == nil { + return "" + } + return session.Customer.ID + case "payment_method.attached": + var pm stripe.PaymentMethod + if err := json.Unmarshal(event.Data.Raw, &pm); err != nil { + return "" + } + if pm.Customer == nil { + return "" + } + return pm.Customer.ID + case "customer.updated": + var customer stripe.Customer + if err := json.Unmarshal(event.Data.Raw, &customer); err != nil { + return "" + } + return customer.ID + default: + return "" + } +} + +func isMembershipPaymentIntentEvent(event *stripe.Event) bool { + if event == nil { + return false + } + switch event.Type { + case stripeEventPaymentIntentProcessing, + stripeEventPaymentIntentRequiresAction, + stripeEventPaymentIntentPaymentFailed: + return true + default: + return false + } +} + +func checkMembershipEventOrder(c *RequestContext, event *stripe.Event) bool { + shouldProcess, err := shouldProcessMembershipEventOrder(c, c.Conn, event) + if err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed membership event ordering guard") + return false + } + if !shouldProcess { + c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("stale membership event by created timestamp; ignoring") + return false + } + return true +} + func StripeWebhook(c *RequestContext) ResponseData { const MaxBodyBytes = 65536 payload, err := io.ReadAll(io.LimitReader(c.Req.Body, MaxBodyBytes)) @@ -36,64 +209,305 @@ func StripeWebhook(c *RequestContext) ResponseData { return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "failed to verify Stripe webhook signature")) } + if event.ID == "" { + c.Logger.Warn().Str("type", string(event.Type)).Msg("Stripe webhook missing event ID; ignoring") + return ResponseData{StatusCode: http.StatusOK} + } + + shouldProcess, err := beginStripeWebhookEvent(c, c.Conn, &event) + if err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to initialize Stripe webhook event state") + return ResponseData{StatusCode: http.StatusOK} + } + if !shouldProcess { + c.Logger.Info().Str("eventID", event.ID).Str("type", string(event.Type)).Msg("already processed Stripe webhook event; ignoring") + return ResponseData{StatusCode: http.StatusOK} + } + markFailed := func(processErr error) { + if processErr == nil { + return + } + if err := finishStripeWebhookEvent(c, c.Conn, &event, processErr); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event failure state") + } + } + c.Logger.Info().Str("type", string(event.Type)).Msg("received Stripe webhook") - switch event.Type { - case "checkout.session.completed": - var session stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &session) - if err != nil { - return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + if isMembershipGracePaymentRetryEvent(&event) { + if checkMembershipEventOrder(c, &event) { + handleMembershipGracePaymentRetryWebhook(c, sc, &event) } - return stripeCheckoutSessionCompleted(c, &session) - case "checkout.session.expired": - var session stripe.CheckoutSession - err := json.Unmarshal(event.Data.Raw, &session) - if err != nil { - return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") } - return stripeCheckoutSessionExpired(c, &session) + return ResponseData{StatusCode: http.StatusOK} + } + + if isMembershipPaymentIntentEvent(&event) && !checkMembershipEventOrder(c, &event) { + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") + } + return ResponseData{StatusCode: http.StatusOK} + } + if tryHandleMembershipPaymentIntentWebhook(c, sc, &event) { + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") + } + return ResponseData{StatusCode: http.StatusOK} + } + + priceIDs, err := stripePriceIDsForEvent(c, sc, &event) + if err != nil { + markFailed(err) + c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to resolve price IDs for stripe event") + return ResponseData{StatusCode: http.StatusOK} + } + + kind, err := classifyStripePriceIDs(c, c.Conn, priceIDs) + if err != nil { + markFailed(err) + c.Logger.Error().Err(err).Msg("failed to classify stripe webhook by price") + return ResponseData{StatusCode: http.StatusOK} + } + + switch kind { + case stripeWebhookKindTicket: + res := handleTicketStripeEvent(c, sc, &event) + if res.StatusCode >= http.StatusBadRequest { + markFailed(oops.New(nil, "ticket Stripe webhook handler returned status %d", res.StatusCode)) + return res + } + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") + } + return res + case stripeWebhookKindMembership: + if checkMembershipEventOrder(c, &event) { + handleMembershipStripeEvent(c, sc, &event) + } + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") + } + return ResponseData{StatusCode: http.StatusOK} default: + c.Logger.Warn(). + Str("type", string(event.Type)). + Strs("prices", priceIDs). + Msg("Stripe webhook did not match any known ticket or membership price; ignoring") + if err := finishStripeWebhookEvent(c, c.Conn, &event, nil); err != nil { + c.Logger.Error().Err(err).Str("eventID", event.ID).Msg("failed to mark Stripe webhook event processed") + } return ResponseData{StatusCode: http.StatusOK} } } -func stripeCheckoutSessionCompleted(c *RequestContext, session *stripe.CheckoutSession) ResponseData { - // Different Stripe checkout flows may dispatch to different things. - - ticket, err := hmndata.FetchTicket(c, c.Conn, hmndata.TicketQuery{ - StripeCheckoutSessionID: session.ID, - }) - if err == nil { - err := confirmStripeTicketPurchase(c, c.Conn, session, ticket) +func finishStripeWebhookEvent(ctx context.Context, conn db.ConnOrTx, event *stripe.Event, processErr error) error { + if processErr == nil { + _, err := conn.Exec(ctx, ` + UPDATE stripe_webhook_event + SET + status = $2, + last_error = NULL, + updated_at = NOW(), + processed_at = NOW() + WHERE event_id = $1 + `, event.ID, stripeWebhookStatusProcessed) if err != nil { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to process ticket purchase")) - } - return c.JSONResponse(http.StatusOK, map[string]any{ - "confirmedTicket": ticket.ID, - }) - } else if err == db.NotFound { - // all good, move on to other checkout things - } else { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up ticket for checkout session")) - } - - c.Logger.Warn(). - Str("session ID", session.ID). - Str("payment intent ID", session.PaymentIntent.ID). - Msg("Unknown checkout session! What could it mean???") - return ResponseData{StatusCode: http.StatusOK} + return oops.New(err, "failed to mark stripe webhook event as processed") + } + return nil + } + + _, err := conn.Exec(ctx, ` + UPDATE stripe_webhook_event + SET + status = $2, + last_error = $3, + updated_at = NOW() + WHERE event_id = $1 + `, event.ID, stripeWebhookStatusFailed, processErr.Error()) + if err != nil { + return oops.New(err, "failed to mark stripe webhook event as failed") + } + return nil +} + +type stripeWebhookKind int + +const ( + stripeWebhookKindUnknown stripeWebhookKind = iota + stripeWebhookKindTicket + stripeWebhookKindMembership +) + +func membershipPriceIDAllowed(priceID string) bool { + if priceID == "" { + return false + } + for _, id := range membershipWebhookPriceIDs() { + if priceID == id { + return true + } + } + return false } -func stripeCheckoutSessionExpired(c *RequestContext, session *stripe.CheckoutSession) ResponseData { - // Different Stripe checkout flows may dispatch to different things. +func membershipWebhookPriceIDs() []string { + var out []string + seen := map[string]struct{}{} + add := func(id string) { + if id == "" { + return + } + if _, ok := seen[id]; ok { + return + } + seen[id] = struct{}{} + out = append(out, id) + } + add(config.Config.Stripe.PriceID) + for _, id := range config.Config.Stripe.MembershipAlternatePriceIDs { + add(id) + } + return out +} + +func classifyStripePriceIDs(ctx context.Context, conn db.ConnOrTx, priceIDs []string) (stripeWebhookKind, error) { + if len(priceIDs) == 0 { + return stripeWebhookKindUnknown, nil + } + + membership := map[string]struct{}{} + for _, id := range membershipWebhookPriceIDs() { + membership[id] = struct{}{} + } + for _, id := range priceIDs { + if _, ok := membership[id]; ok { + return stripeWebhookKindMembership, nil + } + } - numDeleted, err := cancelPendingTicketsForCheckoutSession(c, c.Conn, session) + ticketPriceIDs, err := db.QueryScalar[string](ctx, conn, ` + SELECT stripe_price_id + FROM ticket_metadata + WHERE stripe_price_id <> '' + `) if err != nil { - return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to clear tickets for expired checkout session")) + return stripeWebhookKindUnknown, oops.New(err, "failed to load ticket price ids") + } + + known := make(map[string]struct{}, len(ticketPriceIDs)) + for _, id := range ticketPriceIDs { + known[id] = struct{}{} + } + for _, id := range priceIDs { + if _, ok := known[id]; ok { + return stripeWebhookKindTicket, nil + } + } + + return stripeWebhookKindUnknown, nil +} + +func stripePriceIDsForEvent(ctx context.Context, sc *stripe.Client, event *stripe.Event) ([]string, error) { + switch event.Type { + case "checkout.session.completed", "checkout.session.expired", "checkout.session.async_payment_failed", "checkout.session.async_payment_succeeded": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return nil, oops.New(err, "bad checkout session JSON in stripe webhook") + } + return checkoutSessionPriceIDs(ctx, sc, &session) + + case "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "customer.subscription.paused", "customer.subscription.resumed", "customer.subscription.trial_will_end": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + return nil, oops.New(err, "bad subscription JSON in stripe webhook") + } + return subscriptionPriceIDs(&sub), nil + + case "invoice.paid", "invoice.payment_failed", "invoice.payment_succeeded", "invoice.finalized", "invoice.upcoming": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + return nil, oops.New(err, "bad invoice JSON in stripe webhook") + } + return invoicePriceIDs(&inv), nil } - return c.JSONResponse(http.StatusOK, map[string]any{ - "ticketsDeleted": numDeleted, + return nil, nil +} + +func checkoutSessionPriceIDs(ctx context.Context, sc *stripe.Client, session *stripe.CheckoutSession) ([]string, error) { + var ids []string + seen := map[string]struct{}{} + + iter := sc.V1CheckoutSessions.ListLineItems(ctx, &stripe.CheckoutSessionListLineItemsParams{ + Session: stripe.String(session.ID), }) + var iterErr error + iter(func(item *stripe.LineItem, err error) bool { + if err != nil { + iterErr = err + return false + } + if item == nil || item.Price == nil || item.Price.ID == "" { + return true + } + pid := item.Price.ID + if _, ok := seen[pid]; ok { + return true + } + seen[pid] = struct{}{} + ids = append(ids, pid) + return true + }) + if iterErr != nil { + return nil, oops.New(iterErr, "failed to list checkout session line items") + } + return ids, nil +} + +func subscriptionPriceIDs(sub *stripe.Subscription) []string { + if sub == nil || sub.Items == nil { + return nil + } + var ids []string + seen := map[string]struct{}{} + for _, item := range sub.Items.Data { + if item == nil || item.Price == nil || item.Price.ID == "" { + continue + } + pid := item.Price.ID + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + ids = append(ids, pid) + } + return ids +} + +func invoicePriceIDs(inv *stripe.Invoice) []string { + if inv == nil || inv.Lines == nil { + return nil + } + var ids []string + seen := map[string]struct{}{} + for _, line := range inv.Lines.Data { + if line == nil || line.Pricing == nil || line.Pricing.PriceDetails == nil { + continue + } + pid := line.Pricing.PriceDetails.Price + if pid == "" { + continue + } + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + ids = append(ids, pid) + } + return ids } diff --git a/src/website/stripe_tickets.go b/src/website/stripe_tickets.go new file mode 100644 index 00000000..f5925695 --- /dev/null +++ b/src/website/stripe_tickets.go @@ -0,0 +1,61 @@ +package website + +import ( + "encoding/json" + "net/http" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmndata" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/stripe/stripe-go/v84" +) + +// handleTicketStripeEvent handles ticket Stripe webhook events. +func handleTicketStripeEvent(c *RequestContext, sc *stripe.Client, event *stripe.Event) ResponseData { + switch event.Type { + case "checkout.session.completed": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + } + return ticketCheckoutSessionCompleted(c, &session) + case "checkout.session.expired": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return c.JSONErrorResponse(http.StatusBadRequest, oops.New(err, "bad JSON in stripe webhook")) + } + return ticketCheckoutSessionExpired(c, &session) + } + return ResponseData{StatusCode: http.StatusOK} +} + +func ticketCheckoutSessionCompleted(c *RequestContext, session *stripe.CheckoutSession) ResponseData { + ticket, err := hmndata.FetchTicket(c, c.Conn, hmndata.TicketQuery{ + StripeCheckoutSessionID: session.ID, + }) + if err == db.NotFound { + c.Logger.Warn(). + Str("session ID", session.ID). + Msg("checkout session matched a ticket product but no ticket row exists") + return ResponseData{StatusCode: http.StatusOK} + } else if err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to look up ticket for checkout session")) + } + + if err := confirmStripeTicketPurchase(c, c.Conn, session, ticket); err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to process ticket purchase")) + } + return c.JSONResponse(http.StatusOK, map[string]any{ + "confirmedTicket": ticket.ID, + }) +} + +func ticketCheckoutSessionExpired(c *RequestContext, session *stripe.CheckoutSession) ResponseData { + numDeleted, err := cancelPendingTicketsForCheckoutSession(c, c.Conn, session) + if err != nil { + return c.JSONErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to clear tickets for expired checkout session")) + } + return c.JSONResponse(http.StatusOK, map[string]any{ + "ticketsDeleted": numDeleted, + }) +} diff --git a/src/website/stripe_webhook_test.go b/src/website/stripe_webhook_test.go new file mode 100644 index 00000000..16286698 --- /dev/null +++ b/src/website/stripe_webhook_test.go @@ -0,0 +1,21 @@ +package website + +import ( + "testing" + + "git.handmade.network/hmn/hmn/src/hmnurl" + "github.com/stretchr/testify/assert" +) + +// Routing regression: info page and subscription manage must not share the same path. +func TestFoundationMembershipVsManageRoutes(t *testing.T) { + assert.True(t, hmnurl.RegexHSFMembership.MatchString("/foundation/membership")) + assert.False(t, hmnurl.RegexHSFMembership.MatchString("/foundation/membership/manage")) + + assert.True(t, hmnurl.RegexSubscriptionManage.MatchString("/foundation/membership/manage")) + assert.False(t, hmnurl.RegexSubscriptionManage.MatchString("/foundation/membership")) +} + +func TestStripeWebhookIsTheOnlyStripeEndpoint(t *testing.T) { + assert.True(t, hmnurl.RegexStripeWebhook.MatchString("/stripe/webhook")) +} diff --git a/src/website/subscription_grace.go b/src/website/subscription_grace.go new file mode 100644 index 00000000..9547deff --- /dev/null +++ b/src/website/subscription_grace.go @@ -0,0 +1,366 @@ +package website + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/email" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/perf" + "github.com/stripe/stripe-go/v84" +) + +const ( + SubscriptionStatusGracePeriod = "grace_period" + SubscriptionStatusGraceFailed = "grace_failed" + SubscriptionStatusPendingVerification = "pending_verification" + subscriptionGracePeriodDuration = 7 * 24 * time.Hour +) + +var subscriptionNowOverride *time.Time + +func SubscriptionNow() time.Time { + if subscriptionNowOverride != nil { + return *subscriptionNowOverride + } + if override := config.Config.Stripe.SubscriptionNowOverride; override != "" { + if parsed, err := time.Parse(time.RFC3339, override); err == nil { + return parsed + } + } + return time.Now() +} + +func SetSubscriptionNowForTests(t time.Time) { + subscriptionNowOverride = &t +} + +func ClearSubscriptionNowForTests() { + subscriptionNowOverride = nil +} + +func isGraceActive(user *models.User, now time.Time) bool { + if user == nil || user.GracePeriodEndsAt == nil { + return false + } + if user.SubscriptionStatus != nil && *user.SubscriptionStatus == SubscriptionStatusGracePeriod { + return now.Before(*user.GracePeriodEndsAt) + } + return user.GracePeriodStartedAt != nil && now.Before(*user.GracePeriodEndsAt) +} + +func canStartGrace(user *models.User, now time.Time) bool { + if user == nil || !user.GraceAvailable { + return false + } + if isGraceActive(user, now) { + return false + } + return true +} + +func stripeSubscriptionGrantsAccess(status string) bool { + return status == "active" || status == "trialing" +} + +func isFailedPaymentStripeStatus(status string) bool { + switch status { + case "past_due", "unpaid", "incomplete", "incomplete_expired": + return true + default: + return false + } +} + +func startGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int, now time.Time) (bool, error) { + endsAt := now.Add(subscriptionGracePeriodDuration) + tag, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = true, + subscription_status = $1, + grace_period_started_at = $2, + grace_period_ends_at = $3, + grace_available = false, + thank_you_email_sent = false + WHERE id = $4 + AND grace_available = true + AND (grace_period_ends_at IS NULL OR grace_period_ends_at <= $2) + `, SubscriptionStatusGracePeriod, now, endsAt, userID) + if err != nil { + return false, err + } + if tag.RowsAffected() == 0 { + return false, nil + } + logging.Info().Int("userID", userID).Time("graceEndsAt", endsAt).Msg("started subscription grace period") + SyncSupporterDiscordRole(ctx, conn, userID) + return true, nil +} + +func clearGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error { + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = true + WHERE id = $1 + `, userID) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Msg("cleared subscription grace period after successful payment") + return nil +} + +func activateSubscriptionAfterSuccessfulPayment(ctx context.Context, conn db.ConnOrTx, userID int, currentPeriodEnd *time.Time) error { + if err := clearGracePeriod(ctx, conn, userID); err != nil { + return err + } + if currentPeriodEnd != nil { + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET is_subscribed = true, subscription_status = 'active', current_period_end = $1 + WHERE id = $2 + `, currentPeriodEnd, userID) + return err + } + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET is_subscribed = true, subscription_status = 'active' + WHERE id = $1 + `, userID) + return err +} + +func subscriptionIDFromInvoice(inv *stripe.Invoice) string { + if inv == nil { + return "" + } + if inv.Lines != nil { + for _, line := range inv.Lines.Data { + if line.Subscription != nil && line.Subscription.ID != "" { + return line.Subscription.ID + } + } + } + if inv.Parent != nil && inv.Parent.SubscriptionDetails != nil && inv.Parent.SubscriptionDetails.Subscription != nil { + return inv.Parent.SubscriptionDetails.Subscription.ID + } + return "" +} + +func expireGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error { + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = false, + subscription_status = $1, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = false + WHERE id = $2 + AND subscription_status = $3 + `, SubscriptionStatusGraceFailed, userID, SubscriptionStatusGracePeriod) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Msg("expired subscription grace period without payment") + SyncSupporterDiscordRole(ctx, conn, userID) + return nil +} + +func expireDueGracePeriods(ctx context.Context, conn db.ConnOrTx, now time.Time) ([]int, error) { + userIDPtrs, err := db.Query[int](ctx, conn, ` + UPDATE hmn_user + SET + is_subscribed = false, + subscription_status = $1, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = false + WHERE subscription_status = $2 + AND grace_period_ends_at IS NOT NULL + AND grace_period_ends_at < $3 + RETURNING id + `, SubscriptionStatusGraceFailed, SubscriptionStatusGracePeriod, now) + if err != nil { + return nil, err + } + userIDs := make([]int, len(userIDPtrs)) + for i, id := range userIDPtrs { + userIDs[i] = *id + } + return userIDs, nil +} + +func userInGracePeriod(user *models.User) bool { + return user != nil && user.SubscriptionStatus != nil && *user.SubscriptionStatus == SubscriptionStatusGracePeriod +} + +func userNeedsBankVerificationReminder(user *models.User) bool { + if user == nil || user.SubscriptionStatus == nil { + return false + } + switch *user.SubscriptionStatus { + case SubscriptionStatusPendingVerification, "incomplete": + return true + case SubscriptionStatusGracePeriod: + return user.IsSubscribed + default: + return false + } +} + +func gracePeriodDaysRemaining(user *models.User, now time.Time) int { + if user == nil || user.GracePeriodEndsAt == nil || !user.GracePeriodEndsAt.After(now) { + return 0 + } + + hoursRemaining := user.GracePeriodEndsAt.Sub(now).Hours() + days := int(hoursRemaining / 24) + if hoursRemaining > float64(days*24) { + days++ + } + if days < 1 { + return 1 + } + return days +} + +func StartSubscriptionGracePeriod(ctx context.Context, conn db.ConnOrTx, userID int) error { + _, err := startGracePeriod(ctx, conn, userID, SubscriptionNow()) + return err +} + +func ExpireSubscriptionGracePeriods(ctx context.Context, conn db.ConnOrTx) (int64, error) { + userIDs, err := expireDueGracePeriods(ctx, conn, SubscriptionNow()) + if err != nil { + return 0, err + } + for _, userID := range userIDs { + SyncSupporterDiscordRole(ctx, conn, userID) + sendGracePeriodEndedEmail(ctx, conn, userID) + } + return int64(len(userIDs)), nil +} + +func sendGracePeriodEndedEmail(ctx context.Context, conn db.ConnOrTx, userID int) { + user, err := db.QueryOne[models.User](ctx, conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for grace period ended email") + return + } + + p := perf.ExtractPerf(ctx) + if p == nil { + p = perf.MakeNewRequestPerf("subscription grace expiry", "JOB", "/subscription/grace-expiry") + } + if err := email.SendGracePeriodEndedEmail(user.Email, user.BestName(), p); err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to send grace period ended email") + } +} + +func shouldRetrySubscriptionPayment(user *models.User) bool { + if user == nil || user.StripeCustomerID == nil || user.StripeSubscriptionID == nil { + return false + } + if userInGracePeriod(user) { + return true + } + if !user.IsSubscribed || user.SubscriptionStatus == nil { + return false + } + switch *user.SubscriptionStatus { + case SubscriptionStatusPendingVerification, "incomplete", "past_due", "unpaid": + return true + default: + return false + } +} + +func retryPastDueSubscriptionPayment(ctx context.Context, conn db.ConnOrTx, sc *stripe.Client, user *models.User) error { + if !shouldRetrySubscriptionPayment(user) { + return nil + } + + invoiceID, err := findOpenSubscriptionInvoice(ctx, sc, *user.StripeCustomerID, *user.StripeSubscriptionID) + if err != nil { + return err + } + if invoiceID == "" { + logging.Info().Int("userID", user.ID).Msg("no open subscription invoice to retry") + return nil + } + + inv, err := sc.V1Invoices.Pay(ctx, invoiceID, &stripe.InvoicePayParams{}) + if err != nil { + return err + } + + logging.Info().Int("userID", user.ID).Str("invoiceID", invoiceID).Str("status", string(inv.Status)).Msg("retried open subscription invoice payment") + + if inv.Status == stripe.InvoiceStatusPaid { + var renewalDate *time.Time + if user.StripeSubscriptionID != nil { + if sub, retrieveErr := sc.V1Subscriptions.Retrieve(ctx, *user.StripeSubscriptionID, nil); retrieveErr == nil { + renewalDate = getSubscriptionPeriodEnd(sub) + } + } + if err := activateSubscriptionAfterSuccessfulPayment(ctx, conn, user.ID, renewalDate); err != nil { + return err + } + SyncSupporterDiscordRole(ctx, conn, user.ID) + } + + return nil +} + +func findOpenSubscriptionInvoice(ctx context.Context, sc *stripe.Client, customerID, subscriptionID string) (string, error) { + subParams := &stripe.SubscriptionRetrieveParams{} + subParams.AddExpand("latest_invoice") + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, subParams) + if err != nil { + return "", err + } + if sub.LatestInvoice != nil && sub.LatestInvoice.Status == stripe.InvoiceStatusOpen && sub.LatestInvoice.AmountRemaining > 0 { + return sub.LatestInvoice.ID, nil + } + + listParams := &stripe.InvoiceListParams{ + Customer: stripe.String(customerID), + Status: stripe.String(string(stripe.InvoiceStatusOpen)), + } + var invoiceID string + var listErr error + sc.V1Invoices.List(ctx, listParams)(func(inv *stripe.Invoice, err error) bool { + if err != nil { + listErr = err + return false + } + if inv == nil || inv.AmountRemaining <= 0 { + return true + } + if !invoiceBelongsToSubscription(inv, subscriptionID) { + return true + } + invoiceID = inv.ID + return false + }) + if listErr != nil { + return "", listErr + } + return invoiceID, nil +} + +func invoiceBelongsToSubscription(inv *stripe.Invoice, subscriptionID string) bool { + if inv == nil || inv.Parent == nil || inv.Parent.SubscriptionDetails == nil { + return false + } + sub := inv.Parent.SubscriptionDetails.Subscription + return sub != nil && sub.ID == subscriptionID +} diff --git a/src/website/subscription_grace_eligibility.go b/src/website/subscription_grace_eligibility.go new file mode 100644 index 00000000..0104e15f --- /dev/null +++ b/src/website/subscription_grace_eligibility.go @@ -0,0 +1,238 @@ +package website + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/models" + "github.com/stripe/stripe-go/v84" +) + +func isAsyncPaymentMethodType(pmType string) bool { + switch pmType { + case "us_bank_account", "acss_debit", "sepa_debit": + return true + default: + return false + } +} + +func paymentIntentHasMicrodepositVerification(pi *stripe.PaymentIntent) bool { + if pi == nil || pi.NextAction == nil { + return false + } + return pi.NextAction.Type == stripe.PaymentIntentNextActionTypeVerifyWithMicrodeposits +} + +// shouldSendACHVerificationEmailForPaymentIntent returns true only when Stripe +// indicates the user still needs to complete bank verification. +func shouldSendACHVerificationEmailForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool { + if pi == nil { + return false + } + if !isAsyncPaymentMethodType(resolvePaymentMethodType(pi, paymentMethodType)) { + return false + } + return paymentIntentHasMicrodepositVerification(pi) +} + +// shouldGrantGraceForPaymentIntent returns true when payment is in-flight for an async +// method (e.g. ACH processing or microdeposit verification), not a card decline. +func shouldGrantGraceForPaymentIntent(pi *stripe.PaymentIntent, paymentMethodType string) bool { + if pi == nil { + return false + } + switch pi.Status { + case stripe.PaymentIntentStatusRequiresAction: + // Bank microdeposit verification; payment method type is often unset on the PI this early. + return paymentIntentHasMicrodepositVerification(pi) + case stripe.PaymentIntentStatusProcessing: + return isAsyncPaymentMethodType(resolvePaymentMethodType(pi, paymentMethodType)) + default: + return false + } +} + +func resolvePaymentMethodType(pi *stripe.PaymentIntent, explicit string) string { + if explicit != "" { + return explicit + } + if pi.PaymentMethod != nil && pi.PaymentMethod.Type != "" { + return string(pi.PaymentMethod.Type) + } + for _, t := range pi.PaymentMethodTypes { + if isAsyncPaymentMethodType(t) { + return t + } + } + return "" +} + +func paymentIntentIsHardDecline(pi *stripe.PaymentIntent, paymentMethodType string) bool { + if pi == nil || isAsyncPaymentMethodType(paymentMethodType) { + return false + } + if pi.LastPaymentError != nil { + return isHardDeclineErrorCode(string(pi.LastPaymentError.Code)) + } + return pi.Status == stripe.PaymentIntentStatusRequiresPaymentMethod || + pi.Status == stripe.PaymentIntentStatusCanceled +} + +func isHardDeclineErrorCode(code string) bool { + switch stripe.ErrorCode(code) { + case stripe.ErrorCodeCardDeclined, + stripe.ErrorCodeInsufficientFunds, + stripe.ErrorCodeExpiredCard, + stripe.ErrorCodeIncorrectCVC, + stripe.ErrorCodeIncorrectNumber, + stripe.ErrorCodeInvalidCVC, + stripe.ErrorCodeInvalidExpiryMonth, + stripe.ErrorCodeInvalidExpiryYear, + stripe.ErrorCodeInvalidNumber, + stripe.ErrorCodeProcessingError, + stripe.ErrorCodeAuthenticationRequired: + return true + default: + return false + } +} + +func paymentIntentPaymentMethodType(ctx context.Context, sc *stripe.Client, pi *stripe.PaymentIntent) string { + if pi == nil { + return "" + } + if resolved := resolvePaymentMethodType(pi, ""); resolved != "" { + return resolved + } + if pi.PaymentMethod == nil || pi.PaymentMethod.ID == "" { + return "" + } + pm, err := sc.V1PaymentMethods.Retrieve(ctx, pi.PaymentMethod.ID, nil) + if err != nil || pm == nil { + return "" + } + return string(pm.Type) +} + +func retrievePaymentIntent(ctx context.Context, sc *stripe.Client, paymentIntentID string) (*stripe.PaymentIntent, error) { + if paymentIntentID == "" { + return nil, nil + } + params := &stripe.PaymentIntentRetrieveParams{} + params.AddExpand("payment_method") + return sc.V1PaymentIntents.Retrieve(ctx, paymentIntentID, params) +} + +func checkoutSessionPaymentIntent(ctx context.Context, sc *stripe.Client, session *stripe.CheckoutSession) (*stripe.PaymentIntent, string, error) { + if session == nil || session.PaymentIntent == nil { + return nil, "", nil + } + piID := session.PaymentIntent.ID + pi, err := retrievePaymentIntent(ctx, sc, piID) + if err != nil { + return nil, "", err + } + return pi, paymentIntentPaymentMethodType(ctx, sc, pi), nil +} + +func invoicePaymentIntent(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) (*stripe.PaymentIntent, string, error) { + if inv == nil { + return nil, "", nil + } + params := &stripe.InvoicePaymentListParams{ + Invoice: stripe.String(inv.ID), + } + params.AddExpand("data.payment.payment_intent") + + var pi *stripe.PaymentIntent + sc.V1InvoicePayments.List(ctx, params)(func(ip *stripe.InvoicePayment, err error) bool { + if err != nil || ip == nil || ip.Payment == nil || ip.Payment.PaymentIntent == nil { + return true + } + pi = ip.Payment.PaymentIntent + return false + }) + if pi == nil { + return nil, "", nil + } + if pi.PaymentMethod == nil || pi.PaymentMethod.Type == "" { + full, err := retrievePaymentIntent(ctx, sc, pi.ID) + if err != nil { + return nil, "", err + } + pi = full + } + return pi, paymentIntentPaymentMethodType(ctx, sc, pi), nil +} + +func shouldGrantGraceForSubscription(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) bool { + if sub == nil || sub.LatestInvoice == nil { + return false + } + invParams := &stripe.InvoiceRetrieveParams{} + invParams.AddExpand("payments.data.payment.payment_intent") + inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams) + if err != nil { + return false + } + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil { + return false + } + return shouldGrantGraceForPaymentIntent(pi, pmType) +} + +func shouldSendACHVerificationEmailForSubscription(ctx context.Context, sc *stripe.Client, sub *stripe.Subscription) bool { + if sub == nil || sub.LatestInvoice == nil { + return false + } + invParams := &stripe.InvoiceRetrieveParams{} + invParams.AddExpand("payments.data.payment.payment_intent") + inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams) + if err != nil { + return false + } + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil { + return false + } + return shouldSendACHVerificationEmailForPaymentIntent(pi, pmType) +} + +func shouldGrantGraceForInvoice(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool { + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil { + return false + } + return shouldGrantGraceForPaymentIntent(pi, pmType) +} + +func shouldSendACHVerificationEmailForInvoice(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool { + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil { + return false + } + return shouldSendACHVerificationEmailForPaymentIntent(pi, pmType) +} + +func invoicePaymentIsHardDecline(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) bool { + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil || pi == nil { + return false + } + return paymentIntentIsHardDecline(pi, pmType) +} + +// shouldStartGraceOnPaymentFailure returns true when a failed payment should begin the +// one-time grace period. Async methods (ACH processing / verification) always qualify; +// card declines qualify only for existing subscribers (renewal), not initial sign-up. +func shouldStartGraceOnPaymentFailure(user *models.User, now time.Time, asyncGraceEligible bool) bool { + if user == nil || !canStartGrace(user, now) { + return false + } + if asyncGraceEligible { + return true + } + return user.IsSubscribed +} diff --git a/src/website/subscription_grace_eligibility_test.go b/src/website/subscription_grace_eligibility_test.go new file mode 100644 index 00000000..fb18d1ac --- /dev/null +++ b/src/website/subscription_grace_eligibility_test.go @@ -0,0 +1,88 @@ +package website + +import ( + "testing" + "time" + + "github.com/stripe/stripe-go/v84" + "github.com/stretchr/testify/assert" + + "git.handmade.network/hmn/hmn/src/models" +) + +func TestShouldGrantGraceForPaymentIntent(t *testing.T) { + achPI := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing} + assert.True(t, shouldGrantGraceForPaymentIntent(achPI, "us_bank_account")) + assert.False(t, shouldGrantGraceForPaymentIntent(achPI, "card")) + + cardPI := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing} + assert.False(t, shouldGrantGraceForPaymentIntent(cardPI, "card")) + + achVerify := &stripe.PaymentIntent{ + Status: stripe.PaymentIntentStatusRequiresAction, + NextAction: &stripe.PaymentIntentNextAction{ + Type: stripe.PaymentIntentNextActionTypeVerifyWithMicrodeposits, + }, + } + assert.True(t, shouldGrantGraceForPaymentIntent(achVerify, "us_bank_account")) + assert.True(t, shouldGrantGraceForPaymentIntent(achVerify, "")) + + cardVerify := &stripe.PaymentIntent{ + Status: stripe.PaymentIntentStatusRequiresAction, + NextAction: &stripe.PaymentIntentNextAction{ + Type: stripe.PaymentIntentNextActionTypeUseStripeSDK, + }, + } + assert.False(t, shouldGrantGraceForPaymentIntent(cardVerify, "card")) +} + +func TestResolvePaymentMethodType(t *testing.T) { + pi := &stripe.PaymentIntent{ + PaymentMethodTypes: []string{"card", "us_bank_account"}, + } + assert.Equal(t, "us_bank_account", resolvePaymentMethodType(pi, "")) + assert.Equal(t, "card", resolvePaymentMethodType(pi, "card")) +} + +func TestPaymentIntentIsHardDecline(t *testing.T) { + declined := &stripe.PaymentIntent{ + Status: stripe.PaymentIntentStatusRequiresPaymentMethod, + LastPaymentError: &stripe.Error{ + Code: stripe.ErrorCodeInsufficientFunds, + }, + } + assert.True(t, paymentIntentIsHardDecline(declined, "card")) + assert.False(t, paymentIntentIsHardDecline(declined, "us_bank_account")) + + processingACH := &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusProcessing} + assert.False(t, paymentIntentIsHardDecline(processingACH, "us_bank_account")) +} + +func TestIsAsyncPaymentMethodType(t *testing.T) { + assert.True(t, isAsyncPaymentMethodType("us_bank_account")) + assert.False(t, isAsyncPaymentMethodType("card")) +} + +func TestShouldStartGraceOnPaymentFailure(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + + activeSubscriber := &models.User{ + IsSubscribed: true, + GraceAvailable: true, + } + assert.True(t, shouldStartGraceOnPaymentFailure(activeSubscriber, now, false)) + assert.True(t, shouldStartGraceOnPaymentFailure(activeSubscriber, now, true)) + + initialSignup := &models.User{ + IsSubscribed: false, + GraceAvailable: true, + } + assert.False(t, shouldStartGraceOnPaymentFailure(initialSignup, now, false)) + assert.True(t, shouldStartGraceOnPaymentFailure(initialSignup, now, true)) + + noGraceLeft := &models.User{ + IsSubscribed: true, + GraceAvailable: false, + } + assert.False(t, shouldStartGraceOnPaymentFailure(noGraceLeft, now, false)) +} diff --git a/src/website/subscription_grace_job.go b/src/website/subscription_grace_job.go new file mode 100644 index 00000000..48373874 --- /dev/null +++ b/src/website/subscription_grace_job.go @@ -0,0 +1,42 @@ +package website + +import ( + "time" + + "git.handmade.network/hmn/hmn/src/jobs" + "git.handmade.network/hmn/hmn/src/utils" + "github.com/jackc/pgx/v5/pgxpool" +) + +func ExpireSubscriptionGracePeriodsJob(dbConn *pgxpool.Pool) *jobs.Job { + job := jobs.New("subscription grace expiry") + go func() { + defer job.Finish() + + t := time.NewTicker(1 * time.Minute) + for { + select { + case <-t.C: + err := func() (err error) { + defer utils.RecoverPanicAsError(&err) + + n, err := ExpireSubscriptionGracePeriods(job.Ctx, dbConn) + if err != nil { + job.Logger.Error().Err(err).Msg("failed to expire subscription grace periods") + return err + } + if n > 0 { + job.Logger.Info().Int64("num expired", n).Msg("Expired subscription grace periods") + } + return nil + }() + if err != nil { + job.Logger.Error().Err(err).Msg("Panicked in subscription grace expiry job") + } + case <-job.Canceled(): + return + } + } + }() + return job +} diff --git a/src/website/subscription_grace_revoke.go b/src/website/subscription_grace_revoke.go new file mode 100644 index 00000000..2b63636f --- /dev/null +++ b/src/website/subscription_grace_revoke.go @@ -0,0 +1,36 @@ +package website + +import ( + "context" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/logging" +) + +// RevokeSubscriptionAccessAfterDeclinedPayment clears member access when a payment +// was declined (not processing). Exported for admin subscription test tooling. +func RevokeSubscriptionAccessAfterDeclinedPayment(ctx context.Context, conn db.ConnOrTx, userID int, subscriptionStatus string) error { + return revokeSubscriptionAccessAfterDeclinedPayment(ctx, conn, userID, subscriptionStatus) +} + +// revokeSubscriptionAccessAfterDeclinedPayment clears member access when a payment +// was declined (not processing). Restores grace_available so a future ACH attempt can +// still use the one-time grace period. +func revokeSubscriptionAccessAfterDeclinedPayment(ctx context.Context, conn db.ConnOrTx, userID int, subscriptionStatus string) error { + _, err := conn.Exec(ctx, ` + UPDATE hmn_user + SET + is_subscribed = false, + subscription_status = $1, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = true + WHERE id = $2 + `, subscriptionStatus, userID) + if err != nil { + return err + } + logging.Info().Int("userID", userID).Str("status", subscriptionStatus).Msg("revoked subscription access after declined payment") + SyncSupporterDiscordRole(ctx, conn, userID) + return nil +} diff --git a/src/website/subscription_grace_test.go b/src/website/subscription_grace_test.go new file mode 100644 index 00000000..b518b1bd --- /dev/null +++ b/src/website/subscription_grace_test.go @@ -0,0 +1,166 @@ +package website + +import ( + "testing" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/models" + "github.com/stripe/stripe-go/v84" + "github.com/stretchr/testify/assert" +) + +func statusPtr(s string) *string { + return &s +} + +func timePtr(t time.Time) *time.Time { + return &t +} + +func TestSubscriptionNowUsesOverride(t *testing.T) { + original := config.Config.Stripe.SubscriptionNowOverride + defer func() { + config.Config.Stripe.SubscriptionNowOverride = original + ClearSubscriptionNowForTests() + }() + + fixed := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + config.Config.Stripe.SubscriptionNowOverride = fixed.Format(time.RFC3339) + ClearSubscriptionNowForTests() + + assert.Equal(t, fixed, SubscriptionNow()) + + SetSubscriptionNowForTests(fixed.Add(2 * time.Hour)) + assert.Equal(t, fixed.Add(2*time.Hour), SubscriptionNow()) +} + +func TestIsGraceActive(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + endsAt := now.Add(48 * time.Hour) + + user := &models.User{ + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + GracePeriodStartedAt: timePtr(now.Add(-24 * time.Hour)), + GracePeriodEndsAt: timePtr(endsAt), + } + assert.True(t, isGraceActive(user, now)) + assert.False(t, isGraceActive(user, endsAt)) + assert.False(t, isGraceActive(&models.User{}, now)) +} + +func TestCanStartGrace(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + + assert.True(t, canStartGrace(&models.User{GraceAvailable: true}, now)) + assert.False(t, canStartGrace(&models.User{GraceAvailable: false}, now)) + + activeGraceUser := &models.User{ + GraceAvailable: true, + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + GracePeriodStartedAt: timePtr(now.Add(-24 * time.Hour)), + GracePeriodEndsAt: timePtr(now.Add(24 * time.Hour)), + } + assert.False(t, canStartGrace(activeGraceUser, now)) +} + +func TestIsFailedPaymentStripeStatus(t *testing.T) { + assert.True(t, isFailedPaymentStripeStatus("past_due")) + assert.True(t, isFailedPaymentStripeStatus("unpaid")) + assert.False(t, isFailedPaymentStripeStatus("active")) + assert.False(t, isFailedPaymentStripeStatus("trialing")) +} + +func TestStripeSubscriptionGrantsAccess(t *testing.T) { + assert.True(t, stripeSubscriptionGrantsAccess("active")) + assert.True(t, stripeSubscriptionGrantsAccess("trialing")) + assert.False(t, stripeSubscriptionGrantsAccess("past_due")) +} + +func TestUserInGracePeriod(t *testing.T) { + assert.True(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod)})) + assert.False(t, userInGracePeriod(&models.User{SubscriptionStatus: statusPtr("active")})) +} + +func TestGracePeriodDaysRemaining(t *testing.T) { + now := time.Date(2026, 5, 24, 12, 0, 0, 0, time.UTC) + + assert.Equal(t, 0, gracePeriodDaysRemaining(nil, now)) + assert.Equal(t, 0, gracePeriodDaysRemaining(&models.User{}, now)) + + user := &models.User{GracePeriodEndsAt: timePtr(now.Add(6 * time.Hour))} + assert.Equal(t, 1, gracePeriodDaysRemaining(user, now)) + + user.GracePeriodEndsAt = timePtr(now.Add(7 * 24 * time.Hour)) + assert.Equal(t, 7, gracePeriodDaysRemaining(user, now)) + + user.GracePeriodEndsAt = timePtr(now.Add(7*24*time.Hour + time.Hour)) + assert.Equal(t, 8, gracePeriodDaysRemaining(user, now)) + + user.GracePeriodEndsAt = timePtr(now.Add(-time.Hour)) + assert.Equal(t, 0, gracePeriodDaysRemaining(user, now)) +} + +func TestShouldRetrySubscriptionPayment(t *testing.T) { + subID := "sub_123" + custID := "cus_123" + + assert.False(t, shouldRetrySubscriptionPayment(nil)) + assert.False(t, shouldRetrySubscriptionPayment(&models.User{IsSubscribed: true, SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod)})) + + user := &models.User{ + IsSubscribed: true, + StripeCustomerID: &custID, + StripeSubscriptionID: &subID, + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + } + assert.True(t, shouldRetrySubscriptionPayment(user)) + + user.SubscriptionStatus = statusPtr("incomplete") + assert.True(t, shouldRetrySubscriptionPayment(user)) + + user.SubscriptionStatus = statusPtr("active") + user.IsSubscribed = true + assert.False(t, shouldRetrySubscriptionPayment(user)) +} + +func TestUserNeedsBankVerificationReminder(t *testing.T) { + assert.True(t, userNeedsBankVerificationReminder(&models.User{ + SubscriptionStatus: statusPtr(SubscriptionStatusPendingVerification), + })) + assert.True(t, userNeedsBankVerificationReminder(&models.User{ + SubscriptionStatus: statusPtr("incomplete"), + })) + assert.True(t, userNeedsBankVerificationReminder(&models.User{ + IsSubscribed: true, + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + })) + assert.False(t, userNeedsBankVerificationReminder(&models.User{ + IsSubscribed: false, + SubscriptionStatus: statusPtr(SubscriptionStatusGracePeriod), + })) + assert.False(t, userNeedsBankVerificationReminder(&models.User{ + IsSubscribed: true, + SubscriptionStatus: statusPtr("active"), + })) +} + +func TestSubscriptionIDFromInvoice(t *testing.T) { + assert.Equal(t, "", subscriptionIDFromInvoice(nil)) + + assert.Equal(t, "sub_line", subscriptionIDFromInvoice(&stripe.Invoice{ + Lines: &stripe.InvoiceLineItemList{ + Data: []*stripe.InvoiceLineItem{ + {Subscription: &stripe.Subscription{ID: "sub_line"}}, + }, + }, + })) + + assert.Equal(t, "sub_parent", subscriptionIDFromInvoice(&stripe.Invoice{ + Parent: &stripe.InvoiceParent{ + SubscriptionDetails: &stripe.InvoiceParentSubscriptionDetails{ + Subscription: &stripe.Subscription{ID: "sub_parent"}, + }, + }, + })) +} diff --git a/src/website/subscription_payment_intent_webhook.go b/src/website/subscription_payment_intent_webhook.go new file mode 100644 index 00000000..9be2be6d --- /dev/null +++ b/src/website/subscription_payment_intent_webhook.go @@ -0,0 +1,106 @@ +package website + +import ( + "encoding/json" + "strconv" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "github.com/stripe/stripe-go/v84" +) + +func tryHandleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client, event *stripe.Event) bool { + switch event.Type { + case stripe.EventTypePaymentIntentProcessing, + stripe.EventTypePaymentIntentRequiresAction, + stripe.EventTypePaymentIntentPaymentFailed: + default: + return false + } + + var pi stripe.PaymentIntent + if err := json.Unmarshal(event.Data.Raw, &pi); err != nil { + c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to unmarshal payment_intent for membership") + return false + } + + return handleMembershipPaymentIntentWebhook(c, sc, event.Type, &pi) +} + +func handleMembershipPaymentIntentWebhook(c *RequestContext, sc *stripe.Client, eventType stripe.EventType, pi *stripe.PaymentIntent) bool { + if pi == nil || pi.Customer == nil { + return false + } + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", pi.Customer.ID) + if err != nil { + return false + } + if user.StripeSubscriptionID == nil { + return false + } + + fullPI, err := retrievePaymentIntent(c, sc, pi.ID) + if err != nil { + logging.Warn().Err(err).Str("paymentIntentID", pi.ID).Msg("failed to retrieve payment intent for membership webhook") + fullPI = pi + } else if fullPI != nil { + pi = fullPI + } + + pmType := paymentIntentPaymentMethodType(c, sc, pi) + now := SubscriptionNow() + + switch eventType { + case stripe.EventTypePaymentIntentProcessing, stripe.EventTypePaymentIntentRequiresAction: + if shouldGrantGraceForPaymentIntent(pi, pmType) && canStartGrace(user, now) { + shouldSendVerificationEmail := shouldSendACHVerificationEmailForPaymentIntent(pi, pmType) + if user.StripeCustomerID == nil || user.StripeSubscriptionID == nil { + return true + } + invoiceID, invoiceErr := findOpenSubscriptionInvoice(c, sc, *user.StripeCustomerID, *user.StripeSubscriptionID) + if invoiceErr != nil { + logging.Warn().Err(invoiceErr).Int("userID", user.ID).Msg("failed to resolve open subscription invoice for payment intent webhook") + return true + } + if invoiceID == "" { + logging.Info().Int("userID", user.ID).Str("eventType", string(eventType)).Msg("skipping grace start; no open subscription invoice") + return true + } + startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment intent webhook") + } else if startedGrace && shouldSendVerificationEmail { + sendACHVerificationGraceEmail(c, user.ID) + } + } + case stripe.EventTypePaymentIntentPaymentFailed: + if paymentIntentIsHardDecline(pi, pmType) { + if shouldStartGraceOnPaymentFailure(user, now, false) { + if _, err := startGracePeriod(c, c.Conn, user.ID, now); err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start grace period from payment_intent.payment_failed") + } + } else if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, "incomplete"); err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to revoke access from payment_intent.payment_failed") + } + } + default: + return false + } + + return true +} + +func handleCheckoutAsyncPaymentFailed(c *RequestContext, sc *stripe.Client, session *stripe.CheckoutSession) { + if session.ClientReferenceID == "" { + return + } + userID, err := strconv.Atoi(session.ClientReferenceID) + if err != nil { + return + } + if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, userID, "incomplete"); err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to revoke access after async checkout payment failed") + } +} diff --git a/src/website/subscription_payment_retry.go b/src/website/subscription_payment_retry.go new file mode 100644 index 00000000..23c3d16c --- /dev/null +++ b/src/website/subscription_payment_retry.go @@ -0,0 +1,67 @@ +package website + +import ( + "encoding/json" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "github.com/stripe/stripe-go/v84" +) + +func isMembershipGracePaymentRetryEvent(event *stripe.Event) bool { + switch event.Type { + case "payment_method.attached", "customer.updated": + return true + default: + return false + } +} + +func handleMembershipGracePaymentRetryWebhook(c *RequestContext, sc *stripe.Client, event *stripe.Event) { + switch event.Type { + case "payment_method.attached": + var pm stripe.PaymentMethod + if err := json.Unmarshal(event.Data.Raw, &pm); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal payment_method.attached for grace retry") + return + } + if pm.Customer == nil { + return + } + maybeRetrySubscriptionPaymentForCustomer(c, sc, pm.Customer.ID) + case "customer.updated": + if !customerDefaultPaymentMethodChanged(event) { + return + } + var customer stripe.Customer + if err := json.Unmarshal(event.Data.Raw, &customer); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.updated for grace retry") + return + } + maybeRetrySubscriptionPaymentForCustomer(c, sc, customer.ID) + } +} + +func customerDefaultPaymentMethodChanged(event *stripe.Event) bool { + if event.Data == nil || len(event.Data.PreviousAttributes) == 0 { + return false + } + if _, ok := event.Data.PreviousAttributes["invoice_settings"]; ok { + return true + } + if _, ok := event.Data.PreviousAttributes["default_source"]; ok { + return true + } + return false +} + +func maybeRetrySubscriptionPaymentForCustomer(c *RequestContext, sc *stripe.Client, customerID string) { + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", customerID) + if err != nil { + return + } + if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, user); err != nil { + logging.Warn().Err(err).Int("userID", user.ID).Str("customerID", customerID).Msg("failed to retry subscription payment after payment method change") + } +} diff --git a/src/website/subscription_stripe_testutil.go b/src/website/subscription_stripe_testutil.go new file mode 100644 index 00000000..bbc1d7ce --- /dev/null +++ b/src/website/subscription_stripe_testutil.go @@ -0,0 +1,67 @@ +package website + +import ( + "context" + "encoding/json" + + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/perf" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stripe/stripe-go/v84" +) + +// ProcessMembershipStripeWebhookForTests routes a membership Stripe event through the +// same handlers used by StripeWebhook. Intended for admin subscription integration tests. +func ProcessMembershipStripeWebhookForTests(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, event *stripe.Event) { + if event == nil { + return + } + + logger := logging.GlobalLogger() + c := &RequestContext{ + ctx: ctx, + Conn: pool, + Logger: logger, + Perf: perf.MakeNewRequestPerf("subscription-test", "POST", "/stripe/webhook"), + } + + if isMembershipGracePaymentRetryEvent(event) { + handleMembershipGracePaymentRetryWebhook(c, sc, event) + } + + if tryHandleMembershipPaymentIntentWebhook(c, sc, event) { + return + } + + handleMembershipStripeEvent(c, sc, event) +} + +// StripeEventFromObject builds a synthetic Stripe webhook event from a Stripe object. +func StripeEventFromObject(eventType stripe.EventType, obj any) (*stripe.Event, error) { + raw, err := json.Marshal(obj) + if err != nil { + return nil, err + } + return &stripe.Event{ + Type: eventType, + Data: &stripe.EventData{Raw: raw}, + }, nil +} + +// InvoicePaymentIntentForTests resolves the payment intent on an invoice. Exported for admin tests. +func InvoicePaymentIntentForTests(ctx context.Context, sc *stripe.Client, inv *stripe.Invoice) (*stripe.PaymentIntent, string, error) { + return invoicePaymentIntent(ctx, sc, inv) +} + +// Exported for admin subscription integration tests. +func RetrySubscriptionPaymentForTests(ctx context.Context, pool *pgxpool.Pool, sc *stripe.Client, customerID string) error { + logger := logging.GlobalLogger() + c := &RequestContext{ + ctx: ctx, + Conn: pool, + Logger: logger, + Perf: perf.MakeNewRequestPerf("subscription-test", "POST", "/stripe/webhook"), + } + maybeRetrySubscriptionPaymentForCustomer(c, sc, customerID) + return nil +} diff --git a/src/website/subscriptions.go b/src/website/subscriptions.go new file mode 100644 index 00000000..424f3956 --- /dev/null +++ b/src/website/subscriptions.go @@ -0,0 +1,1126 @@ +package website + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/email" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/logging" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" + "github.com/stripe/stripe-go/v84" +) + +// handleMembershipStripeEvent handles membership Stripe webhook events. +func handleMembershipStripeEvent(c *RequestContext, sc *stripe.Client, event *stripe.Event) { + switch event.Type { + case "checkout.session.completed", "checkout.session.async_payment_succeeded": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + c.Logger.Error().Err(err).Str("type", string(event.Type)).Msg("failed to unmarshal checkout session") + return + } + handleCheckoutSessionCompleted(c, sc, &session) + case "customer.subscription.created": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.created") + return + } + handleSubscriptionCreated(c, sc, &sub) + case "customer.subscription.updated": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.updated") + return + } + c.Logger.Trace().RawJSON("sub_json", event.Data.Raw).Msg("received subscription update JSON") + handleSubscriptionUpdated(c, sc, &sub) + case "customer.subscription.deleted": + var sub stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &sub); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal customer.subscription.deleted") + return + } + handleSubscriptionDeleted(c, sc, &sub) + case "invoice.paid": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal invoice.paid") + return + } + handleInvoicePaid(c, sc, &inv) + case "invoice.payment_failed": + var inv stripe.Invoice + if err := json.Unmarshal(event.Data.Raw, &inv); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal invoice.payment_failed") + return + } + handleInvoicePaymentFailed(c, sc, &inv) + case "checkout.session.async_payment_failed": + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + c.Logger.Error().Err(err).Msg("failed to unmarshal checkout.session.async_payment_failed") + return + } + handleCheckoutAsyncPaymentFailed(c, sc, &session) + } +} + +type PaymentHistoryItem struct { + Date string + Amount string + CardInfo string +} + +type ManageSubscriptionTemplateData struct { + templates.BaseData + SubscribeUrl string + CancelSubscriptionUrl string + ResumeSubscriptionUrl string + UpdatePaymentMethodUrl string + CurrentCurrencySymbol string + CurrentAmount string + PaymentHistory []PaymentHistoryItem + CurrentPeriodEnd string + LastPaymentAmount string + LastPaymentMethod string + GracePeriodEnd string + IsInGracePeriod bool + NeedsBankVerification bool + IsPaymentPending bool + ShowPostCheckoutPendingInfo bool + + DefaultMembershipPriceID string + EurMembershipPriceID string +} + +func SubscriptionManageRedirect(c *RequestContext) ResponseData { + target := hmnurl.BuildHSFMembership() + if query := c.Req.URL.RawQuery; query != "" { + target += "?" + query + } + return c.Redirect(target, http.StatusSeeOther) +} + +func buildMembershipPageData(c *RequestContext, baseData templates.BaseData) ManageSubscriptionTemplateData { + var history []PaymentHistoryItem + currentCurrencySymbol := "$" + currentAmount := "5.00" + + if c.CurrentUser != nil && c.CurrentUser.IsSubscribed { + payments, _ := db.Query[models.UserPayment](c, c.Conn, "SELECT $columns FROM user_payment WHERE user_id = $1 ORDER BY paid_at DESC", c.CurrentUser.ID) + if len(payments) > 0 { + if strings.EqualFold(payments[0].Currency, "eur") { + currentCurrencySymbol = "€" + } + currentAmount = fmt.Sprintf("%.2f", float64(payments[0].AmountCents)/100.0) + } + + for _, p := range payments { + sym := "$" + if strings.EqualFold(p.Currency, "eur") { + sym = "€" + } + card := "" + if p.CardBrand != nil { + card = strings.ToUpper(*p.CardBrand) + } + if p.CardLast4 != nil { + if card != "" { + card += " " + } + card += "•••• " + *p.CardLast4 + } + history = append(history, PaymentHistoryItem{ + Date: p.PaidAt.UTC().Format("Jan 2, 2006"), + Amount: fmt.Sprintf("%s%.2f", sym, float64(p.AmountCents)/100.0), + CardInfo: card, + }) + } + } + + currentPeriodEnd := "" + if c.CurrentUser != nil && c.CurrentUser.CurrentPeriodEnd != nil { + currentPeriodEnd = c.CurrentUser.CurrentPeriodEnd.UTC().Format("Jan 2, 2006") + } + + lastAmount := "" + lastMethod := "" + if len(history) > 0 { + lastAmount = history[0].Amount + lastMethod = history[0].CardInfo + } + + eurPriceID := "" + if alts := config.Config.Stripe.MembershipAlternatePriceIDs; len(alts) > 0 { + eurPriceID = alts[0] + } + + gracePeriodEnd := "" + isInGracePeriod := false + needsBankVerification := false + isPaymentPending := false + if c.CurrentUser != nil && userInGracePeriod(c.CurrentUser) { + isInGracePeriod = true + if c.CurrentUser.GracePeriodEndsAt != nil { + gracePeriodEnd = c.CurrentUser.GracePeriodEndsAt.UTC().Format("Jan 2, 2006") + } + + status := stringOrEmpty(c.CurrentUser.SubscriptionStatus) + needsBankVerification = status == SubscriptionStatusPendingVerification || status == "incomplete" + if c.CurrentUser.StripeSubscriptionID != nil { + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + if hostedBankVerificationURL(c, sc, *c.CurrentUser.StripeSubscriptionID) != "" { + needsBankVerification = true + } + if !needsBankVerification && asyncSubscriptionPaymentStillProcessing(c, sc, *c.CurrentUser.StripeSubscriptionID) { + isPaymentPending = true + } + } + } + showPostCheckoutPendingInfo := c.CurrentUser != nil && + !c.CurrentUser.IsSubscribed && + strings.TrimSpace(c.Req.URL.Query().Get("session_id")) != "" + + return ManageSubscriptionTemplateData{ + BaseData: baseData, + SubscribeUrl: hmnurl.BuildSubscriptionSubscribe(), + CancelSubscriptionUrl: hmnurl.BuildSubscriptionCancel(), + ResumeSubscriptionUrl: hmnurl.BuildSubscriptionResume(), + UpdatePaymentMethodUrl: hmnurl.BuildSubscriptionUpdatePaymentMethod(), + CurrentCurrencySymbol: currentCurrencySymbol, + CurrentAmount: currentAmount, + PaymentHistory: history, + CurrentPeriodEnd: currentPeriodEnd, + LastPaymentAmount: lastAmount, + LastPaymentMethod: lastMethod, + GracePeriodEnd: gracePeriodEnd, + IsInGracePeriod: isInGracePeriod, + NeedsBankVerification: needsBankVerification, + IsPaymentPending: isPaymentPending, + ShowPostCheckoutPendingInfo: showPostCheckoutPendingInfo, + DefaultMembershipPriceID: config.Config.Stripe.PriceID, + EurMembershipPriceID: eurPriceID, + } +} + +func asyncSubscriptionPaymentStillProcessing(ctx context.Context, sc *stripe.Client, subscriptionID string) bool { + if sc == nil || subscriptionID == "" { + return false + } + + params := &stripe.SubscriptionRetrieveParams{} + params.AddExpand("latest_invoice") + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, params) + if err != nil || sub == nil || sub.LatestInvoice == nil { + return false + } + + invParams := &stripe.InvoiceRetrieveParams{} + invParams.AddExpand("payments.data.payment.payment_intent") + inv, err := sc.V1Invoices.Retrieve(ctx, sub.LatestInvoice.ID, invParams) + if err != nil { + return false + } + pi, pmType, err := invoicePaymentIntent(ctx, sc, inv) + if err != nil || pi == nil { + return false + } + + return pi.Status == stripe.PaymentIntentStatusProcessing && + isAsyncPaymentMethodType(pmType) && + !paymentIntentHasMicrodepositVerification(pi) +} + +func SubscriptionSubscribe(c *RequestContext) ResponseData { + if c.CurrentUser.IsSubscribed { + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + if userInGracePeriod(c.CurrentUser) { + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + + if err := c.Req.ParseForm(); err != nil { + return c.ErrorResponse(http.StatusBadRequest, oops.New(err, "failed to parse subscribe form")) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + priceID := strings.TrimSpace(c.Req.FormValue("price_id")) + if priceID == "" { + priceID = config.Config.Stripe.PriceID + } + if !membershipPriceIDAllowed(priceID) { + return c.RejectRequest("Membership billing is not configured. Set Stripe.PriceID (and optional MembershipAlternatePriceIDs) in config.") + } + + params := &stripe.CheckoutSessionCreateParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + SuccessURL: stripe.String(hmnurl.BuildHSFMembership() + "?session_id={CHECKOUT_SESSION_ID}"), + CancelURL: stripe.String(hmnurl.BuildHSFMembership()), + ClientReferenceID: stripe.String(strconv.Itoa(c.CurrentUser.ID)), + LineItems: []*stripe.CheckoutSessionCreateLineItemParams{ + { + Price: stripe.String(priceID), + Quantity: stripe.Int64(1), + }, + }, + CustomerEmail: stripe.String(c.CurrentUser.Email), + SubscriptionData: &stripe.CheckoutSessionCreateSubscriptionDataParams{ + Metadata: map[string]string{"user_id": strconv.Itoa(c.CurrentUser.ID)}, + }, + } + + p, err := sc.V1Prices.Retrieve(c, priceID, nil) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get requested price")) + } + targetCurrency := p.Currency + targetAmount := p.UnitAmount + + if c.CurrentUser.StripeCustomerID != nil { + params.Customer = stripe.String(*c.CurrentUser.StripeCustomerID) + params.CustomerEmail = nil + + listParams := &stripe.CheckoutSessionListParams{ + Customer: stripe.String(*c.CurrentUser.StripeCustomerID), + Status: stripe.String(string(stripe.CheckoutSessionStatusOpen)), + } + + iter := sc.V1CheckoutSessions.List(c, listParams) + var existingURL string + var outdatedSessionID string + var listErr error + iter(func(session *stripe.CheckoutSession, err error) bool { + listErr = err + if err == nil && session != nil { + if session.Currency == targetCurrency && session.AmountTotal == targetAmount { + existingURL = session.URL + } else { + outdatedSessionID = session.ID + } + } + return false // pull only the first item + }) + + if listErr != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(listErr, "failed to list checkout sessions")) + } + + if existingURL != "" { + return c.Redirect(existingURL, http.StatusSeeOther) + } else if outdatedSessionID != "" { + _, _ = sc.V1CheckoutSessions.Expire(c, outdatedSessionID, nil) + } + } + + s, err := sc.V1CheckoutSessions.Create(c, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create checkout session")) + } + + return c.Redirect(s.URL, http.StatusSeeOther) +} + +func SubscriptionCancel(c *RequestContext) ResponseData { + if c.CurrentUser.StripeSubscriptionID == nil { + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + if userInGracePeriod(c.CurrentUser) { + _, err := sc.V1Subscriptions.Cancel(c, *c.CurrentUser.StripeSubscriptionID, nil) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to cancel subscription immediately")) + } + + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + is_subscribed = false, + stripe_subscription_id = NULL, + subscription_status = 'canceled', + current_period_end = NULL, + cancel_at_period_end = false, + thank_you_email_sent = false, + grace_period_started_at = NULL, + grace_period_ends_at = NULL + WHERE id = $1 + `, c.CurrentUser.ID) + if err != nil { + logging.Error().Err(err).Msg("failed to apply immediate local cancel state for grace-period user") + } + SyncSupporterDiscordRole(c, c.Conn, c.CurrentUser.ID) + + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + + params := &stripe.SubscriptionUpdateParams{ + CancelAtPeriodEnd: stripe.Bool(true), + } + _, err := sc.V1Subscriptions.Update(c, *c.CurrentUser.StripeSubscriptionID, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to cancel subscription")) + } + + _, err = c.Conn.Exec(c, "UPDATE hmn_user SET cancel_at_period_end = true WHERE id = $1", c.CurrentUser.ID) + if err != nil { + logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically") + } + + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) +} + +func SubscriptionResume(c *RequestContext) ResponseData { + if c.CurrentUser.StripeSubscriptionID == nil { + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + + if c.CurrentUser.CurrentPeriodEnd == nil || c.CurrentUser.CurrentPeriodEnd.Before(SubscriptionNow()) { + return c.Redirect(hmnurl.BuildSubscriptionSubscribe(), http.StatusSeeOther) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + + params := &stripe.SubscriptionUpdateParams{ + CancelAtPeriodEnd: stripe.Bool(false), + } + _, err := sc.V1Subscriptions.Update(c, *c.CurrentUser.StripeSubscriptionID, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to resume subscription")) + } + + _, err = c.Conn.Exec(c, "UPDATE hmn_user SET cancel_at_period_end = false WHERE id = $1", c.CurrentUser.ID) + if err != nil { + logging.Error().Err(err).Msg("failed to update user cancel_at_period_end optimistically") + } + + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) +} + +func SubscriptionUpdatePaymentMethod(c *RequestContext) ResponseData { + if !c.CurrentUser.IsSubscribed || c.CurrentUser.StripeCustomerID == nil { + return c.Redirect(hmnurl.BuildHSFMembership(), http.StatusSeeOther) + } + + sc := stripe.NewClient(config.Config.Stripe.SecretKey) + returnURL := hmnurl.BuildHSFMembershipPaymentMethodReturn() + params := &stripe.BillingPortalSessionCreateParams{ + Customer: stripe.String(*c.CurrentUser.StripeCustomerID), + ReturnURL: stripe.String(returnURL), + FlowData: &stripe.BillingPortalSessionCreateFlowDataParams{ + Type: stripe.String(string(stripe.BillingPortalSessionFlowTypePaymentMethodUpdate)), + AfterCompletion: &stripe.BillingPortalSessionCreateFlowDataAfterCompletionParams{ + Type: stripe.String(string(stripe.BillingPortalSessionFlowAfterCompletionTypeRedirect)), + Redirect: &stripe.BillingPortalSessionCreateFlowDataAfterCompletionRedirectParams{ + ReturnURL: stripe.String(returnURL), + }, + }, + }, + } + session, err := sc.V1BillingPortalSessions.Create(c, params) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create billing portal session")) + } + + return c.Redirect(session.URL, http.StatusSeeOther) +} + +func handleSubscriptionCreated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, err := strconv.Atoi(uidStr); err == nil { + renewalDate := getSubscriptionPeriodEnd(sub) + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = $3, + current_period_end = $4 + WHERE id = $5 + `, sub.Customer.ID, sub.ID, sub.Status, renewalDate, uid) + if err != nil { + logging.Error().Err(err).Int("userID", uid).Msg("failed to handle subscription.created") + } + } + } +} + +func handleCheckoutSessionCompleted(c *RequestContext, sc *stripe.Client, session *stripe.CheckoutSession) { + if session.ClientReferenceID == "" { + logging.Error().Msg("checkout.session.completed missing client_reference_id") + return + } + + userID, err := strconv.Atoi(session.ClientReferenceID) + if err != nil { + logging.Error().Err(err).Str("client_reference_id", session.ClientReferenceID).Msg("invalid client_reference_id") + return + } + + if session.Customer == nil || session.Subscription == nil { + logging.Error().Int("userID", userID).Msg("checkout.session.completed missing customer or subscription") + return + } + + if session.PaymentStatus != stripe.CheckoutSessionPaymentStatusPaid { + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for pending checkout session") + return + } + + pi, pmType, err := checkoutSessionPaymentIntent(c, sc, session) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to load checkout payment intent") + return + } + + grantGrace := shouldGrantGraceForPaymentIntent(pi, pmType) + shouldSendVerificationEmail := shouldSendACHVerificationEmailForPaymentIntent(pi, pmType) + if !grantGrace { + // Some ACH checkout.session.completed payloads do not include a usable payment_intent yet. + // Fall back to subscription/invoice inspection to decide grace eligibility. + if sub, subErr := sc.V1Subscriptions.Retrieve(c, session.Subscription.ID, nil); subErr == nil { + grantGrace = shouldGrantGraceForSubscription(c, sc, sub) + shouldSendVerificationEmail = shouldSendACHVerificationEmailForSubscription(c, sc, sub) + } else { + logging.Warn().Err(subErr).Int("userID", userID).Str("subscriptionID", session.Subscription.ID).Msg("failed to inspect subscription for pending checkout grace fallback") + } + } + now := SubscriptionNow() + + if grantGrace && canStartGrace(user, now) { + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + cancel_at_period_end = false + WHERE id = $3 + `, session.Customer.ID, session.Subscription.ID, userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to link pending checkout subscription") + return + } + startedGrace, err := startGracePeriod(c, c.Conn, userID, now) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to start grace period for processing checkout payment") + } else if startedGrace && shouldSendVerificationEmail { + sendACHVerificationGraceEmail(c, userID) + } + } else if isGraceActive(user, now) { + // Grace may already have started via payment_intent.requires_action/processing. + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + cancel_at_period_end = false, + is_subscribed = true, + subscription_status = $4 + WHERE id = $3 + `, session.Customer.ID, session.Subscription.ID, userID, SubscriptionStatusGracePeriod) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to link checkout subscription during active grace") + } + SyncSupporterDiscordRole(c, c.Conn, userID) + } else { + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + is_subscribed = false, + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = $3, + cancel_at_period_end = false + WHERE id = $4 + `, session.Customer.ID, session.Subscription.ID, "incomplete", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to link incomplete checkout subscription") + } + SyncSupporterDiscordRole(c, c.Conn, userID) + } + + logging.Info(). + Int("userID", userID). + Str("paymentStatus", string(session.PaymentStatus)). + Bool("grantGrace", grantGrace). + Str("paymentMethodType", pmType). + Msg("checkout completed with pending payment") + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, ` + UPDATE hmn_user + SET + is_subscribed = true, + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = 'active', + cancel_at_period_end = false, + grace_period_started_at = NULL, + grace_period_ends_at = NULL, + grace_available = true + WHERE id = $3 + RETURNING $columns + `, session.Customer.ID, session.Subscription.ID, userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to update user subscription status") + } else { + logging.Info().Int("userID", userID).Msg("user subscription linked, attempting thank you email") + attemptThankYouEmail(c, user.ID, session.AmountTotal, session.Currency) + SyncSupporterDiscordRole(c, c.Conn, userID) + } +} + +func sendACHVerificationGraceEmail(c *RequestContext, userID int) { + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to fetch user for ACH verification grace email") + return + } + + err = email.SendACHVerificationGraceEmail(user.Email, user.BestName(), user.GracePeriodEndsAt, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to send ACH verification grace email") + } +} + +func handleSubscriptionUpdated(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + renewalDate := getSubscriptionPeriodEnd(sub) + now := SubscriptionNow() + stripeStatus := string(sub.Status) + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", sub.Customer.ID) + if err == db.NotFound { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, subErr := strconv.Atoi(uidStr); subErr == nil { + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", uid) + } + } + } + + if err != nil { + logging.Error().Err(err).Str("customerID", sub.Customer.ID).Msg("failed to fetch user for subscription update") + return + } + + isCancelling := sub.CancelAtPeriodEnd || (sub.CancelAt > 0 && sub.Status != "canceled") + + logging.Info(). + Int("userID", user.ID). + Str("status", stripeStatus). + Bool("cancelAtPeriodEnd", sub.CancelAtPeriodEnd). + Int64("cancelAt", sub.CancelAt). + Bool("isCancelling", isCancelling). + Msg("updating user subscription from webhook") + + if isFailedPaymentStripeStatus(stripeStatus) { + if isGraceActive(user, now) { + if err := retryPastDueSubscriptionPayment(c, c.Conn, sc, user); err != nil { + logging.Warn().Err(err).Int("userID", user.ID).Msg("failed to retry subscription payment on subscription update") + } else { + refreshed, refreshErr := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", user.ID) + if refreshErr == nil { + user = refreshed + } + if refreshedSub, retrieveErr := sc.V1Subscriptions.Retrieve(c, sub.ID, nil); retrieveErr == nil { + stripeStatus = string(refreshedSub.Status) + } + } + } + + asyncGraceEligible := shouldGrantGraceForSubscription(c, sc, sub) + shouldSendVerificationEmail := shouldSendACHVerificationEmailForSubscription(c, sc, sub) + if isFailedPaymentStripeStatus(stripeStatus) && shouldStartGraceOnPaymentFailure(user, now, asyncGraceEligible) { + startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period") + return + } else if startedGrace && shouldSendVerificationEmail { + sendACHVerificationGraceEmail(c, user.ID) + } + } else if isFailedPaymentStripeStatus(stripeStatus) && !isGraceActive(user, now) { + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = $3, + cancel_at_period_end = $4, + is_subscribed = false, + current_period_end = $5 + WHERE id = $6 + `, sub.Customer.ID, sub.ID, stripeStatus, isCancelling, renewalDate, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook") + } + SyncSupporterDiscordRole(c, c.Conn, user.ID) + return + } + + if !isFailedPaymentStripeStatus(stripeStatus) { + // Payment retry cleared the past-due state; fall through to active handling. + } else if isGraceActive(user, now) { + // Grace already started via startGracePeriod; sync Stripe metadata only. + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + cancel_at_period_end = $3, + is_subscribed = true, + current_period_end = $4 + WHERE id = $5 + `, sub.Customer.ID, sub.ID, isCancelling, renewalDate, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to sync subscription metadata during grace") + } + SyncSupporterDiscordRole(c, c.Conn, user.ID) + return + } + } + + isSubscribed := stripeSubscriptionGrantsAccess(stripeStatus) + if isSubscribed { + if err := clearGracePeriod(c, c.Conn, user.ID); err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to clear subscription grace period") + } + } + + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + stripe_customer_id = $1, + stripe_subscription_id = $2, + subscription_status = $3, + cancel_at_period_end = $4, + is_subscribed = $5, + current_period_end = $6 + WHERE id = $7 + `, sub.Customer.ID, sub.ID, stripeStatus, isCancelling, isSubscribed, renewalDate, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to update user subscription from webhook") + } + SyncSupporterDiscordRole(c, c.Conn, user.ID) + + if isCancelling && !user.CancelAtPeriodEnd { + var expirationDate *time.Time + if sub.CancelAt > 0 { + t := time.Unix(sub.CancelAt, 0) + expirationDate = &t + } else if renewalDate != nil { + expirationDate = renewalDate + } + logging.Info().Int("userID", user.ID).Msg("sending subscription cancellation initiation email") + err = email.SendSubscriptionCancelledEmail(user.Email, user.BestName(), expirationDate, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send cancellation initiation email") + } + } +} + +func handleSubscriptionDeleted(c *RequestContext, sc *stripe.Client, sub *stripe.Subscription) { + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", sub.Customer.ID) + if err == db.NotFound { + if uidStr, ok := sub.Metadata["user_id"]; ok { + if uid, subErr := strconv.Atoi(uidStr); subErr == nil { + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", uid) + } + } + } + + if err != nil { + logging.Error().Err(err).Str("customerID", sub.Customer.ID).Msg("failed to fetch user for subscription deletion") + return + } + + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET + is_subscribed = false, + stripe_subscription_id = NULL, + subscription_status = 'canceled', + current_period_end = NULL, + cancel_at_period_end = false, + thank_you_email_sent = false, + grace_period_started_at = NULL, + grace_period_ends_at = NULL + WHERE id = $1 + `, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to handle subscription deletion") + return + } + + logging.Info().Int("userID", user.ID).Msg("user subscription deactivated") + SyncSupporterDiscordRole(c, c.Conn, user.ID) + + if !sub.CancelAtPeriodEnd { + err = email.SendSubscriptionCancelledEmail(user.Email, user.BestName(), nil, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send cancellation email") + } + } +} + +func handleInvoicePaid(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice) { + if inv.Customer == nil { + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", inv.Customer.ID) + if err == db.NotFound { + } + if err != nil { + logging.Error().Err(err).Str("customerID", inv.Customer.ID).Msg("failed to fetch user for invoice.paid") + return + } + + details := getPaymentDetails(c, sc, inv) + + _, err = c.Conn.Exec(c, ` + INSERT INTO user_payment (user_id, stripe_invoice_id, amount_cents, currency, payment_method_type, card_last4, card_brand, paid_at, stripe_fee_cents, net_amount_cents) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (stripe_invoice_id) DO UPDATE SET + payment_method_type = EXCLUDED.payment_method_type, + card_last4 = EXCLUDED.card_last4, + card_brand = EXCLUDED.card_brand, + stripe_fee_cents = EXCLUDED.stripe_fee_cents, + net_amount_cents = EXCLUDED.net_amount_cents + `, user.ID, inv.ID, inv.AmountPaid, string(inv.Currency), details.methodType, details.last4, details.brand, time.Unix(inv.StatusTransitions.PaidAt, 0), details.feeCents, details.netCents) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to record user payment") + } + + subscriptionID := subscriptionIDFromInvoice(inv) + if subscriptionID == "" && user.StripeSubscriptionID != nil { + subscriptionID = *user.StripeSubscriptionID + } + var renewalDate *time.Time + if subscriptionID != "" { + sub, err := sc.V1Subscriptions.Retrieve(c, subscriptionID, nil) + if err == nil { + renewalDate = getSubscriptionPeriodEnd(sub) + } else { + logging.Warn().Err(err).Int("userID", user.ID).Str("subscriptionID", subscriptionID).Msg("failed to retrieve subscription after invoice payment") + } + } + if err := activateSubscriptionAfterSuccessfulPayment(c, c.Conn, user.ID, renewalDate); err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to activate subscription after invoice payment") + } + + attemptThankYouEmail(c, user.ID, inv.AmountPaid, inv.Currency) + SyncSupporterDiscordRole(c, c.Conn, user.ID) +} + +func handleInvoicePaymentFailed(c *RequestContext, sc *stripe.Client, inv *stripe.Invoice) { + if inv.Customer == nil { + return + } + + user, err := db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE stripe_customer_id = $1", inv.Customer.ID) + if err != nil { + logging.Error().Err(err).Str("customerID", inv.Customer.ID).Msg("failed to fetch user for invoice.payment_failed") + return + } + + now := SubscriptionNow() + grantGrace := shouldGrantGraceForInvoice(c, sc, inv) + shouldSendVerificationEmail := shouldSendACHVerificationEmailForInvoice(c, sc, inv) + hardDecline := invoicePaymentIsHardDecline(c, sc, inv) + if shouldStartGraceOnPaymentFailure(user, now, grantGrace) { + startedGrace, err := startGracePeriod(c, c.Conn, user.ID, now) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to start subscription grace period from invoice.payment_failed") + } else if startedGrace && shouldSendVerificationEmail { + sendACHVerificationGraceEmail(c, user.ID) + } + } else if hardDecline && !isGraceActive(user, now) { + status := "incomplete" + if inv.Status != "" { + status = string(inv.Status) + } + if err := revokeSubscriptionAccessAfterDeclinedPayment(c, c.Conn, user.ID, status); err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to revoke access after declined invoice payment") + } + } else if !isGraceActive(user, now) && !user.GraceAvailable { + _, err = c.Conn.Exec(c, ` + UPDATE hmn_user + SET is_subscribed = false, subscription_status = $1 + WHERE id = $2 + `, SubscriptionStatusGraceFailed, user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to mark subscription grace_failed") + } + SyncSupporterDiscordRole(c, c.Conn, user.ID) + } + + user, err = db.QueryOne[models.User](c, c.Conn, "SELECT $columns FROM hmn_user WHERE id = $1", user.ID) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to reload user after payment failure") + return + } + + amountStr := "" + if inv.AmountDue > 0 { + curr := strings.ToUpper(string(inv.Currency)) + symbol := "$" + if curr != "USD" { + symbol = curr + " " + } + amountStr = fmt.Sprintf("%s%.2f", symbol, float64(inv.AmountDue)/100.0) + } + + var nextAttemptDate *time.Time + if inv.NextPaymentAttempt > 0 { + t := time.Unix(inv.NextPaymentAttempt, 0) + nextAttemptDate = &t + } + + logging.Info().Int("userID", user.ID).Str("invoiceID", inv.ID).Msg("sending payment failed email") + err = email.SendPaymentFailedEmail(user.Email, user.BestName(), amountStr, nextAttemptDate, user.GracePeriodEndsAt, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send payment failed email") + } +} + +func getSubscriptionPeriodEnd(sub *stripe.Subscription) *time.Time { + if sub != nil && sub.Items != nil && len(sub.Items.Data) > 0 { + t := time.Unix(sub.Items.Data[0].CurrentPeriodEnd, 0) + return &t + } + return nil +} + +type paymentDetails struct { + methodType *string + last4 *string + brand *string + feeCents *int + netCents *int +} + +func getPaymentDetails(c context.Context, sc *stripe.Client, inv *stripe.Invoice) paymentDetails { + var details paymentDetails + params := &stripe.InvoicePaymentListParams{ + Invoice: stripe.String(inv.ID), + } + params.AddExpand("data.payment.charge.balance_transaction") + params.AddExpand("data.payment.payment_intent.latest_charge") + + sc.V1InvoicePayments.List(c, params)(func(ip *stripe.InvoicePayment, err error) bool { + if err != nil || ip.Payment == nil { + return true + } + + var targetCharge *stripe.Charge + if ip.Payment.Charge != nil { + targetCharge = ip.Payment.Charge + } else if ip.Payment.PaymentIntent != nil && ip.Payment.PaymentIntent.LatestCharge != nil { + targetCharge = ip.Payment.PaymentIntent.LatestCharge + } + + if targetCharge == nil { + return true + } + + if targetCharge.PaymentMethodDetails != nil { + mt := string(targetCharge.PaymentMethodDetails.Type) + details.methodType = &mt + if targetCharge.PaymentMethodDetails.Card != nil { + l4 := targetCharge.PaymentMethodDetails.Card.Last4 + details.last4 = &l4 + b := string(targetCharge.PaymentMethodDetails.Card.Brand) + details.brand = &b + } else if targetCharge.PaymentMethodDetails.USBankAccount != nil { + l4 := targetCharge.PaymentMethodDetails.USBankAccount.Last4 + details.last4 = &l4 + b := targetCharge.PaymentMethodDetails.USBankAccount.BankName + details.brand = &b + } + } + + bt := targetCharge.BalanceTransaction + if bt == nil || (bt.Net == 0 && inv.AmountPaid != 0) { + retrieveParams := &stripe.ChargeRetrieveParams{} + retrieveParams.AddExpand("balance_transaction") + fullCharge, err := sc.V1Charges.Retrieve(c, targetCharge.ID, retrieveParams) + if err == nil && fullCharge.BalanceTransaction != nil && (fullCharge.BalanceTransaction.Net != 0 || inv.AmountPaid == 0) { + bt = fullCharge.BalanceTransaction + } else { + btListParams := &stripe.BalanceTransactionListParams{ + Source: stripe.String(targetCharge.ID), + } + sc.V1BalanceTransactions.List(c, btListParams)(func(item *stripe.BalanceTransaction, err error) bool { + if err == nil && (item.Net != 0 || inv.AmountPaid == 0) { + bt = item + return false + } + return true + }) + } + } + + if bt != nil { + fc := int(bt.Fee) + details.feeCents = &fc + nc := int(bt.Net) + details.netCents = &nc + } + + return false // Found a payment, stop iteration + }) + + return details +} + +func attemptThankYouEmail(c *RequestContext, userID int, amountCents int64, currency stripe.Currency) { + tx, err := c.Conn.Begin(c) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to begin transaction for thank you email") + return + } + defer tx.Rollback(c) + + user, err := db.QueryOne[models.User](c, tx, "SELECT $columns FROM hmn_user WHERE id = $1 FOR UPDATE", userID) + if err != nil { + if err != db.NotFound { + logging.Error().Err(err).Int("userID", userID).Msg("failed to query user for thank you email") + } + return + } + + // Only send if we have both pieces of info (active status and renewal date) and haven't sent it yet. + // We check that the renewal date is at least reasonably in the future to avoid race conditions + // where an old or "initiation" date is still in the DB. + shouldSend := false + if user.IsSubscribed && user.CurrentPeriodEnd != nil && user.CurrentPeriodEnd.After(SubscriptionNow().Add(24*time.Hour)) && !user.ThankYouEmailSent { + shouldSend = true + _, err = tx.Exec(c, "UPDATE hmn_user SET thank_you_email_sent = true WHERE id = $1", userID) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to update thank_you_email_sent flag") + return + } + } + + err = tx.Commit(c) + if err != nil { + logging.Error().Err(err).Int("userID", userID).Msg("failed to commit transaction for thank you email") + return + } + + if shouldSend { + sendThankYouEmail(c, user, user.CurrentPeriodEnd, amountCents, currency) + } +} + +func sendThankYouEmail(c *RequestContext, user *models.User, renewalDate *time.Time, amountCents int64, currency stripe.Currency) { + amountStr := "" + if amountCents > 0 { + curr := strings.ToUpper(string(currency)) + symbol := "$" + if curr != "USD" { + symbol = curr + " " + } + amountStr = fmt.Sprintf("%s%.2f", symbol, float64(amountCents)/100.0) + } + + err := email.SendThankYouEmail(user.Email, user.BestName(), renewalDate, amountStr, c.Perf) + if err != nil { + logging.Error().Err(err).Int("userID", user.ID).Msg("failed to send thank you email") + } +} + +func hostedBankVerificationURL(ctx context.Context, sc *stripe.Client, subscriptionID string) string { + if sc == nil || subscriptionID == "" { + return "" + } + + params := &stripe.SubscriptionRetrieveParams{} + params.AddExpand("latest_invoice") + sub, err := sc.V1Subscriptions.Retrieve(ctx, subscriptionID, params) + if err != nil { + logging.Warn().Err(err).Str("subscriptionID", subscriptionID).Msg("failed to retrieve subscription for bank verification banner") + return "" + } + if sub.LatestInvoice == nil || sub.LatestInvoice.ID == "" { + return "" + } + + listParams := &stripe.InvoicePaymentListParams{ + Invoice: stripe.String(sub.LatestInvoice.ID), + } + listParams.AddExpand("data.payment.payment_intent") + + var hostedURL string + sc.V1InvoicePayments.List(ctx, listParams)(func(ip *stripe.InvoicePayment, err error) bool { + if err != nil { + logging.Warn().Err(err).Str("invoiceID", sub.LatestInvoice.ID).Msg("failed to list invoice payments for bank verification banner") + return false + } + if ip == nil || ip.Payment == nil || ip.Payment.PaymentIntent == nil { + return true + } + if url := paymentIntentHostedVerificationURL(ip.Payment.PaymentIntent); url != "" { + hostedURL = url + return false + } + return true + }) + + return hostedURL +} + +func paymentIntentHostedVerificationURL(pi *stripe.PaymentIntent) string { + if pi == nil || pi.NextAction == nil || pi.NextAction.VerifyWithMicrodeposits == nil { + return "" + } + hostedURL := pi.NextAction.VerifyWithMicrodeposits.HostedVerificationURL + if hostedURL == "" { + return "" + } + return appendReturnURLParam(hostedURL, bankVerificationReturnURL()) +} + +func appendReturnURLParam(rawURL, returnURL string) string { + if rawURL == "" || returnURL == "" { + return rawURL + } + u, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + q := u.Query() + q.Set("return_url", returnURL) + u.RawQuery = q.Encode() + return u.String() +} + +func bankVerificationReturnURL() string { + const verificationCompleteParam = "bank_verified" + const verificationCompleteValue = "1" + + base := hmnurl.BuildHSFMembership() + u, err := url.Parse(base) + if err != nil { + return base + } + q := u.Query() + q.Set(verificationCompleteParam, verificationCompleteValue) + u.RawQuery = q.Encode() + return u.String() +} diff --git a/src/website/website.go b/src/website/website.go index af740a56..d2c4ba2b 100644 --- a/src/website/website.go +++ b/src/website/website.go @@ -56,6 +56,7 @@ var WebsiteCommand = &cobra.Command{ buildcss.RunServer(), email.MonitorBounces(conn), NagUsersToCreateJamProjectsJob(conn), + ExpireSubscriptionGracePeriodsJob(conn), } // Create HTTP server