Skip to content
Merged
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
22 changes: 22 additions & 0 deletions .changeset/conversions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@gmc-cli/api": patch
"@gmc-cli/cli": patch
---

feat(conversions): add `gmc conversions` (Merchant API conversions/v1)

Adds the `conversions/v1` `accounts.conversionSources` sub-API — the 10th of 11
Merchant API sub-APIs gmc covers. A conversion source links an account to a
conversion-measurement origin: a **Merchant Center destination** or a **Google
Analytics property link**.

`gmc conversions list | get | create | update | delete | undelete`. `delete`
soft-archives a source; `undelete` restores it. `create` takes `--ga-property`
for a Google Analytics link or `--merchant-center --currency` for a Merchant
Center destination (or `--file` for the full body, e.g. nested
`attributionSettings`). `update` patches Merchant Center fields via a nested
`updateMask` so the rest of the destination is untouched.

New `ConversionsService` in `@gmc-cli/api` plus the `conversions` rate-limit
bucket and OAuth-scope wiring. Field names and RPC paths verified against the
`conversions_v1` discovery document.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default defineConfig({
{ text: "gmc quota", link: "/reference/quota" },
{ text: "gmc issues", link: "/reference/issues" },
{ text: "gmc reports", link: "/reference/reports" },
{ text: "gmc conversions", link: "/reference/conversions" },
{ text: "gmc preflight", link: "/reference/preflight" },
{ text: "gmc migrate", link: "/reference/migrate" },
],
Expand Down
76 changes: 76 additions & 0 deletions docs/reference/conversions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# gmc conversions

**Manage conversion sources** — where a Merchant Center account measures conversions
(`conversions/v1` `accounts.conversionSources`). A source is either a **Merchant Center
destination** or a **Google Analytics property link**. Full lifecycle: **list / get / create /
update / delete / undelete** (`delete` archives, `undelete` restores). Every subcommand operates on
the account resolved from `--account` / `GMC_ACCOUNT_ID` / your profile.

```sh
gmc conversions create --ga-property 123456789
gmc conversions create --merchant-center --currency USD --display-name "Store"
gmc conversions list
gmc conversions get <id>
gmc conversions update <id> --display-name "New name"
gmc conversions delete <id> # archives (soft-delete)
gmc conversions undelete <id> # restores
```

## Commands

| Command | Description |
| --------------------------------------------------------- | ------------------------------------------------------- |
| `gmc conversions list` | List conversion sources for the account |
| `gmc conversions get <id>` | Fetch one source (id or resource name) |
| `gmc conversions create [flags]` | Create a source (its id is auto-generated) |
| `gmc conversions update <id> [flags] [--update-mask <m>]` | Patch a source — only the fields you pass |
| `gmc conversions delete <id>` | Archive a source (soft-delete; restore with `undelete`) |
| `gmc conversions undelete <id>` | Restore a previously archived source |

## Defining a source

A source is **one type** — pass the flags for a Google Analytics link **or** a Merchant Center
destination (not both), or a full `--file` body.

| Flag | Sets |
| ----------------------- | --------------------------------------------------------------------------- |
| `--ga-property <id>` | `googleAnalyticsLink.propertyId` — link a Google Analytics property |
| `--merchant-center` | Create a `merchantCenterDestination` source |
| `--currency <code>` | `merchantCenterDestination.currencyCode` (ISO 4217; **required** for MC) |
| `--display-name <name>` | `merchantCenterDestination.displayName` |
| `--file <path>` | Full `ConversionSource` JSON (else stdin); for nested `attributionSettings` |

`--file` and the convenience flags are mutually exclusive. Output-only fields (`name`, `state`,
`controller`, `expireTime`) in a `--file` body are dropped, so a body saved from `get` re-applies.

## Updating

`update` patches only what you pass. The convenience flags target Merchant Center fields with a
**nested** `updateMask` (so the rest of the destination is untouched):

| Flag | Patches | Mask |
| ----------------------- | ---------------------------------------- | ---------------------------------------- |
| `--display-name <name>` | `merchantCenterDestination.displayName` | `merchantCenterDestination.displayName` |
| `--currency <code>` | `merchantCenterDestination.currencyCode` | `merchantCenterDestination.currencyCode` |
| `--file <path>` | Replaces the named source object | its top-level keys (or `--update-mask`) |

The Google Analytics `propertyId` is immutable, so there is no update flag for it.

## Output

`list` prints `id · state · type` (type is `Merchant Center "name" (CUR)` or `GA property <id>`);
`get` adds `state` / `controller` / `expireTime` detail lines. `--json` emits the raw API result
(`{ "conversionSources": [...] }` for list, the resource for get/create/update/undelete,
`{ "deleted": "<id>" }` for delete).

::: tip Read-after-write
`create` returns the source immediately, but the Merchant API is eventually consistent: a `get` /
`update` / `delete` in the next instant may briefly return `404` until it propagates — usually a few
seconds, up to ~20s. If you script `create` followed by another call on the new id, allow a short
delay or retry.
:::

## Exit codes

`0` success · `2` usage (no account, no/both source types, `--merchant-center` without `--currency`,
`--file` with convenience flags, nothing-to-update) · `3` auth · `5` Merchant API.
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ gmc [global options] <command> [subcommand] [args]
| [`gmc quota`](/reference/quota) | Inspect daily Merchant API call quota and usage (`list`) |
| [`gmc issues`](/reference/issues) | Render account & product issues with resolution content (`account` / `product`) |
| [`gmc reports`](/reference/reports) | Reports — `performance` / `competitive-visibility` / `price-competitiveness` / `check` (CI gate) / `query` (MCQL) |
| [`gmc conversions`](/reference/conversions) | Conversion sources — `list` / `get` / `create` / `update` / `delete` / `undelete` |
| [`gmc feeds`](/reference/feeds) | Feeds as code (`pull` / `push` / `diff`) |
| [`gmc preflight`](/reference/preflight) | Offline feed-compliance scanner — catch disapprovals before upload |
| [`gmc migrate`](/reference/migrate) | Content API → Merchant API assistant (`scopes` / `products` / `feed-labels`) |
Expand Down
153 changes: 153 additions & 0 deletions packages/api/src/conversions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Typed Conversions service (Merchant API `conversions/v1`,
// `accounts.conversionSources`). A conversion source links a merchant account to a
// conversion-measurement origin — either a Merchant Center destination or a Google
// Analytics property. Full CRUD plus `undelete`: `delete` soft-archives a source and
// `undelete` restores it. `create` returns an auto-generated id (no client-supplied id),
// and `update` carries an `updateMask` query param, mirroring the `notifications` /
// `regions` write shape. Sources live on the new "conversions" rate-limit bucket.
// Wraps a MerchantClient scoped to one account.

import type { MerchantClient } from "./client.js";

const CONVERSIONS_API = "conversions/v1";

// As elsewhere, these model only the fields the CLI reads/writes; the Merchant API
// accepts and returns more, and `client.get`/`request` round-trip the full JSON, so
// `--json` output and the bodies sent on create/update are never lossy.

/**
* Attribution configuration shared by both source types. `attributionModel` is one of the
* `*_CLICK` / `*_DATA_DRIVEN` / `*_LINEAR` … enum values; the rest round-trip via `--file`.
*/
export interface AttributionSettings {
conversionType?: unknown[];
attributionModel?: string;
attributionLookbackWindowDays?: number;
}

/** A conversion source backed by a Merchant Center destination. */
export interface MerchantCenterDestination {
/** Output-only: the Merchant Center destination id. */
destination?: string;
/** Human-readable name shown in Merchant Center. */
displayName?: string;
/** Required. Three-letter ISO 4217 currency code for reported conversion values. */
currencyCode?: string;
/** Writable attribution configuration. */
attributionSettings?: AttributionSettings;
}

/** A conversion source backed by a Google Analytics property link. */
export interface GoogleAnalyticsLink {
/** Output-only resource name of the linked GA property. */
property?: string;
/** Required, immutable. The Google Analytics property id. */
propertyId?: string;
/** Output-only attribution configuration (inherited from the GA property). */
attributionSettings?: AttributionSettings;
}

/**
* A conversion source (`accounts/{account}/conversionSources/{conversionSource}`). Exactly
* one of `merchantCenterDestination` / `googleAnalyticsLink` is set (a union — fixed at
* create, not switched on patch). `name`, `state`, `controller`, and `expireTime` are
* output-only.
*/
export interface ConversionSource {
/** Output-only resource name: `accounts/{account}/conversionSources/{id}`. */
name?: string;
/** Output-only: `ACTIVE` / `ARCHIVED` / `PENDING` / `STATE_UNSPECIFIED`. */
state?: string;
/** Output-only: who owns the source — `MERCHANT` / `YOUTUBE_AFFILIATES`. */
controller?: string;
/** Output-only: when an archived source is permanently removed. */
expireTime?: string;
merchantCenterDestination?: MerchantCenterDestination;
googleAnalyticsLink?: GoogleAnalyticsLink;
}

/** The writable subset of a conversion source accepted on create / update. */
export type ConversionSourceInput = Pick<
ConversionSource,
"merchantCenterDestination" | "googleAnalyticsLink"
>;

/** One page of `conversionSources.list`. */
interface ConversionSourcesListPage {
conversionSources?: ConversionSource[];
nextPageToken?: string;
}

/**
* Reduce a conversion-source id or full resource name to its bare id, mirroring
* {@link notificationSegment}, so callers can pass either a bare id or the `name` from `list`.
*/
export function conversionSourceSegment(idOrName: string): string {
return idOrName.replace(/^.*\/conversionSources\//, "");
}

/** Full create/list/get/update/delete/undelete access to Merchant API conversion sources. */
export class ConversionsService {
constructor(private readonly client: MerchantClient) {}

private get base(): string {
return `${CONVERSIONS_API}/${this.client.accountResource}/conversionSources`;
}

private resource(idOrName: string): string {
return `${this.base}/${encodeURIComponent(conversionSourceSegment(idOrName))}`;
}

/** List every conversion source for the account, following pagination. */
async listConversionSources(): Promise<ConversionSource[]> {
const sources: ConversionSource[] = [];
for await (const s of this.client.paginate<ConversionSource>("conversions", this.base, {
select: (page) => (page as ConversionSourcesListPage).conversionSources ?? [],
})) {
sources.push(s);
}
return sources;
}

/** Fetch a single conversion source by id (or full resource name). */
getConversionSource(idOrName: string): Promise<ConversionSource> {
return this.client.get<ConversionSource>("conversions", this.resource(idOrName));
}

/** Create a conversion source. The id is auto-generated, so none is supplied. */
createConversionSource(body: ConversionSourceInput): Promise<ConversionSource> {
return this.client.post<ConversionSource>("conversions", this.base, body);
}

/**
* Patch a conversion source. The `updateMask` defaults to the input's own keys, so only
* what you pass is changed; pass `updateMask` to override (e.g. a nested
* `merchantCenterDestination.displayName`). Mirrors `notifications.update`.
*/
updateConversionSource(
idOrName: string,
body: ConversionSourceInput,
opts: { updateMask?: string } = {},
): Promise<ConversionSource> {
const updateMask = opts.updateMask ?? Object.keys(body).join(",");
return this.client.request<ConversionSource>("conversions", "PATCH", this.resource(idOrName), {
query: { updateMask },
body,
});
}

/** Soft-delete (archive) a conversion source by id. Restorable with {@link undeleteConversionSource}. */
async deleteConversionSource(idOrName: string): Promise<void> {
await this.client.delete<undefined>("conversions", this.resource(idOrName));
}

/** Re-enable a previously soft-deleted conversion source (`:undelete` colon-verb). */
undeleteConversionSource(idOrName: string): Promise<ConversionSource> {
return this.client.request<ConversionSource>(
"conversions",
"POST",
`${this.resource(idOrName)}:undelete`,
{ body: {} },
);
}
}
9 changes: 9 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ export type {
RegisteredEvent,
} from "./notifications.js";

export { ConversionsService, conversionSourceSegment } from "./conversions.js";
export type {
ConversionSource,
ConversionSourceInput,
MerchantCenterDestination,
GoogleAnalyticsLink,
AttributionSettings,
} from "./conversions.js";

export { RegionsService, regionSegment } from "./regions.js";
export type {
Region,
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const DEFAULT_RATE_LIMITS: RateLimitConfig = {
notifications: { capacity: 30, refillPerSecond: 5 },
quota: { capacity: 30, refillPerSecond: 5 },
issueresolution: { capacity: 30, refillPerSecond: 5 },
conversions: { capacity: 30, refillPerSecond: 5 },
};

class TokenBucket {
Expand Down
4 changes: 3 additions & 1 deletion packages/api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export type SubApi =
| "promotions"
| "notifications"
| "quota"
| "issueresolution";
| "issueresolution"
| "conversions";

/** All sub-API keys, in a stable order. */
export const SUB_APIS: readonly SubApi[] = [
Expand All @@ -24,4 +25,5 @@ export const SUB_APIS: readonly SubApi[] = [
"notifications",
"quota",
"issueresolution",
"conversions",
];
Loading