diff --git a/src/behavioural-events/paywall-event.ts b/src/behavioural-events/paywall-event.ts index 516063a64..9c307a75f 100644 --- a/src/behavioural-events/paywall-event.ts +++ b/src/behavioural-events/paywall-event.ts @@ -1,3 +1,4 @@ +import type { PresentedOfferingContext } from "../entities/offerings"; import { generateUUID } from "../helpers/uuid-helper"; export type PaywallEventType = @@ -12,11 +13,18 @@ export interface PaywallEventData { offeringId: string; paywallRevision: number; paywallRcPublicId: string | null; + presentedOfferingContext?: PresentedOfferingContext; displayMode?: string; darkMode?: boolean; locale?: string; } +type PresentedOfferingContextPayload = { + placement_identifier?: string; + targeting_revision?: number; + targeting_rule_id?: string; +}; + type PaywallEventPayload = { type: string; version: number; @@ -27,11 +35,32 @@ type PaywallEventPayload = { paywall_revision: number; timestamp: number; paywall_rc_public_id: string | null; + presented_offering_context?: PresentedOfferingContextPayload; display_mode?: string; dark_mode?: boolean; locale?: string; }; +function toPresentedOfferingContextPayload( + context: PresentedOfferingContext | undefined, +): PresentedOfferingContextPayload | undefined { + if (!context) return undefined; + if (!context.placementIdentifier && !context.targetingContext) { + return undefined; + } + return { + ...(context.placementIdentifier + ? { placement_identifier: context.placementIdentifier } + : {}), + ...(context.targetingContext + ? { + targeting_revision: context.targetingContext.revision, + targeting_rule_id: context.targetingContext.ruleId, + } + : {}), + }; +} + export class PaywallEvent { public readonly id: string; public readonly timestamp: number; @@ -56,6 +85,13 @@ export class PaywallEvent { paywall_rc_public_id: this.data.paywallRcPublicId, }; + const contextPayload = toPresentedOfferingContextPayload( + this.data.presentedOfferingContext, + ); + if (contextPayload) { + payload.presented_offering_context = contextPayload; + } + if (this.data.displayMode !== undefined) { payload.display_mode = this.data.displayMode; payload.dark_mode = this.data.darkMode ?? false; diff --git a/src/main.ts b/src/main.ts index 1c4889857..520d961d1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -585,6 +585,10 @@ export class Purchases { const paywallSessionId = generateUUID(); + const presentedOfferingContext = + offering.availablePackages[0]?.webBillingProduct + ?.presentedOfferingContext; + const trackPaywallEvent = (type: PaywallEventType) => { this.eventsTracker.trackPaywallEvent({ type, @@ -593,6 +597,7 @@ export class Purchases { offeringId: offering.identifier, paywallRevision: 0, paywallRcPublicId: offering.paywallComponents?.id ?? null, + presentedOfferingContext, ...(type === "paywall_impression" ? { displayMode: "full_screen", diff --git a/src/tests/behavioral-events/paywall-event.test.ts b/src/tests/behavioral-events/paywall-event.test.ts index 14bb9b961..250ff7a08 100644 --- a/src/tests/behavioral-events/paywall-event.test.ts +++ b/src/tests/behavioral-events/paywall-event.test.ts @@ -18,7 +18,7 @@ const baseData: PaywallEventData = { }; describe("PaywallEvent", () => { - it("serializes impression event with visual fields", () => { + it("serializes impression event with visual fields and no context", () => { const event = new PaywallEvent({ ...baseData, displayMode: "full_screen", @@ -42,6 +42,43 @@ describe("PaywallEvent", () => { dark_mode: true, locale: "es_ES", }); + expect(json.presented_offering_context).toBeUndefined(); + }); + + it("serializes full impression event with context and visual fields", () => { + const event = new PaywallEvent({ + ...baseData, + displayMode: "full_screen", + darkMode: true, + locale: "es_ES", + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: "home_banner", + targetingContext: { revision: 3, ruleId: "rule_abc123" }, + }, + }); + + const json = event.toJSON(); + + expect(json).toEqual({ + type: "paywall_impression", + version: 1, + id: "test-uuid-1234", + app_user_id: "user-123", + session_id: "session-456", + offering_id: "offering-789", + paywall_revision: 0, + timestamp: expect.any(Number), + paywall_rc_public_id: "pw-public-id", + presented_offering_context: { + placement_identifier: "home_banner", + targeting_revision: 3, + targeting_rule_id: "rule_abc123", + }, + display_mode: "full_screen", + dark_mode: true, + locale: "es_ES", + }); }); it("serializes close event without visual fields", () => { @@ -91,4 +128,113 @@ describe("PaywallEvent", () => { expect(event.toJSON().paywall_rc_public_id).toBeNull(); }); + + it("includes presented_offering_context with placement and targeting", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: "home_banner", + targetingContext: { revision: 3, ruleId: "rule_abc123" }, + }, + }); + + expect(event.toJSON().presented_offering_context).toEqual({ + placement_identifier: "home_banner", + targeting_revision: 3, + targeting_rule_id: "rule_abc123", + }); + }); + + it("includes presented_offering_context with placement only", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: "home_banner", + targetingContext: null, + }, + }); + + expect(event.toJSON().presented_offering_context).toEqual({ + placement_identifier: "home_banner", + }); + }); + + it("includes presented_offering_context with targeting only", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: null, + targetingContext: { revision: 7, ruleId: "rule_xyz" }, + }, + }); + + expect(event.toJSON().presented_offering_context).toEqual({ + targeting_revision: 7, + targeting_rule_id: "rule_xyz", + }); + }); + + it("omits presented_offering_context when no placement or targeting", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: null, + targetingContext: null, + }, + }); + + expect(event.toJSON().presented_offering_context).toBeUndefined(); + }); + + it("omits presented_offering_context when placementIdentifier is empty string", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: "", + targetingContext: null, + }, + }); + + expect(event.toJSON().presented_offering_context).toBeUndefined(); + }); + + it("omits presented_offering_context when not provided", () => { + const event = new PaywallEvent(baseData); + + expect(event.toJSON().presented_offering_context).toBeUndefined(); + }); + + it("JSON.stringify round-trip preserves presented_offering_context", () => { + const event = new PaywallEvent({ + ...baseData, + presentedOfferingContext: { + offeringIdentifier: "offering-789", + placementIdentifier: "home_banner", + targetingContext: { revision: 3, ruleId: "rule_abc123" }, + }, + }); + + const serialized = JSON.stringify(event.toJSON()); + const parsed = JSON.parse(serialized); + + expect(parsed.presented_offering_context).toEqual({ + placement_identifier: "home_banner", + targeting_revision: 3, + targeting_rule_id: "rule_abc123", + }); + }); + + it("JSON.stringify omits presented_offering_context when absent", () => { + const event = new PaywallEvent(baseData); + + const serialized = JSON.stringify(event.toJSON()); + const parsed = JSON.parse(serialized); + + expect(parsed.presented_offering_context).toBeUndefined(); + }); }); diff --git a/src/tests/present-paywall.events.test.ts b/src/tests/present-paywall.events.test.ts index de0073239..cdee36e16 100644 --- a/src/tests/present-paywall.events.test.ts +++ b/src/tests/present-paywall.events.test.ts @@ -156,6 +156,29 @@ describe("Purchases.presentPaywall() paywall events", () => { ).toEqual(["paywall_impression"]); }); + test("passes presentedOfferingContext to paywall events", async () => { + const purchases = configurePurchases(); + const offering = createOfferingWithPaywall(); + const trackPaywallEventSpy = vi.spyOn( + purchases["eventsTracker"], + "trackPaywallEvent", + ); + + const paywallPromise = purchases.presentPaywall({ offering }); + void paywallPromise.catch(() => undefined); + + expect(trackPaywallEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "paywall_impression", + presentedOfferingContext: { + offeringIdentifier: "offering_1", + targetingContext: { ruleId: "test_rule_id", revision: 123 }, + placementIdentifier: null, + }, + }), + ); + }); + test("fires paywall_close when external code clears the paywall container", async () => { const purchases = configurePurchases(); const offering = createOfferingWithPaywall();