Files
pad/internal/store/task3601_payer_test.go
T
xarmian cff306e05a feat: a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601) (#2080)
* feat(billing): a plan-limit refusal says who can lift it (TASK-3601, server)

LimitResult carries the payer: the workspace owner for a workspace limit,
the user for a user limit (never serialized). planLimitDetails adds
payer_is_you, and payer_name when the payer is someone else (the owner's
display name, which members already see). Additive: a result with no
payer keeps today's shape.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* feat(web): a plan-limit refusal is a persistent prompt that names the limit and who can lift it (TASK-3601)

A refusal was a 6-second toast with a generic billing link, offered to
everyone alike. Now:
- planLimitPromptModel decides what the viewer can do. The payer, where
  Pro is sold, gets Upgrade. Anyone else is asked to get the owner to
  upgrade ("Ask Dana, the workspace owner, to upgrade.") and gets a note
  to copy. With billing off, or no commerce here (self-host, the mobile
  apps), it shows the statement alone.
- One store (planLimitPrompt) holds the prompt until it is dismissed.
  showPlanLimitToast keeps its name and raises it, so the 46 callers are
  unchanged. Inline hosts show it where the action was blocked: invite,
  create/import workspace (import refusals used to be a generic "Import
  failed" toast), sidebar quick-add, the mobile capture sheet, and the
  list's quick-create. Everything else uses the global host in the root
  layout. It is cleared on an identity change.
- Upgrade remembers the blocked flow in sessionStorage (upgradeResume:
  same-origin path, 1h, one-shot). After checkout, /console/billing
  offers "Continue: <action>" once the upgrade is confirmed. The action
  is never re-run for the user. No pad-cloud change.
- The no-commerce guard's allowlist names planLimitPrompt.ts. The
  identity-gate rows for handleInvite, quickCreate and quickCreateInColumn
  are re-reviewed: the change is to the plan-limit call's arguments only,
  still under each catch arm's identityHeld.

Copy as proposed to the lead, who is showing it to Dave.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(web): an unknown payer gets no upgrade offer (TASK-3601 codex r1)

Checkout sells Pro to the current user, so offering it when the refusal
does not say who pays could sell the wrong account a plan that does not
lift the limit. An unknown payer now gets the statement alone. A test
pins that a newer refusal from another flow shows in its own host or the
global one, never nowhere. The no-commerce e2e's refusal mock says
payer_is_you, as the server now does.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ

* fix(web): a plan-limit prompt stays with the workspace it was raised in (TASK-3601 codex r2)

A route component can stay mounted across a workspace switch, so an
invite or item-limit prompt raised in one workspace could show in the
next, naming the first workspace's owner and blocked action. The prompt
records the workspace route (owner/slug) when raised. A host shows it
only there, and drops it once the page has left.

Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ
2026-10-11 18:39:37 -04:00

37 lines
1.0 KiB
Go

package store
import (
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3601: a limit check names its payer: the workspace owner for a
// workspace limit, the user for a user limit.
func TestTask3601_LimitChecksNameThePayer(t *testing.T) {
t.Parallel()
s := testStore(t)
owner := createTestUser(t, s, "payer3601@example.com", "Dana", "s3cret")
if _, err := s.SetUserPlan(owner.ID, PlanWrite{Plan: "free", Source: PlanSourceManual, Force: true}); err != nil {
t.Fatal(err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Payer 3601", OwnerID: owner.ID})
if err != nil {
t.Fatal(err)
}
wl, err := s.CheckLimit(ws.ID, "members_per_workspace")
if err != nil {
t.Fatal(err)
}
if wl.PayerID != owner.ID || wl.PayerName != "Dana" {
t.Errorf("workspace limit payer = %q/%q, want %q/Dana", wl.PayerID, wl.PayerName, owner.ID)
}
ul, err := s.CheckUserLimit(owner.ID, "workspaces")
if err != nil {
t.Fatal(err)
}
if ul.PayerID != owner.ID {
t.Errorf("user limit payer = %q, want %q", ul.PayerID, owner.ID)
}
}