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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## Unreleased

- New **`LinkTrail.trackRevenue({ transactionId, value, currency, productId? })`** — report in-app
purchases / subscription payments for revenue and ROAS. Resolves with
`{ id?, attributed, duplicate }`; deduped on `transactionId` (safe to retry). Invalid input
rejects with the new typed `LinkTrailRevenueError` (`invalid_transaction_id` / `invalid_value` /
`invalid_currency`) before anything is sent. Exports `LinkTrailRevenueParams`,
`LinkTrailRevenueResult`, `LinkTrailRevenueErrorCode`.
- Adds the first unit tests (`npm test`, jest).
- **Not releasable yet:** the native bridge calls `trackRevenue` / `trackRevenueAsync`, which ship in
the next iOS (`LinkTrailSDK`) and Android (`io.linktrail:sdk`) releases. The pinned versions
(`~> 0.0.11` / `0.0.5`) must be bumped to those releases before this release is cut.

## 0.0.4

- Bump the iOS native SDK to **`LinkTrailSDK ~> 0.0.11`** (from `0.0.10`).
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ Links / custom schemes) are forwarded via React Native's `Linking` API — no ex
// Custom post-install events:
await LinkTrail.trackEvent('purchase', { value: 59.99, currency: 'USD' });

// Revenue — see "Tracking revenue" below:
await LinkTrail.trackRevenue({ transactionId: '2000000123456', value: 9.99, currency: 'USD' });

// Cached results:
const attribution = await LinkTrail.getLastAttribution();
const lastLink = await LinkTrail.getLastDeepLink();
Expand All @@ -63,6 +66,58 @@ LinkTrail.updateConversionValue(42, 'medium');
autoTrackInstall, requireConsent, clickTokenSource, autoHandleLinks }`. Set `autoHandleLinks: false`
to forward URLs yourself via `LinkTrail.handleDeepLink(url)`.

### Tracking revenue

`trackRevenue` reports an in-app purchase or subscription payment (revenue / ROAS). JS purchase
libraries don't expose StoreKit/Play Billing objects, so you pass the store's values yourself:

| Field | Value |
| --- | --- |
| `transactionId` | The store's unique id for the payment — the **dedupe key**. iOS: StoreKit `transactionId` (use the *transaction* id, not `originalTransactionId`, so each renewal counts). Android: Google Play `orderId` (e.g. `GPA.1234-5678-9012-34567`). 1–255 characters. |
| `value` | Amount charged, `>= 0` (rounded to 2 decimals). |
| `currency` | 3-letter ISO 4217 code, case-insensitive (`'usd'` becomes `'USD'`). |
| `productId` | Optional store product id. |

```ts
import LinkTrail, { LinkTrailRevenueError } from 'linktrail-react-native';

// react-native-iap — in the purchase listener, for a verified purchase:
purchaseUpdatedListener(async (purchase) => {
await LinkTrail.trackRevenue({
transactionId: purchase.transactionId!, // iOS: StoreKit transaction id; Android: Play orderId
value: Number(product.price), // numeric price from your product object
currency: product.currency,
productId: purchase.productId,
}).catch(() => {}); // failures are queued natively and retried
await finishTransaction({ purchase, isConsumable: false });
});

// expo-iap: same shape in its purchase callback (transactionId / orderId, price, currency).

