From 0c2ed46e76c6f5b63db3ad26f157bb3279ca2cb3 Mon Sep 17 00:00:00 2001 From: Graham Tackley Date: Wed, 12 Jul 2023 17:26:02 +0100 Subject: [PATCH] feat: switch to using simplified pricing api (#836) re: https://github.com/brave/ads-serve/issues/2195 --- codegen.ts | 5 + package-lock.json | 9 ++ package.json | 1 + src/graphql/ad-set.generated.tsx | 95 ++---------- src/graphql/ad-set.graphql | 12 +- src/graphql/campaign.generated.tsx | 12 +- src/graphql/types.ts | 2 +- src/user/library/index.test.ts | 188 +++++++++++++++++++++++ src/user/library/index.ts | 50 +++--- src/user/views/adsManager/types/index.ts | 1 + 10 files changed, 250 insertions(+), 125 deletions(-) create mode 100644 src/user/library/index.test.ts diff --git a/codegen.ts b/codegen.ts index 57b5a9cc..4e6ca5ab 100644 --- a/codegen.ts +++ b/codegen.ts @@ -4,6 +4,11 @@ const config: CodegenConfig = { schema: "../ads-serve/src/graphql/schema.graphql", documents: "./src/**/*.graphql", hooks: { afterAllFileWrite: ["prettier --write"] }, + config: { + scalars: { + Numeric: "string", + }, + }, generates: { "src/": { preset: "near-operation-file", diff --git a/package-lock.json b/package-lock.json index 1a6710b9..7f6ebcb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@mui/x-date-pickers": "5.0.20", "axios": "1.4.0", "base64url": "3.0.1", + "bignumber.js": "9.1.1", "date-fns": "2.30.0", "date-fns-tz": "2.0.0", "formik": "2.4.1", @@ -4128,6 +4129,14 @@ "node": ">=6.0.0" } }, + "node_modules/bignumber.js": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", + "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", diff --git a/package.json b/package.json index 06a5e217..a0d8d694 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@mui/x-date-pickers": "5.0.20", "axios": "1.4.0", "base64url": "3.0.1", + "bignumber.js": "9.1.1", "date-fns": "2.30.0", "date-fns-tz": "2.0.0", "formik": "2.4.1", diff --git a/src/graphql/ad-set.generated.tsx b/src/graphql/ad-set.generated.tsx index b267f053..f555b739 100644 --- a/src/graphql/ad-set.generated.tsx +++ b/src/graphql/ad-set.generated.tsx @@ -31,7 +31,8 @@ export type AdSetFragment = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -54,7 +55,8 @@ export type AdFragment = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -105,7 +107,8 @@ export type CreateAdSetMutation = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -158,7 +161,8 @@ export type UpdateAdSetMutation = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -178,35 +182,6 @@ export type UpdateAdSetMutation = { }; }; -export type CreateAdMutationVariables = Types.Exact<{ - createAdInput: Types.CreateAdInput; -}>; - -export type CreateAdMutation = { - __typename?: "Mutation"; - createAd: { - __typename?: "Ad"; - id: string; - state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; - creative: { - __typename?: "Creative"; - id: string; - createdAt: any; - modifiedAt: any; - name: string; - state: string; - type: { __typename?: "CreativeType"; code: string }; - payloadNotification?: { - __typename?: "NotificationPayload"; - body: string; - title: string; - targetUrl: string; - } | null; - }; - }; -}; - export type UpdateAdMutationVariables = Types.Exact<{ updateAdInput: Types.UpdateAdInput; }>; @@ -220,10 +195,8 @@ export const AdFragmentDoc = gql` fragment Ad on Ad { id state - prices { - amount - type - } + price + priceType creative { ...Creative } @@ -362,54 +335,6 @@ export type UpdateAdSetMutationOptions = Apollo.BaseMutationOptions< UpdateAdSetMutation, UpdateAdSetMutationVariables >; -export const CreateAdDocument = gql` - mutation createAd($createAdInput: CreateAdInput!) { - createAd(createAdInput: $createAdInput) { - ...Ad - } - } - ${AdFragmentDoc} -`; -export type CreateAdMutationFn = Apollo.MutationFunction< - CreateAdMutation, - CreateAdMutationVariables ->; - -/** - * __useCreateAdMutation__ - * - * To run a mutation, you first call `useCreateAdMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useCreateAdMutation` returns a tuple that includes: - * - A mutate function that you can call at any time to execute the mutation - * - An object with fields that represent the current status of the mutation's execution - * - * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; - * - * @example - * const [createAdMutation, { data, loading, error }] = useCreateAdMutation({ - * variables: { - * createAdInput: // value for 'createAdInput' - * }, - * }); - */ -export function useCreateAdMutation( - baseOptions?: Apollo.MutationHookOptions< - CreateAdMutation, - CreateAdMutationVariables - >, -) { - const options = { ...defaultOptions, ...baseOptions }; - return Apollo.useMutation( - CreateAdDocument, - options, - ); -} -export type CreateAdMutationHookResult = ReturnType; -export type CreateAdMutationResult = Apollo.MutationResult; -export type CreateAdMutationOptions = Apollo.BaseMutationOptions< - CreateAdMutation, - CreateAdMutationVariables ->; export const UpdateAdDocument = gql` mutation updateAd($updateAdInput: UpdateAdInput!) { updateCreativeInstanceState(updateAdInput: $updateAdInput) { diff --git a/src/graphql/ad-set.graphql b/src/graphql/ad-set.graphql index f6d8a2d2..858e072d 100644 --- a/src/graphql/ad-set.graphql +++ b/src/graphql/ad-set.graphql @@ -29,10 +29,8 @@ fragment AdSet on AdSet { fragment Ad on Ad { id state - prices { - amount - type - } + price + priceType creative { ...Creative } @@ -50,12 +48,6 @@ mutation updateAdSet($updateAdSetInput: UpdateAdSetInput!) { } } -mutation createAd($createAdInput: CreateAdInput!) { - createAd(createAdInput: $createAdInput) { - ...Ad - } -} - mutation updateAd($updateAdInput: UpdateAdInput!) { updateCreativeInstanceState(updateAdInput: $updateAdInput) { id diff --git a/src/graphql/campaign.generated.tsx b/src/graphql/campaign.generated.tsx index db323cfc..69bf2fdc 100644 --- a/src/graphql/campaign.generated.tsx +++ b/src/graphql/campaign.generated.tsx @@ -60,7 +60,8 @@ export type CampaignFragment = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -144,7 +145,8 @@ export type CampaignAdsFragment = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -226,7 +228,8 @@ export type LoadCampaignQuery = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; @@ -292,7 +295,8 @@ export type LoadCampaignAdsQuery = { __typename?: "Ad"; id: string; state: string; - prices: Array<{ __typename?: "AdPrice"; amount: number; type: string }>; + price: string; + priceType: Types.ConfirmationType; creative: { __typename?: "Creative"; id: string; diff --git a/src/graphql/types.ts b/src/graphql/types.ts index 2da4a102..e79df52a 100644 --- a/src/graphql/types.ts +++ b/src/graphql/types.ts @@ -19,7 +19,7 @@ export type Scalars = { /** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */ DateTime: any; /** The `Numeric` datatype represents a fixed-precision number, which does not suffer from the rounding errors of a javascript floating point number. It's always returned as a string, but for input types either a string or number can be used, though strings are preferred to avoid risk of inaccuracy. */ - Numeric: any; + Numeric: string; }; export type AdvertiserCampaignFilter = { diff --git a/src/user/library/index.test.ts b/src/user/library/index.test.ts new file mode 100644 index 00000000..2e7d432a --- /dev/null +++ b/src/user/library/index.test.ts @@ -0,0 +1,188 @@ +import { CampaignFragment } from "graphql/campaign.generated"; +import { it, describe, expect } from "vitest"; +import { editCampaignValues, transformCreative } from "."; +import { + CampaignFormat, + CampaignPacingStrategies, + CampaignSource, + ConfirmationType, + PaymentType, +} from "graphql/types"; +import { produce } from "immer"; +import { Creative } from "user/views/adsManager/types"; + +const BASE_CPM_CAMPAIGN_FRAGMENT: Readonly = { + id: "3495317a-bb47-4daf-8d3e-14cdc0e87457", + name: "demo", + state: "under_review", + dailyCap: 1, + priority: 1, + passThroughRate: 1, + pacingOverride: false, + pacingStrategy: CampaignPacingStrategies.ModelV1, + externalId: "", + currency: "USD", + budget: 2500, + dailyBudget: 147, + spent: 0, + createdAt: "2023-07-11T16:13:31.205Z", + startAt: "2023-07-14T04:00:00.000Z", + endAt: "2023-08-01T03:59:00.000Z", + source: CampaignSource.SelfServe, + type: "paid", + format: CampaignFormat.PushNotification, + paymentType: PaymentType.Stripe, + geoTargets: [ + { + code: "US", + name: "United States", + }, + ], + adSets: [ + { + id: "39644642-b56a-430a-90f8-8917651bb62f", + createdAt: "2023-07-11T16:13:31.286Z", + billingType: "cpm", + name: "Demo ad set", + totalMax: 10, + perDay: 1, + state: "active", + execution: "per_click", + segments: [ + { + code: "elchqV0qNh", + name: "Arts & Entertainment", + }, + ], + oses: [ + { + code: "i1g4cO6Pl", + name: "windows", + }, + { + code: "_Bt5nxrNo", + name: "macos", + }, + { + code: "-Ug5OXisJ", + name: "linux", + }, + { + code: "k80syyzDa", + name: "ios", + }, + { + code: "mbwfZU-4W", + name: "android", + }, + ], + conversions: [], + ads: [ + { + id: "13e4d556-cec4-4b2a-85e6-73fdf625c0cb", + state: "active", + price: "0.006", + priceType: ConfirmationType.View, + creative: { + id: "3ee776b6-dd70-4dc5-ba5d-6147c10f2d3d", + createdAt: "2023-07-11T16:13:19.322Z", + modifiedAt: "2023-07-11T16:15:18.200Z", + name: "Demo creative", + state: "under_review", + type: { + code: "notification_all_v1", + }, + payloadNotification: { + body: "demo body", + title: "demo title", + targetUrl: "https://brave.com/", + }, + }, + }, + ], + }, + ], + advertiser: { + id: "a3803f55-a755-42df-bf15-9655ea98bac1", + }, +}; + +describe("pricing logic (read)", () => { + // prices in the adsever are always expressed per single unit, i.e. the price per click + // or per impression. Conventionally, impression prices are usually displayed the the user + // as CPM's, or the price per thousand impressions. + + // In the campaign form backing object CampaignForm, designed for the UI, we follow that convention: + // the `price` field is per click for CPC, and per thousand views for CPM. + + // But we need to perform that conversion both when populating CampaignForm, and populating + // the input values to create / update + + it("should convert per-impression values to CPM when populating CampaignForm", () => { + const campaign = produce(BASE_CPM_CAMPAIGN_FRAGMENT, (c) => { + c.adSets.forEach((adset) => { + adset.billingType = "cpm"; + adset.ads?.forEach((ad) => { + ad.price = "0.007"; + ad.priceType = ConfirmationType.View; + }); + }); + }); + const campaignForm = editCampaignValues(campaign, "abc"); + expect(campaignForm.price).toEqual(7); + expect(campaignForm.billingType).toEqual("cpm"); + }); + + it("should convert per-impression values to CPM when populating CampaignForm", () => { + const campaign = produce(BASE_CPM_CAMPAIGN_FRAGMENT, (c) => { + c.adSets.forEach((adset) => { + adset.billingType = "cpc"; + adset.ads?.forEach((ad) => { + ad.price = "1"; + ad.priceType = ConfirmationType.View; + }); + }); + }); + const campaignForm = editCampaignValues(campaign, "abc"); + expect(campaignForm.price).toEqual(1); + expect(campaignForm.billingType).toEqual("cpc"); + }); + + it("should default to a price if no adsets are found", () => { + const campaign = produce(BASE_CPM_CAMPAIGN_FRAGMENT, (c) => { + c.adSets = []; + }); + const formObject = editCampaignValues(campaign, "abc"); + expect(formObject.price).toEqual(6); + expect(formObject.billingType).toEqual("cpm"); + }); +}); + +describe("pricing logic (write)", () => { + const creative: Creative = { + name: "some name", + title: "some title", + body: "body", + targetUrl: "https://some.example.org", + }; + + it("should convert from CPM to per-impression values when populating a CPM creative", () => { + const inputObject = transformCreative(creative, { + billingType: "cpm", + price: 9, + }); + + expect(inputObject.price).toEqual("0.009"); + expect(inputObject.priceType).toEqual(ConfirmationType.View); + }); + + it("should not convert CPC to per-impression values when populating a CPC creative", () => { + const inputObject = transformCreative(creative, { + billingType: "cpc", + price: 9, + }); + + expect(inputObject.price).toEqual("9"); + expect(inputObject.priceType).toEqual(ConfirmationType.Click); + }); +}); diff --git a/src/user/library/index.ts b/src/user/library/index.ts index cf8f7d2c..f95cdde1 100644 --- a/src/user/library/index.ts +++ b/src/user/library/index.ts @@ -1,23 +1,15 @@ import { - AdvertiserCampaignFilter, CampaignFormat, + ConfirmationType, CreateAdInput, - CreateAdSetInput, CreateCampaignInput, CreateNotificationCreativeInput, GeocodeInput, - UpdateAdSetInput, UpdateCampaignInput, UpdateNotificationCreativeInput, } from "graphql/types"; -import axios from "axios"; -import { DocumentNode, print } from "graphql"; import { CampaignFragment } from "graphql/campaign.generated"; -import { AdFragment, CreateAdDocument } from "graphql/ad-set.generated"; -import { - CreateNotificationCreativeDocument, - UpdateNotificationCreativeDocument, -} from "graphql/creative.generated"; +import { AdFragment } from "graphql/ad-set.generated"; import { Billing, CampaignForm, @@ -28,11 +20,7 @@ import { Segment, } from "user/views/adsManager/types"; import _ from "lodash"; -import { renderStatsCell } from "user/analytics/renderers"; -import { ColumnDescriptor } from "components/EnhancedTable"; -import { AdDetails } from "user/ads/AdList"; -import { EngagementFragment } from "graphql/analytics-overview.generated"; -import { StatsMetric } from "user/analytics/analyticsOverview/types"; +import BigNumber from "bignumber.js"; const TYPE_CODE_LOOKUP: Record = { notification_all_v1: "Push Notification", @@ -90,19 +78,26 @@ function transformConversion(conv: Conversion[]) { })); } -function transformCreative( +export function transformCreative( creative: Creative, - campaign: CampaignForm, + campaign: Pick, ): CreateAdInput { + let price: BigNumber; + let priceType: ConfirmationType; + + if (campaign.billingType === "cpm") { + price = BigNumber(campaign.price).dividedBy(1000); + priceType = ConfirmationType.View; + } else { + price = BigNumber(campaign.price); + priceType = ConfirmationType.Click; + } + return { webhooks: [], creativeId: creative.id!, - prices: [ - { - amount: campaign.price, - type: campaign.billingType === "cpc" ? "click" : "view", - }, - ], + price: price.toString(), + priceType: priceType, }; } @@ -144,6 +139,11 @@ export function editCampaignValues( ): CampaignForm { const ads: AdFragment[] = _.flatMap(campaign.adSets, "ads"); + const billingType = (_.head(campaign.adSets)?.billingType ?? + "cpm") as Billing; + const rawPrice = BigNumber(_.head(ads)?.price ?? "0.006"); + const price = billingType === "cpm" ? rawPrice.multipliedBy(1000) : rawPrice; + return { adSets: campaign.adSets.map((adSet) => { const seg = adSet.segments ?? ([] as Segment[]); @@ -163,8 +163,8 @@ export function editCampaignValues( creatives: creativeList(ads).map((a) => a.id!), newCreative: initialCreative, isCreating: false, - price: campaign.adSets[0].ads?.[0].prices[0].amount ?? 6, - billingType: (campaign.adSets[0].billingType ?? "cpm") as Billing, + price: price.toNumber(), + billingType: billingType, validateStart: false, budget: campaign.budget, currency: campaign.currency, diff --git a/src/user/views/adsManager/types/index.ts b/src/user/views/adsManager/types/index.ts index d729a4d9..2cc37ba9 100644 --- a/src/user/views/adsManager/types/index.ts +++ b/src/user/views/adsManager/types/index.ts @@ -28,6 +28,7 @@ export type CampaignForm = { name: string; state: string; type: "paid"; + // this is per click for CPC campaigns, but per thousand views for CPM campaigns price: number; billingType: Billing; pacingStrategy: CampaignPacingStrategies;