diff --git a/docs/command-surface.md b/docs/command-surface.md index 348069db..6615ff43 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -173,6 +173,7 @@ rc products store plan [app-id] # persist desired state rc products store list # list persisted store plans 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 product of each IAP type must be reviewed with a new app version rc products store discard # discard without applying; requires confirmation/--yes rc products store screenshot # upload an App Review screenshot to App Store Connect for a product diff --git a/docs/previews/products-store-submit.svg b/docs/previews/products-store-submit.svg new file mode 100644 index 00000000..9dba6c6f --- /dev/null +++ b/docs/previews/products-store-submit.svg @@ -0,0 +1,31 @@ + + + + + +$ rc products store submit prod_snap1 prod_snap2 --yes --no-input --project-id proj_snap --api-key sk_snap + +▍ Submit products for Apple review +  Send these App Store products to Apple for review. + +  Project                     proj_snap +  Product 1                   prod_snap1 +  Product 2                   prod_snap2 + +▍ Plan +· 1. Submit 2 products for Apple review +· Submitted 1 of 2 products for review +  1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. +PRODUCT     STATUS     DETAIL                           +prod_snap1  submitted  sub_snap1                        +prod_snap2  skipped    App Store metadata is incomplete + + + 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 7ae7448d..366aac20 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 c49c2dcb..9d76e770 100644 --- a/internal/api/store_state_direct.go +++ b/internal/api/store_state_direct.go @@ -92,6 +92,34 @@ 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. +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 0ec9f15e..d98c4309 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -27265,6 +27265,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" @@ -43795,6 +43810,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 @@ -45474,6 +45503,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 90436fce..02fc9e1b 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(), @@ -274,6 +275,125 @@ 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. Apple must review and approve products after submission +before they can be purchased. + +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 product of each In-App Purchase type must be reviewed with a new app +version. Add it on the app's version page in App Store Connect and submit that +version. Later products of that type can be submitted separately after the +first is approved and the app has an approved version. + +The result lists each product as submitted or skipped with a reason. If every +product is skipped, the command exits nonzero; partial submissions exit zero. + +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 + } + rt.Out.Title("Submit products for Apple review") + rt.Out.Lead("Send these App Store products to Apple for review.") + rt.Out.Field("Project", projectID) + for i, id := range productIDs { + rt.Out.Field(fmt.Sprintf("Product %d", i+1), id) + } + rt.Out.Plan([]string{fmt.Sprintf("Submit %s for Apple review", pluralize(len(productIDs), "product"))}) + if err := confirmOrAbort(rt, fmt.Sprintf("Submit %s for Apple review?", pluralize(len(productIDs), "App Store product")), + "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 +} + +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 %s for review", resp.SubmittedCount, pluralize(len(resp.Results), "product"))) + 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 + verb := "were" + if skipped == 1 { + verb = "was" + } + rt.Out.Hint(fmt.Sprintf("%s %s not submitted. Check each reason in DETAIL; resolve it before retrying.", pluralize(skipped, "product"), verb)) + } + if resp.SubmittedCount == 0 { + rt.Out.Warn("No products were submitted for review") + return &SilentExitError{Code: 1} + } + 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 5d6ae197..88a0dc45 100644 --- a/internal/cli/products_store_lifecycle_test.go +++ b/internal/cli/products_store_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -191,6 +192,116 @@ 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_AllSkippedExitsNonzero(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") + var exit *SilentExitError + if !errors.As(err, &exit) || exit.Code != 1 { + t.Fatalf("a fully-skipped response must exit 1, 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) { diff --git a/internal/cli/snapshot_test.go b/internal/cli/snapshot_test.go index 0586eb09..d8ab291d 100644 --- a/internal/cli/snapshot_test.go +++ b/internal/cli/snapshot_test.go @@ -28,6 +28,8 @@ func snapshotServer(t *testing.T) *httptest.Server { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.URL.Path == "/projects/proj_snap/products/actions/submit_to_store": + io.WriteString(w, `{"object":"submit_products_to_store_response","submitted_count":1,"results":[{"object":"submit_product_to_store_result","product_id":"prod_snap1","status":"submitted","submission_id":"sub_snap1","message":null},{"object":"submit_product_to_store_result","product_id":"prod_snap2","status":"skipped","submission_id":null,"message":"App Store metadata is incomplete"}]}`) case strings.HasSuffix(r.URL.Path, "/offerings/ofrng_snap"): io.WriteString(w, `{"object":"offering","id":"ofrng_snap","lookup_key":"default","display_name":"Default","is_current":true,"created_at":1784297950368,"project_id":"proj_snap"}`) case strings.HasSuffix(r.URL.Path, "/apps/app_snap1"): @@ -62,6 +64,7 @@ func TestOutputSnapshots(t *testing.T) { {"apps-list-all-projects", []string{"apps", "list", "--all-projects", "--bundle-id", "com.example.moodly", "--no-input", "--api-key", "sk_snap"}}, {"error-not-found", []string{"offerings", "show", "ofrng_missing", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, {"apps-apple-setup", []string{"apps", "apple", "setup", "app_snap1", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, + {"products-store-submit", []string{"products", "store", "submit", "prod_snap1", "prod_snap2", "--yes", "--no-input", "--project-id", "proj_snap", "--api-key", "sk_snap"}}, } for _, sc := range scenarios { diff --git a/internal/cli/testdata/snapshots/products-store-submit.golden b/internal/cli/testdata/snapshots/products-store-submit.golden new file mode 100644 index 00000000..b7dcfc8d --- /dev/null +++ b/internal/cli/testdata/snapshots/products-store-submit.golden @@ -0,0 +1,16 @@ +$ rc products store submit prod_snap1 prod_snap2 --yes --no-input --project-id proj_snap --api-key sk_snap + +▍ Submit products for Apple review + Send these App Store products to Apple for review. + + Project proj_snap + Product 1 prod_snap1 + Product 2 prod_snap2 + +▍ Plan +· 1. Submit 2 products for Apple review +· Submitted 1 of 2 products for review + 1 product was not submitted. Check each reason in DETAIL; resolve it before retrying. +PRODUCT STATUS DETAIL +prod_snap1 submitted sub_snap1 +prod_snap2 skipped App Store metadata is incomplete