From 1639804b112f9fe7f78b26262cf59bfb34b275d5 Mon Sep 17 00:00:00 2001 From: Ian Krieger <48930920+IanKrieger@users.noreply.github.com> Date: Thu, 28 Sep 2023 15:57:12 -0400 Subject: [PATCH] feat: allow price per advertiser (#900) * feat: allow for price-per-advertiser * fix: tests * fix: add default * fix: update for new pricing structure * fix: no need for default, use prices when switching * test: validate price schema * test: validate price schema pt 2 * fix: flaky test --- src/form/fragmentUtil.ts | 3 +- src/graphql/advertiser.generated.tsx | 93 ++++++ src/graphql/advertiser.graphql | 14 + src/graphql/types.ts | 18 +- src/user/hooks/useAdvertiserWithPrices.tsx | 41 +++ src/user/library/index.test.ts | 16 +- src/user/views/adsManager/types/index.ts | 17 +- .../components/campaign/CampaignSettings.tsx | 5 +- .../campaign/fields/BudgetField.tsx | 14 - .../campaign/fields/FormatField.tsx | 34 +- .../advanced/components/form/EditCampaign.tsx | 28 +- .../advanced/components/form/NewCampaign.tsx | 23 +- .../components/form/components/BaseForm.tsx | 6 +- src/validation/CampaignSchema.test.ts | 63 +++- src/validation/CampaignSchema.tsx | 310 ++++++++++-------- 15 files changed, 497 insertions(+), 188 deletions(-) create mode 100644 src/user/hooks/useAdvertiserWithPrices.tsx diff --git a/src/form/fragmentUtil.ts b/src/form/fragmentUtil.ts index e4833743..4329fa13 100644 --- a/src/form/fragmentUtil.ts +++ b/src/form/fragmentUtil.ts @@ -48,13 +48,12 @@ export function createAdSetFromFragment( campaignId?: string, ): CreateAdSetInput { const ads = (data.ads ?? []).filter((ad) => ad.state !== "deleted"); - return { campaignId, ads: ads.map((ad) => ({ creativeId: ad.creative.id, })), - price: ads[0].price, + price: ads[0].price ?? "6", bannedKeywords: data.bannedKeywords, billingType: data.billingType ?? "cpm", conversions: (data.conversions ?? []).map((c) => ({ diff --git a/src/graphql/advertiser.generated.tsx b/src/graphql/advertiser.generated.tsx index 5d9809ea..e45b3ff7 100644 --- a/src/graphql/advertiser.generated.tsx +++ b/src/graphql/advertiser.generated.tsx @@ -132,6 +132,12 @@ export type AdvertiserImageFragment = { createdAt: any; }; +export type AdvertiserPriceFragment = { + price: string; + billingType: Types.BillingType; + format: Types.CampaignFormat; +}; + export type AdvertiserImagesQueryVariables = Types.Exact<{ id: Types.Scalars["String"]; }>; @@ -148,6 +154,20 @@ export type AdvertiserImagesQuery = { } | null; }; +export type AdvertiserPricesQueryVariables = Types.Exact<{ + id: Types.Scalars["String"]; +}>; + +export type AdvertiserPricesQuery = { + advertiser?: { + prices: Array<{ + price: string; + billingType: Types.BillingType; + format: Types.CampaignFormat; + }>; + } | null; +}; + export type UploadAdvertiserImageMutationVariables = Types.Exact<{ input: Types.CreateAdvertiserImageInput; }>; @@ -209,6 +229,13 @@ export const AdvertiserImageFragmentDoc = gql` createdAt } `; +export const AdvertiserPriceFragmentDoc = gql` + fragment AdvertiserPrice on AdvertiserPrice { + price + billingType + format + } +`; export const AdvertiserDocument = gql` query advertiser($id: String!) { advertiser(id: $id) { @@ -451,6 +478,72 @@ export function refetchAdvertiserImagesQuery( ) { return { query: AdvertiserImagesDocument, variables: variables }; } +export const AdvertiserPricesDocument = gql` + query advertiserPrices($id: String!) { + advertiser(id: $id) { + prices { + ...AdvertiserPrice + } + } + } + ${AdvertiserPriceFragmentDoc} +`; + +/** + * __useAdvertiserPricesQuery__ + * + * To run a query within a React component, call `useAdvertiserPricesQuery` and pass it any options that fit your needs. + * When your component renders, `useAdvertiserPricesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAdvertiserPricesQuery({ + * variables: { + * id: // value for 'id' + * }, + * }); + */ +export function useAdvertiserPricesQuery( + baseOptions: Apollo.QueryHookOptions< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + AdvertiserPricesDocument, + options, + ); +} +export function useAdvertiserPricesLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables + >(AdvertiserPricesDocument, options); +} +export type AdvertiserPricesQueryHookResult = ReturnType< + typeof useAdvertiserPricesQuery +>; +export type AdvertiserPricesLazyQueryHookResult = ReturnType< + typeof useAdvertiserPricesLazyQuery +>; +export type AdvertiserPricesQueryResult = Apollo.QueryResult< + AdvertiserPricesQuery, + AdvertiserPricesQueryVariables +>; +export function refetchAdvertiserPricesQuery( + variables: AdvertiserPricesQueryVariables, +) { + return { query: AdvertiserPricesDocument, variables: variables }; +} export const UploadAdvertiserImageDocument = gql` mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { createAdvertiserImage(createImageInput: $input) { diff --git a/src/graphql/advertiser.graphql b/src/graphql/advertiser.graphql index 2c79350a..f8b95b5f 100644 --- a/src/graphql/advertiser.graphql +++ b/src/graphql/advertiser.graphql @@ -65,6 +65,12 @@ fragment AdvertiserImage on AdvertiserImage { createdAt } +fragment AdvertiserPrice on AdvertiserPrice { + price + billingType + format +} + query advertiserImages($id: String!) { advertiser(id: $id) { images { @@ -73,6 +79,14 @@ query advertiserImages($id: String!) { } } +query advertiserPrices($id: String!) { + advertiser(id: $id) { + prices { + ...AdvertiserPrice + } + } +} + mutation uploadAdvertiserImage($input: CreateAdvertiserImageInput!) { createAdvertiserImage(createImageInput: $input) { name diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 4c5763b5..49402b25 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -31,6 +31,12 @@ export type AdvertiserCampaignFilter = { includeCreativeSets?: InputMaybe; }; +export type AdvertiserPriceInput = { + billingType: BillingType; + format: CampaignFormat; + price: Scalars["Numeric"]; +}; + export enum AdvertiserSource { Managed = "MANAGED", SelfServe = "SELF_SERVE", @@ -40,6 +46,12 @@ export type ApproveCampaignInput = { campaignId: Scalars["String"]; }; +export enum BillingType { + Cpc = "CPC", + Cpm = "CPM", + Cpv = "CPV", +} + export type CampaignFilter = { /** only include campaigns for this format */ format?: InputMaybe; @@ -114,8 +126,6 @@ export type CreateAdInput = { creativeId?: InputMaybe; creativeSetId?: InputMaybe; id?: InputMaybe; - price?: InputMaybe; - priceType?: InputMaybe; webhooks?: InputMaybe>; }; @@ -134,7 +144,7 @@ export type CreateAdSetInput = { oses?: InputMaybe>; perDay: Scalars["Float"]; /** The price in the owning campaign's currency for each single confirmation of the priceType specified. Note therefore that the caller is responsible for dividing cost-per-mille by 1000. */ - price?: InputMaybe; + price: Scalars["Numeric"]; segments: Array; splitTestGroup?: InputMaybe; state?: InputMaybe; @@ -165,6 +175,7 @@ export type CreateAdvertiserInput = { mailingAddress: CreateAddressInput; name: Scalars["String"]; phone?: InputMaybe; + prices?: InputMaybe>; referrer?: InputMaybe; selfServiceCreate?: InputMaybe; selfServiceEdit?: InputMaybe; @@ -443,6 +454,7 @@ export type UpdateAdvertiserInput = { mailingAddress?: InputMaybe; name?: InputMaybe; phone?: InputMaybe; + prices?: InputMaybe>; publicKey?: InputMaybe; referrer?: InputMaybe; selfServiceCreate?: InputMaybe; diff --git a/src/user/hooks/useAdvertiserWithPrices.tsx b/src/user/hooks/useAdvertiserWithPrices.tsx new file mode 100644 index 00000000..a388728a --- /dev/null +++ b/src/user/hooks/useAdvertiserWithPrices.tsx @@ -0,0 +1,41 @@ +import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; +import { + AdvertiserPriceFragment, + useAdvertiserPricesQuery, +} from "graphql/advertiser.generated"; +import { useState } from "react"; +import { IAdvertiser } from "auth/context/auth.interface"; +import _ from "lodash"; + +export type AdvertiserWithPrices = IAdvertiser & { + prices: AdvertiserPriceFragment[]; +}; +export function useAdvertiserWithPrices() { + const { advertiser } = useAdvertiser(); + const [data, setData] = useState({ + ...advertiser, + prices: [], + }); + const [error, setError] = useState(); + + const { loading } = useAdvertiserPricesQuery({ + variables: { id: advertiser.id }, + onCompleted(data) { + const prices = data.advertiser?.prices ?? []; + if (_.isEmpty(prices)) { + setError("Unable to create a new campaign"); + return; + } + + setData({ + ...advertiser, + prices, + }); + }, + onError(error) { + setError(error.message); + }, + }); + + return { data, loading, error }; +} diff --git a/src/user/library/index.test.ts b/src/user/library/index.test.ts index 04f7a1de..2b6522a6 100644 --- a/src/user/library/index.test.ts +++ b/src/user/library/index.test.ts @@ -385,7 +385,13 @@ describe("edit form tests", () => { ); it("should result in a valid campaign form", () => { const omitted = _.omit(editForm, ["newCreative"]); - expect(omitted).toMatchInlineSnapshot(` + const sorted = { + ...omitted, + adSets: omitted.adSets?.sort( + (a, b) => a.id?.localeCompare(b.id ?? "") ?? 1, + ), + }; + expect(sorted).toMatchInlineSnapshot(` { "adSets": [ { @@ -528,7 +534,13 @@ describe("edit form tests", () => { it("should resolve to update input", () => { const update = transformEditForm(editForm, editForm.id ?? ""); - expect(update).toMatchInlineSnapshot(` + const sorted = { + ...update, + adSets: update.adSets?.sort( + (a, b) => a.id?.localeCompare(b.id ?? "") ?? 1, + ), + }; + expect(sorted).toMatchInlineSnapshot(` { "adSets": [ { diff --git a/src/user/views/adsManager/types/index.ts b/src/user/views/adsManager/types/index.ts index 85753b97..bfbded55 100644 --- a/src/user/views/adsManager/types/index.ts +++ b/src/user/views/adsManager/types/index.ts @@ -1,7 +1,7 @@ import { CampaignFormat, CreativeInput, PaymentType } from "graphql/types"; import { defaultEndDate, defaultStartDate } from "form/DateFieldHelpers"; import { MIN_PER_CAMPAIGN } from "validation/CampaignSchema"; -import { IAdvertiser } from "auth/context/auth.interface"; +import { AdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; export type Billing = "cpm" | "cpc" | "cpv"; @@ -106,7 +106,14 @@ export const initialAdSet: AdSetForm = { creatives: [], }; -export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => { +export const initialCampaign = ( + advertiser: AdvertiserWithPrices, +): CampaignForm => { + const format = CampaignFormat.PushNotification; + const billingType = "cpm"; + const price = advertiser.prices.find( + (p) => p.billingType === billingType.toUpperCase() && p.format === format, + ); return { isCreating: false, advertiserId: advertiser.id, @@ -117,15 +124,15 @@ export const initialCampaign = (advertiser: IAdvertiser): CampaignForm => { dailyBudget: MIN_PER_CAMPAIGN, geoTargets: [], newCreative: initialCreative, - billingType: "cpm", + billingType, currency: "USD", - price: "6", + price: price?.price ?? "6", adSets: [ { ...initialAdSet, }, ], - format: CampaignFormat.PushNotification, + format, name: "", state: "draft", type: "paid", diff --git a/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx b/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx index 20876549..f6302035 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/CampaignSettings.tsx @@ -4,8 +4,9 @@ import { CampaignDateRange } from "components/Campaigns/CampaignDateRange"; import { LocationField } from "user/views/adsManager/views/advanced/components/campaign/fields/LocationField"; import { Typography } from "@mui/material"; import { FormatField } from "user/views/adsManager/views/advanced/components/campaign/fields/FormatField"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; -export function CampaignSettings() { +export function CampaignSettings(props: { prices: AdvertiserPriceFragment[] }) { const { isDraft } = useIsEdit(); return ( @@ -20,7 +21,7 @@ export function CampaignSettings() { - + {isDraft && } diff --git a/src/user/views/adsManager/views/advanced/components/campaign/fields/BudgetField.tsx b/src/user/views/adsManager/views/advanced/components/campaign/fields/BudgetField.tsx index 037d1a2e..df910158 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/fields/BudgetField.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/fields/BudgetField.tsx @@ -16,15 +16,8 @@ import { uiLabelsForBillingType } from "util/billingType"; import { uiTextForCampaignFormat } from "user/library"; import { CampaignFormat } from "graphql/types"; -type DefaultPrice = { cpm: number; cpc: number }; -const campaignDefaultPrices = new Map([ - [CampaignFormat.PushNotification, { cpm: 6, cpc: 0.1 }], - [CampaignFormat.NewsDisplayAd, { cpm: 10, cpc: 0.15 }], -]); - export function BudgetField() { const [, , dailyBudget] = useField("dailyBudget"); - const [, , price] = useField("price"); const { isDraft } = useIsEdit(); const { advertiser } = useAdvertiser(); const { values, errors } = useFormikContext(); @@ -102,13 +95,6 @@ export function BudgetField() { { - const defaultPrice = campaignDefaultPrices.get(values.format); - if (defaultPrice) - price.setValue( - defaultPrice[e.target.value as keyof DefaultPrice], - ); - }} options={[ { value: "cpm", diff --git a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx index fb2208a3..f1e29b30 100644 --- a/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx +++ b/src/user/views/adsManager/views/advanced/components/campaign/fields/FormatField.tsx @@ -12,8 +12,13 @@ import _ from "lodash"; import HelpIcon from "@mui/icons-material/Help"; import { useIsEdit } from "form/FormikHelpers"; import { Billing } from "user/views/adsManager/types"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; -export function FormatField() { +interface PriceProps { + prices: AdvertiserPriceFragment[]; +} + +export function FormatField({ prices }: PriceProps) { return ( @@ -34,18 +39,24 @@ export function FormatField() { - - + + ); } -const FormatItemButton = (props: { format: CampaignFormat }) => { +const FormatItemButton = (props: { format: CampaignFormat } & PriceProps) => { const { isEdit } = useIsEdit(); const [, meta, format] = useField("format"); - const [, , price] = useField("price"); - const [, , billing] = useField("billingType"); + const [, , price] = useField("price"); + const [, bMeta, billing] = useField("billingType"); return ( { selected={meta.value === props.format} onClick={() => { format.setValue(props.format); + const found = props.prices.find((p) => { + return ( + p.format === props.format && + p.billingType === bMeta.value.toUpperCase() + ); + }); + console.log(found); if (props.format === CampaignFormat.NewsDisplayAd) { - price.setValue(10); + price.setValue(found?.price ?? "10"); billing.setValue("cpm"); } else { - price.setValue(6); + price.setValue(found?.price ?? "6"); } }} sx={{ diff --git a/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx b/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx index a9e259ae..90d477b0 100644 --- a/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx +++ b/src/user/views/adsManager/views/advanced/components/form/EditCampaign.tsx @@ -9,12 +9,12 @@ import { } from "graphql/campaign.generated"; import { useHistory, useParams } from "react-router-dom"; import { BaseForm } from "./components/BaseForm"; -import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { useCreatePaymentSession } from "checkout/hooks/useCreatePaymentSession"; import { ErrorDetail } from "components/Error/ErrorDetail"; import { refetchAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; import { useContext } from "react"; import { FilterContext } from "state/context"; +import { useAdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; interface Params { campaignId: string; @@ -22,10 +22,14 @@ interface Params { export function EditCampaign() { const { fromDate } = useContext(FilterContext); - const { advertiser } = useAdvertiser(); const history = useHistory(); const params = useParams(); const { createPaymentSession, loading } = useCreatePaymentSession(); + const { + data, + loading: priceLoading, + error: priceError, + } = useAdvertiserWithPrices(); const { data: initialData, @@ -53,27 +57,33 @@ export function EditCampaign() { refetchQueries: [ { ...refetchAdvertiserCampaignsQuery({ - id: advertiser.id, + id: data.id, filter: { from: fromDate }, }), }, ], }); - if (error) { + if (error || priceError) { return ( ); } - if (!initialData || !initialData.campaign || qLoading || loading) { + if ( + !initialData || + !initialData.campaign || + qLoading || + loading || + priceLoading + ) { return ; } - const initialValues = editCampaignValues(initialData.campaign, advertiser.id); + const initialValues = editCampaignValues(initialData.campaign, data.id); return ( - + ); diff --git a/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx b/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx index dc4e3a5d..544d7983 100644 --- a/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx +++ b/src/user/views/adsManager/views/advanced/components/form/NewCampaign.tsx @@ -9,11 +9,12 @@ import { useHistory, useParams } from "react-router-dom"; import { BaseForm } from "./components/BaseForm"; import { PersistFormValues } from "form/PersistFormValues"; import { DraftContext, FilterContext } from "state/context"; -import { useAdvertiser } from "auth/hooks/queries/useAdvertiser"; import { useCreatePaymentSession } from "checkout/hooks/useCreatePaymentSession"; import { PaymentType } from "graphql/types"; import { useUser } from "auth/hooks/queries/useUser"; import { refetchAdvertiserCampaignsQuery } from "graphql/advertiser.generated"; +import { useAdvertiserWithPrices } from "user/hooks/useAdvertiserWithPrices"; +import { ErrorDetail } from "components/Error/ErrorDetail"; interface Params { draftId: string; @@ -23,14 +24,15 @@ export function NewCampaign() { const history = useHistory(); const { fromDate } = useContext(FilterContext); const params = useParams(); - const { advertiser } = useAdvertiser(); const { userId } = useUser(); - const { createPaymentSession, loading } = useCreatePaymentSession(); + const { createPaymentSession, loading: sessionLoading } = + useCreatePaymentSession(); + const { data, loading, error } = useAdvertiserWithPrices(); const { setDrafts } = useContext(DraftContext); const initial: CampaignForm = { - ...initialCampaign(advertiser), + ...initialCampaign(data), draftId: params.draftId, }; @@ -54,20 +56,25 @@ export function NewCampaign() { refetchQueries: [ { ...refetchAdvertiserCampaignsQuery({ - id: advertiser.id, + id: data.id, filter: { from: fromDate }, }), }, ], }); - if (loading) { + if (loading || sessionLoading) { return ; } + if (error) { + return ; + } + return ( { setSubmitting(true); @@ -75,10 +82,10 @@ export function NewCampaign() { await mutation({ variables: { input } }); setSubmitting(false); }} - validationSchema={CampaignSchema} + validationSchema={CampaignSchema(data.prices)} > <> - + diff --git a/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx b/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx index bf964cb2..82571bef 100644 --- a/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx +++ b/src/user/views/adsManager/views/advanced/components/form/components/BaseForm.tsx @@ -10,12 +10,14 @@ import { Route, Switch, useRouteMatch } from "react-router-dom"; import { BudgetSettings } from "user/views/adsManager/views/advanced/components/campaign/BudgetSettings"; import { FormContext } from "state/context"; import { useState } from "react"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; interface Props { hasPaymentIntent?: boolean | null; + prices: AdvertiserPriceFragment[]; } -export function BaseForm({ hasPaymentIntent }: Props) { +export function BaseForm({ hasPaymentIntent, prices }: Props) { const { url } = useRouteMatch(); const [isShowingAds, setIsShowingAds] = useState(false); @@ -23,7 +25,7 @@ export function BaseForm({ hasPaymentIntent }: Props) { { label: "Campaign Settings", path: `${url}/settings`, - component: , + component: , }, { label: "Budget", diff --git a/src/validation/CampaignSchema.test.ts b/src/validation/CampaignSchema.test.ts index ba2b8102..cdd61ebf 100644 --- a/src/validation/CampaignSchema.test.ts +++ b/src/validation/CampaignSchema.test.ts @@ -1,7 +1,26 @@ import { parseISO } from "date-fns"; import { produce } from "immer"; -import { CampaignFormat, CampaignPacingStrategies } from "../graphql/types"; +import { + BillingType, + CampaignFormat, + CampaignPacingStrategies, +} from "graphql/types"; import { CampaignSchema } from "./CampaignSchema"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; +import { describe } from "vitest"; + +const prices: Omit[] = [ + { + format: CampaignFormat.PushNotification, + price: "6", + billingType: BillingType.Cpm, + }, + { + format: CampaignFormat.PushNotification, + price: ".15", + billingType: BillingType.Cpc, + }, +]; const validCampaign = { name: "some campaign", @@ -9,7 +28,7 @@ const validCampaign = { currency: "GBP", dailyBudget: 100, billingType: "cpm", - price: 6, + price: "6", dailyCap: 1, startAt: parseISO("2030-07-18"), endAt: parseISO("2030-07-20"), @@ -22,7 +41,7 @@ const validCampaign = { }; it("should pass on a valid object", () => { - CampaignSchema.validateSync(validCampaign); + CampaignSchema(prices).validateSync(validCampaign); }); it("should fail if the campaign start date is in past", () => { @@ -31,8 +50,42 @@ it("should fail if the campaign start date is in past", () => { }); expect(() => - CampaignSchema.validateSync(c), + CampaignSchema(prices).validateSync(c), ).toThrowErrorMatchingInlineSnapshot( - `"Start Date must be minimum of 2 days from today"`, + '"Start Date must be minimum of 2 days from today"', ); }); + +describe("pricing tests", () => { + it("should fail if the campaign price is less than allowed price", () => { + const c = produce(validCampaign, (draft) => { + draft.price = "5"; + }); + + expect(() => + CampaignSchema(prices).validateSync(c), + ).toThrowErrorMatchingInlineSnapshot('"CPM price must be 6 or higher"'); + }); + + it("should validate against default if none found", () => { + const c = produce(validCampaign, (draft) => { + (draft.format = CampaignFormat.NewsDisplayAd), (draft.price = "9"); + }); + + expect(() => + CampaignSchema(prices).validateSync(c), + ).toThrowErrorMatchingInlineSnapshot('"CPM price must be 10 or higher"'); + }); + + it("should validate against default if none found", () => { + const c = produce(validCampaign, (draft) => { + (draft.format = CampaignFormat.NewsDisplayAd), + (draft.billingType = "cpc"); + draft.price = ".1"; + }); + + expect(() => + CampaignSchema(prices).validateSync(c), + ).toThrowErrorMatchingInlineSnapshot('"CPC price must be 0.15 or higher"'); + }); +}); diff --git a/src/validation/CampaignSchema.tsx b/src/validation/CampaignSchema.tsx index 1e64cd03..c4e9957b 100644 --- a/src/validation/CampaignSchema.tsx +++ b/src/validation/CampaignSchema.tsx @@ -1,140 +1,194 @@ -import { array, boolean, date, number, object, ref, string } from "yup"; +import { + AnyObject, + array, + boolean, + date, + number, + object, + ref, + string, + StringSchema, +} from "yup"; import { startOfDay } from "date-fns"; import { twoDaysOut } from "form/DateFieldHelpers"; import { TrailingAsteriskRegex } from "validation/regex"; import { CreativeSchema } from "validation/CreativeSchema"; -import { CampaignFormat } from "graphql/types"; +import { BillingType, CampaignFormat } from "graphql/types"; +import { AdvertiserPriceFragment } from "graphql/advertiser.generated"; +import BigNumber from "bignumber.js"; export const MIN_PER_DAY = 33; export const MIN_PER_CAMPAIGN = 100; -export const CampaignSchema = object().shape({ - name: string().label("Campaign Name").required(), - format: string() - .label("Campaign Format") - .oneOf([CampaignFormat.NewsDisplayAd, CampaignFormat.PushNotification]) - .required(), - budget: number() - .label("Lifetime Budget") - .required() - .min( - MIN_PER_CAMPAIGN, - `Lifetime budget must be $${MIN_PER_CAMPAIGN} or more`, - ), - newCreative: object().when("isCreating", { - is: true, - then: () => CreativeSchema, - }), - validateStart: boolean(), - dailyBudget: number() - .label("Daily Budget") - .required() - .positive() - .min(MIN_PER_DAY, "Lifetime budget must be higher for date range provided"), - startAt: date() - .label("Start Date") - .when("validateStart", { +export const CampaignSchema = (prices: AdvertiserPriceFragment[]) => + object().shape({ + name: string().label("Campaign Name").required(), + format: string() + .label("Campaign Format") + .oneOf([CampaignFormat.NewsDisplayAd, CampaignFormat.PushNotification]) + .required(), + budget: number() + .label("Lifetime Budget") + .required() + .min( + MIN_PER_CAMPAIGN, + `Lifetime budget must be $${MIN_PER_CAMPAIGN} or more`, + ), + newCreative: object().when("isCreating", { is: true, - then: (schema) => - schema - .min( - startOfDay(twoDaysOut()), - "Start Date must be minimum of 2 days from today", - ) - .required(), + then: () => CreativeSchema, }), - endAt: date() - .label("End Date") - .required() - .min(ref("startAt"), "End date must be after Start date"), - geoTargets: array() - .label("Locations") - .of( - object().shape({ - code: string().required(), - name: string().required(), + validateStart: boolean(), + dailyBudget: number() + .label("Daily Budget") + .required() + .positive() + .min( + MIN_PER_DAY, + "Lifetime budget must be higher for date range provided", + ), + startAt: date() + .label("Start Date") + .when("validateStart", { + is: true, + then: (schema) => + schema + .min( + startOfDay(twoDaysOut()), + "Start Date must be minimum of 2 days from today", + ) + .required(), }), - ) - .min(1, "At least one country must be targeted") - .default([]), - price: number() - .label("Price") - .when("billingType", { - is: (b: string) => b === "cpc", - then: (schema) => - schema.moreThan(0.09, "CPC price must be .10 or higher"), - }) - .when(["billingType", "format"], { - is: (b: string, f: CampaignFormat) => - b === "cpm" && f === CampaignFormat.PushNotification, - then: (schema) => schema.moreThan(5, "CPM price must be 6 or higher"), - }) - .when(["billingType", "format"], { - is: (b: string, f: CampaignFormat) => - b === "cpm" && f === CampaignFormat.NewsDisplayAd, - then: (schema) => schema.moreThan(9, "CPM price must be 10 or higher"), - }) - .required("Price is a required field"), - billingType: string() - .label("Pricing Type") - .oneOf(["cpm", "cpc"]) - .required("Pricing type is a required field"), - adSets: array() - .min(1) - .of( - object().shape({ - name: string().label("Ad Set Name").optional(), - segments: array() - .label("Audiences") - .of( - object().shape({ - code: string().required(), - name: string().required(), - }), - ) - .min(1, "At least one audience must be targeted") - .default([]), - oses: array() - .label("Platforms") - .of( - object().shape({ - code: string().required(), - name: string().required(), - }), - ) - .min(1, "At least one platform must be targeted") - .default([]), - conversions: array() - .label("Conversions") - .min(0) - .max(1) - .of( - object().shape({ - urlPattern: string() - .required("Conversion URL required.") - .matches( - TrailingAsteriskRegex, - "Conversion URL must end in trailing asterisk (*)", - ), - observationWindow: number() - .oneOf( - [1, 7, 30], - "Observation Window must be 1, 7, or 30 days.", - ) - .required("Observation Window required."), - type: string() - .oneOf( - ["postclick", "postview"], - "Conversion type must be Post Click or Post View", - ) - .required("Conversion Type required."), - }), + endAt: date() + .label("End Date") + .required() + .min(ref("startAt"), "End date must be after Start date"), + geoTargets: array() + .label("Locations") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one country must be targeted") + .default([]), + price: string() + .label("Price") + .when("billingType", { + is: (b: string) => b === "cpc", + then: (schema) => + findPrice( + prices, + CampaignFormat.PushNotification, + BillingType.Cpc, + "0.1", + schema, ), - creatives: array().test( - "min-length", - "Ad Sets must have at least one Ad", - (value) => (value ?? []).filter((c) => c.included).length > 0, - ), - }), - ), -}); + }) + .when(["billingType", "format"], { + is: (b: string, f: CampaignFormat) => + b === "cpm" && f === CampaignFormat.PushNotification, + then: (schema) => + findPrice( + prices, + CampaignFormat.PushNotification, + BillingType.Cpm, + "6", + schema, + ), + }) + .when(["billingType", "format"], { + is: (b: string, f: CampaignFormat) => + b === "cpm" && f === CampaignFormat.NewsDisplayAd, + then: (schema) => + findPrice( + prices, + CampaignFormat.NewsDisplayAd, + BillingType.Cpm, + "10", + schema, + ), + }) + .required("Price is a required field"), + billingType: string() + .label("Pricing Type") + .oneOf(["cpm", "cpc"]) + .required("Pricing type is a required field"), + adSets: array() + .min(1) + .of( + object().shape({ + name: string().label("Ad Set Name").optional(), + segments: array() + .label("Audiences") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one audience must be targeted") + .default([]), + oses: array() + .label("Platforms") + .of( + object().shape({ + code: string().required(), + name: string().required(), + }), + ) + .min(1, "At least one platform must be targeted") + .default([]), + conversions: array() + .label("Conversions") + .min(0) + .max(1) + .of( + object().shape({ + urlPattern: string() + .required("Conversion URL required.") + .matches( + TrailingAsteriskRegex, + "Conversion URL must end in trailing asterisk (*)", + ), + observationWindow: number() + .oneOf( + [1, 7, 30], + "Observation Window must be 1, 7, or 30 days.", + ) + .required("Observation Window required."), + type: string() + .oneOf( + ["postclick", "postview"], + "Conversion type must be Post Click or Post View", + ) + .required("Conversion Type required."), + }), + ), + creatives: array().test( + "min-length", + "Ad Sets must have at least one Ad", + (value) => (value ?? []).filter((c) => c.included).length > 0, + ), + }), + ), + }); + +export function findPrice( + prices: AdvertiserPriceFragment[], + format: CampaignFormat, + billingType: BillingType, + defaultPrice: string, + schema: StringSchema, +) { + const found = prices.find( + (p) => p.format === format && p.billingType === billingType, + ); + const price = BigNumber(found?.price ?? defaultPrice); + return schema.test( + "is-lte-price", + `${billingType} price must be ${price} or higher`, + (value) => (value ? price.isLessThanOrEqualTo(value) : true), + ); +}