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

feat(lfp): add `gmc lfp` (Merchant API lfp/v1) — completes 11/11 sub-APIs

Adds the `lfp/v1` Local Feeds Partnership sub-API — the **11th and final** Merchant
API sub-API gmc covers. This is a provider-side API: the scoped account is the LFP
**provider**, and each resource names a **`targetAccount`** (the merchant it's for).

`gmc lfp stores list | get | insert | delete`, `gmc lfp inventory insert`,
`gmc lfp sales insert`, and `gmc lfp state get <merchant>`. Inserts take convenience
flags (`--target-account`, `--store-code`, `--offer-id`, `--price`/`--currency`,
`--quantity`, …) or a full `--file` body.

New `LfpService` in `@gmc-cli/api` plus the `lfp` rate-limit bucket and OAuth-scope
wiring. Resource shapes and the `:insert` colon-verb paths were verified against the
`lfp_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 @@ -133,6 +133,7 @@ export default defineConfig({
{ text: "gmc issues", link: "/reference/issues" },
{ text: "gmc reports", link: "/reference/reports" },
{ text: "gmc conversions", link: "/reference/conversions" },
{ text: "gmc lfp", link: "/reference/lfp" },
{ text: "gmc preflight", link: "/reference/preflight" },
{ text: "gmc migrate", link: "/reference/migrate" },
],
Expand Down
1 change: 1 addition & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ gmc [global options] <command> [subcommand] [args]
| [`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 lfp`](/reference/lfp) | Local Feeds Partnership (provider) — `stores` / `inventory` / `sales` / `state` |
| [`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
52 changes: 52 additions & 0 deletions docs/reference/lfp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# gmc lfp

**Local Feeds Partnership** (`lfp/v1`) — submit local **stores**, **inventory**, and **sales** for
merchants you manage. Stores: **list / get / insert / delete**; inventory and sales: **insert**
(upsert); merchant state: **get** (diagnostics).

::: warning Provider-side API
This sub-API is for **LFP providers** — calls require an approved Local Feeds Partnership provider
account, so most users will get a `403`. The account `gmc` is scoped to (`--account` /
`GMC_ACCOUNT_ID` / your profile) is the **provider**; `--target-account` names the **merchant** the
data is for. This is the one `gmc` sub-API where the scoped account is _not_ the merchant.
:::

```sh
gmc lfp stores insert --target-account 123456789 --store-code store-1 --store-name "Downtown"
gmc lfp stores list --target-account 123456789
gmc lfp inventory insert --target-account 123456789 --store-code store-1 \
--offer-id SKU1 --quantity 12 --price 19.99 --currency USD --availability in_stock
gmc lfp sales insert --target-account 123456789 --store-code store-1 \
--offer-id SKU1 --quantity 1 --price 19.99 --currency USD --sale-time 2026-06-14T10:00:00Z
gmc lfp state get 123456789
```

## Commands

| Command | Description |
| ---------------------------------- | -------------------------------------------------------------- |
| `gmc lfp stores list` | List a merchant's registered stores (needs `--target-account`) |
| `gmc lfp stores get <id>` | Fetch one store (id or resource name) |
| `gmc lfp stores insert [flags]` | Insert (create or replace) a store for a target merchant |
| `gmc lfp stores delete <id>` | Delete a store |
| `gmc lfp inventory insert [flags]` | Submit a local inventory entry (upsert) |
| `gmc lfp sales insert [flags]` | Submit a local sale event |
| `gmc lfp state get <merchant>` | Read a merchant's LFP onboarding state (diagnostics) |

## Common flags

`stores list` and every `insert` require `--target-account <merchant>` — the merchant's **numeric
Merchant Center id** (an `accounts/{id}` form is accepted and reduced to the id). Inserts also
require `--store-code <code>`, and inventory/sales require `--offer-id <id>`. A `--price <decimal>`
needs `--currency <code>` (the decimal is converted to the API's micros). Inventory `--quantity` is
a non-negative integer; sales `--quantity` may be negative (a return). Pass `--file <path>` (or pipe
stdin) for the full `Lfp*` JSON body; the convenience flags overlay it.

`--json` emits the raw API result (`{ "lfpStores": [...] }` for the stores list, the resource for
get/insert, `{ "deleted": "<id>" }` for delete).

## Exit codes

`0` success · `2` usage (no provider account, missing `--target-account` / `--store-code` /
`--offer-id`, a bad `--quantity` / `--price`, unreadable `--file`) · `3` auth · `5` Merchant API
(incl. `403` if the account isn't an LFP provider).
11 changes: 11 additions & 0 deletions packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ export type {
AttributionSettings,
} from "./conversions.js";

export { LfpService, lfpStoreSegment, lfpMerchantStateSegment } from "./lfp.js";
export type {
LfpStore,
LfpStoreInput,
LfpInventory,
LfpInventoryInput,
LfpSale,
LfpSaleInput,
LfpMerchantState,
} from "./lfp.js";

export { RegionsService, regionSegment } from "./regions.js";
export type {
Region,
Expand Down
237 changes: 237 additions & 0 deletions packages/api/src/lfp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
// Typed LFP service (Merchant API `lfp/v1`) — the Local Feeds Partnership API.
// IMPORTANT: this is a PROVIDER-side API. The account the client is scoped to is the
// **LFP provider**, and every resource carries a `targetAccount` naming the **merchant**
// the data is submitted for. (Every other gmc sub-API treats the scoped account as the
// merchant itself; LFP is the exception.) Providers submit local store / inventory / sales
// data on behalf of the merchants they manage.
//
// Resources: lfpStores (list/get/insert/delete), lfpInventories (insert-only),
// lfpSales (insert-only), lfpMerchantStates (get-only — diagnostics). The `insert` verbs
// are upserts on the `<collection>:insert` colon path with the resource as the body
// (verified against the lfp_v1 discovery doc). Lives on the new "lfp" rate-limit bucket.

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

const LFP_API = "lfp/v1";

// As elsewhere, these model the fields the CLI reads/writes; the API round-trips the full
// JSON via `client.get`/`request`, so `--json` and the submitted bodies are never lossy.

/**
* A physical store a provider registers for a merchant
* (`accounts/{provider}/lfpStores/{targetMerchant}~{storeCode}`). `name`, `matchingState`,
* and `matchingStateHint` are output-only (the match to a Google Business Profile location).
*/
export interface LfpStore {
/** Output-only resource name: `accounts/{provider}/lfpStores/{id}`. */
name?: string;
/** The merchant this store belongs to (`accounts/{merchant}` or a bare id). */
targetAccount?: string;
storeCode?: string;
storeName?: string;
/** Single-line address of the store. */
storeAddress?: string;
phoneNumber?: string;
websiteUri?: string;
/** Google Place ID of the store location. */
placeId?: string;
/** Google category ids describing the store. */
gcidCategory?: string[];
/** Output-only: whether the store matched a Business Profile location. */
matchingState?: string;
/** Output-only: hint on why a store did/didn't match. */
matchingStateHint?: string;
}

/** The writable subset of an LfpStore accepted on insert. */
export type LfpStoreInput = Pick<
LfpStore,
| "targetAccount"
| "storeCode"
| "storeName"
| "storeAddress"
| "phoneNumber"
| "websiteUri"
| "placeId"
| "gcidCategory"
>;

/**
* A local inventory entry for one product at one store
* (`accounts/{provider}/lfpInventories`). Insert-only (an upsert keyed by
* target/store/offer/region/language). `name` is output-only.
*/
export interface LfpInventory {
/** Output-only resource name. */
name?: string;
targetAccount?: string;
storeCode?: string;
offerId?: string;
regionCode?: string;
contentLanguage?: string;
feedLabel?: string;
gtin?: string;
price?: Price;
/** Available quantity (int64 as string). */
quantity?: string;
availability?: string;
pickupMethod?: string;
pickupSla?: string;
collectionTime?: string;
}

/** The writable subset of an LfpInventory accepted on insert. */
export type LfpInventoryInput = Pick<
LfpInventory,
| "targetAccount"
| "storeCode"
| "offerId"
| "regionCode"
| "contentLanguage"
| "feedLabel"
| "gtin"
| "price"
| "quantity"
| "availability"
| "pickupMethod"
| "pickupSla"
| "collectionTime"
>;

/**
* A local sale event (`accounts/{provider}/lfpSales`). Insert-only. `name` and `uid`
* are output-only.
*/
export interface LfpSale {
/** Output-only resource name. */
name?: string;
/** Output-only unique id assigned to the submitted sale. */
uid?: string;
targetAccount?: string;
storeCode?: string;
offerId?: string;
regionCode?: string;
contentLanguage?: string;
feedLabel?: string;
gtin?: string;
price?: Price;
/** Quantity sold (int64 as string; negative for a return). */
quantity?: string;
saleTime?: string;
}

/** The writable subset of an LfpSale accepted on insert. */
export type LfpSaleInput = Pick<
LfpSale,
| "targetAccount"
| "storeCode"
| "offerId"
| "regionCode"
| "contentLanguage"
| "feedLabel"
| "gtin"
| "price"
| "quantity"
| "saleTime"
>;

/**
* A merchant's LFP onboarding state (`accounts/{provider}/lfpMerchantStates/{merchant}`) —
* read-only diagnostics: per-country settings, per-store match states, inventory stats, and
* linked Google Business Profile ids. The nested shapes are rich; they round-trip via `--json`.
*/
export interface LfpMerchantState {
name?: string;
countrySettings?: unknown[];
storeStates?: unknown[];
linkedGbps?: string;
inventoryStats?: unknown;
}

/** One page of `lfpStores.list`. */
interface LfpStoresListPage {
lfpStores?: LfpStore[];
nextPageToken?: string;
}

/** Reduce an LfpStore id or full resource name to its bare id. */
export function lfpStoreSegment(idOrName: string): string {
return idOrName.replace(/^.*\/lfpStores\//, "");
}

/** Reduce a merchant-state id or full resource name to the bare target-account id. */
export function lfpMerchantStateSegment(idOrName: string): string {
return idOrName.replace(/^.*\/lfpMerchantStates\//, "");
}

/** Provider-side access to the Merchant API Local Feeds Partnership sub-API. */
export class LfpService {
constructor(private readonly client: MerchantClient) {}

/** `lfp/v1/accounts/{provider}/{collection}` — the provider account is the path account. */
private base(collection: string): string {
return `${LFP_API}/${this.client.accountResource}/${collection}`;
}

/**
* List the stores the provider has registered for one merchant, following pagination.
* `targetAccount` (the merchant's numeric Merchant Center id) is a required query filter.
*/
async listStores(targetAccount: string): Promise<LfpStore[]> {
const stores: LfpStore[] = [];
for await (const s of this.client.paginate<LfpStore>("lfp", this.base("lfpStores"), {
query: { targetAccount },
select: (page) => (page as LfpStoresListPage).lfpStores ?? [],
})) {
stores.push(s);
}
return stores;
}

/** Fetch a single store by id (or full resource name). */
getStore(idOrName: string): Promise<LfpStore> {
return this.client.get<LfpStore>(
"lfp",
`${this.base("lfpStores")}/${encodeURIComponent(lfpStoreSegment(idOrName))}`,
);
}

/** Insert (upsert) a store for a target merchant (`lfpStores:insert`). */
insertStore(body: LfpStoreInput): Promise<LfpStore> {
return this.client.request<LfpStore>("lfp", "POST", `${this.base("lfpStores")}:insert`, {
body,
});
}

/** Delete a store by id. */
async deleteStore(idOrName: string): Promise<void> {
await this.client.delete<undefined>(
"lfp",
`${this.base("lfpStores")}/${encodeURIComponent(lfpStoreSegment(idOrName))}`,
);
}

/** Insert (upsert) a local inventory entry (`lfpInventories:insert`). */
insertInventory(body: LfpInventoryInput): Promise<LfpInventory> {
return this.client.request<LfpInventory>(
"lfp",
"POST",
`${this.base("lfpInventories")}:insert`,
{ body },
);
}

/** Submit a local sale event (`lfpSales:insert`). */
insertSale(body: LfpSaleInput): Promise<LfpSale> {
return this.client.request<LfpSale>("lfp", "POST", `${this.base("lfpSales")}:insert`, { body });
}

/** Fetch a merchant's LFP onboarding state by target-account id (or full resource name). */
getMerchantState(idOrName: string): Promise<LfpMerchantState> {
return this.client.get<LfpMerchantState>(
"lfp",
`${this.base("lfpMerchantStates")}/${encodeURIComponent(lfpMerchantStateSegment(idOrName))}`,
);
}
}
1 change: 1 addition & 0 deletions packages/api/src/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const DEFAULT_RATE_LIMITS: RateLimitConfig = {
quota: { capacity: 30, refillPerSecond: 5 },
issueresolution: { capacity: 30, refillPerSecond: 5 },
conversions: { capacity: 30, refillPerSecond: 5 },
lfp: { 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 @@ -12,7 +12,8 @@ export type SubApi =
| "notifications"
| "quota"
| "issueresolution"
| "conversions";
| "conversions"
| "lfp";

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