diff --git a/docs/command-surface.md b/docs/command-surface.md index f7200732..2aafe2a7 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -197,6 +197,7 @@ rc products store sync [app-id] # human flow: input → rc products store plan [app-id] # persist desired state + diff on the backend; accepts --file rc products store show # inspect the exact persisted plan from any process rc products store apply # apply that same reviewed plan; requires confirmation/--yes +rc products store submit ... # start Apple review for App Store products (--store app_store); requires confirmation/--yes; first IAP for an app must be reviewed with a new app version (skipped until one product is approved) rc products store discard # discard without applying; requires confirmation/--yes # Paywalls diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index ccafd5d1..a979da00 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -133,6 +133,8 @@ endpoints: path: /projects/{project_id}/products/{product_id} - method: DELETE path: /projects/{project_id}/products/{product_id} + - method: POST + path: /projects/{project_id}/products/actions/submit_to_store - method: POST path: /projects/{project_id}/products/{product_id}/actions/archive - method: POST diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 185264c1..6cbe6636 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -7,6 +7,62 @@ info: title: RevenueCat v2 — beta overlay version: overlay paths: + /projects/{project_id}/products/actions/submit_to_store: + post: + summary: Submit products to store + description: "Submits App Store products for Apple review and returns a per-product\ + \ outcome. Products that are not yet ready for submission are skipped with a\ + \ reason rather than failing the whole request. All products must belong to\ + \ the same app.\n This endpoint requires the following permission(s): project_configuration:products:read_write.\ + \ This endpoint belongs to the Project Configuration domain,\ + \ which has a default rate limit of 60 requests per minute." + operationId: submit-products-to-store + x-revenuecat-rate-limiting-domain: project_configuration + x-scopes: + - project_configuration:products:read_write + x-release-status: development + x-revenuecat-mcp-include: false + x-revenuecat-release-gatekeeping: false + tags: + - Product + parameters: + - name: project_id + description: ID of the project + required: true + in: path + schema: + type: string + maxLength: 255 + example: proj1ab2c3d4 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - store + - product_ids + properties: + store: + description: The target store for this operation. + type: string + enum: + - app_store + example: app_store + product_ids: + description: Product IDs to attempt to submit to store. + type: array + minItems: 1 + maxItems: 200 + items: + type: string + minLength: 1 + maxLength: 255 + example: prod1a2b3c4d5e + responses: + '200': + description: OK /projects/{project_id}/products/{product_id}/test_store_prices: get: summary: 'List product prices (deprecated: use /prices instead)' diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index 028784f5..77424322 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -234,6 +234,10 @@ func pathProducts(projectID string) string { return encodePath("projects", projectID, "products") } +func pathProductsActionsSubmitToStore(projectID string) string { + return encodePath("projects", projectID, "products", "actions", "submit_to_store") +} + func pathProjects() string { return encodePath("projects") } diff --git a/internal/api/store_state_direct.go b/internal/api/store_state_direct.go index 5e904c8e..a5fdea21 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -84,6 +84,36 @@ type TerritoryPrice struct { StartDate *string `json:"start_date"` } +// SubmitProductsToStoreResponse is the outcome of submitting products for +// store review. submitted_count counts only the products that were actually +// submitted; results carries the per-product outcome (including skips). +type SubmitProductsToStoreResponse struct { + Object string `json:"object"` + SubmittedCount int `json:"submitted_count"` + Results []SubmitProductToStoreResult `json:"results"` +} + +// SubmitProductToStoreResult is one product's outcome. Status is "submitted" +// or "skipped"; SubmissionID is set on submitted, Message carries the reason on +// skipped. There is no per-product failure — hard failures are HTTP errors. +type SubmitProductToStoreResult struct { + Object string `json:"object"` + ProductID string `json:"product_id"` + Status string `json:"status"` + SubmissionID *string `json:"submission_id"` + Message *string `json:"message"` +} + +// SubmitToStore starts store review for the given products. The store is +// required by the API and only app_store is accepted today; products that are +// not yet ready come back with a skipped result rather than failing the call. +func (s *StoreStateService) SubmitToStore(ctx context.Context, projectID, store string, productIDs []string) (*SubmitProductsToStoreResponse, error) { + var out SubmitProductsToStoreResponse + body := map[string]any{"store": store, "product_ids": productIDs} + err := s.c.do(ctx, http.MethodPost, pathProductsActionsSubmitToStore(projectID), body, &out) + return &out, err +} + func (s *StoreStateService) Get(ctx context.Context, projectID, productID string) (*LiveStoreState, error) { var out LiveStoreState err := s.c.do(ctx, http.MethodGet, pathProductStoreState(projectID, productID), nil, &out) diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 31a2871a..0a4da53a 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -27204,6 +27204,21 @@ func (e CreateProduct503JSONResponseBodyType) Valid() bool { } } +// Defines values for SubmitProductsToStoreJSONBodyStore. +const ( + SubmitProductsToStoreJSONBodyStoreAppStore SubmitProductsToStoreJSONBodyStore = "app_store" +) + +// Valid indicates whether the value is a known member of the SubmitProductsToStoreJSONBodyStore enum. +func (e SubmitProductsToStoreJSONBodyStore) Valid() bool { + switch e { + case SubmitProductsToStoreJSONBodyStoreAppStore: + return true + default: + return false + } +} + // Defines values for DeleteProduct400JSONResponseBodyObject. const ( DeleteProduct400JSONResponseBodyObjectError DeleteProduct400JSONResponseBodyObject = "error" @@ -43614,6 +43629,20 @@ type CreateProduct503JSONResponseBodyObject string // CreateProduct503JSONResponseBodyType defines parameters for CreateProduct. type CreateProduct503JSONResponseBodyType string +// SubmitProductsToStoreJSONBody defines parameters for SubmitProductsToStore. +type SubmitProductsToStoreJSONBody struct { + // ProductIds Product IDs to attempt to submit to store. + ProductIds []string `json:"product_ids"` + + // Store The target store for this operation. + // + // Example: app_store + Store SubmitProductsToStoreJSONBodyStore `json:"store"` +} + +// SubmitProductsToStoreJSONBodyStore defines parameters for SubmitProductsToStore. +type SubmitProductsToStoreJSONBodyStore string + // DeleteProduct400JSONResponseBodyObject defines parameters for DeleteProduct. type DeleteProduct400JSONResponseBodyObject string @@ -45287,6 +45316,9 @@ type CreatePaywallVersionJSONRequestBody CreatePaywallVersionJSONBody // CreateProductJSONRequestBody defines body for CreateProduct for application/json ContentType. type CreateProductJSONRequestBody CreateProductJSONBody +// SubmitProductsToStoreJSONRequestBody defines body for SubmitProductsToStore for application/json ContentType. +type SubmitProductsToStoreJSONRequestBody SubmitProductsToStoreJSONBody + // UpdateProductJSONRequestBody defines body for UpdateProduct for application/json ContentType. type UpdateProductJSONRequestBody UpdateProductJSONBody diff --git a/internal/cli/products_store.go b/internal/cli/products_store.go index be95fa75..985918fe 100644 --- a/internal/cli/products_store.go +++ b/internal/cli/products_store.go @@ -27,6 +27,7 @@ that exact plan. Files are optional; pass --file - to read CSV or JSON stdin.`, newProductsStorePlanCmd(), newProductsStoreShowCmd(), newProductsStoreApplyCmd(), + newProductsStoreSubmitCmd(), newProductsStoreDiscardCmd(), newProductsStoreScreenshotCmd(), newProductsStoreListCmd(), @@ -254,6 +255,110 @@ Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.` return cmd } +func newProductsStoreSubmitCmd() *cobra.Command { + var store string + cmd := &cobra.Command{ + Use: "submit [product-id...]", + Short: "Submit App Store products for Apple review", + Long: `Starts Apple review for the named App Store products. Applying a +store-state plan pushes configuration to App Store Connect but never submits +anything for review, so products stay configured-but-not-purchasable until this +command runs. + +Only the products passed as arguments are submitted, and they must all belong +to the same app. A product is submittable only once it exists in App Store +Connect; one that isn't ready comes back skipped with a reason instead of +failing the whole run. App Store only — Apple is the sole store that accepts +review submissions through this command. + +The first In-App Purchase or subscription for an app cannot be submitted this +way: App Store Connect requires the first one to be reviewed with a new app +version. Add it on the app's version page in App Store Connect and submit that +version; this command works once the app has at least one approved product +(before then it returns the product as skipped, explaining this). + +Reversibility: starts Apple review — manage the submission in App Store Connect. + +Confirmation: prompts under TTY; pass --yes to skip. Required under --no-input.`, + Example: ` rc products store submit prod_abc prod_def --yes + rc products store submit prod_abc --yes --json --no-input`, + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + if store != "app_store" { + return fmt.Errorf("only App Store products can be submitted for review; --store %q is not supported", store) + } + productIDs, err := cleanSubmitProductIDs(args) + if err != nil { + return err + } + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + if err := confirmOrAbort(rt, fmt.Sprintf("Submit %d App Store product(s) for Apple review?", len(productIDs)), + "nothing was submitted"); err != nil { + return err + } + resp, err := client.StoreState.SubmitToStore(cmd.Context(), projectID, store, productIDs) + if err != nil { + return err + } + return renderStoreSubmitResult(rt, resp) + }, + } + cmd.Flags().StringVar(&store, "store", "app_store", "store to submit to (only app_store is supported)") + return cmd +} + +// cleanSubmitProductIDs trims the product IDs and enforces the server's bounds +// (non-empty, at most 200) before spending a round trip. +func cleanSubmitProductIDs(args []string) ([]string, error) { + ids := make([]string, 0, len(args)) + for _, arg := range args { + id := strings.TrimSpace(arg) + if id == "" { + return nil, fmt.Errorf("product IDs cannot be empty") + } + ids = append(ids, id) + } + if len(ids) > 200 { + return nil, fmt.Errorf("cannot submit more than 200 products at once; got %d", len(ids)) + } + return ids, nil +} + +func renderStoreSubmitResult(rt *Runtime, resp *api.SubmitProductsToStoreResponse) error { + rows := make([][]string, 0, len(resp.Results)) + for _, result := range resp.Results { + detail := "" + switch result.Status { + case "submitted": + detail = optionalString(result.SubmissionID, "") + default: + detail = optionalString(result.Message, "") + } + rows = append(rows, []string{result.ProductID, result.Status, detail}) + } + rt.Out.Info(fmt.Sprintf("Submitted %d of %d product(s) for review", resp.SubmittedCount, len(resp.Results))) + if err := rt.Out.RenderTable(output.Table{ + Columns: []string{"PRODUCT", "STATUS", "DETAIL"}, + Rows: rows, + Raw: resp, + }); err != nil { + return err + } + if resp.SubmittedCount < len(resp.Results) { + skipped := len(resp.Results) - resp.SubmittedCount + rt.Out.Hint(fmt.Sprintf("%d product(s) were not submitted — see the DETAIL column for why. Confirm each exists in App Store Connect (apply its plan first), then re-run submit.", skipped)) + } + return nil +} + func newProductsStoreDiscardCmd() *cobra.Command { return &cobra.Command{ Use: "discard ", diff --git a/internal/cli/products_store_lifecycle_test.go b/internal/cli/products_store_lifecycle_test.go index 76726364..53248b0f 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -157,6 +157,115 @@ func TestReadStoreStateJSON_RejectsAnotherApp(t *testing.T) { } } +func TestProductsStoreSubmit_ReportsPerProductOutcomes(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/projects/proj/products/actions/submit_to_store" { + http.Error(w, "unexpected request", http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + _, _ = io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":1,"results":[`+ + `{"object":"submit_product_to_store_result","product_id":"prod_abc","status":"submitted","submission_id":"sub_123","message":null},`+ + `{"object":"submit_product_to_store_result","product_id":"prod_def","status":"skipped","submission_id":null,"message":"not ready to submit"}]}`) + })) + t.Cleanup(server.Close) + + out, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "prod_def", "--yes", "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if body["store"] != "app_store" { + t.Fatalf("store = %v, want app_store", body["store"]) + } + ids, _ := body["product_ids"].([]any) + if len(ids) != 2 || ids[0] != "prod_abc" || ids[1] != "prod_def" { + t.Fatalf("product_ids = %v, want [prod_abc prod_def]", body["product_ids"]) + } + if !strings.Contains(out, `"submitted_count": 1`) { + t.Fatalf("output missing submitted_count: %s", out) + } + if !strings.Contains(out, `"status": "skipped"`) || !strings.Contains(out, "not ready to submit") { + t.Fatalf("output missing skipped outcome: %s", out) + } +} + +func TestProductsStoreSubmit_RejectsNonAppStore(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "must not reach the API for a rejected store", http.StatusInternalServerError) + })) + t.Cleanup(server.Close) + + _, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "--store", "play_store", "--yes", "--json", "--no-input") + if err == nil || !strings.Contains(err.Error(), "only App Store products") { + t.Fatalf("error = %v, want App Store only rejection", err) + } +} + +func TestProductsStoreSubmit_SurfacesSubmissionFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"type":"parameter_error","message":"all products must belong to the same app"}`) + })) + t.Cleanup(server.Close) + + _, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "prod_def", "--yes", "--json", "--no-input") + if err == nil { + t.Fatal("expected error for failed submission") + } + if code := ExitCodeFor(err); code == 0 { + t.Fatalf("exit code = %d, want non-zero", code) + } + if !strings.Contains(err.Error(), "same app") { + t.Fatalf("error = %v, want the API message surfaced", err) + } +} + +func TestCleanSubmitProductIDs(t *testing.T) { + if got, err := cleanSubmitProductIDs([]string{" prod_abc ", "prod_def"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } else if len(got) != 2 || got[0] != "prod_abc" || got[1] != "prod_def" { + t.Fatalf("trim = %v, want [prod_abc prod_def]", got) + } + + if _, err := cleanSubmitProductIDs([]string{"prod_abc", " "}); err == nil { + t.Fatal("expected error for a whitespace-only product ID") + } + + tooMany := make([]string, 201) + for i := range tooMany { + tooMany[i] = "prod" + } + if _, err := cleanSubmitProductIDs(tooMany); err == nil { + t.Fatal("expected error for more than 200 product IDs") + } +} + +func TestProductsStoreSubmit_AllSkippedExitsZero(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":0,"results":[`+ + `{"object":"submit_product_to_store_result","product_id":"prod_abc","status":"skipped","submission_id":null,"message":"not ready to submit"}]}`) + })) + t.Cleanup(server.Close) + + out, _, err := runStoreLifecycleCommand(t, server.URL, "", + "products", "store", "submit", "prod_abc", "--yes", "--json", "--no-input") + if err != nil { + t.Fatalf("a fully-skipped response must exit 0, got %v", err) + } + if !strings.Contains(out, `"submitted_count": 0`) { + t.Fatalf("output missing submitted_count 0: %s", out) + } +} + type staticStoreStatePlanService struct{ plan *api.StoreStatePlan } func (s staticStoreStatePlanService) Get(context.Context, string, string) (*api.StoreStatePlan, error) { diff --git a/internal/cli/schema_test.go b/internal/cli/schema_test.go index f5b9da84..3b638533 100644 --- a/internal/cli/schema_test.go +++ b/internal/cli/schema_test.go @@ -124,6 +124,63 @@ func TestInferCapabilities_DriftGuard(t *testing.T) { walk(root) } +func TestParseArgsFromUse(t *testing.T) { + cases := []struct { + use string + want []map[string]any + }{ + {"show ", []map[string]any{{"name": "id", "required": true, "variadic": false}}}, + {"submit [product-id...]", []map[string]any{ + {"name": "product-id", "required": true, "variadic": false}, + {"name": "product-id", "required": false, "variadic": true}, + }}, + {"schema [command...]", []map[string]any{{"name": "command", "required": false, "variadic": true}}}, + {"list", []map[string]any{}}, + } + for _, tc := range cases { + got := parseArgsFromUse(tc.use) + if len(got) != len(tc.want) { + t.Errorf("%q: got %d args, want %d (%v)", tc.use, len(got), len(tc.want), got) + continue + } + for i := range got { + for k, v := range tc.want[i] { + if got[i][k] != v { + t.Errorf("%q arg %d: %s = %v, want %v", tc.use, i, k, got[i][k], v) + } + } + } + } +} + +// TestSchemaArgsMatchVariadicUse guards against variadic positionals that hide +// from the agent schema, e.g. "..." with the dots outside the brackets. +func TestSchemaArgsMatchVariadicUse(t *testing.T) { + root := NewRootCmd("test") + + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + if strings.Contains(c.Use, "...") { + args := parseArgsFromUse(c.Use) + variadic := false + for _, a := range args { + if v, _ := a["variadic"].(bool); v { + variadic = true + break + } + } + if !variadic { + t.Errorf("%q Use %q signals variadic args but schema exposes none %v", + commandPath(c), c.Use, args) + } + } + for _, sc := range c.Commands() { + walk(sc) + } + } + walk(root) +} + func hasRunnableDescendant(c *cobra.Command) bool { for _, sc := range c.Commands() { if isExperimental(sc) {