Skip to content

Commit 87657f9

Browse files
authored
[WEB-4448] Track correct package after purchase (#968)
## Motivation / Description See thread here https://revenuecat.slack.com/archives/C08H0L0RV09/p1782487589364049 **Problem** When a Web Paywall user switches away from the default package and then purchases via the express checkout button (Apple Pay / Google Pay), the returned PaywallPurchaseResult reports the default package instead of the one actually purchased. The charge and entitlements are correct — only the returned metadata is wrong — which corrupts downstream ad/analytics attribution for customers. **Root cause** Two stale closures captured the package selected at render time rather than purchase time: getWalletButtonRender (src/main.ts) — the wallet button is rendered once with the initially selected package. When the user switches packages, the Svelte action's update() forwards the new package to the button via buttonUpdater.updatePurchase(), so the charge is correct — but the success handler still spread the pkg captured at first render into selectedPackage. presentExpressPurchaseButton's onFinished — built storeTransaction.productIdentifier from the rcPackage argument (also the initial package), ignoring operationResult.productIdentifier, which the backend already returns with the actual purchased product. **Fix** getWalletButtonRender now tracks the currently selected package in the render closure (updated alongside updatePurchase()) and reports it as selectedPackage on success. onFinished now uses operationResult.productIdentifier (backend-reported, reflects what was actually charged) for storeTransaction.productIdentifier instead of the captured package. https://www.loom.com/share/d9341d0479c54f52873484ba1e56e153 ## Changes introduced ## Linear ticket (if any) ## Additional comments <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches purchase success payloads used for analytics/attribution; billing behavior is unchanged but incorrect metadata previously affected downstream tracking. > > **Overview** > Fixes **wrong `PaywallPurchaseResult` metadata** when a paywall user changes the selected package and completes purchase via Apple Pay / Google Pay. Charges and entitlements were already correct; only the returned package and product id were stale. > > **`presentExpressPurchaseButton`**: `storeTransaction.productIdentifier` now comes from **`operationResult.productIdentifier`** (backend) instead of the initially passed `rcPackage`. > > **`getWalletButtonRender`**: maintains **`currentPkg`** in the render closure, updated whenever `update()` calls `buttonUpdater.updatePurchase()`, and returns that as **`selectedPackage`** on success instead of the package captured at first render. > > Adds **`wallet-button-render.test.ts`** covering package switch vs no switch. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 1d4f261. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eefd1a7 commit 87657f9

2 files changed

Lines changed: 106 additions & 2 deletions

File tree

src/main.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,7 +1407,7 @@ export class Purchases {
14071407
attributionMetadata: operationResult.attributionMetadata,
14081408
storeTransaction: {
14091409
storeTransactionId: operationResult.storeTransactionIdentifier,
1410-
productIdentifier: rcPackage.webBillingProduct.identifier,
1410+
productIdentifier: operationResult.productIdentifier,
14111411
purchaseDate: operationResult.purchaseDate,
14121412
},
14131413
};
@@ -1475,6 +1475,8 @@ export class Purchases {
14751475
return {};
14761476
}
14771477

1478+
let currentPkg = pkg;
1479+
14781480
let buttonUpdater: ExpressPurchaseButtonUpdater | null = null;
14791481
this.presentExpressPurchaseButton({
14801482
rcPackage: pkg,
@@ -1488,7 +1490,7 @@ export class Purchases {
14881490
walletButtonTheme,
14891491
})
14901492
.then((purchaseResult) => {
1491-
onSuccess({ ...purchaseResult, selectedPackage: pkg });
1493+
onSuccess({ ...purchaseResult, selectedPackage: currentPkg });
14921494
})
14931495
.catch(onError);
14941496

@@ -1504,6 +1506,7 @@ export class Purchases {
15041506
}
15051507
const purchaseOptionToUse =
15061508
pkg.webBillingProduct.defaultPurchaseOption;
1509+
currentPkg = pkg;
15071510
buttonUpdater.updatePurchase(pkg, purchaseOptionToUse);
15081511
}
15091512
},
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2+
import { mount } from "svelte";
3+
import { configurePurchases } from "./base.purchases_test";
4+
import { createMonthlyPackageMock } from "./mocks/offering-mock-provider";
5+
import type { Purchases } from "../main";
6+
import type { Offering, Package } from "../entities/offerings";
7+
import type { PaywallPurchaseResult } from "../entities/purchase-result";
8+
import type { ExpressPurchaseButtonProps } from "../ui/express-purchase-button/express-purchase-button-props";
9+
10+
vi.mock("svelte", () => ({
11+
mount: vi.fn(),
12+
unmount: vi.fn(),
13+
}));
14+
15+
const monthlyPackage = createMonthlyPackageMock();
16+
const annualPackage: Package = {
17+
...monthlyPackage,
18+
identifier: "$rc_annual",
19+
rcBillingProduct: {
20+
...monthlyPackage.rcBillingProduct,
21+
identifier: "annual",
22+
},
23+
webBillingProduct: {
24+
...monthlyPackage.webBillingProduct,
25+
identifier: "annual",
26+
},
27+
};
28+
29+
const offering = {
30+
packagesById: {
31+
[monthlyPackage.identifier]: monthlyPackage,
32+
[annualPackage.identifier]: annualPackage,
33+
},
34+
} as unknown as Offering;
35+
36+
describe("Purchases.getWalletButtonRender()", () => {
37+
let purchases: Purchases;
38+
let buttonProps: ExpressPurchaseButtonProps | undefined;
39+
let onSuccess: ReturnType<
40+
typeof vi.fn<(result: PaywallPurchaseResult) => void>
41+
>;
42+
43+
beforeEach(() => {
44+
purchases = configurePurchases();
45+
buttonProps = undefined;
46+
onSuccess = vi.fn();
47+
vi.mocked(mount).mockImplementation((_component, options) => {
48+
buttonProps = options.props as ExpressPurchaseButtonProps;
49+
return {} as ReturnType<typeof mount>;
50+
});
51+
});
52+
53+
afterEach(() => {
54+
vi.clearAllMocks();
55+
});
56+
57+
const renderButton = async () => {
58+
const render = purchases.getWalletButtonRender(offering, onSuccess);
59+
const action = render!(document.createElement("div"), {
60+
selectedPackageId: monthlyPackage.identifier,
61+
onReady: vi.fn(),
62+
});
63+
await vi.waitFor(() => expect(buttonProps).toBeDefined());
64+
// Simulates the wallet button becoming ready, which delivers the updater.
65+
buttonProps!.onReady?.(true);
66+
return action;
67+
};
68+
69+
const completePurchase = (purchasedPackage: Package) => {
70+
buttonProps!.onFinished({
71+
redemptionInfo: null,
72+
operationSessionId: "test-operation-session-id",
73+
storeTransactionIdentifier: "test-store-transaction-id",
74+
productIdentifier: purchasedPackage.webBillingProduct.identifier,
75+
purchaseDate: new Date("2024-01-01T00:00:00.000Z"),
76+
});
77+
};
78+
79+
test("reports the package selected at purchase time after switching packages", async () => {
80+
const action = await renderButton();
81+
82+
action.update?.({ selectedPackageId: annualPackage.identifier });
83+
completePurchase(annualPackage);
84+
85+
await vi.waitFor(() => expect(onSuccess).toHaveBeenCalled());
86+
const result = onSuccess.mock.calls[0]![0];
87+
expect(result.selectedPackage).toBe(annualPackage);
88+
expect(result.storeTransaction.productIdentifier).toBe("annual");
89+
});
90+
91+
test("reports the initially selected package when it is never switched", async () => {
92+
await renderButton();
93+
94+
completePurchase(monthlyPackage);
95+
96+
await vi.waitFor(() => expect(onSuccess).toHaveBeenCalled());
97+
const result = onSuccess.mock.calls[0]![0];
98+
expect(result.selectedPackage).toBe(monthlyPackage);
99+
expect(result.storeTransaction.productIdentifier).toBe("monthly");
100+
});
101+
});

0 commit comments

Comments
 (0)