Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/command-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path|->
rc products store show <plan-id> # inspect the exact persisted plan from any process
rc products store apply <plan-id> # apply that same reviewed plan; requires confirmation/--yes
rc products store submit <product-id>... # 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 <plan-id> # discard without applying; requires confirmation/--yes

# Paywalls
Expand Down
2 changes: 2 additions & 0 deletions docs/specs/cli-coverage.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions docs/specs/v2-beta-overlay.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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): <code>project_configuration:products:read_write</code>.\
\ This endpoint belongs to the <strong>Project Configuration</strong> domain,\
\ which has a default rate limit of <strong>60 requests per minute</strong>."
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)'
Expand Down
4 changes: 4 additions & 0 deletions internal/api/paths_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions internal/api/store_state_direct.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions internal/api/types_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

105 changes: 105 additions & 0 deletions internal/cli/products_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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> [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 <plan-id>",
Expand Down
Loading
Loading