From 754d7c9b2e355f9a3912214f7251224bd60efca9 Mon Sep 17 00:00:00 2001 From: Yasser's studio Date: Sun, 14 Jun 2026 20:51:30 +0100 Subject: [PATCH] =?UTF-8?q?feat(lfp):=20add=20gmc=20lfp=20(Merchant=20API?= =?UTF-8?q?=20lfp/v1)=20=E2=80=94=20completes=2011/11=20sub-APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the lfp/v1 Local Feeds Partnership sub-API, the 11th and final Merchant API sub-API. Provider-side: the scoped account is the LFP provider and each resource names a targetAccount (the merchant). New LfpService: lfpStores list/get/insert/delete, lfpInventories insert, lfpSales insert, lfpMerchantStates get. CLI gmc lfp stores|inventory| sales|state with convenience flags or --file. Adds the lfp rate-limit bucket and OAuth-scope wiring. Resource shapes, the :insert colon-verb paths, the per-merchant targetAccount (a bare int64 id), and the required targetAccount list filter were verified against the lfp_v1 discovery document. --- .changeset/lfp.md | 19 ++ docs/.vitepress/config.ts | 1 + docs/reference/index.md | 1 + docs/reference/lfp.md | 52 +++ packages/api/src/index.ts | 11 + packages/api/src/lfp.ts | 237 +++++++++++++ packages/api/src/rate-limiter.ts | 1 + packages/api/src/types.ts | 4 +- packages/api/tests/lfp.test.ts | 128 ++++++++ packages/api/tests/rate-limiter.test.ts | 6 +- packages/auth/src/scopes.ts | 5 +- packages/cli/src/commands/lfp.ts | 420 ++++++++++++++++++++++++ packages/cli/src/program.ts | 2 + packages/cli/tests/lfp.test.ts | 270 +++++++++++++++ packages/migrate/tests/scopes.test.ts | 1 + 15 files changed, 1153 insertions(+), 5 deletions(-) create mode 100644 .changeset/lfp.md create mode 100644 docs/reference/lfp.md create mode 100644 packages/api/src/lfp.ts create mode 100644 packages/api/tests/lfp.test.ts create mode 100644 packages/cli/src/commands/lfp.ts create mode 100644 packages/cli/tests/lfp.test.ts diff --git a/.changeset/lfp.md b/.changeset/lfp.md new file mode 100644 index 0000000..12edae4 --- /dev/null +++ b/.changeset/lfp.md @@ -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 `. 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. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 259a9c1..3fc0466 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -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" }, ], diff --git a/docs/reference/index.md b/docs/reference/index.md index d88d40f..bfcf2dc 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -26,6 +26,7 @@ gmc [global options] [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`) | diff --git a/docs/reference/lfp.md b/docs/reference/lfp.md new file mode 100644 index 0000000..4523f4d --- /dev/null +++ b/docs/reference/lfp.md @@ -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 ` | 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 ` | 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 ` | Read a merchant's LFP onboarding state (diagnostics) | + +## Common flags + +`stores list` and every `insert` require `--target-account ` — the merchant's **numeric +Merchant Center id** (an `accounts/{id}` form is accepted and reduced to the id). Inserts also +require `--store-code `, and inventory/sales require `--offer-id `. A `--price ` +needs `--currency ` (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 ` (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": "" }` 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). diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 148c184..bd31fa4 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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, diff --git a/packages/api/src/lfp.ts b/packages/api/src/lfp.ts new file mode 100644 index 0000000..a3a5683 --- /dev/null +++ b/packages/api/src/lfp.ts @@ -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 `: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 { + const stores: LfpStore[] = []; + for await (const s of this.client.paginate("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 { + return this.client.get( + "lfp", + `${this.base("lfpStores")}/${encodeURIComponent(lfpStoreSegment(idOrName))}`, + ); + } + + /** Insert (upsert) a store for a target merchant (`lfpStores:insert`). */ + insertStore(body: LfpStoreInput): Promise { + return this.client.request("lfp", "POST", `${this.base("lfpStores")}:insert`, { + body, + }); + } + + /** Delete a store by id. */ + async deleteStore(idOrName: string): Promise { + await this.client.delete( + "lfp", + `${this.base("lfpStores")}/${encodeURIComponent(lfpStoreSegment(idOrName))}`, + ); + } + + /** Insert (upsert) a local inventory entry (`lfpInventories:insert`). */ + insertInventory(body: LfpInventoryInput): Promise { + return this.client.request( + "lfp", + "POST", + `${this.base("lfpInventories")}:insert`, + { body }, + ); + } + + /** Submit a local sale event (`lfpSales:insert`). */ + insertSale(body: LfpSaleInput): Promise { + return this.client.request("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 { + return this.client.get( + "lfp", + `${this.base("lfpMerchantStates")}/${encodeURIComponent(lfpMerchantStateSegment(idOrName))}`, + ); + } +} diff --git a/packages/api/src/rate-limiter.ts b/packages/api/src/rate-limiter.ts index 84b980c..3fd0b19 100644 --- a/packages/api/src/rate-limiter.ts +++ b/packages/api/src/rate-limiter.ts @@ -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 { diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 4bf550b..79766ec 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -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[] = [ @@ -26,4 +27,5 @@ export const SUB_APIS: readonly SubApi[] = [ "quota", "issueresolution", "conversions", + "lfp", ]; diff --git a/packages/api/tests/lfp.test.ts b/packages/api/tests/lfp.test.ts new file mode 100644 index 0000000..c7ad16d --- /dev/null +++ b/packages/api/tests/lfp.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect } from "vitest"; +import { MerchantClient } from "../src/client.js"; +import { LfpService, lfpStoreSegment, lfpMerchantStateSegment } from "../src/lfp.js"; +import type { Clock } from "../src/rate-limiter.js"; + +const auth = { + getAccessToken: async () => "tok", + getClientEmail: () => "e", + getProjectId: () => undefined, +}; +const instantClock: Clock = { now: () => 0, sleep: async () => {} }; + +function jsonResponse(status: number, body?: unknown): Response { + return new Response(body === undefined ? null : JSON.stringify(body), { status }); +} + +function capturing( + body: unknown, + status = 200, +): { service: LfpService; calls: { url: string; method?: string; body?: unknown }[] } { + const calls: { url: string; method?: string; body?: unknown }[] = []; + const fetchImpl = (async (u: string, init?: RequestInit) => { + calls.push({ + url: u, + method: init?.method, + body: typeof init?.body === "string" ? JSON.parse(init.body) : undefined, + }); + return jsonResponse(status, body); + }) as unknown as typeof fetch; + // The path account is the LFP *provider*. + const service = new LfpService( + new MerchantClient({ auth, accountId: "777", fetchImpl, clock: instantClock }), + ); + return { service, calls }; +} + +const BASE = "https://merchantapi.googleapis.com/lfp/v1/accounts/777"; + +describe("lfp segments", () => { + it("reduce full resource names to bare ids", () => { + expect(lfpStoreSegment("accounts/777/lfpStores/m1~s1")).toBe("m1~s1"); + expect(lfpStoreSegment("m1~s1")).toBe("m1~s1"); + expect(lfpMerchantStateSegment("accounts/777/lfpMerchantStates/123")).toBe("123"); + }); +}); + +describe("LfpService", () => { + it("lists a merchant's stores, sending targetAccount and following pagination", async () => { + let call = 0; + const pages = [ + { lfpStores: [{ name: "accounts/777/lfpStores/a" }], nextPageToken: "t2" }, + { lfpStores: [{ name: "accounts/777/lfpStores/b" }] }, + ]; + const urls: string[] = []; + const fetchImpl = (async (u: string) => { + urls.push(u); + return jsonResponse(200, pages[call++]); + }) as unknown as typeof fetch; + const service = new LfpService( + new MerchantClient({ auth, accountId: "777", fetchImpl, clock: instantClock }), + ); + const list = await service.listStores("123"); + expect(list.map((s) => lfpStoreSegment(s.name ?? ""))).toEqual(["a", "b"]); + expect(urls[0]).toContain(`${BASE}/lfpStores?`); + expect(urls[0]).toContain("targetAccount=123"); + expect(urls[1]).toContain("pageToken=t2"); + }); + + it("gets a store, normalizing a full resource name", async () => { + const { service, calls } = capturing({ name: "accounts/777/lfpStores/m1~s1" }); + await service.getStore("accounts/777/lfpStores/m1~s1"); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe(`${BASE}/lfpStores/m1~s1`); + }); + + it("inserts a store via lfpStores:insert with the body", async () => { + const { service, calls } = capturing({ name: "accounts/777/lfpStores/m1~s1" }); + const input = { targetAccount: "123", storeCode: "s1", storeName: "Shop" }; + await service.insertStore(input); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${BASE}/lfpStores:insert`); + expect(calls[0]?.body).toEqual(input); + }); + + it("deletes a store by id", async () => { + const { service, calls } = capturing(undefined, 204); + await service.deleteStore("m1~s1"); + expect(calls[0]?.method).toBe("DELETE"); + expect(calls[0]?.url).toBe(`${BASE}/lfpStores/m1~s1`); + }); + + it("inserts inventory via lfpInventories:insert", async () => { + const { service, calls } = capturing({ name: "accounts/777/lfpInventories/x" }); + const input = { + targetAccount: "123", + storeCode: "s1", + offerId: "sku1", + quantity: "5", + price: { amountMicros: "1990000", currencyCode: "USD" }, + }; + await service.insertInventory(input); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${BASE}/lfpInventories:insert`); + expect(calls[0]?.body).toEqual(input); + }); + + it("inserts a sale via lfpSales:insert", async () => { + const { service, calls } = capturing({ name: "accounts/777/lfpSales/x" }); + const input = { + targetAccount: "123", + storeCode: "s1", + offerId: "sku1", + quantity: "1", + saleTime: "2026-06-14T00:00:00Z", + }; + await service.insertSale(input); + expect(calls[0]?.method).toBe("POST"); + expect(calls[0]?.url).toBe(`${BASE}/lfpSales:insert`); + expect(calls[0]?.body).toEqual(input); + }); + + it("gets a merchant state by target-account id", async () => { + const { service, calls } = capturing({ name: "accounts/777/lfpMerchantStates/123" }); + await service.getMerchantState("123"); + expect(calls[0]?.method).toBe("GET"); + expect(calls[0]?.url).toBe(`${BASE}/lfpMerchantStates/123`); + }); +}); diff --git a/packages/api/tests/rate-limiter.test.ts b/packages/api/tests/rate-limiter.test.ts index a13c7d6..a10c1ba 100644 --- a/packages/api/tests/rate-limiter.test.ts +++ b/packages/api/tests/rate-limiter.test.ts @@ -20,9 +20,9 @@ function fakeClock(): Clock & { advance: (ms: number) => void; sleeps: number[] } describe("rate limiter buckets", () => { - it("DEFAULT_RATE_LIMITS has a bucket for all ten sub-APIs", () => { - expect(SUB_APIS).toHaveLength(10); - expect(SUB_APIS).toContain("conversions"); + it("DEFAULT_RATE_LIMITS has a bucket for all eleven sub-APIs", () => { + expect(SUB_APIS).toHaveLength(11); + expect(SUB_APIS).toContain("lfp"); expect(Object.keys(DEFAULT_RATE_LIMITS).sort()).toEqual([...SUB_APIS].sort()); }); diff --git a/packages/auth/src/scopes.ts b/packages/auth/src/scopes.ts index 89af625..4bf0d6c 100644 --- a/packages/auth/src/scopes.ts +++ b/packages/auth/src/scopes.ts @@ -23,7 +23,8 @@ export type SubApi = | "notifications" | "quota" | "issueresolution" - | "conversions"; + | "conversions" + | "lfp"; /** All sub-API keys, in a stable order. Mirrors `@gmc-cli/api` SUB_APIS. */ export const SUB_APIS: readonly SubApi[] = [ @@ -37,6 +38,7 @@ export const SUB_APIS: readonly SubApi[] = [ "quota", "issueresolution", "conversions", + "lfp", ]; // Per-sub-API scope map. Every entry currently resolves to the content scope; @@ -52,6 +54,7 @@ const SUB_API_SCOPES: Readonly> = { quota: [MERCHANT_API_SCOPE], issueresolution: [MERCHANT_API_SCOPE], conversions: [MERCHANT_API_SCOPE], + lfp: [MERCHANT_API_SCOPE], }; /** diff --git a/packages/cli/src/commands/lfp.ts b/packages/cli/src/commands/lfp.ts new file mode 100644 index 0000000..18ceb6d --- /dev/null +++ b/packages/cli/src/commands/lfp.ts @@ -0,0 +1,420 @@ +import type { Command } from "commander"; +import { emitJson, reportError, UsageError } from "@gmc-cli/core"; +import { + LfpService, + lfpStoreSegment, + toMicros, + type LfpStore, + type LfpStoreInput, + type LfpInventoryInput, + type LfpSaleInput, + type Price, +} from "@gmc-cli/api"; +import { contextFrom, wantsJson } from "../context.js"; +import { clientFor, resolveAccount, line, readJsonObject } from "./_shared.js"; + +// NOTE: every `gmc lfp` command targets the LFP **provider** account (resolved from +// --account / GMC_ACCOUNT_ID / profile). `--target-account` names the **merchant** the +// data is submitted for. This is the one sub-API where the scoped account is not the merchant. + +interface StoreInsertOpts { + targetAccount?: string; + storeCode?: string; + storeName?: string; + storeAddress?: string; + phone?: string; + website?: string; + placeId?: string; + gcidCategory?: string; + file?: string; +} +interface InventoryInsertOpts { + targetAccount?: string; + storeCode?: string; + offerId?: string; + regionCode?: string; + contentLanguage?: string; + feedLabel?: string; + gtin?: string; + quantity?: string; + price?: string; + currency?: string; + availability?: string; + pickupMethod?: string; + pickupSla?: string; + collectionTime?: string; + file?: string; +} +interface SaleInsertOpts { + targetAccount?: string; + storeCode?: string; + offerId?: string; + regionCode?: string; + contentLanguage?: string; + feedLabel?: string; + gtin?: string; + quantity?: string; + price?: string; + currency?: string; + saleTime?: string; + file?: string; +} + +/** + * Resolve `--target-account` to the bare numeric Merchant Center id the LFP API expects + * (the `targetAccount` field is an int64 id, NOT an `accounts/{id}` resource name). Accepts + * a bare id or an `accounts/{id}` form and returns the bare id. + */ +function targetAccountId(raw: string): string { + const bare = raw.replace(/^accounts\//, ""); + if (!/^\d+$/.test(bare)) { + throw new UsageError( + `Invalid --target-account "${raw}".`, + "Pass the merchant's numeric Merchant Center id, e.g. 123456789.", + ); + } + return bare; +} + +/** An int64-as-string quantity for the API; `allowNegative` permits a return (`-1`). */ +function parseQuantity(raw: string, allowNegative: boolean): string { + const re = allowNegative ? /^-?\d+$/ : /^\d+$/; + if (!re.test(raw)) { + throw new UsageError( + `Invalid --quantity "${raw}".`, + allowNegative ? "Use an integer (negative for a return)." : "Use a non-negative integer.", + ); + } + return raw; +} + +/** Build a Price from `--price ` + `--currency`, falling back to a file currency. */ +function buildPrice( + amount: string, + currency: string | undefined, + existing: Price | undefined, +): Price { + const amountMicros = toMicros(amount); + if (amountMicros === null) { + throw new UsageError(`Invalid --price "${amount}".`, "Use a non-negative decimal, e.g. 19.99."); + } + const currencyCode = currency ?? existing?.currencyCode; + if (!currencyCode) { + throw new UsageError( + "--currency is required with --price.", + "Pass --currency (e.g. USD), or set price.currencyCode in --file.", + ); + } + return { amountMicros, currencyCode }; +} + +/** Common required-field check for the three inserts: a target merchant and a store. */ +function requireTargetAndStore( + input: { targetAccount?: string; storeCode?: string }, + what: string, +): void { + if (!input.targetAccount) { + throw new UsageError( + `--target-account is required to insert ${what}.`, + "Pass --target-account , or include targetAccount in --file.", + ); + } + if (!input.storeCode) { + throw new UsageError( + `--store-code is required to insert ${what}.`, + "Pass --store-code , or include storeCode in --file.", + ); + } +} + +async function buildStore(opts: StoreInsertOpts): Promise { + const input: LfpStoreInput = opts.file + ? ((await readJsonObject(opts.file, "LFP store")) as LfpStoreInput) + : {}; + if (opts.targetAccount !== undefined) input.targetAccount = targetAccountId(opts.targetAccount); + if (opts.storeCode !== undefined) input.storeCode = opts.storeCode; + if (opts.storeName !== undefined) input.storeName = opts.storeName; + if (opts.storeAddress !== undefined) input.storeAddress = opts.storeAddress; + if (opts.phone !== undefined) input.phoneNumber = opts.phone; + if (opts.website !== undefined) input.websiteUri = opts.website; + if (opts.placeId !== undefined) input.placeId = opts.placeId; + if (opts.gcidCategory !== undefined) { + input.gcidCategory = opts.gcidCategory + .split(",") + .map((c) => c.trim()) + .filter(Boolean); + } + requireTargetAndStore(input, "a store"); + return input; +} + +async function buildInventory(opts: InventoryInsertOpts): Promise { + const input: LfpInventoryInput = opts.file + ? ((await readJsonObject(opts.file, "LFP inventory")) as LfpInventoryInput) + : {}; + if (opts.targetAccount !== undefined) input.targetAccount = targetAccountId(opts.targetAccount); + if (opts.storeCode !== undefined) input.storeCode = opts.storeCode; + if (opts.offerId !== undefined) input.offerId = opts.offerId; + if (opts.regionCode !== undefined) input.regionCode = opts.regionCode; + if (opts.contentLanguage !== undefined) input.contentLanguage = opts.contentLanguage; + if (opts.feedLabel !== undefined) input.feedLabel = opts.feedLabel; + if (opts.gtin !== undefined) input.gtin = opts.gtin; + if (opts.availability !== undefined) input.availability = opts.availability; + if (opts.pickupMethod !== undefined) input.pickupMethod = opts.pickupMethod; + if (opts.pickupSla !== undefined) input.pickupSla = opts.pickupSla; + if (opts.collectionTime !== undefined) input.collectionTime = opts.collectionTime; + if (opts.quantity !== undefined) input.quantity = parseQuantity(opts.quantity, false); + if (opts.price !== undefined) input.price = buildPrice(opts.price, opts.currency, input.price); + requireTargetAndStore(input, "an inventory"); + if (!input.offerId) { + throw new UsageError( + "--offer-id is required to insert an inventory.", + "Pass --offer-id , or include offerId in --file.", + ); + } + return input; +} + +async function buildSale(opts: SaleInsertOpts): Promise { + const input: LfpSaleInput = opts.file + ? ((await readJsonObject(opts.file, "LFP sale")) as LfpSaleInput) + : {}; + if (opts.targetAccount !== undefined) input.targetAccount = targetAccountId(opts.targetAccount); + if (opts.storeCode !== undefined) input.storeCode = opts.storeCode; + if (opts.offerId !== undefined) input.offerId = opts.offerId; + if (opts.regionCode !== undefined) input.regionCode = opts.regionCode; + if (opts.contentLanguage !== undefined) input.contentLanguage = opts.contentLanguage; + if (opts.feedLabel !== undefined) input.feedLabel = opts.feedLabel; + if (opts.gtin !== undefined) input.gtin = opts.gtin; + if (opts.saleTime !== undefined) input.saleTime = opts.saleTime; + if (opts.quantity !== undefined) input.quantity = parseQuantity(opts.quantity, true); + if (opts.price !== undefined) input.price = buildPrice(opts.price, opts.currency, input.price); + requireTargetAndStore(input, "a sale"); + if (!input.offerId) { + throw new UsageError( + "--offer-id is required to insert a sale.", + "Pass --offer-id , or include offerId in --file.", + ); + } + return input; +} + +/** The bare store id, preferring the resource `name` segment. */ +function storeIdOf(store: LfpStore): string { + return store.name ? lfpStoreSegment(store.name) : (store.storeCode ?? "—"); +} + +function renderStores(stores: LfpStore[]): void { + if (stores.length === 0) { + process.stdout.write("No LFP stores for this provider.\n"); + return; + } + const width = Math.max(...stores.map((s) => storeIdOf(s).length)); + process.stdout.write(`${stores.length} store(s):\n`); + for (const s of stores) { + const match = s.matchingState ? ` · ${s.matchingState}` : ""; + process.stdout.write(` ${storeIdOf(s).padEnd(width)} ${s.storeName ?? "—"}${match}\n`); + } +} + +function renderStore(store: LfpStore): void { + line("Store", storeIdOf(store)); + if (store.storeName) line("Name", store.storeName); + if (store.targetAccount) line("Merchant", store.targetAccount); + if (store.storeAddress) line("Address", store.storeAddress); + if (store.matchingState) line("Matching", store.matchingState); +} + +/** Register the `gmc lfp` command group (Local Feeds Partnership — provider-side). */ +export function registerLfpCommands(program: Command): void { + const lfp = program + .command("lfp") + .description( + "Local Feeds Partnership — provider submits stores/inventory/sales for target merchants", + ); + + const stores = lfp.command("stores").description("Manage the provider's registered stores"); + + stores + .command("list") + .option("--target-account ", "Merchant account to list stores for (required)") + .description("List the provider's LFP stores for a target merchant") + .action(async (opts: { targetAccount?: string }) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + if (opts.targetAccount === undefined) { + throw new UsageError( + "--target-account is required to list stores.", + "Pass --target-account (LFP stores are listed per merchant).", + ); + } + const service = new LfpService(await clientFor(ctx, account)); + const list = await service.listStores(targetAccountId(opts.targetAccount)); + if (ctx.json) emitJson({ lfpStores: list }); + else renderStores(list); + } catch (err) { + reportError(err, { json }, "gmc lfp stores list"); + } + }); + + stores + .command("get") + .argument("", "Store id or resource name (from `lfp stores list`)") + .description("Fetch one LFP store") + .action(async (id: string) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new LfpService(await clientFor(ctx, account)); + const result = await service.getStore(id); + if (ctx.json) emitJson(result); + else renderStore(result); + } catch (err) { + reportError(err, { json }, "gmc lfp stores get"); + } + }); + + stores + .command("insert") + .option("--target-account ", "Merchant account the store belongs to (required)") + .option("--store-code ", "Store code (required)") + .option("--store-name ", "Store display name") + .option("--store-address ", "Single-line store address") + .option("--phone ", "Store phone number") + .option("--website ", "Store website URI") + .option("--place-id ", "Google Place ID") + .option("--gcid-category ", "Comma-separated Google category ids") + .option("--file ", "Read the LfpStore JSON base from this file") + .description("Insert (create or replace) a store for a target merchant") + .action(async (opts: StoreInsertOpts) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const input = await buildStore(opts); + const service = new LfpService(await clientFor(ctx, account)); + const result = await service.insertStore(input); + if (ctx.json) emitJson(result); + else process.stdout.write(`Inserted store ${storeIdOf(result)}.\n`); + } catch (err) { + reportError(err, { json }, "gmc lfp stores insert"); + } + }); + + stores + .command("delete") + .argument("", "Store id or resource name") + .description("Delete an LFP store") + .action(async (id: string) => { + const json = wantsJson(program); + try { + const ctx = contextFrom(program); + const account = resolveAccount(undefined, ctx); + const service = new LfpService(await clientFor(ctx, account)); + await service.deleteStore(id); + const seg = lfpStoreSegment(id); + if (ctx.json) emitJson({ deleted: seg }); + else process.stdout.write(`Deleted store ${seg}.\n`); + } catch (err) { + reportError(err, { json }, "gmc lfp stores delete"); + } + }); + + const inventory = lfp + .command("inventory") + .description("Submit local inventory for a target merchant"); + + inventory + .command("insert") + .option("--target-account ", "Merchant account (required)") + .option("--store-code ", "Store code (required)") + .option("--offer-id ", "Product offer id (required)") + .option("--region-code ", "CLDR territory code, e.g. US") + .option("--content-language ", "Content language, e.g. en") + .option("--feed-label