diff --git a/README.md b/README.md index 705275c..9eda180 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,8 @@ gumroad admin users mark-compliant --user-id 2245593582708 --expected-email sell gumroad admin users suspend --user-id 2245593582708 --expected-email seller@example.com --note "Chargeback risk confirmed" gumroad admin users suspend-for-tos-violation --user-id 2245593582708 --expected-email seller@example.com --note "DMCA takedown notice confirmed" gumroad admin products flag-for-tos-violation abc123 --user-id 2245593582708 --expected-email seller@example.com +gumroad admin payouts scheduled create --user-id 2245593582708 --expected-email seller@example.com --processor stripe --payout-date 2026-06-15 +gumroad admin payouts scheduled list --status pending --user-id 2245593582708 gumroad admin purchases lookup --stripe-fingerprint fp_abc --limit 25 gumroad admin users watch --user-id 2245593582708 --expected-email seller@example.com --revenue-threshold 200 --note "Review next buyers" gumroad admin users update-watch --user-id 2245593582708 --expected-email seller@example.com --revenue-threshold 500 diff --git a/internal/cmd/admin/admin.go b/internal/cmd/admin/admin.go index 6c008ec..82d0332 100644 --- a/internal/cmd/admin/admin.go +++ b/internal/cmd/admin/admin.go @@ -32,6 +32,7 @@ func NewAdminCmd() *cobra.Command { gumroad admin users watch --user-id --expected-email --revenue-threshold 200 gumroad admin payouts list --email gumroad admin payouts pause --user-id --expected-email + gumroad admin payouts scheduled create --user-id --processor stripe gumroad admin products list --email gumroad admin products view gumroad admin products flag-for-tos-violation --user-id `, diff --git a/internal/cmd/admin/payouts/payouts.go b/internal/cmd/admin/payouts/payouts.go index a26e693..028b713 100644 --- a/internal/cmd/admin/payouts/payouts.go +++ b/internal/cmd/admin/payouts/payouts.go @@ -12,6 +12,7 @@ func NewPayoutsCmd() *cobra.Command { gumroad admin payouts pause --user-id 2245593582708 --expected-email seller@example.com --reason "Verification pending" gumroad admin payouts resume --user-id 2245593582708 gumroad admin payouts issue --user-id 2245593582708 --through 2026-04-30 --processor stripe --yes + gumroad admin payouts scheduled create --user-id 2245593582708 --processor stripe --yes gumroad admin payouts scheduled list --status flagged`, } diff --git a/internal/cmd/admin/payouts/scheduled.go b/internal/cmd/admin/payouts/scheduled.go index 026a2f9..ca3c203 100644 --- a/internal/cmd/admin/payouts/scheduled.go +++ b/internal/cmd/admin/payouts/scheduled.go @@ -8,11 +8,13 @@ func newScheduledCmd() *cobra.Command { Short: "Inspect and act on ScheduledPayout records", Example: ` gumroad admin payouts scheduled list gumroad admin payouts scheduled list --status flagged + gumroad admin payouts scheduled create --user-id 2245593582708 --processor stripe --yes gumroad admin payouts scheduled execute pay_abc123 --yes gumroad admin payouts scheduled cancel pay_abc123`, } cmd.AddCommand(newScheduledListCmd()) + cmd.AddCommand(newScheduledCreateCmd()) cmd.AddCommand(newScheduledExecuteCmd()) cmd.AddCommand(newScheduledCancelCmd()) diff --git a/internal/cmd/admin/payouts/scheduled_create.go b/internal/cmd/admin/payouts/scheduled_create.go new file mode 100644 index 0000000..31c5938 --- /dev/null +++ b/internal/cmd/admin/payouts/scheduled_create.go @@ -0,0 +1,186 @@ +package payouts + +import ( + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/antiwork/gumroad-cli/internal/adminapi" + "github.com/antiwork/gumroad-cli/internal/admincmd" + "github.com/antiwork/gumroad-cli/internal/cmdutil" + "github.com/antiwork/gumroad-cli/internal/output" + "github.com/spf13/cobra" +) + +type scheduledCreateRequest struct { + UserID string `json:"user_id"` + ExpectedEmail string `json:"expected_email,omitempty"` + Processor string `json:"processor"` + PayoutDate string `json:"payout_date,omitempty"` + Note string `json:"note,omitempty"` +} + +type scheduledCreateResponse struct { + Success bool `json:"success"` + UserID string `json:"user_id"` + Message string `json:"message"` + ScheduledPayout scheduledPayout `json:"scheduled_payout"` +} + +func newScheduledCreateCmd() *cobra.Command { + var ( + targetFlags mutationFlags + processor string + payoutDate string + note string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a delayed scheduled payout for a suspended user", + Long: `Create a ScheduledPayout row for a suspended user with unpaid balance. + +The server defaults --payout-date to 21 days from the current UTC date. Pass a +YYYY-MM-DD date to schedule a specific UTC payout date. --processor is required +and must be stripe or paypal. + +This schedules money movement. --yes is required.`, + Example: ` gumroad admin payouts scheduled create --user-id 2245593582708 --processor stripe --yes + gumroad admin payouts scheduled create --user-id 2245593582708 --expected-email seller@example.com --processor paypal --payout-date 2026-06-15 --note "Appeal window closes before payout." --yes`, + Args: cmdutil.ExactArgs(0), + RunE: func(c *cobra.Command, args []string) error { + opts := cmdutil.OptionsFrom(c) + target, err := resolveMutationTarget(c, targetFlags) + if err != nil { + return err + } + if processor == "" { + return cmdutil.MissingFlagError(c, "--processor") + } + normalizedProcessor := strings.ToLower(strings.TrimSpace(processor)) + if normalizedProcessor != "stripe" && normalizedProcessor != "paypal" { + return cmdutil.UsageErrorf(c, "--processor must be one of: stripe, paypal") + } + if err := cmdutil.RequireDateFlag(c, "payout-date", payoutDate); err != nil { + return err + } + + req := scheduledCreateRequest{ + UserID: target.UserID, + ExpectedEmail: target.ExpectedEmail, + Processor: normalizedProcessor, + PayoutDate: payoutDate, + Note: note, + } + + confirmMsg := "Schedule " + normalizedProcessor + " payout for user_id " + target.UserID + "? This schedules money movement." + if payoutDate != "" { + confirmMsg = "Schedule " + normalizedProcessor + " payout for user_id " + target.UserID + " on " + payoutDate + "? This schedules money movement." + } + ok, err := cmdutil.ConfirmAction(opts, confirmMsg) + if err != nil { + return err + } + if !ok { + return cmdutil.PrintCancelledAction(opts, "schedule payout for user_id "+target.UserID, target.UserID) + } + + path := "scheduled_payouts" + if opts.DryRun { + return cmdutil.PrintDryRunRequest(opts, http.MethodPost, adminapi.AdminPath(path), scheduledCreateDryRunParams(req)) + } + + data, err := admincmd.FetchPostJSON(opts, "Creating scheduled payout...", path, req) + if err != nil { + return err + } + if opts.UsesJSONOutput() { + return cmdutil.PrintJSONResponse(opts, data) + } + + decoded, err := cmdutil.DecodeJSON[scheduledCreateResponse](data) + if err != nil { + return err + } + return renderScheduledCreate(opts, fallbackStr(decoded.UserID, target.UserID), decoded) + }, + } + + addMutationFlags(cmd, &targetFlags) + cmd.Flags().StringVar(&processor, "processor", "", "Payout processor: stripe or paypal (required)") + cmd.Flags().StringVar(&payoutDate, "payout-date", "", "UTC payout date in YYYY-MM-DD (defaults server-side to today + 21 days)") + cmd.Flags().StringVar(¬e, "note", "", "Optional payout note recorded on the user") + + return cmd +} + +func scheduledCreateDryRunParams(req scheduledCreateRequest) url.Values { + params := url.Values{} + params.Set("user_id", req.UserID) + if req.ExpectedEmail != "" { + params.Set("expected_email", req.ExpectedEmail) + } + params.Set("processor", req.Processor) + if req.PayoutDate != "" { + params.Set("payout_date", req.PayoutDate) + } + if req.Note != "" { + params.Set("note", req.Note) + } + return params +} + +func renderScheduledCreate(opts cmdutil.Options, userID string, resp scheduledCreateResponse) error { + headline := fallbackStr(resp.Message, "Scheduled payout created") + payout := resp.ScheduledPayout + + if opts.PlainOutput { + return output.PrintPlain(opts.Out(), [][]string{{ + strconv.FormatBool(resp.Success), + headline, + userID, + payout.ExternalID, + formatScheduledAmount(payout), + payout.Status, + payout.ScheduledAt, + payout.Processor, + }}) + } + + if opts.Quiet { + return nil + } + + style := opts.Style() + if err := output.Writeln(opts.Out(), style.Green(headline)); err != nil { + return err + } + if err := writeUserIDLine(opts.Out(), headline, userID); err != nil { + return err + } + if payout.ExternalID != "" { + if err := output.Writef(opts.Out(), "Payout ID: %s\n", payout.ExternalID); err != nil { + return err + } + } + if payout.AmountCents != 0 { + if err := output.Writef(opts.Out(), "Amount: %s\n", formatScheduledAmount(payout)); err != nil { + return err + } + } + if payout.Status != "" { + if err := output.Writef(opts.Out(), "Status: %s\n", payout.Status); err != nil { + return err + } + } + if payout.ScheduledAt != "" { + if err := output.Writef(opts.Out(), "Scheduled: %s\n", payout.ScheduledAt); err != nil { + return err + } + } + if payout.Processor != "" { + return output.Writef(opts.Out(), "Processor: %s\n", payout.Processor) + } + return nil +} diff --git a/internal/cmd/admin/payouts/scheduled_list.go b/internal/cmd/admin/payouts/scheduled_list.go index 5ebecab..e1aefbb 100644 --- a/internal/cmd/admin/payouts/scheduled_list.go +++ b/internal/cmd/admin/payouts/scheduled_list.go @@ -23,6 +23,7 @@ type scheduledPayout struct { Status string `json:"status"` Action string `json:"action"` ScheduledAt string `json:"scheduled_at"` + Processor string `json:"processor"` ExecutedAt string `json:"executed_at"` CreatedAt string `json:"created_at"` CreatedBy scheduledPayoutCreator `json:"created_by"` diff --git a/internal/cmd/admin/payouts/scheduled_test.go b/internal/cmd/admin/payouts/scheduled_test.go index 1e8ed1e..1a552bc 100644 --- a/internal/cmd/admin/payouts/scheduled_test.go +++ b/internal/cmd/admin/payouts/scheduled_test.go @@ -1,7 +1,9 @@ package payouts import ( + "bytes" "encoding/json" + "io" "net/http" "strings" "testing" @@ -9,6 +11,256 @@ import ( "github.com/antiwork/gumroad-cli/internal/testutil" ) +func TestScheduledCreate_RequiresUserID(t *testing.T) { + cmd := newScheduledCreateCmd() + cmd.SetArgs([]string{"--processor", "stripe"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "missing required flag: --user-id") { + t.Fatalf("expected missing user ID error, got %v", err) + } +} + +func TestScheduledCreate_RequiresProcessor(t *testing.T) { + cmd := newScheduledCreateCmd() + cmd.SetArgs([]string{"--user-id", "2245593582708"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--processor") { + t.Fatalf("expected missing processor error, got %v", err) + } +} + +func TestScheduledCreate_RejectsInvalidProcessor(t *testing.T) { + cmd := newScheduledCreateCmd() + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "ach"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--processor must be one of: stripe, paypal") { + t.Fatalf("expected processor validation error, got %v", err) + } +} + +func TestScheduledCreate_RejectsBadPayoutDate(t *testing.T) { + cmd := newScheduledCreateCmd() + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "stripe", "--payout-date", "06/15/2026"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "YYYY-MM-DD") { + t.Fatalf("expected payout-date validation error, got %v", err) + } +} + +func TestScheduledCreate_RequiresConfirmation(t *testing.T) { + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("must not reach API without confirmation") + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.NoInput(true)) + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "stripe"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--yes") { + t.Fatalf("expected --yes error, got %v", err) + } +} + +func TestScheduledCreate_SendsRequestAndShowsResult(t *testing.T) { + var gotMethod, gotPath, gotQuery, gotAuth string + var body scheduledCreateRequest + + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotAuth = r.Header.Get("Authorization") + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(raw, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + testutil.JSON(t, w, map[string]any{ + "success": true, + "user_id": "2245593582708", + "message": "Scheduled payout created", + "scheduled_payout": map[string]any{ + "external_id": "pay_abc", + "payout_amount_cents": 12345, + "status": "pending", + "action": "payout", + "scheduled_at": "2026-06-15", + "processor": "PAYPAL", + "unpaid_balance_cents": 12345, + }, + }) + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.Yes(true), testutil.Quiet(false)) + cmd.SetArgs([]string{ + "--user-id", "2245593582708", + "--expected-email", "seller@example.com", + "--processor", "PayPal", + "--payout-date", "2026-06-15", + "--note", "Appeal window closes before payout.", + }) + out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) }) + + if gotMethod != "POST" || gotPath != "/internal/admin/scheduled_payouts" { + t.Fatalf("got %s %s, want POST /internal/admin/scheduled_payouts", gotMethod, gotPath) + } + if gotQuery != "" { + t.Fatalf("body fields must not appear in query string, got %q", gotQuery) + } + if gotAuth != "Bearer admin-token" { + t.Fatalf("got auth %q, want Bearer admin-token", gotAuth) + } + if body.UserID != "2245593582708" || body.ExpectedEmail != "seller@example.com" || body.Processor != "paypal" || body.PayoutDate != "2026-06-15" || body.Note != "Appeal window closes before payout." { + t.Fatalf("unexpected request body: %#v", body) + } + for _, want := range []string{ + "Scheduled payout created", + "User ID: 2245593582708", + "Payout ID: pay_abc", + "Amount: 12345 cents", + "Status: pending", + "Scheduled: 2026-06-15", + "Processor: PAYPAL", + } { + if !strings.Contains(out, want) { + t.Errorf("output missing %q: %q", want, out) + } + } +} + +func TestScheduledCreate_DryRunDoesNotContactEndpoint(t *testing.T) { + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + t.Error("dry-run must not POST to scheduled_payouts") + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.DryRun(true), testutil.NoInput(true)) + cmd.SetArgs([]string{ + "--user-id", "2245593582708", + "--expected-email", "seller@example.com", + "--processor", "stripe", + "--payout-date", "2026-06-15", + "--note", "Appeal window closes before payout.", + }) + out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) }) + + for _, want := range []string{ + "POST", + "/internal/admin/scheduled_payouts", + "user_id: 2245593582708", + "expected_email: seller@example.com", + "processor: stripe", + "payout_date: 2026-06-15", + "note: Appeal window closes before payout.", + } { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q: %q", want, out) + } + } +} + +func TestScheduledCreate_JSONPreservesResponse(t *testing.T) { + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + testutil.JSON(t, w, map[string]any{ + "success": true, + "user_id": "2245593582708", + "message": "Scheduled payout created", + "scheduled_payout": map[string]any{ + "external_id": "pay_abc", + "payout_amount_cents": 12345, + "status": "pending", + "processor": "stripe", + }, + }) + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.Yes(true), testutil.JSONOutput()) + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "stripe"}) + out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) }) + + var resp scheduledCreateResponse + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("not valid JSON: %v\n%s", err, out) + } + if !resp.Success || resp.UserID != "2245593582708" || resp.ScheduledPayout.ExternalID != "pay_abc" || resp.ScheduledPayout.Processor != "stripe" { + t.Fatalf("unexpected JSON payload: %s", out) + } +} + +func TestScheduledCreate_PlainOutput(t *testing.T) { + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + testutil.JSON(t, w, map[string]any{ + "success": true, + "user_id": "2245593582708", + "message": "Scheduled payout created", + "scheduled_payout": map[string]any{ + "external_id": "pay_abc", + "payout_amount_cents": 12345, + "status": "pending", + "scheduled_at": "2026-06-15", + "processor": "stripe", + }, + }) + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.Yes(true), testutil.PlainOutput()) + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "stripe"}) + out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) }) + + want := "true\tScheduled payout created\t2245593582708\tpay_abc\t12345 cents\tpending\t2026-06-15\tstripe" + if strings.TrimSpace(out) != want { + t.Fatalf("unexpected plain output: %q", out) + } +} + +func TestScheduledCreate_PlainOutputUsesResponseSuccess(t *testing.T) { + var out bytes.Buffer + opts := testutil.TestOptions(testutil.PlainOutput(), testutil.Stdout(&out)) + + err := renderScheduledCreate(opts, "2245593582708", scheduledCreateResponse{ + Success: false, + Message: "Not created", + ScheduledPayout: scheduledPayout{ + ExternalID: "pay_abc", + AmountCents: 12345, + Status: "pending", + ScheduledAt: "2026-06-15", + Processor: "stripe", + }, + }) + if err != nil { + t.Fatalf("render failed: %v", err) + } + + want := "false\tNot created\t2245593582708\tpay_abc\t12345 cents\tpending\t2026-06-15\tstripe" + if strings.TrimSpace(out.String()) != want { + t.Fatalf("unexpected plain output: %q", out.String()) + } +} + +func TestScheduledCreate_ServerErrorSurfacesMessage(t *testing.T) { + testutil.SetupAdmin(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "message": "User already has a scheduled payout in progress", + }) + }) + + cmd := testutil.Command(newScheduledCreateCmd(), testutil.Yes(true)) + cmd.SetArgs([]string{"--user-id", "2245593582708", "--processor", "stripe"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "User already has a scheduled payout in progress") { + t.Fatalf("expected server message in error, got %v", err) + } +} + func TestScheduledList_Default(t *testing.T) { var gotMethod, gotPath, gotQuery string @@ -416,7 +668,7 @@ func TestScheduledCancel_PlainOutput(t *testing.T) { func TestScheduledCmdWiresChildren(t *testing.T) { cmd := newScheduledCmd() - want := map[string]bool{"list": false, "execute ": false, "cancel ": false} + want := map[string]bool{"list": false, "create": false, "execute ": false, "cancel ": false} for _, sub := range cmd.Commands() { if _, ok := want[sub.Use]; ok { want[sub.Use] = true diff --git a/skills/embed_test.go b/skills/embed_test.go index 9483580..489582d 100644 --- a/skills/embed_test.go +++ b/skills/embed_test.go @@ -124,6 +124,8 @@ func TestSkillMarkdown_ContainsAdminRolloutCommands(t *testing.T) { "`admin users mark-compliant`, `admin users suspend`, `admin users suspend-for-tos-violation` → `.status`, `.message`, `.user_id`", "gumroad admin products flag-for-tos-violation --user-id", "`admin products flag-for-tos-violation` → `.status`, `.message`, `.user_id`, `.product_id`", + "gumroad admin payouts scheduled create --user-id", + "`admin payouts scheduled create` → `.message`, `.user_id`, `.scheduled_payout`", "gumroad admin purchases view --with-clusters", "gumroad admin purchases search --email", "gumroad admin purchases lookup --stripe-fingerprint", diff --git a/skills/gumroad/SKILL.md b/skills/gumroad/SKILL.md index 0439022..765078a 100644 --- a/skills/gumroad/SKILL.md +++ b/skills/gumroad/SKILL.md @@ -73,6 +73,7 @@ Responses are wrapped in `{"success": true, ...}` with resource-specific keys: - `admin users related` → `.related_users[]`, `.truncated`, `.per_signal_limit` - `admin users mark-compliant`, `admin users suspend`, `admin users suspend-for-tos-violation` → `.status`, `.message`, `.user_id` - `admin products flag-for-tos-violation` → `.status`, `.message`, `.user_id`, `.product_id` +- `admin payouts scheduled create` → `.message`, `.user_id`, `.scheduled_payout` - `admin purchases view` → `.purchase` - `admin purchases search` → `.purchases[]`, `.has_more`, `.limit` - `admin purchases lookup` → `.purchases[]` @@ -148,6 +149,8 @@ gumroad admin users mark-compliant --user-id 2245593582708 --expected-email sell gumroad admin users suspend --user-id 2245593582708 --expected-email seller@example.com --note "Chargeback risk confirmed" --yes --json --non-interactive --no-input gumroad admin users suspend-for-tos-violation --user-id 2245593582708 --expected-email seller@example.com --note "DMCA takedown notice confirmed" --yes --json --non-interactive --no-input gumroad admin products flag-for-tos-violation --user-id 2245593582708 --expected-email seller@example.com --yes --json --non-interactive --no-input +gumroad admin payouts scheduled create --user-id 2245593582708 --expected-email seller@example.com --processor stripe --payout-date 2026-06-15 --note "Appeal window closes before payout." --yes --json --non-interactive --no-input +gumroad admin payouts scheduled list --status pending --user-id 2245593582708 --json --non-interactive --no-input # Inspect purchase and product fraud context gumroad admin purchases view --with-clusters --json --non-interactive --no-input