Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/ts/internal/local-receipts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ namespace CdvPurchase {
for (const transaction of receipt.transactions) {
for (const trProducts of transaction.products) {
if (trProducts.id === product.id) {
// No matching transaction has been found or the tested one is newer than the already found one?
// Then we chose the tested one.
if (!found || (transaction.purchaseDate ?? 0) < (found.purchaseDate ?? 1))
// Select the newer known date; preserve existing ordering when either date is missing.
if (!found || (transaction.purchaseDate && found.purchaseDate
? transaction.purchaseDate > found.purchaseDate
: (transaction.purchaseDate ?? 0) < (found.purchaseDate ?? 1)))
found = transaction;
}
}
Expand Down
21 changes: 12 additions & 9 deletions src/ts/platforms/google-play/googleplay-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,10 +393,19 @@ namespace CdvPurchase {
}

/**
* Called when the platform reports some purchases
* Called when the platform refreshes the purchase inventory
*/
onSetPurchases(purchases: Bridge.Purchase[]): void {
this.log.debug("onSetPurchases: " + JSON.stringify(purchases));

// Reconcile removed purchases when the platform refreshes the inventory.
const removedReceipts = this.receipts.filter(r => !purchases.find(p => p.purchaseToken === r.purchaseToken));
if (removedReceipts.length > 0) {
this.log.debug("Removed purchases: " + removedReceipts.map(r => r.purchaseToken).join(', '));
removedReceipts.forEach(receipt => receipt.removed());
this.context.listener.receiptsUpdated(Platform.GOOGLE_PLAY, removedReceipts);
}

this.onPurchasesUpdated(purchases);
this.context.listener.receiptsReady(Platform.GOOGLE_PLAY);

Expand All @@ -407,19 +416,13 @@ namespace CdvPurchase {
/**
* Called when the platform reports updates for some purchases
*
* Notice that purchases can be removed from the array, we should handle that so they stop
* being "owned" by the user.
* Live billing events can contain only the changed purchases, so an omitted
* purchase does not indicate removal.
*/
onPurchasesUpdated(purchases: Bridge.Purchase[]): void {
this.log.debug("onPurchaseUpdated: " + purchases.map(p => p.orderId).join(', '));
// GooglePlay generates one receipt for each purchase

const removedReceipts = this.receipts.filter(r => !purchases.find(p => p.purchaseToken === r.purchaseToken));
if (removedReceipts.length > 0) {
this.log.debug("Removed purchases: " + removedReceipts.map(r => r.purchaseToken).join(', '));
removedReceipts.forEach(receipt => receipt.removed());
}

purchases.forEach(purchase => {
const existingReceipt = this.receipts.find(r => r.purchaseToken === purchase.purchaseToken);
if (existingReceipt) {
Expand Down
98 changes: 98 additions & 0 deletions tests/googleplay-receipts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import '../www/store';

describe('GooglePlay purchase inventory', () => {
const { GooglePlay, Platform, ProductType, TransactionState, RenewalIntent } = CdvPurchase;
const premium = { id: 'premium', platform: Platform.GOOGLE_PLAY };
let adapter: CdvPurchase.GooglePlay.Adapter;
let receiptsUpdated: jest.Mock;

function purchase(id: string, overrides: Partial<CdvPurchase.GooglePlay.Bridge.Purchase> = {}) {
return {
productId: id,
productIds: [id],
purchaseToken: id + '-token',
orderId: id + '-order',
purchaseTime: Date.now() - 1000,
getPurchaseState: GooglePlay.Bridge.PurchaseState.PURCHASED,
acknowledged: true,
quantity: 1,
...overrides,
} as CdvPurchase.GooglePlay.Bridge.Purchase;
}

function subscription(withExpiry: boolean, autoRenewing = false) {
return purchase(premium.id, {
autoRenewing,
expiryTimeMillis: withExpiry ? String(Date.now() + 86400000) : undefined,
});
}

function ownsPremium() {
return CdvPurchase.Internal.LocalReceipts.isOwned(adapter.receipts, premium);
}

beforeEach(() => {
jest.useFakeTimers();
GooglePlay.Adapter._instance = undefined;
receiptsUpdated = jest.fn();
adapter = new GooglePlay.Adapter({
log: new CdvPurchase.Logger({ verbosity: CdvPurchase.LogLevel.QUIET }),
apiDecorators: {},
listener: { receiptsUpdated, receiptsReady: jest.fn() },
} as unknown as CdvPurchase.Internal.AdapterContext);
adapter.products.push({ id: premium.id, type: ProductType.PAID_SUBSCRIPTION } as CdvPurchase.GooglePlay.GProduct);
});

afterEach(() => {
GooglePlay.Adapter._instance = undefined;
jest.clearAllTimers();
jest.useRealTimers();
});

it.each([false, true])('keeps ownership when renewal is canceled (expiry supplied: %s)', withExpiry => {
adapter.onSetPurchases([subscription(withExpiry, true)]);
adapter.onSetPurchases([subscription(withExpiry, false)]);

expect(adapter.receipts[0].transactions[0].renewalIntent).toBe(RenewalIntent.LAPSE);
expect(ownsPremium()).toBe(true);
});

describe.each(['purchase update', 'consumption'])('%s for another product', event => {
it.each([false, true])('preserves a canceled renewal (expiry supplied: %s)', withExpiry => {
const coins = purchase('coins');
adapter.onSetPurchases([subscription(withExpiry), coins]);
const transaction = adapter.receipts[0].transactions[0];
const expirationDate = transaction.expirationDate;

if (event === 'consumption') adapter.onPurchaseConsumed(coins);
else adapter.onPurchasesUpdated([coins]);

expect(ownsPremium()).toBe(true);
expect(transaction.state).toBe(TransactionState.APPROVED);
expect(transaction.expirationDate).toEqual(expirationDate);
});
});

it.each([false, true])('revokes and notifies for a missing subscription (empty snapshot: %s)', empty => {
const coins = purchase('coins');
adapter.onSetPurchases([subscription(true), coins]);
const receipt = adapter.receipts[0];
receiptsUpdated.mockClear();

adapter.onSetPurchases(empty ? [] : [coins]);

expect(ownsPremium()).toBe(false);
expect(receipt.transactions[0].state).toBe(TransactionState.CANCELLED);
expect(receiptsUpdated).toHaveBeenCalledWith(Platform.GOOGLE_PLAY, expect.arrayContaining([receipt]));
});

it('revokes ownership when the subscription expiration is reported', () => {
adapter.onSetPurchases([subscription(true)]);
adapter.onPurchasesUpdated([purchase(premium.id, {
autoRenewing: false,
expiryTimeMillis: String(Date.now() - 1000),
})]);

expect(ownsPremium()).toBe(false);
});
});
79 changes: 79 additions & 0 deletions tests/local-receipts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,84 @@
import '../www/store';

describe('Internal.LocalReceipts.find', () => {
const { LocalReceipts } = CdvPurchase.Internal;
const product = { id: 'subscription.monthly', platform: CdvPurchase.Platform.TEST };

function makeReceipt(transactions: Partial<CdvPurchase.Transaction>[], platform = product.platform): CdvPurchase.Receipt {
return {
platform,
transactions: transactions.map(transaction => ({ products: [{ id: product.id }], ...transaction })),
} as unknown as CdvPurchase.Receipt;
}

it.each([false, true])('uses the active transaction with the newer purchaseDate regardless of receipt order (reversed: %s)', reversed => {
const now = Date.now();
const expired = makeReceipt([{ purchaseDate: new Date(now - 2000), expirationDate: new Date(now - 1000) }]);
const newer = makeReceipt([{ purchaseDate: new Date(now - 500), expirationDate: new Date(now + 24 * 60 * 60 * 1000) }]);
const receipts = reversed ? [newer, expired] : [expired, newer];

expect(LocalReceipts.find(receipts, product)).toBe(newer.transactions[0]);
expect(LocalReceipts.isOwned(receipts, product)).toBe(true);
expect(LocalReceipts.canPurchase(receipts, product)).toBe(false);
});

it('selects the latest matching transaction while ignoring other products and platforms', () => {
const matching = makeReceipt([
{ purchaseDate: new Date(1000) },
{ purchaseDate: new Date(2000), products: [{ id: 'other.product' }, { id: product.id }] },
]);
const otherProduct = makeReceipt([{ purchaseDate: new Date(3000), products: [{ id: 'other.product' }] }]);
const otherPlatform = makeReceipt([{ purchaseDate: new Date(4000) }], CdvPurchase.Platform.GOOGLE_PLAY);

expect(LocalReceipts.find([matching, otherProduct, otherPlatform], product)).toBe(matching.transactions[1]);
});

it.each([false, true])('preserves undated transaction precedence (reversed: %s)', reversed => {
const dated = makeReceipt([{ purchaseDate: new Date(1000) }]);
const undated = makeReceipt([{}]);
const receipts = reversed ? [undated, dated] : [dated, undated];

expect(LocalReceipts.find(receipts, product)).toBe(undated.transactions[0]);
});

it('keeps the first transaction when purchase dates are equal', () => {
const purchaseDate = new Date(1000);
const receipt = makeReceipt([{ purchaseDate }, { purchaseDate }]);

expect(LocalReceipts.find([receipt], product)).toBe(receipt.transactions[0]);
});

it('keeps the last undated transaction as before', () => {
const receipt = makeReceipt([{}, {}]);

expect(LocalReceipts.find([receipt], product)).toBe(receipt.transactions[1]);
});

it.each([false, true])('blocks repurchase for an undated pending transaction after a consumed purchase (reversed: %s)', reversed => {
const consumed = makeReceipt([{ purchaseDate: new Date(1000), isConsumed: true }]);
const pending = makeReceipt([{ isPending: true }]);
const receipts = reversed ? [pending, consumed] : [consumed, pending];

expect(LocalReceipts.find(receipts, product)).toBe(pending.transactions[0]);
expect(LocalReceipts.isOwned(receipts, product)).toBe(false);
expect(LocalReceipts.canPurchase(receipts, product)).toBe(false);
});

it('uses the latest consumable purchase state instead of an older consumed purchase', () => {
const receipt = makeReceipt([
{ purchaseDate: new Date(1000), isConsumed: true },
{ purchaseDate: new Date(2000), isConsumed: false },
]);

expect(LocalReceipts.isOwned([receipt], product)).toBe(true);
expect(LocalReceipts.canPurchase([receipt], product)).toBe(false);

receipt.transactions[1].isConsumed = true;
expect(LocalReceipts.isOwned([receipt], product)).toBe(false);
expect(LocalReceipts.canPurchase([receipt], product)).toBe(true);
});
});

/**
* Unit tests for Internal.LocalReceipts.canPurchase — covers the fix for
* issue #1705: canPurchase used to return true for transactions that were
Expand Down
6 changes: 3 additions & 3 deletions www/store.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4910,14 +4910,14 @@ declare namespace CdvPurchase {
*/
private scheduleRefreshesForSubscriptions;
/**
* Called when the platform reports some purchases
* Called when the platform refreshes the purchase inventory
*/
onSetPurchases(purchases: Bridge.Purchase[]): void;
/**
* Called when the platform reports updates for some purchases
*
* Notice that purchases can be removed from the array, we should handle that so they stop
* being "owned" by the user.
* Live billing events can contain only the changed purchases, so an omitted
* purchase does not indicate removal.
*/
onPurchasesUpdated(purchases: Bridge.Purchase[]): void;
onPriceChangeConfirmationResult(result: "OK" | "UserCanceled" | "UnknownProduct"): void;
Expand Down
25 changes: 14 additions & 11 deletions www/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -3155,9 +3155,10 @@ var CdvPurchase;
for (const transaction of receipt.transactions) {
for (const trProducts of transaction.products) {
if (trProducts.id === product.id) {
// No matching transaction has been found or the tested one is newer than the already found one?
// Then we chose the tested one.
if (!found || ((_a = transaction.purchaseDate) !== null && _a !== void 0 ? _a : 0) < ((_b = found.purchaseDate) !== null && _b !== void 0 ? _b : 1))
// Select the newer known date; preserve existing ordering when either date is missing.
if (!found || (transaction.purchaseDate && found.purchaseDate
? transaction.purchaseDate > found.purchaseDate
: ((_a = transaction.purchaseDate) !== null && _a !== void 0 ? _a : 0) < ((_b = found.purchaseDate) !== null && _b !== void 0 ? _b : 1)))
found = transaction;
}
}
Expand Down Expand Up @@ -6683,10 +6684,17 @@ var CdvPurchase;
}
}
/**
* Called when the platform reports some purchases
* Called when the platform refreshes the purchase inventory
*/
onSetPurchases(purchases) {
this.log.debug("onSetPurchases: " + JSON.stringify(purchases));
// Reconcile removed purchases when the platform refreshes the inventory.
const removedReceipts = this.receipts.filter(r => !purchases.find(p => p.purchaseToken === r.purchaseToken));
if (removedReceipts.length > 0) {
this.log.debug("Removed purchases: " + removedReceipts.map(r => r.purchaseToken).join(', '));
removedReceipts.forEach(receipt => receipt.removed());
this.context.listener.receiptsUpdated(CdvPurchase.Platform.GOOGLE_PLAY, removedReceipts);
}
this.onPurchasesUpdated(purchases);
this.context.listener.receiptsReady(CdvPurchase.Platform.GOOGLE_PLAY);
// Schedule refreshes for subscriptions without expiration dates
Expand All @@ -6695,17 +6703,12 @@ var CdvPurchase;
/**
* Called when the platform reports updates for some purchases
*
* Notice that purchases can be removed from the array, we should handle that so they stop
* being "owned" by the user.
* Live billing events can contain only the changed purchases, so an omitted
* purchase does not indicate removal.
*/
onPurchasesUpdated(purchases) {
this.log.debug("onPurchaseUpdated: " + purchases.map(p => p.orderId).join(', '));
// GooglePlay generates one receipt for each purchase
const removedReceipts = this.receipts.filter(r => !purchases.find(p => p.purchaseToken === r.purchaseToken));
if (removedReceipts.length > 0) {
this.log.debug("Removed purchases: " + removedReceipts.map(r => r.purchaseToken).join(', '));
removedReceipts.forEach(receipt => receipt.removed());
}
purchases.forEach(purchase => {
var _a;
const existingReceipt = this.receipts.find(r => r.purchaseToken === purchase.purchaseToken);
Expand Down