Product feed: Product Pricing update (#840)

* Add attribute mapping

* Improve release note

* Log the error

* Add pattern attribute

* Add group ID

* Update the item pricing
This commit is contained in:
Krzysztof Wolski 2023-08-04 13:59:40 +02:00 committed by GitHub
parent 261957fda4
commit 0b0297eeb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 161 additions and 23 deletions

View file

@ -0,0 +1,13 @@
---
"saleor-app-products-feed": minor
---
Updated pricing attributes according to the Google guidelines.
Was:
- Price: base or discounted price
Now:
- Price: always the base price. Attribute skipped if amount is equal to 0.
- Sale price: discounted price. Attribute skipped if value is the same as base price

View file

@ -3,6 +3,12 @@ fragment GoogleFeedProductVariant on ProductVariant {
name name
sku sku
pricing { pricing {
priceUndiscounted{
gross {
currency
amount
}
}
price { price {
gross { gross {
currency currency

View file

@ -29,6 +29,14 @@ const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
currency: "USD", currency: "USD",
}, },
}, },
priceUndiscounted: {
__typename: "TaxedMoney",
gross: {
__typename: "Money",
amount: 2,
currency: "USD",
},
},
}; };
describe("generateGoogleXmlFeed", () => { describe("generateGoogleXmlFeed", () => {
@ -78,7 +86,8 @@ describe("generateGoogleXmlFeed", () => {
<g:product_type>Category Name</g:product_type> <g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category> <g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link> <link>https://example.com/p/product-slug</link>
<g:price>1.00 USD</g:price> <g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item> </item>
<item> <item>
<g:id>sku2</g:id> <g:id>sku2</g:id>
@ -89,7 +98,8 @@ describe("generateGoogleXmlFeed", () => {
<g:product_type>Category Name</g:product_type> <g:product_type>Category Name</g:product_type>
<g:google_product_category>1</g:google_product_category> <g:google_product_category>1</g:google_product_category>
<link>https://example.com/p/product-slug</link> <link>https://example.com/p/product-slug</link>
<g:price>1.00 USD</g:price> <g:price>2.00 USD</g:price>
<g:sale_price>1.00 USD</g:sale_price>
</item> </item>
</channel> </channel>
</rss>" </rss>"

View file

@ -5,6 +5,7 @@ import { shopDetailsToProxy } from "./shop-details-to-proxy";
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer"; import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
import { RootConfig } from "../app-configuration/app-config"; import { RootConfig } from "../app-configuration/app-config";
import { getMappedAttributes } from "./attribute-mapping"; import { getMappedAttributes } from "./attribute-mapping";
import { priceMapping } from "./price-mapping";
interface GenerateGoogleXmlFeedArgs { interface GenerateGoogleXmlFeedArgs {
productVariants: GoogleFeedProductVariantFragment[]; productVariants: GoogleFeedProductVariantFragment[];
@ -15,22 +16,6 @@ interface GenerateGoogleXmlFeedArgs {
shopDescription?: string; shopDescription?: string;
} }
/**
* Price format has to be altered from the en format to the one expected by Google
* eg. 1.00 USD, 5.00 PLN
*/
const formatCurrency = (currency: string, amount: number) => {
return (
new Intl.NumberFormat("en-EN", {
useGrouping: false,
minimumFractionDigits: 2,
style: "decimal",
currencyDisplay: "code",
currency: currency,
}).format(amount) + ` ${currency}`
);
};
export const generateGoogleXmlFeed = ({ export const generateGoogleXmlFeed = ({
attributeMapping, attributeMapping,
productVariants, productVariants,
@ -45,10 +30,7 @@ export const generateGoogleXmlFeed = ({
variant, variant,
}); });
const currency = variant.pricing?.price?.gross.currency; const pricing = priceMapping({ pricing: variant.pricing });
const amount = variant.pricing?.price?.gross.amount;
const price = currency ? formatCurrency(currency, amount!) : undefined;
return productToProxy({ return productToProxy({
storefrontUrlTemplate: productStorefrontUrl, storefrontUrlTemplate: productStorefrontUrl,
@ -62,13 +44,13 @@ export const generateGoogleXmlFeed = ({
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock", variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
category: variant.product.category?.name || "unknown", category: variant.product.category?.name || "unknown",
googleProductCategory: variant.product.category?.googleCategoryId || "", googleProductCategory: variant.product.category?.googleCategoryId || "",
price: price,
imageUrl: variant.product.thumbnail?.url || "", imageUrl: variant.product.thumbnail?.url || "",
material: attributes?.material, material: attributes?.material,
color: attributes?.color, color: attributes?.color,
brand: attributes?.brand, brand: attributes?.brand,
pattern: attributes?.pattern, pattern: attributes?.pattern,
size: attributes?.size, size: attributes?.size,
...pricing,
}); });
}); });

View file

@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { priceMapping } from "./price-mapping";
describe("priceMapping", () => {
it("Return undefined, when no pricing available", () => {
expect(
priceMapping({
pricing: undefined,
})
).toStrictEqual(undefined);
});
it("Return undefined, when amount is equal to 0", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 0,
currency: "USD",
},
},
},
})
).toStrictEqual(undefined);
});
it("Return formatted base price, when there is no sale", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 10.5,
currency: "USD",
},
},
},
})
).toStrictEqual({ price: "10.50 USD" });
});
it("Return formatted base and sale prices, when there is a sale", () => {
expect(
priceMapping({
pricing: {
priceUndiscounted: {
gross: {
amount: 10.5,
currency: "USD",
},
},
price: {
gross: {
amount: 5.25,
currency: "USD",
},
},
},
})
).toStrictEqual({ price: "10.50 USD", salePrice: "5.25 USD" });
});
});

