From 119e2d8aa272aefa27f6a45bd7c11fa2227d6f99 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 24 Apr 2026 01:50:08 -0400 Subject: [PATCH] feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) (#232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(billing): payment-failed email endpoint + template (TASK-712 1 of 2) Pairs with pad-cloud's invoice.payment_failed webhook handler (shipping next) to give paying users a chance to update their card before dunning exhausts and the subscription cancels. pad owns the Maileroo integration and the user→email mapping; the sidecar forwards the invoice metadata here. Changes: - email.Sender.SendPaymentFailed — new template (HTML + plain). Subject "Your Pad payment couldn't be processed"; body names the amount + next retry date when provided, falls back to generic copy when Stripe omits them, and CTAs to the billing portal so the user can update their card. Transactional (no unsubscribe link) — users who want the emails to stop either fix their card or cancel the subscription. - POST /api/v1/admin/payment-failed — new cloud-secret-gated endpoint (handlers_cloud.go). Accepts stripe_customer_id + optional pre- formatted amount_display + next_retry_display. Looks up the user, sends the email, logs a payment_failed_email_sent audit entry. Returns 200 + email_sent=false with a reason string for every non-error skip (unknown customer, no email on file, Maileroo not configured) so the sidecar never rolls back the Stripe webhook over an email failure. Returns 200 + email_sent=false + reason=send_failed when Maileroo itself errors — still no rollback. - Registered the path in cloudAdminPaths, the server router, and the CloudAdmin rate limiter so the sidecar's calls share the same rate bucket as /plan + /stripe-customer-id. - ActionPaymentFailedEmailSent audit constant for the new entry. - Three focused tests: cus_ prefix validation, unknown-customer 200, and email-not-configured 200. Added an entry to the cloud-mode gate table-driven test to confirm /admin/payment-failed also 404s when cloud mode is off. Parent: PLAN-645 (Pad Cloud Beta Readiness). TASK-712 bullet 3, pad side. pad-cloud's handlePaymentFailed wiring ships in a sibling PR. * fix(billing): audit every outcome; target user ID; add send-path tests (Codex round 1) Addresses PR #232 round 1 findings: MEDIUM — payment-failed handler only wrote an audit row on the actual send attempt, so no_customer / no_email_address / email_not_configured skip paths left no durable trail. Consolidated the audit + response into a single auditAndRespond closure called from every outcome branch, so operators can always reconstruct whether (and why) a customer was notified during dunning reconciliation. MEDIUM — audit UserID was set to actorID, which is empty for sidecar calls. /audit-log?user= would never surface these events. Now set UserID to targetUser.ID whenever we have one; the no_customer branch still writes a row but with empty UserID (filtered only by action + stripe_customer_id metadata). Moved actor identity into an actor_is_admin metadata field instead. LOW — test coverage was thin: no assertion on the most important contract ("return 200 with reason=send_failed and still record the attempt"), no test of the happy send path, no audit-log assertions. Added email.Sender.SetEndpoint (exported, test-only — comment says so) so tests can point the Sender at a mock Maileroo server, plus three new tests: - TestPaymentFailed_HappyPath_SendsAndAudits - TestPaymentFailed_MailerooError_Returns200_SendFailed_AndAudits - TestPaymentFailed_UnknownCustomer_AuditsWithoutUserID The first two verify audit metadata per outcome; the third proves unknown-customer cases still leave a findable audit row. Thread-safety fix as a side-effect: Send/SendAs were reading s.endpoint outside the sender's RWMutex — fine before the mutable SetEndpoint existed, now a data race. Pulled the endpoint read into the same RLock scope as fromAddr/fromName. * fix: capture admin actor ID + audit-log formatter for payment_failed (Codex round 2) Addresses PR #232 round 2 findings: MEDIUM — auditAndRespond recorded actor_is_admin=true/false but not which admin. For manual operator-triggered calls, that meant the audit trail could not answer "who sent the dunning email?" when multiple admins touched the endpoint. Added admin_actor_id to the metadata whenever the authenticated caller has role=admin. Sidecar calls with no authenticated user still have no admin_actor_id, which correctly distinguishes them from manual admin operations. LOW — web/src/routes/console/admin/audit-log/+page.svelte falls back to "first 3 metadata keys" when no formatter exists for an action, which could hide the important reason/sent fields. Added a dedicated case for payment_failed_email_sent that renders either "sent (cus_...)" or "skipped: (cus_...)" depending on the outcome, matching the terse display style of the other switch cases. * fix(audit-log): distinguish send_failed from skip; surface admin actor (Codex round 3) Addresses PR #232 round 3 LOWs: - The formatter lumped every sent=false outcome under 'skipped', which conflates a genuine Maileroo delivery failure with a pre-send skip. Now: sent → 'sent (...)'; send_failed → 'send failed (...)'; other reasons → 'skipped () (...)'. - admin_actor_id was recorded in metadata but invisible in the UI: the User column shows the target user via a.user_id. Appended 'by admin:' to the formatted string whenever admin_actor_id is present, so manual operator calls are attributable at a glance. Sidecar calls have no admin_actor_id and render without the suffix. * fix(audit-log): register payment_failed_email_sent in action filter dropdown (Codex round 4) The backend emits payment_failed_email_sent and the custom formatter knows how to render it, but the audit-log page's ACTION_TYPES / ACTION_LABELS registry omitted the action, so admins couldn't filter for these events from the dropdown — undercutting the dunning reconciliation workflow this PR is adding. Added 'payment_failed_email_sent' to the ACTION_TYPES list and 'Payment Failed Email' to ACTION_LABELS. --- internal/email/sender.go | 15 +- internal/email/templates.go | 90 +++++ internal/models/activity.go | 5 + internal/server/cloud_admin_gate_test.go | 312 ++++++++++++++++++ internal/server/handlers_cloud.go | 146 ++++++++ internal/server/middleware_ratelimit.go | 2 +- internal/server/server.go | 1 + .../console/admin/audit-log/+page.svelte | 20 +- 8 files changed, 586 insertions(+), 5 deletions(-) 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(` + + + +
+ Pad +
+

+ Hi %s, +

+

+ We tried to charge your card for your Pad Pro subscription but the payment didn't go through. +

+ %s + %s +

+ + Update payment method + +

+

+ 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= finds the event; when the + // customer is unknown, the row still exists, just unfiltered by user. + auditAndRespond := func(targetUserID, reason string, sent bool) { + meta := map[string]string{ + "stripe_customer_id": input.StripeCustomerID, + "amount_display": input.AmountDisplay, + "next_retry_display": input.NextRetryDisplay, + "reason": reason, + "sent": boolToString(sent), + } + // When an authenticated admin hits this endpoint manually (instead + // of the sidecar with cloud_secret), record which admin did it. + // logAuditEventForUser only carries one UserID field, which we use + // for the TARGET user so /audit-log?user= surfaces the event — + // the admin's own ID has to live in metadata. Sidecar calls have + // no authenticated user, so admin_actor_id is absent for those. + if isAdmin && user != nil { + meta["admin_actor_id"] = user.ID + } + s.logAuditEventForUser(models.ActionPaymentFailedEmailSent, r, targetUserID, auditMeta(meta)) + writeJSON(w, http.StatusOK, map[string]any{ + "stripe_customer_id": input.StripeCustomerID, + "email_sent": sent, + "reason": reason, + }) + } + + if targetUser == nil { + slog.Info("payment-failed: no user for customer", + "customer_id", input.StripeCustomerID) + auditAndRespond("", "no_customer", false) + return + } + if targetUser.Email == "" { + slog.Info("payment-failed: user has no email on file", + "user_id", targetUser.ID) + auditAndRespond(targetUser.ID, "no_email_address", false) + return + } + + // 4. Verify email provider is wired up. Same pattern as password reset + // (handlers_auth.go) — no panic if the operator hasn't configured + // Maileroo, just log and skip. + if s.email == nil || s.baseURL == "" { + slog.Warn("payment-failed: email provider not configured; skipping send", + "user_id", targetUser.ID) + auditAndRespond(targetUser.ID, "email_not_configured", false) + return + } + + billingPortalURL := strings.TrimRight(s.baseURL, "/") + "/billing/portal" + sendErr := s.email.SendPaymentFailed(r.Context(), + targetUser.Email, targetUser.Name, + input.AmountDisplay, input.NextRetryDisplay, billingPortalURL) + + if sendErr != nil { + slog.Error("payment-failed: email send failed", + "user_id", targetUser.ID, "error", sendErr) + auditAndRespond(targetUser.ID, "send_failed", false) + return + } + + slog.Info("payment-failed email sent", + "user_id", targetUser.ID, "customer_id", input.StripeCustomerID) + auditAndRespond(targetUser.ID, "sent", true) +} + // boolToString converts a Go bool to the string representation used in // audit metadata. Kept local to this file because audit metadata is // string-typed (JSON object of string→string), so we need the literal diff --git a/internal/server/middleware_ratelimit.go b/internal/server/middleware_ratelimit.go index d3f52173..57938a41 100644 --- a/internal/server/middleware_ratelimit.go +++ b/internal/server/middleware_ratelimit.go @@ -236,7 +236,7 @@ func (s *Server) RateLimit(next http.Handler) http.Handler { // Cloud admin endpoints (sidecar → pad): plan changes, Stripe mapping, user lookup if strings.HasPrefix(path, "/api/v1/admin/") { switch path { - case "/api/v1/admin/plan", "/api/v1/admin/stripe-customer-id", "/api/v1/admin/user-by-customer", "/api/v1/admin/stripe-event-processed", "/api/v1/admin/stripe-event-unmark": + case "/api/v1/admin/plan", "/api/v1/admin/stripe-customer-id", "/api/v1/admin/user-by-customer", "/api/v1/admin/stripe-event-processed", "/api/v1/admin/stripe-event-unmark", "/api/v1/admin/payment-failed": l := s.rateLimiters.CloudAdmin.getLimiter(ip) if !l.Allow() { slog.Warn("rate limited", "ip", ip, "path", path, "limiter", "cloud_admin") diff --git a/internal/server/server.go b/internal/server/server.go index 32afbabd..9d29d4d3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -445,6 +445,7 @@ func (s *Server) setupRouter() { r.Get("/user-by-customer", s.handleGetUserByCustomerID) // Cloud: sidecar looks up user by Stripe customer ID r.Post("/stripe-event-processed", s.handleStripeEventProcessed) // Cloud: sidecar webhook idempotency (TASK-696) r.Post("/stripe-event-unmark", s.handleStripeEventUnmark) // Cloud: sidecar handler-failure rollback (TASK-736) + r.Post("/payment-failed", s.handlePaymentFailed) // Cloud: sidecar forwards invoice.payment_failed to trigger email (TASK-712) }) // User management diff --git a/web/src/routes/console/admin/audit-log/+page.svelte b/web/src/routes/console/admin/audit-log/+page.svelte index 40fd55fd..4a95fbe6 100644 --- a/web/src/routes/console/admin/audit-log/+page.svelte +++ b/web/src/routes/console/admin/audit-log/+page.svelte @@ -23,7 +23,8 @@ 'token_rotated', 'totp_enabled', 'totp_disabled', 'member_invited', 'member_removed', 'role_changed', 'settings_changed', 'oauth_login', 'oauth_login_failed', 'plan_changed', 'password_reset_by_admin', - 'user_disabled', 'user_enabled', 'account_deleted' + 'user_disabled', 'user_enabled', 'account_deleted', + 'payment_failed_email_sent' ]; const ACTION_LABELS: Record = { @@ -49,7 +50,8 @@ password_reset_by_admin: 'Password Reset (Admin)', user_disabled: 'User Disabled', user_enabled: 'User Enabled', - account_deleted: 'Account Deleted' + account_deleted: 'Account Deleted', + payment_failed_email_sent: 'Payment Failed Email' }; const LIMIT = 50; @@ -129,6 +131,20 @@ case 'register': if (data.email) return data.email; break; + case 'payment_failed_email_sent': { + // Differentiate operationally distinct outcomes: a genuine delivery + // failure (Maileroo 5xx) should not read the same as a pre-send + // skip (unknown customer, no email on file, provider not wired up). + // Surface admin_actor_id when present so manual operator calls + // show which admin triggered the send; sidecar calls omit that + // field and the User column's target user tells the story. + const cus = data.stripe_customer_id ? ` (${data.stripe_customer_id})` : ''; + const by = data.admin_actor_id ? ` by admin:${data.admin_actor_id}` : ''; + if (data.sent === 'true') return `sent${cus}${by}`; + if (data.reason === 'send_failed') return `send failed${cus}${by}`; + if (data.reason) return `skipped (${data.reason})${cus}${by}`; + break; + } default: break; }