mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
cff306e05a
* 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
37 lines
1.0 KiB
Go
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)
|
|
}
|
|
}
|