// RevenueCat — in Purchases.addCustomerInfoUpdateListener / after purchasePackage():
const { transaction, productIdentifier } = await Purchases.purchasePackage(pkg);
await LinkTrail.trackRevenue({
transactionId: transaction.transactionIdentifier, // StoreKit transaction id / Play orderId
value: pkg.product.price,
currency: pkg.product.currencyCode,
productId: productIdentifier,
});
```

The snippets are illustrative: check which field your library exposes for the StoreKit transaction id
and the Play `orderId`, and always send the same id for the same payment.

**Retries are safe.** The server dedupes on `transactionId`, and the native SDK also remembers ids it
has delivered. Calling `trackRevenue` twice for the same purchase (a retried callback, restored
purchases, several listeners) never double-counts — the repeat resolves with `duplicate: true`.
Failed deliveries are queued and retried natively; the returned promise rejects so you can observe the
first failure. Like `trackEvent`, it honors `requireConsent`.

The promise resolves with `{ id?, attributed, duplicate }`. Invalid input (blank or over-long
`transactionId`, negative/non-finite `value`, non-3-letter `currency`) rejects with a
`LinkTrailRevenueError` whose `code` is `invalid_transaction_id`, `invalid_value` or `invalid_currency`;
nothing is sent. Requires native SDKs that include the revenue API (see the CHANGELOG).

### Consent gating

`requireConsent` (default `true`) gates attribution/tracking behind the user's decision —
Expand Down
89 changes: 89 additions & 0 deletions __tests__/revenue.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const mockTrackRevenue = jest.fn();

jest.mock('react-native', () => ({
Linking: {
getInitialURL: jest.fn(() => Promise.resolve(null)),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
},
}));

jest.mock('../src/NativeLinkTrail', () => ({
__esModule: true,
default: { trackRevenue: (...args: unknown[]) => mockTrackRevenue(...args) },
}));

jest.mock('../src/LinkTrailPasteButton', () => ({
LinkTrailPasteButton: () => null,
}));

import { LinkTrail, LinkTrailRevenueError } from '../src';

const valid = { transactionId: '2000000123456', value: 9.99, currency: 'USD' };

beforeEach(() => {
mockTrackRevenue.mockReset();
mockTrackRevenue.mockResolvedValue({ id: 7, attributed: true, duplicate: false });
});

describe('LinkTrail.trackRevenue', () => {
it('forwards normalised arguments to the native module', async () => {
await LinkTrail.trackRevenue({
transactionId: ' GPA.3300-1 ',
value: 4.5,
currency: ' usd ',
productId: ' pro.monthly ',
});
expect(mockTrackRevenue).toHaveBeenCalledWith('GPA.3300-1', 4.5, 'USD', 'pro.monthly');
});

it('omits a missing or blank productId', async () => {
await LinkTrail.trackRevenue(valid);
await LinkTrail.trackRevenue({ ...valid, productId: ' ' });
expect(mockTrackRevenue).toHaveBeenNthCalledWith(1, '2000000123456', 9.99, 'USD', undefined);
expect(mockTrackRevenue).toHaveBeenNthCalledWith(2, '2000000123456', 9.99, 'USD', undefined);
});

it('resolves with the native result', async () => {
await expect(LinkTrail.trackRevenue(valid)).resolves.toEqual({
id: 7,
attributed: true,
duplicate: false,
});
});

it('reports duplicates and tolerates a missing id', async () => {
mockTrackRevenue.mockResolvedValue({ attributed: false, duplicate: true });
const result = await LinkTrail.trackRevenue(valid);
expect(result).toEqual({ attributed: false, duplicate: true });
expect('id' in result).toBe(false);
});

it('accepts zero value and a 255-character transactionId', async () => {
await LinkTrail.trackRevenue({ ...valid, value: 0, transactionId: 'x'.repeat(255) });
expect(mockTrackRevenue).toHaveBeenCalledTimes(1);
});

it.each([
['empty transactionId', { transactionId: '' }, 'invalid_transaction_id'],
['blank transactionId', { transactionId: ' ' }, 'invalid_transaction_id'],
['256-char transactionId', { transactionId: 'x'.repeat(256) }, 'invalid_transaction_id'],
['negative value', { value: -0.01 }, 'invalid_value'],
['NaN value', { value: NaN }, 'invalid_value'],
['Infinity value', { value: Infinity }, 'invalid_value'],
['string value', { value: '9.99' as unknown as number }, 'invalid_value'],
['2-letter currency', { currency: 'US' }, 'invalid_currency'],
['4-letter currency', { currency: 'USDX' }, 'invalid_currency'],
['numeric currency', { currency: '840' }, 'invalid_currency'],
])('rejects %s without calling native', async (_name, override, code) => {
const promise = LinkTrail.trackRevenue({ ...valid, ...override });
await expect(promise).rejects.toBeInstanceOf(LinkTrailRevenueError);
await expect(promise).rejects.toMatchObject({ code, name: 'LinkTrailRevenueError' });
expect(mockTrackRevenue).not.toHaveBeenCalled();
});

it('propagates native rejections unchanged', async () => {
const nativeError = Object.assign(new Error('offline'), { code: 'transport' });
mockTrackRevenue.mockRejectedValue(nativeError);
await expect(LinkTrail.trackRevenue(valid)).rejects.toBe(nativeError);
});
});
28 changes: 28 additions & 0 deletions android/src/main/java/io/linktrail/reactnative/LinkTrailModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,34 @@ class LinkTrailModule(reactContext: ReactApplicationContext) :
}
}

override fun trackRevenue(
transactionId: String,
value: Double,
currency: String,
productId: String?,
promise: Promise
) {
val sdk = requireSdk(promise) ?: return
scope.launch {
try {
val result = sdk.trackRevenueAsync(transactionId, value, currency, productId)
promise.resolve(
Arguments.createMap().apply {
result.id?.let { putInt("id", it) }
putBoolean("attributed", result.attributed)
putBoolean("duplicate", result.duplicate)
}
)
} catch (e: IllegalArgumentException) {
// trackRevenueAsync rejects invalid input this way. The JS layer
// validates first, so this is only a backstop against rule drift.
promise.reject("invalid_revenue", e.message, e)
} catch (t: Throwable) {
promise.reject(errorCode(t), t.message, t)
}
}
}

// ── Deep linking ───────────────────────────────────────────────────────────

override fun handleDeepLink(url: String, promise: Promise) {
Expand Down
15 changes: 15 additions & 0 deletions ios/LinkTrailModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ - (void)trackEvent:(NSString *)name
reject:^(NSString *code, NSString *message) { reject(code, message, nil); }];
}

- (void)trackRevenue:(NSString *)transactionId
value:(double)value
currency:(NSString *)currency
productId:(NSString *)productId
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
[_impl trackRevenue:transactionId
value:value
currency:currency
productId:productId
resolve:resolve
reject:^(NSString *code, NSString *message) { reject(code, message, nil); }];
}

- (void)trackInstallWithClickToken:(NSString *)clickToken
force:(BOOL)force
resolve:(RCTPromiseResolveBlock)resolve
Expand Down
29 changes: 29 additions & 0 deletions ios/LinkTrailModuleImpl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,28 @@ public final class LinkTrailModuleImpl: NSObject {
}
}

@objc public func trackRevenue(
_ transactionId: String,
value: Double,
currency: String,
productId: String?,
resolve: @escaping Resolve,
reject: @escaping Reject
) {
guard let sdk = requireSDK(reject) else { return }
Task {
do {
let result: LinkTrailRevenueResult = try await sdk.trackRevenue(
transactionId: transactionId, value: value, currency: currency, productId: productId)
var dict: [String: Any] = ["attributed": result.attributed, "duplicate": result.duplicate]
if let id = result.id { dict["id"] = id }
resolve(dict)
} catch {
reject(Self.errorCode(error), error.localizedDescription)
}
}
}

// MARK: - Deep linking

@objc public func handleDeepLink(
Expand Down Expand Up @@ -255,6 +277,13 @@ public final class LinkTrailModuleImpl: NSObject {
}

private static func errorCode(_ error: Error) -> String {
if let revenueError = error as? LinkTrailRevenueError {
switch revenueError {
case .invalidTransactionId: return "invalid_transaction_id"
case .invalidValue: return "invalid_value"
case .invalidCurrency: return "invalid_currency"
}
}
guard let error = error as? LinkTrailError else { return "unknown" }
switch error {
case .invalidURL: return "invalid_url"
Expand Down
7 changes: 7 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['<rootDir>/__tests__/**/*.test.ts'],
transform: {
'\\.[jt]sx?$': ['babel-jest', { presets: ['module:@react-native/babel-preset'] }],
},
};
Loading
Loading