diff --git a/internal/email/sender.go b/internal/email/sender.go index 55b0d0e8..fda8b624 100644 --- a/internal/email/sender.go +++ b/internal/email/sender.go @@ -61,6 +61,15 @@ func (s *Sender) Configure(apiKey, fromAddr, fromName, baseURL string) { } } +// SetEndpoint overrides the Maileroo API endpoint. Intended for tests that +// stand up an httptest server mimicking Maileroo's v2 API — production +// callers should leave the default in place. Thread-safe. +func (s *Sender) SetEndpoint(url string) { + s.mu.Lock() + defer s.mu.Unlock() + s.endpoint = url +} + // BaseURL returns the configured base URL. func (s *Sender) BaseURL() string { s.mu.RLock() @@ -94,8 +103,9 @@ func (s *Sender) Send(ctx context.Context, to, toName, subject, html, plain stri s.mu.RLock() fromAddr := s.fromAddr fromName := s.fromName + endpoint := s.endpoint s.mu.RUnlock() - return s.sendWith(ctx, s.endpoint, fromAddr, fromName, to, toName, subject, html, plain) + return s.sendWith(ctx, endpoint, fromAddr, fromName, to, toName, subject, html, plain) } // SendAs sends an email with a custom from name (address stays the same @@ -103,8 +113,9 @@ func (s *Sender) Send(ctx context.Context, to, toName, subject, html, plain stri func (s *Sender) SendAs(ctx context.Context, fromName, to, toName, subject, html, plain string) error { s.mu.RLock() fromAddr := s.fromAddr + endpoint := s.endpoint s.mu.RUnlock() - return s.sendWith(ctx, s.endpoint, fromAddr, fromName, to, toName, subject, html, plain) + return s.sendWith(ctx, endpoint, fromAddr, fromName, to, toName, subject, html, plain) } // sendWith is the internal send implementation. diff --git a/internal/email/templates.go b/internal/email/templates.go index 3a625047..fd9c589f 100644 --- a/internal/email/templates.go +++ b/internal/email/templates.go @@ -163,6 +163,96 @@ This link expires in 1 hour. If you didn't request a password reset, you can saf return s.Send(ctx, to, name, subject, htmlBody, plainBody) } +// SendPaymentFailed notifies a user that a Stripe invoice attempt failed. +// Called by the sidecar (via POST /api/v1/admin/payment-failed) after it +// handles an invoice.payment_failed webhook. The email links to the +// billing portal so the user can update their card before Stripe's next +// dunning attempt. amountDisplay is a pre-formatted human-readable +// string like "$10.00" or empty to omit the amount line; nextRetryDisplay +// is the same for the retry date ("April 30, 2026" or empty). +func (s *Sender) SendPaymentFailed(ctx context.Context, to, name, amountDisplay, nextRetryDisplay, billingPortalURL string) error { + subject := "Your Pad payment couldn't be processed" + + // Build optional lines conditionally so we don't ship empty paragraphs + // when the webhook payload lacks amount or retry info (Stripe normally + // includes both, but we avoid assuming it). + amountLineHTML := "" + amountLinePlain := "" + if amountDisplay != "" { + amountLineHTML = fmt.Sprintf( + `
Amount: %s
`, + html.EscapeString(amountDisplay), + ) + amountLinePlain = fmt.Sprintf("Amount: %s\n", amountDisplay) + } + + retryLineHTML := "" + retryLinePlain := "" + if nextRetryDisplay != "" { + retryLineHTML = fmt.Sprintf( + `Stripe will retry on %s. To avoid an interruption, update your card before then.
`, + html.EscapeString(nextRetryDisplay), + ) + retryLinePlain = fmt.Sprintf("Stripe will retry on %s. To avoid an interruption, update your card before then.\n\n", nextRetryDisplay) + } else { + retryLineHTML = `Stripe will retry automatically over the next few days. To avoid an interruption, update your card before then.
` + retryLinePlain = "Stripe will retry automatically over the next few days. To avoid an interruption, update your card before then.\n\n" + } + + htmlBody := fmt.Sprintf(` + + + ++ Hi %s, +
++ We tried to charge your card for your Pad Pro subscription but the payment didn't go through. +
+ %s + %s + ++ If you meant to cancel, you can ignore this email — the subscription will cancel automatically after Stripe's retries are exhausted. +
++ You received this because your Pad account has a Pro subscription with a failed payment. Replies go to support@getpad.dev. +
+ +`, + html.EscapeString(name), + amountLineHTML, + retryLineHTML, + html.EscapeString(billingPortalURL), + ) + + plainBody := fmt.Sprintf(`Hi %s, + +We tried to charge your card for your Pad Pro subscription but the payment didn't go through. + +%s%sUpdate your payment method: %s + +If you meant to cancel, you can ignore this email — the subscription will cancel automatically after Stripe's retries are exhausted. + +-- +You received this because your Pad account has a Pro subscription with a failed payment. Replies go to support@getpad.dev.`, + name, + amountLinePlain, + retryLinePlain, + billingPortalURL, + ) + + return s.Send(ctx, to, name, subject, htmlBody, plainBody) +} + // SendTest sends a test email to verify the email configuration. func (s *Sender) SendTest(ctx context.Context, to string) error { subject := "Pad — Test Email" diff --git a/internal/models/activity.go b/internal/models/activity.go index eaf45224..be3bdf51 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -45,6 +45,11 @@ const ( // audit trail is required — a compromised cloud_secret could otherwise // spam unmarks invisible to the admin /audit-log UI. ActionStripeEventUnmarked = "stripe_event_unmarked" + // ActionPaymentFailedEmailSent is logged when the sidecar triggers the + // /admin/payment-failed endpoint and pad dispatches a failed-payment + // notification to the user. Audit trail exists so operators can prove + // a customer was notified before a dunning-related plan change. + ActionPaymentFailedEmailSent = "payment_failed_email_sent" ) type Activity struct { diff --git a/internal/server/cloud_admin_gate_test.go b/internal/server/cloud_admin_gate_test.go index 6656efdc..63f98729 100644 --- a/internal/server/cloud_admin_gate_test.go +++ b/internal/server/cloud_admin_gate_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/xarmian/pad/internal/email" "github.com/xarmian/pad/internal/models" ) @@ -51,6 +52,7 @@ func TestCloudAdminGate_SelfHost_Returns404(t *testing.T) { {"GET /admin/user-by-customer with header", "GET", "/api/v1/admin/user-by-customer?customer_id=cus_x", nil}, {"POST /admin/stripe-event-processed with header", "POST", "/api/v1/admin/stripe-event-processed", map[string]string{"cloud_secret": "x", "event_id": "evt_x"}}, {"POST /admin/stripe-event-unmark with header", "POST", "/api/v1/admin/stripe-event-unmark", map[string]string{"cloud_secret": "x", "event_id": "evt_x", "processed_at": "2025-01-01T00:00:00Z"}}, + {"POST /admin/payment-failed with header", "POST", "/api/v1/admin/payment-failed", map[string]string{"cloud_secret": "x", "stripe_customer_id": "cus_x"}}, } for _, tt := range tests { @@ -609,6 +611,316 @@ func TestStripeEventProcessed_ValidatesEventIDPrefix(t *testing.T) { } } +// TestPaymentFailed_ValidatesCustomerIDPrefix rejects stripe_customer_id +// values that don't start with 'cus_'. Same shape as the evt_ prefix +// check on stripe-event-processed. +func TestPaymentFailed_ValidatesCustomerIDPrefix(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "admin@example.com", "Admin") + srv.SetCloudMode("shh-its-a-secret") + + tests := []struct { + name string + customerID string + }{ + {"empty customer_id", ""}, + {"missing cus_ prefix", "sub_12345"}, + {"wrong prefix evt_", "evt_12345"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": tt.customerID, + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400, got %d: %s", tt.name, rr.Code, rr.Body.String()) + } + }) + } +} + +// TestPaymentFailed_UnknownCustomer_Returns200_NoEmail verifies that when +// the sidecar forwards an invoice.payment_failed for a customer pad does +// not recognise, pad returns 200 with email_sent=false and reason= +// "no_customer" — the sidecar must NOT treat this as a 5xx that would +// trigger a webhook retry. +func TestPaymentFailed_UnknownCustomer_Returns200_NoEmail(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "admin@example.com", "Admin") + srv.SetCloudMode("shh-its-a-secret") + + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": "cus_nonexistent_abc", + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 for unknown customer, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if sent, _ := resp["email_sent"].(bool); sent { + t.Errorf("email_sent=true for unknown customer; want false") + } + if got, _ := resp["reason"].(string); got != "no_customer" { + t.Errorf("reason=%q for unknown customer; want no_customer", got) + } +} + +// TestPaymentFailed_EmailNotConfigured_Returns200_NoEmail verifies the +// handler degrades gracefully when Maileroo is not wired up — audit log +// captures the skip, response is 200 so the sidecar doesn't retry. +func TestPaymentFailed_EmailNotConfigured_Returns200_NoEmail(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "admin@example.com", "Admin") + srv.SetCloudMode("shh-its-a-secret") + // Look up the bootstrapped admin and assign a Stripe customer ID so + // the handler's lookup step resolves, then falls through to the + // "email not configured" branch. + adminUser, err := srv.store.GetUserByEmail("admin@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if err := srv.store.SetUserStripeCustomerID(adminUser.ID, "cus_test_nomail"); err != nil { + t.Fatalf("SetUserStripeCustomerID: %v", err) + } + // Note: testServer does NOT call email.Configure, so s.email has no + // credentials and s.baseURL is empty — both fail the configured check. + + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": "cus_test_nomail", + "amount_display": "$10.00", + "next_retry_display": "April 30, 2026", + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200 when email not configured, got %d: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if sent, _ := resp["email_sent"].(bool); sent { + t.Errorf("email_sent=true with no Maileroo config; want false") + } + if got, _ := resp["reason"].(string); got != "email_not_configured" { + t.Errorf("reason=%q with no Maileroo config; want email_not_configured", got) + } +} + +// paymentFailedAuditForUser walks the audit log for a given user and +// returns every payment_failed_email_sent row's parsed metadata. Used +// by the send-path tests to assert reason + sent fields land correctly +// for each branch. +func paymentFailedAuditForUser(t *testing.T, srv *Server, userID string) []map[string]string { + t.Helper() + events, err := srv.store.ListAuditLog(models.AuditLogParams{ + Action: models.ActionPaymentFailedEmailSent, + Limit: 100, + }) + if err != nil { + t.Fatalf("ListAuditLog: %v", err) + } + var out []map[string]string + for _, ev := range events { + if userID != "" && ev.UserID != userID { + continue + } + var meta map[string]string + _ = json.Unmarshal([]byte(ev.Metadata), &meta) + out = append(out, meta) + } + return out +} + +// mockMailerooEndpoint stands up an httptest server that speaks enough +// of Maileroo's v2 JSON API to satisfy email.Sender. Returning 500 +// exercises the send_failed branch; returning 200 + success:true +// exercises the sent branch. +func mockMailerooEndpoint(t *testing.T, status int, success bool) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + body := `{"success":true,"message":"sent"}` + if !success { + body = `{"success":false,"message":"mock failure"}` + } + _, _ = io.WriteString(w, body) + })) + t.Cleanup(ts.Close) + return ts +} + +// configureEmailForTest attaches an email.Sender wired to the given +// mock endpoint plus a base URL so s.baseURL != "" (the configured +// check looks at both). +func configureEmailForTest(srv *Server, endpoint, baseURL string) { + sender := email.NewSender("test-key", "noreply@test.getpad.dev", "Pad Test", baseURL) + sender.SetEndpoint(endpoint) + srv.SetEmailSender(sender, "test-key") + srv.SetBaseURL(baseURL) +} + +// TestPaymentFailed_HappyPath_SendsAndAudits covers the end-to-end +// success path: Maileroo returns 200/success, pad records an audit row +// with reason=sent and sent=true attached to the target user ID. +func TestPaymentFailed_HappyPath_SendsAndAudits(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "paying@example.com", "Paying User") + srv.SetCloudMode("shh-its-a-secret") + + payingUser, err := srv.store.GetUserByEmail("paying@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if err := srv.store.SetUserStripeCustomerID(payingUser.ID, "cus_happy_path"); err != nil { + t.Fatalf("SetUserStripeCustomerID: %v", err) + } + + mock := mockMailerooEndpoint(t, http.StatusOK, true) + configureEmailForTest(srv, mock.URL, "https://app.test.getpad.dev") + + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": "cus_happy_path", + "amount_display": "10.00 USD", + "next_retry_display": "April 30, 2026", + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("happy path: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if sent, _ := resp["email_sent"].(bool); !sent { + t.Errorf("email_sent=false on happy path; want true: %v", resp) + } + if got, _ := resp["reason"].(string); got != "sent" { + t.Errorf("reason=%q on happy path; want sent", got) + } + + audit := paymentFailedAuditForUser(t, srv, payingUser.ID) + if len(audit) != 1 { + t.Fatalf("expected 1 audit row for happy path, got %d", len(audit)) + } + if audit[0]["reason"] != "sent" || audit[0]["sent"] != "true" { + t.Errorf("audit metadata wrong: %v", audit[0]) + } + if audit[0]["stripe_customer_id"] != "cus_happy_path" { + t.Errorf("audit missing stripe_customer_id: %v", audit[0]) + } +} + +// TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits covers +// the send_failed branch: Maileroo 500 → response stays 200 + reason= +// send_failed so the sidecar does not retry the Stripe webhook, and an +// audit row with reason=send_failed + sent=false is written against +// the target user ID. +func TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "unlucky@example.com", "Unlucky User") + srv.SetCloudMode("shh-its-a-secret") + + unluckyUser, err := srv.store.GetUserByEmail("unlucky@example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + if err := srv.store.SetUserStripeCustomerID(unluckyUser.ID, "cus_send_fail"); err != nil { + t.Fatalf("SetUserStripeCustomerID: %v", err) + } + + mock := mockMailerooEndpoint(t, http.StatusInternalServerError, false) + configureEmailForTest(srv, mock.URL, "https://app.test.getpad.dev") + + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": "cus_send_fail", + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("send_failed: expected 200 (so sidecar does not retry webhook), got %d: %s", + rr.Code, rr.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if sent, _ := resp["email_sent"].(bool); sent { + t.Errorf("email_sent=true on send failure; want false") + } + if got, _ := resp["reason"].(string); got != "send_failed" { + t.Errorf("reason=%q on send failure; want send_failed", got) + } + + audit := paymentFailedAuditForUser(t, srv, unluckyUser.ID) + if len(audit) != 1 { + t.Fatalf("expected 1 audit row for send_failed, got %d", len(audit)) + } + if audit[0]["reason"] != "send_failed" || audit[0]["sent"] != "false" { + t.Errorf("audit metadata wrong: %v", audit[0]) + } +} + +// TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID confirms that +// the no_customer skip path also writes an audit row — just with no +// user filter, since we don't have a user to attribute it to. This is +// important for dunning reconciliation: an operator can still find the +// event via action + stripe_customer_id metadata. +func TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID(t *testing.T) { + srv := testServer(t) + bootstrapFirstUser(t, srv, "admin@example.com", "Admin") + srv.SetCloudMode("shh-its-a-secret") + + req := cloudAdminReq(t, "POST", "/api/v1/admin/payment-failed", map[string]string{ + "stripe_customer_id": "cus_totally_unknown", + "cloud_secret": "shh-its-a-secret", + }, map[string]string{"X-Cloud-Secret": "shh-its-a-secret"}) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + audit := paymentFailedAuditForUser(t, srv, "") + // Filter to rows whose metadata customer ID matches, since the test + // DB might contain other payment_failed rows from earlier tests that + // share the same DB instance if testServer is ever pooled. + var found map[string]string + for _, row := range audit { + if row["stripe_customer_id"] == "cus_totally_unknown" { + found = row + break + } + } + if found == nil { + t.Fatalf("expected audit row for cus_totally_unknown, got %d rows total", len(audit)) + } + if found["reason"] != "no_customer" || found["sent"] != "false" { + t.Errorf("audit metadata wrong: %v", found) + } +} + // TestCloudAdminGate_HeaderSecret_StillAuthenticates confirms the header // form (the only supported sidecar auth after TASK-656) still works on // the GET endpoint that previously used query-param. diff --git a/internal/server/handlers_cloud.go b/internal/server/handlers_cloud.go index 894df8b6..cffe2ea9 100644 --- a/internal/server/handlers_cloud.go +++ b/internal/server/handlers_cloud.go @@ -64,6 +64,7 @@ var cloudAdminPaths = map[string]struct{}{ "/api/v1/admin/user-by-customer": {}, "/api/v1/admin/stripe-event-processed": {}, "/api/v1/admin/stripe-event-unmark": {}, + "/api/v1/admin/payment-failed": {}, } // isCloudAdminPath returns true if the request targets one of the three @@ -835,6 +836,151 @@ func (s *Server) handleStripeEventUnmark(w http.ResponseWriter, r *http.Request) }) } +// handlePaymentFailed handles POST /api/v1/admin/payment-failed. +// Called by the pad-cloud sidecar when it receives an invoice.payment_failed +// webhook from Stripe. Pad owns the user→email mapping and the Maileroo +// integration, so the sidecar forwards the invoice metadata here and pad +// does the actual notification. +// +// Request body: +// +// { +// "stripe_customer_id": "cus_...", +// "amount_display": "$10.00", // optional, pre-formatted +// "next_retry_display": "April 30, 2026", // optional, pre-formatted +// "cloud_secret": "..." +// } +// +// Response: +// +// {"stripe_customer_id": "cus_...", "email_sent": true|false, "reason": "..."} +// +// The handler returns 200 even when no email is sent (e.g. unknown +// customer, email provider not configured, user has no stored email) — +// the sidecar should treat those as non-fatal so Stripe does not retry +// the webhook. Reasons: +// - "sent" — email dispatched +// - "no_customer" — no user matches stripe_customer_id +// - "no_email_address" — user exists but has no email on file +// - "email_not_configured" — no Maileroo key / base URL +// - "send_failed" — Maileroo returned an error (details in logs) +// +// The email is transactional (dunning) and has no unsubscribe link by +// design; users who want to stop receiving dunning mail can update +// their card or cancel the subscription. +func (s *Server) handlePaymentFailed(w http.ResponseWriter, r *http.Request) { + var input struct { + StripeCustomerID string `json:"stripe_customer_id"` + AmountDisplay string `json:"amount_display"` + NextRetryDisplay string `json:"next_retry_display"` + CloudSecret string `json:"cloud_secret"` + } + if err := decodeJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") + return + } + + // 1. Validate cloud secret (or admin auth) + user := currentUser(r) + isAdmin := user != nil && user.Role == "admin" + if !isAdmin { + if !s.validateCloudSecret(input.CloudSecret, w) { + return + } + } + + // 2. Validate input + if input.StripeCustomerID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "stripe_customer_id is required") + return + } + if !strings.HasPrefix(input.StripeCustomerID, "cus_") { + writeError(w, http.StatusBadRequest, "bad_request", "stripe_customer_id must start with 'cus_'") + return + } + + // 3. Look up user. Missing customer is a non-error — Stripe could be + // firing a webhook for an account that was deleted on our side; + // returning 200 tells the sidecar not to rollback/retry. + targetUser, err := s.store.GetUserByStripeCustomerID(input.StripeCustomerID) + if err != nil { + writeInternalError(w, err) + return + } + + // auditAndRespond records a single audit row for every outcome of the + // payment-failed flow — including the skip paths before we even try to + // send — and writes the JSON response. Keeping the audit in one place + // guarantees that no-customer / no-email / email-not-configured / + // send-failed all leave a durable trail that /audit-log can surface + // during dunning reconciliation. UserID attaches to the target user + // when we have one so /audit-log?user=