View file

@ -0,0 +1,56 @@
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
/**
* Price format has to be altered from the en format to the one expected by Google
* eg. 1.00 USD, 5.00 PLN
*/
const formatCurrency = (currency: string, amount: number) => {
return (
new Intl.NumberFormat("en-EN", {
useGrouping: false,
minimumFractionDigits: 2,
style: "decimal",
currencyDisplay: "code",
currency: currency,
}).format(amount) + ` ${currency}`
);
};
interface priceMappingArgs {
pricing: GoogleFeedProductVariantFragment["pricing"];
}
/*
* Maps variant pricing to Google Feed format.
* https://support.google.com/merchants/answer/6324371
*/
export const priceMapping = ({ pricing }: priceMappingArgs) => {
const priceUndiscounted = pricing?.priceUndiscounted?.gross;
// Pricing should not be submitted empty or with 0 value
if (!priceUndiscounted?.amount) {
return;
}
// Price attribute is expected to be a base price
const formattedUndiscountedPrice = formatCurrency(
priceUndiscounted.currency,
priceUndiscounted.amount
);
const discountedPrice = pricing?.price?.gross;
// Return early if there is no sale
if (!discountedPrice || discountedPrice?.amount === priceUndiscounted.amount) {
return {
price: formattedUndiscountedPrice,
};
}
const formattedDiscountedPrice = formatCurrency(discountedPrice.currency, discountedPrice.amount);
return {
price: formattedUndiscountedPrice,
salePrice: formattedDiscountedPrice,
};
};

View file

@ -109,6 +109,16 @@ export const productToProxy = (p: ProductEntry) => {
}); });
} }
if (p.salePrice?.length) {
item.push({
"g:sale_price": [
{
"#text": p.salePrice,
},
],
});
}
if (p.material) { if (p.material) {
item.push({ item.push({
"g:material": [ "g:material": [

View file

@ -9,6 +9,7 @@ export type ProductEntry = {
imageUrl?: string; imageUrl?: string;
condition?: "new" | "refurbished" | "used"; condition?: "new" | "refurbished" | "used";
price?: string; price?: string;
salePrice?: string;
googleProductCategory?: string; googleProductCategory?: string;
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder"; availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
category: string; category: string;