@invonetwork/web-sdk 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/LICENSE +18 -17
- package/README.md +368 -123
- package/dist/chunk-DV3WZGMH.js +231 -0
- package/dist/index.cjs +121 -34
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -3
- package/dist/server.cjs +288 -44
- package/dist/server.d.cts +112 -7
- package/dist/server.d.ts +112 -7
- package/dist/server.js +173 -14
- package/dist/{errors-DV5QsftP.d.cts → types-CBkoUymV.d.cts} +121 -42
- package/dist/{errors-DV5QsftP.d.ts → types-CBkoUymV.d.ts} +121 -42
- package/package.json +2 -2
- package/dist/chunk-A44O4KC3.js +0 -147
package/dist/server.d.ts
CHANGED
|
@@ -1,5 +1,108 @@
|
|
|
1
|
-
import { S as ServerConfig, P as PlayerToken,
|
|
2
|
-
export { I as InvoError, R as Rail, V as VerificationMethod } from './
|
|
1
|
+
import { S as ServerConfig, P as PlayerToken, f as InitiateSendInput, g as InitiateResult, h as InitiateTransferInput, i as CreateCheckoutInput, j as CreateCheckoutResult, k as PurchaseInput, l as PurchaseResult, m as ConfirmPaymentResult, O as OrderDetailsResult, n as PurchaseItemInput, o as PurchaseItemResult, p as ItemHistoryQuery, q as ItemHistoryResult, r as ItemOrderQuery, s as PlayerBalanceQuery, t as PlayerBalanceResult } from './types-CBkoUymV.js';
|
|
2
|
+
export { u as CurrencyBalance, I as InvoError, b as InvoErrorInfo, c as InvoHooks, d as InvoRequestInfo, e as InvoResponseInfo, R as Rail, V as VerificationMethod } from './types-CBkoUymV.js';
|
|
3
|
+
|
|
4
|
+
interface VerifyWebhookOptions {
|
|
5
|
+
/** Max age of the signed timestamp, in seconds. Default 300 (5 min). */
|
|
6
|
+
toleranceSec?: number;
|
|
7
|
+
/** Override the current time (unix seconds) — for tests. Defaults to Date.now(). */
|
|
8
|
+
nowSec?: number;
|
|
9
|
+
}
|
|
10
|
+
interface WebhookBase {
|
|
11
|
+
event_id: string;
|
|
12
|
+
idempotency_key: string;
|
|
13
|
+
schema_version: string;
|
|
14
|
+
created_at: string;
|
|
15
|
+
/** Your game_id. */
|
|
16
|
+
tenant_id: string;
|
|
17
|
+
}
|
|
18
|
+
interface PurchaseCompletedData {
|
|
19
|
+
transaction_id: string;
|
|
20
|
+
order_id: string;
|
|
21
|
+
player_email: string;
|
|
22
|
+
identity_id: string;
|
|
23
|
+
usd_amount: string;
|
|
24
|
+
currency_amount: string;
|
|
25
|
+
currency_name: string;
|
|
26
|
+
new_balance: string;
|
|
27
|
+
rail: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
interface PurchaseEventData {
|
|
31
|
+
transaction_id: string;
|
|
32
|
+
order_id?: string;
|
|
33
|
+
player_email?: string;
|
|
34
|
+
identity_id?: string;
|
|
35
|
+
/** Present on purchase.disputed, e.g. "lost". */
|
|
36
|
+
dispute_status?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
interface ItemPurchasedData {
|
|
40
|
+
transaction_id: string;
|
|
41
|
+
order_id: string;
|
|
42
|
+
player_email: string;
|
|
43
|
+
identity_id: string;
|
|
44
|
+
item_id: string;
|
|
45
|
+
item_name: string;
|
|
46
|
+
item_quantity: number;
|
|
47
|
+
unit_price: string;
|
|
48
|
+
total_price: string;
|
|
49
|
+
currency_name: string;
|
|
50
|
+
new_balance: string;
|
|
51
|
+
fee_breakdown?: Record<string, unknown>;
|
|
52
|
+
[key: string]: unknown;
|
|
53
|
+
}
|
|
54
|
+
interface TransferEventData {
|
|
55
|
+
transaction_id: string;
|
|
56
|
+
/** "transfer" | "send". */
|
|
57
|
+
flow?: string;
|
|
58
|
+
amount?: string;
|
|
59
|
+
net_amount?: string;
|
|
60
|
+
/** "inbound" | "outbound" (relative to the receiving tenant). */
|
|
61
|
+
direction?: string;
|
|
62
|
+
/** On inbound claim_pending: the recipient's phone (match to your player). */
|
|
63
|
+
to_phone?: string;
|
|
64
|
+
/** Recipient's opaque identity, present only when the phone maps to a single player. */
|
|
65
|
+
to_identity_id?: string | null;
|
|
66
|
+
[key: string]: unknown;
|
|
67
|
+
}
|
|
68
|
+
type PurchaseEventType = "purchase.failed" | "purchase.refunded" | "purchase.disputed" | "purchase.fraud_warning";
|
|
69
|
+
type TransferEventType = "transfer.sent" | "transfer.received" | "transfer.claim_pending" | "transfer.claim_expired" | "transfer.refunded";
|
|
70
|
+
/**
|
|
71
|
+
* A verified webhook event. Discriminate on `event_type` to narrow `data`.
|
|
72
|
+
* Unknown/future event types fall through to the generic member (data is a record).
|
|
73
|
+
*/
|
|
74
|
+
type InvoWebhookEvent = (WebhookBase & {
|
|
75
|
+
event_type: "purchase.completed";
|
|
76
|
+
data: PurchaseCompletedData;
|
|
77
|
+
}) | (WebhookBase & {
|
|
78
|
+
event_type: "item.purchased";
|
|
79
|
+
data: ItemPurchasedData;
|
|
80
|
+
}) | (WebhookBase & {
|
|
81
|
+
event_type: PurchaseEventType;
|
|
82
|
+
data: PurchaseEventData;
|
|
83
|
+
}) | (WebhookBase & {
|
|
84
|
+
event_type: TransferEventType;
|
|
85
|
+
data: TransferEventData;
|
|
86
|
+
}) | (WebhookBase & {
|
|
87
|
+
event_type: "payout.status_changed";
|
|
88
|
+
data: Record<string, unknown>;
|
|
89
|
+
}) | (WebhookBase & {
|
|
90
|
+
event_type: "webhook.test";
|
|
91
|
+
data: Record<string, unknown>;
|
|
92
|
+
}) | (WebhookBase & {
|
|
93
|
+
event_type: string;
|
|
94
|
+
data: Record<string, unknown>;
|
|
95
|
+
});
|
|
96
|
+
/**
|
|
97
|
+
* Verify an Invo webhook and return the parsed, typed event.
|
|
98
|
+
*
|
|
99
|
+
* @param rawBody The exact raw request body (Buffer/Uint8Array or string).
|
|
100
|
+
* @param signatureHeader The `X-Invo-Signature` header value.
|
|
101
|
+
* @param secret Your signing secret, or an array of secrets (to accept old + new during rotation).
|
|
102
|
+
* @throws InvoError (status 0) with code WEBHOOK_SIGNATURE_MISSING | WEBHOOK_TIMESTAMP_EXPIRED |
|
|
103
|
+
* WEBHOOK_SIGNATURE_INVALID | WEBHOOK_MALFORMED on any failure.
|
|
104
|
+
*/
|
|
105
|
+
declare function verifyWebhook(rawBody: string | Uint8Array, signatureHeader: string | null | undefined, secret: string | string[], opts?: VerifyWebhookOptions): InvoWebhookEvent;
|
|
3
106
|
|
|
4
107
|
declare class InvoServer {
|
|
5
108
|
private readonly http;
|
|
@@ -30,12 +133,12 @@ declare class InvoServer {
|
|
|
30
133
|
confirmPayment(input: {
|
|
31
134
|
paymentIntentId: string;
|
|
32
135
|
orderId?: string;
|
|
33
|
-
}): Promise<
|
|
136
|
+
}): Promise<ConfirmPaymentResult>;
|
|
34
137
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
35
138
|
getOrderDetails(query: {
|
|
36
139
|
orderId?: string;
|
|
37
140
|
transactionId?: string;
|
|
38
|
-
}): Promise<
|
|
141
|
+
}): Promise<OrderDetailsResult>;
|
|
39
142
|
/**
|
|
40
143
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
41
144
|
* No real money, no payment rail, no passkey — it's a balance debit, authenticated
|
|
@@ -46,11 +149,13 @@ declare class InvoServer {
|
|
|
46
149
|
*/
|
|
47
150
|
purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
|
|
48
151
|
/** Paginated item-purchase history for a player (§4.8 companion read). */
|
|
49
|
-
getItemPurchaseHistory(query: ItemHistoryQuery): Promise<
|
|
152
|
+
getItemPurchaseHistory(query: ItemHistoryQuery): Promise<ItemHistoryResult>;
|
|
50
153
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
51
154
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
52
|
-
getItemOrderDetails(query: ItemOrderQuery): Promise<
|
|
155
|
+
getItemOrderDetails(query: ItemOrderQuery): Promise<OrderDetailsResult>;
|
|
156
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
157
|
+
getPlayerBalance(query: PlayerBalanceQuery): Promise<PlayerBalanceResult>;
|
|
53
158
|
private toInitiateResult;
|
|
54
159
|
}
|
|
55
160
|
|
|
56
|
-
export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
|
|
161
|
+
export { ConfirmPaymentResult, CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, type InvoWebhookEvent, ItemHistoryQuery, ItemHistoryResult, ItemOrderQuery, type ItemPurchasedData, OrderDetailsResult, PlayerBalanceQuery, PlayerBalanceResult, PlayerToken, type PurchaseCompletedData, type PurchaseEventData, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig, type TransferEventData, type VerifyWebhookOptions, verifyWebhook };
|
package/dist/server.js
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
|
-
import { assertSecureBaseUrl, Http, InvoError } from './chunk-
|
|
2
|
-
export { InvoError } from './chunk-
|
|
1
|
+
import { assertSecureBaseUrl, Http, InvoError } from './chunk-DV3WZGMH.js';
|
|
2
|
+
export { InvoError } from './chunk-DV3WZGMH.js';
|
|
3
|
+
import { createHmac } from 'crypto';
|
|
4
|
+
|
|
5
|
+
var DEFAULT_TOLERANCE_SEC = 300;
|
|
6
|
+
var ENCODER = new TextEncoder();
|
|
7
|
+
function toBytes(body) {
|
|
8
|
+
return typeof body === "string" ? ENCODER.encode(body) : body;
|
|
9
|
+
}
|
|
10
|
+
function concatBytes(a, b) {
|
|
11
|
+
const out = new Uint8Array(a.length + b.length);
|
|
12
|
+
out.set(a, 0);
|
|
13
|
+
out.set(b, a.length);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function webhookError(message, code) {
|
|
17
|
+
return new InvoError({ message, code, status: 0 });
|
|
18
|
+
}
|
|
19
|
+
function parseSignatureHeader(header) {
|
|
20
|
+
let t = "";
|
|
21
|
+
const sigs = [];
|
|
22
|
+
for (const part of header.split(",")) {
|
|
23
|
+
const idx = part.indexOf("=");
|
|
24
|
+
if (idx === -1) continue;
|
|
25
|
+
const key = part.slice(0, idx).trim();
|
|
26
|
+
const val = part.slice(idx + 1).trim();
|
|
27
|
+
if (key === "t") t = val;
|
|
28
|
+
else if (key === "v1" && val) sigs.push(val);
|
|
29
|
+
}
|
|
30
|
+
return { t, sigs };
|
|
31
|
+
}
|
|
32
|
+
function hmacHex(secret, message) {
|
|
33
|
+
return createHmac("sha256", secret).update(message).digest("hex");
|
|
34
|
+
}
|
|
35
|
+
function safeEqualHex(a, b) {
|
|
36
|
+
if (a.length !== b.length) return false;
|
|
37
|
+
let diff = 0;
|
|
38
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
39
|
+
return diff === 0;
|
|
40
|
+
}
|
|
41
|
+
function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
42
|
+
if (!signatureHeader) {
|
|
43
|
+
throw webhookError("Missing X-Invo-Signature header.", "WEBHOOK_SIGNATURE_MISSING");
|
|
44
|
+
}
|
|
45
|
+
const secrets = (Array.isArray(secret) ? secret : [secret]).filter(Boolean);
|
|
46
|
+
if (secrets.length === 0) {
|
|
47
|
+
throw webhookError("A signing secret is required to verify webhooks.", "WEBHOOK_SECRET_MISSING");
|
|
48
|
+
}
|
|
49
|
+
const { t, sigs } = parseSignatureHeader(signatureHeader);
|
|
50
|
+
if (!t || sigs.length === 0) {
|
|
51
|
+
throw webhookError("Malformed X-Invo-Signature header.", "WEBHOOK_MALFORMED");
|
|
52
|
+
}
|
|
53
|
+
const ts = Number(t);
|
|
54
|
+
const now = opts.nowSec ?? Math.floor(Date.now() / 1e3);
|
|
55
|
+
const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
|
|
56
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > tolerance) {
|
|
57
|
+
throw webhookError(
|
|
58
|
+
`Webhook timestamp outside the ${tolerance}s tolerance (replay guard).`,
|
|
59
|
+
"WEBHOOK_TIMESTAMP_EXPIRED"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const bodyBytes = toBytes(rawBody);
|
|
63
|
+
const message = concatBytes(ENCODER.encode(`${t}.`), bodyBytes);
|
|
64
|
+
const matched = secrets.some((s) => {
|
|
65
|
+
const expected = hmacHex(s, message);
|
|
66
|
+
return sigs.some((sig) => safeEqualHex(sig, expected));
|
|
67
|
+
});
|
|
68
|
+
if (!matched) {
|
|
69
|
+
throw webhookError("Webhook signature verification failed.", "WEBHOOK_SIGNATURE_INVALID");
|
|
70
|
+
}
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
74
|
+
} catch {
|
|
75
|
+
throw webhookError("Webhook body is not valid JSON.", "WEBHOOK_MALFORMED");
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed) || typeof parsed.event_type !== "string") {
|
|
78
|
+
throw webhookError("Webhook body is not a valid event object.", "WEBHOOK_MALFORMED");
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
3
82
|
|
|
4
83
|
// src/server.ts
|
|
5
84
|
var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
|
|
@@ -34,6 +113,26 @@ function parseMoney(value, max, label) {
|
|
|
34
113
|
function assertUsdAmount(usdAmount) {
|
|
35
114
|
parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
|
|
36
115
|
}
|
|
116
|
+
function toOrderDetails(raw) {
|
|
117
|
+
return {
|
|
118
|
+
order: raw["order"] ?? {},
|
|
119
|
+
financialSummary: raw["financial_summary"] ?? {},
|
|
120
|
+
statusTimeline: raw["status_timeline"] ?? null,
|
|
121
|
+
raw
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function toCurrencyBalance(row) {
|
|
125
|
+
return {
|
|
126
|
+
currencyId: row["currency_id"] ?? "",
|
|
127
|
+
currencyName: String(row["currency_name"] ?? ""),
|
|
128
|
+
currencySymbol: String(row["currency_symbol"] ?? ""),
|
|
129
|
+
availableBalance: String(row["available_balance"] ?? ""),
|
|
130
|
+
reservedBalance: String(row["reserved_balance"] ?? ""),
|
|
131
|
+
totalBalance: String(row["total_balance"] ?? ""),
|
|
132
|
+
lastTransaction: row["last_transaction"],
|
|
133
|
+
raw: row
|
|
134
|
+
};
|
|
135
|
+
}
|
|
37
136
|
function requireField(value, field, raw) {
|
|
38
137
|
const s = value == null ? "" : String(value);
|
|
39
138
|
if (!s) {
|
|
@@ -55,17 +154,25 @@ var InvoServer = class {
|
|
|
55
154
|
baseUrl: config.baseUrl,
|
|
56
155
|
timeoutMs: config.timeoutMs,
|
|
57
156
|
fetchImpl: config.fetch,
|
|
58
|
-
userAgent: DEFAULT_UA
|
|
157
|
+
userAgent: DEFAULT_UA,
|
|
59
158
|
// must be a non-blocked UA (handoff doc §9)
|
|
159
|
+
maxRetries: config.maxRetries,
|
|
160
|
+
retryBaseDelayMs: config.retryBaseDelayMs,
|
|
161
|
+
hooks: config.hooks
|
|
60
162
|
});
|
|
61
163
|
this.auth = { kind: "game-secret", secret: config.gameSecret };
|
|
62
164
|
}
|
|
63
165
|
/** Mint a short-lived, game-scoped player token to hand to the browser InvoClient. */
|
|
64
166
|
async mintPlayerToken(input) {
|
|
167
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
168
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
169
|
+
}
|
|
65
170
|
const raw = await this.http.post(
|
|
66
171
|
"/api/sdk/player-token",
|
|
67
172
|
{ player_email: input.playerEmail },
|
|
68
|
-
this.auth
|
|
173
|
+
this.auth,
|
|
174
|
+
{ idempotent: true }
|
|
175
|
+
// safe to retry: re-mint just issues a fresh token
|
|
69
176
|
);
|
|
70
177
|
return {
|
|
71
178
|
token: requireField(raw["token"], "token", raw),
|
|
@@ -87,7 +194,9 @@ var InvoServer = class {
|
|
|
87
194
|
receiving_game_id: input.receivingGameId,
|
|
88
195
|
amount: input.amount
|
|
89
196
|
},
|
|
90
|
-
this.auth
|
|
197
|
+
this.auth,
|
|
198
|
+
{ idempotent: true }
|
|
199
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
91
200
|
);
|
|
92
201
|
return this.toInitiateResult(raw);
|
|
93
202
|
}
|
|
@@ -105,7 +214,9 @@ var InvoServer = class {
|
|
|
105
214
|
target_game_id: input.targetGameId,
|
|
106
215
|
amount: input.amount
|
|
107
216
|
},
|
|
108
|
-
this.auth
|
|
217
|
+
this.auth,
|
|
218
|
+
{ idempotent: true }
|
|
219
|
+
// client_request_id makes this safe to retry (backend dedupes)
|
|
109
220
|
);
|
|
110
221
|
return this.toInitiateResult(raw);
|
|
111
222
|
}
|
|
@@ -114,6 +225,9 @@ var InvoServer = class {
|
|
|
114
225
|
* handles cards, saved cards, and 3-D Secure. Crediting is server-side via the
|
|
115
226
|
* purchase.completed webhook. */
|
|
116
227
|
async createCheckout(input) {
|
|
228
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
229
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
230
|
+
}
|
|
117
231
|
assertUsdAmount(input.usdAmount);
|
|
118
232
|
const body = {
|
|
119
233
|
player_email: input.playerEmail,
|
|
@@ -143,6 +257,9 @@ var InvoServer = class {
|
|
|
143
257
|
* - rail "steam": NOT a browser flow — the backend returns WRONG_RAIL_ENDPOINT.
|
|
144
258
|
*/
|
|
145
259
|
async purchaseCurrency(input) {
|
|
260
|
+
if (typeof input.playerEmail !== "string" || !input.playerEmail.trim()) {
|
|
261
|
+
throw invalidInput("playerEmail", input.playerEmail, "is required");
|
|
262
|
+
}
|
|
146
263
|
assertUsdAmount(input.usdAmount);
|
|
147
264
|
if (!input.purchaseReference) {
|
|
148
265
|
throw new InvoError({
|
|
@@ -171,7 +288,9 @@ var InvoServer = class {
|
|
|
171
288
|
const raw = await this.http.post(
|
|
172
289
|
"/api/currency-purchases/purchase-currency",
|
|
173
290
|
body,
|
|
174
|
-
this.auth
|
|
291
|
+
this.auth,
|
|
292
|
+
{ idempotent: true }
|
|
293
|
+
// purchase_reference makes this safe to retry (backend dedupes)
|
|
175
294
|
);
|
|
176
295
|
return {
|
|
177
296
|
status: String(raw["status"] ?? ""),
|
|
@@ -188,11 +307,19 @@ var InvoServer = class {
|
|
|
188
307
|
async confirmPayment(input) {
|
|
189
308
|
const body = { payment_intent_id: input.paymentIntentId };
|
|
190
309
|
if (input.orderId) body["order_id"] = input.orderId;
|
|
191
|
-
|
|
310
|
+
const raw = await this.http.post(
|
|
192
311
|
"/api/currency-purchases/confirm-payment",
|
|
193
312
|
body,
|
|
194
|
-
this.auth
|
|
313
|
+
this.auth,
|
|
314
|
+
{ idempotent: true }
|
|
315
|
+
// keyed by payment_intent_id — safe to retry
|
|
195
316
|
);
|
|
317
|
+
return {
|
|
318
|
+
status: String(raw["status"] ?? ""),
|
|
319
|
+
transactionId: raw["transaction_id"],
|
|
320
|
+
newBalance: raw["new_balance"] ?? null,
|
|
321
|
+
raw
|
|
322
|
+
};
|
|
196
323
|
}
|
|
197
324
|
/** Fetch purchase status (order + financial summary + timeline). */
|
|
198
325
|
async getOrderDetails(query) {
|
|
@@ -206,10 +333,11 @@ var InvoServer = class {
|
|
|
206
333
|
const q = new URLSearchParams();
|
|
207
334
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
208
335
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
209
|
-
|
|
336
|
+
const raw = await this.http.get(
|
|
210
337
|
`/api/currency-purchases/order-details?${q.toString()}`,
|
|
211
338
|
this.auth
|
|
212
339
|
);
|
|
340
|
+
return toOrderDetails(raw);
|
|
213
341
|
}
|
|
214
342
|
/**
|
|
215
343
|
* Buy an in-game item by SPENDING the player's existing game currency (§4.8).
|
|
@@ -260,7 +388,9 @@ var InvoServer = class {
|
|
|
260
388
|
const raw = await this.http.post(
|
|
261
389
|
"/api/item-purchases/purchase-item",
|
|
262
390
|
body,
|
|
263
|
-
this.auth
|
|
391
|
+
this.auth,
|
|
392
|
+
{ idempotent: true }
|
|
393
|
+
// client_request_id makes this safe to retry (dup → 409)
|
|
264
394
|
);
|
|
265
395
|
return {
|
|
266
396
|
status: String(raw["status"] ?? ""),
|
|
@@ -282,10 +412,15 @@ var InvoServer = class {
|
|
|
282
412
|
q.set("player_email", query.playerEmail);
|
|
283
413
|
if (query.limit != null) q.set("limit", String(query.limit));
|
|
284
414
|
if (query.offset != null) q.set("offset", String(query.offset));
|
|
285
|
-
|
|
415
|
+
const raw = await this.http.get(
|
|
286
416
|
`/api/item-purchases/player-purchase-history?${q.toString()}`,
|
|
287
417
|
this.auth
|
|
288
418
|
);
|
|
419
|
+
return {
|
|
420
|
+
history: Array.isArray(raw["item_purchase_history"]) ? raw["item_purchase_history"] : [],
|
|
421
|
+
pagination: raw["pagination"] ?? {},
|
|
422
|
+
raw
|
|
423
|
+
};
|
|
289
424
|
}
|
|
290
425
|
/** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
|
|
291
426
|
* (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
|
|
@@ -304,10 +439,34 @@ var InvoServer = class {
|
|
|
304
439
|
if (query.orderId) q.set("order_id", query.orderId);
|
|
305
440
|
if (query.transactionId) q.set("transaction_id", query.transactionId);
|
|
306
441
|
if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
|
|
307
|
-
|
|
442
|
+
const raw = await this.http.get(
|
|
308
443
|
`/api/item-purchases/order-details?${q.toString()}`,
|
|
309
444
|
this.auth
|
|
310
445
|
);
|
|
446
|
+
return toOrderDetails(raw);
|
|
447
|
+
}
|
|
448
|
+
/** Read a player's currency balances, by email or playerId (game-secret). */
|
|
449
|
+
async getPlayerBalance(query) {
|
|
450
|
+
let path;
|
|
451
|
+
if (query.playerId != null && String(query.playerId).trim()) {
|
|
452
|
+
path = `/api/player-balances/player/${encodeURIComponent(String(query.playerId))}`;
|
|
453
|
+
} else if (typeof query.playerEmail === "string" && query.playerEmail.trim()) {
|
|
454
|
+
path = `/api/player-balances/player/by-email/${encodeURIComponent(query.playerEmail)}`;
|
|
455
|
+
} else {
|
|
456
|
+
throw new InvoError({
|
|
457
|
+
message: "getPlayerBalance requires a playerEmail or playerId.",
|
|
458
|
+
code: "INVALID_INPUT",
|
|
459
|
+
status: 0
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const raw = await this.http.get(path, this.auth);
|
|
463
|
+
const rows = Array.isArray(raw["balances"]) ? raw["balances"] : [];
|
|
464
|
+
return {
|
|
465
|
+
player: raw["player"] ?? {},
|
|
466
|
+
balances: rows.map(toCurrencyBalance),
|
|
467
|
+
summary: raw["summary"] ?? {},
|
|
468
|
+
raw
|
|
469
|
+
};
|
|
311
470
|
}
|
|
312
471
|
toInitiateResult(raw) {
|
|
313
472
|
const vm = raw["verification_method"];
|
|
@@ -321,6 +480,6 @@ var InvoServer = class {
|
|
|
321
480
|
}
|
|
322
481
|
};
|
|
323
482
|
|
|
324
|
-
export { InvoServer };
|
|
483
|
+
export { InvoServer, verifyWebhook };
|
|
325
484
|
//# sourceMappingURL=server.js.map
|
|
326
485
|
//# sourceMappingURL=server.js.map
|
|
@@ -1,3 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed error for every INVO backend failure.
|
|
3
|
+
*
|
|
4
|
+
* The backend error envelope is usually `{ "error": <message>, "code": <STABLE_CODE> }`,
|
|
5
|
+
* but a few txn-state/claim errors carry only `error` (no `code`) and some use
|
|
6
|
+
* `{ "status": "error", "message": ... }`. So `code` may be undefined — branch on
|
|
7
|
+
* `code` when present, else fall back to `message`. See the handoff doc §8.
|
|
8
|
+
*/
|
|
9
|
+
declare class InvoError extends Error {
|
|
10
|
+
/** Stable machine code from the backend (`code`), when present. */
|
|
11
|
+
readonly code: string | undefined;
|
|
12
|
+
/** HTTP status. */
|
|
13
|
+
readonly status: number;
|
|
14
|
+
/** Raw parsed body, for debugging / fields not surfaced here. */
|
|
15
|
+
readonly body: unknown;
|
|
16
|
+
/** Backend request id (from the response headers), when present — quote it in support tickets. */
|
|
17
|
+
readonly requestId: string | undefined;
|
|
18
|
+
constructor(args: {
|
|
19
|
+
message: string;
|
|
20
|
+
code?: string;
|
|
21
|
+
status: number;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
requestId?: string;
|
|
24
|
+
});
|
|
25
|
+
/** True if this is the "recipient isn't passkey-enrolled, fall back to claim code" signal. */
|
|
26
|
+
get isReceiverNotEnrolled(): boolean;
|
|
27
|
+
/** True if the session/SDK token has expired and the caller should re-mint + retry. */
|
|
28
|
+
get isTokenExpired(): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* True if an item purchase failed because the player's balance was too low (§4.8 → 400).
|
|
31
|
+
* The backend carries `required_amount` + `current_balance` on the body for the UI.
|
|
32
|
+
* Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
|
|
33
|
+
* is a rate-limit, not a top-up condition) is NOT misclassified as this.
|
|
34
|
+
*/
|
|
35
|
+
get isInsufficientBalance(): boolean;
|
|
36
|
+
/** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
|
|
37
|
+
get isDuplicateRequest(): boolean;
|
|
38
|
+
/** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
|
|
39
|
+
get retryAfter(): number | undefined;
|
|
40
|
+
/** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
|
|
41
|
+
* The `in` operator throws on primitives, so callers must go through this. */
|
|
42
|
+
private bodyObject;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface InvoRequestInfo {
|
|
46
|
+
method: string;
|
|
47
|
+
url: string;
|
|
48
|
+
/** 0-based attempt number (0 = first try, 1 = first retry, …). */
|
|
49
|
+
attempt: number;
|
|
50
|
+
}
|
|
51
|
+
interface InvoResponseInfo extends InvoRequestInfo {
|
|
52
|
+
status: number;
|
|
53
|
+
durationMs: number;
|
|
54
|
+
/** Backend request id from the response headers, when present. */
|
|
55
|
+
requestId?: string;
|
|
56
|
+
}
|
|
57
|
+
interface InvoErrorInfo extends InvoRequestInfo {
|
|
58
|
+
error: InvoError;
|
|
59
|
+
/** Whether the SDK will retry after this error. */
|
|
60
|
+
willRetry: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** Optional observability callbacks. All are best-effort — a throwing hook is swallowed. */
|
|
63
|
+
interface InvoHooks {
|
|
64
|
+
onRequest?: (info: InvoRequestInfo) => void;
|
|
65
|
+
onResponse?: (info: InvoResponseInfo) => void;
|
|
66
|
+
onError?: (info: InvoErrorInfo) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
1
69
|
/** Public, neutral payment-rail names. Provider/processor names are an internal
|
|
2
70
|
* backend routing detail and are deliberately NOT exposed here. */
|
|
3
71
|
type Rail = "platform" | "game" | "steam";
|
|
@@ -10,6 +78,16 @@ interface InvoConfig {
|
|
|
10
78
|
timeoutMs?: number;
|
|
11
79
|
/** Optional fetch override (e.g. a custom agent). Defaults to global fetch. */
|
|
12
80
|
fetch?: typeof fetch;
|
|
81
|
+
/**
|
|
82
|
+
* Max automatic retries on transient failures — network error/timeout, `429`
|
|
83
|
+
* (honoring `retry_after`), and `5xx`. Default 2. Set 0 to disable. Mutating
|
|
84
|
+
* calls carry idempotency keys, so retries are safe.
|
|
85
|
+
*/
|
|
86
|
+
maxRetries?: number;
|
|
87
|
+
/** Base backoff delay in ms (exponential + jitter). Default 250. */
|
|
88
|
+
retryBaseDelayMs?: number;
|
|
89
|
+
/** Optional observability callbacks: onRequest / onResponse / onError (best-effort). */
|
|
90
|
+
hooks?: InvoHooks;
|
|
13
91
|
}
|
|
14
92
|
interface ServerConfig extends InvoConfig {
|
|
15
93
|
/** The game secret. Server-side ONLY — never ship this to a browser. */
|
|
@@ -107,6 +185,24 @@ interface PurchaseResult {
|
|
|
107
185
|
newBalance?: string | number | null;
|
|
108
186
|
raw: Record<string, unknown>;
|
|
109
187
|
}
|
|
188
|
+
interface ConfirmPaymentResult {
|
|
189
|
+
status: string;
|
|
190
|
+
transactionId?: string;
|
|
191
|
+
newBalance?: string | number | null;
|
|
192
|
+
raw: Record<string, unknown>;
|
|
193
|
+
}
|
|
194
|
+
/** Shape of the order-details reads (currency purchase + item purchase). */
|
|
195
|
+
interface OrderDetailsResult {
|
|
196
|
+
order: Record<string, unknown>;
|
|
197
|
+
financialSummary: Record<string, unknown>;
|
|
198
|
+
statusTimeline: unknown;
|
|
199
|
+
raw: Record<string, unknown>;
|
|
200
|
+
}
|
|
201
|
+
interface ItemHistoryResult {
|
|
202
|
+
history: Record<string, unknown>[];
|
|
203
|
+
pagination: Record<string, unknown>;
|
|
204
|
+
raw: Record<string, unknown>;
|
|
205
|
+
}
|
|
110
206
|
interface PurchaseItemInput {
|
|
111
207
|
/** Idempotency key, unique per game. Required. A duplicate returns 409. */
|
|
112
208
|
clientRequestId: string;
|
|
@@ -148,6 +244,30 @@ interface ItemOrderQuery {
|
|
|
148
244
|
transactionId?: string;
|
|
149
245
|
clientRequestId?: string;
|
|
150
246
|
}
|
|
247
|
+
interface PlayerBalanceQuery {
|
|
248
|
+
/** Look up by email, or by playerId — provide one. */
|
|
249
|
+
playerEmail?: string;
|
|
250
|
+
playerId?: string | number;
|
|
251
|
+
}
|
|
252
|
+
interface CurrencyBalance {
|
|
253
|
+
currencyId: string | number;
|
|
254
|
+
currencyName: string;
|
|
255
|
+
currencySymbol: string;
|
|
256
|
+
availableBalance: string;
|
|
257
|
+
reservedBalance: string;
|
|
258
|
+
totalBalance: string;
|
|
259
|
+
lastTransaction?: string;
|
|
260
|
+
/** Raw balance row for any field not surfaced here. */
|
|
261
|
+
raw: Record<string, unknown>;
|
|
262
|
+
}
|
|
263
|
+
interface PlayerBalanceResult {
|
|
264
|
+
/** { player_id, player_name, player_email, date_joined, last_active }. */
|
|
265
|
+
player: Record<string, unknown>;
|
|
266
|
+
balances: CurrencyBalance[];
|
|
267
|
+
/** { total_currencies, total_value_usd, has_funds, last_updated }. */
|
|
268
|
+
summary: Record<string, unknown>;
|
|
269
|
+
raw: Record<string, unknown>;
|
|
270
|
+
}
|
|
151
271
|
interface ApproveResult {
|
|
152
272
|
status: string;
|
|
153
273
|
next: string;
|
|
@@ -167,45 +287,4 @@ interface LinkDeviceResult {
|
|
|
167
287
|
raw: Record<string, unknown>;
|
|
168
288
|
}
|
|
169
289
|
|
|
170
|
-
|
|
171
|
-
* Typed error for every INVO backend failure.
|
|
172
|
-
*
|
|
173
|
-
* The backend error envelope is usually `{ "error": <message>, "code": <STABLE_CODE> }`,
|
|
174
|
-
* but a few txn-state/claim errors carry only `error` (no `code`) and some use
|
|
175
|
-
* `{ "status": "error", "message": ... }`. So `code` may be undefined — branch on
|
|
176
|
-
* `code` when present, else fall back to `message`. See the handoff doc §8.
|
|
177
|
-
*/
|
|
178
|
-
declare class InvoError extends Error {
|
|
179
|
-
/** Stable machine code from the backend (`code`), when present. */
|
|
180
|
-
readonly code: string | undefined;
|
|
181
|
-
/** HTTP status. */
|
|
182
|
-
readonly status: number;
|
|
183
|
-
/** Raw parsed body, for debugging / fields not surfaced here. */
|
|
184
|
-
readonly body: unknown;
|
|
185
|
-
constructor(args: {
|
|
186
|
-
message: string;
|
|
187
|
-
code?: string;
|
|
188
|
-
status: number;
|
|
189
|
-
body?: unknown;
|
|
190
|
-
});
|
|
191
|
-
/** True if this is the "recipient isn't passkey-enrolled, fall back to claim code" signal. */
|
|
192
|
-
get isReceiverNotEnrolled(): boolean;
|
|
193
|
-
/** True if the session/SDK token has expired and the caller should re-mint + retry. */
|
|
194
|
-
get isTokenExpired(): boolean;
|
|
195
|
-
/**
|
|
196
|
-
* True if an item purchase failed because the player's balance was too low (§4.8 → 400).
|
|
197
|
-
* The backend carries `required_amount` + `current_balance` on the body for the UI.
|
|
198
|
-
* Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
|
|
199
|
-
* is a rate-limit, not a top-up condition) is NOT misclassified as this.
|
|
200
|
-
*/
|
|
201
|
-
get isInsufficientBalance(): boolean;
|
|
202
|
-
/** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
|
|
203
|
-
get isDuplicateRequest(): boolean;
|
|
204
|
-
/** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
|
|
205
|
-
get retryAfter(): number | undefined;
|
|
206
|
-
/** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
|
|
207
|
-
* The `in` operator throws on primitives, so callers must go through this. */
|
|
208
|
-
private bodyObject;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InitiateSendInput as b, type InitiateResult as c, type InitiateTransferInput as d, type CreateCheckoutInput as e, type CreateCheckoutResult as f, type PurchaseInput as g, type PurchaseResult as h, type PurchaseItemInput as i, type PurchaseItemResult as j, type ItemHistoryQuery as k, type ItemOrderQuery as l };
|
|
290
|
+
export { type ApproveResult as A, type ClientConfig as C, InvoError as I, type LinkDeviceResult as L, type OrderDetailsResult as O, type PlayerToken as P, type Rail as R, type ServerConfig as S, type VerificationMethod as V, type ConfirmReceiptResult as a, type InvoErrorInfo as b, type InvoHooks as c, type InvoRequestInfo as d, type InvoResponseInfo as e, type InitiateSendInput as f, type InitiateResult as g, type InitiateTransferInput as h, type CreateCheckoutInput as i, type CreateCheckoutResult as j, type PurchaseInput as k, type PurchaseResult as l, type ConfirmPaymentResult as m, type PurchaseItemInput as n, type PurchaseItemResult as o, type ItemHistoryQuery as p, type ItemHistoryResult as q, type ItemOrderQuery as r, type PlayerBalanceQuery as s, type PlayerBalanceResult as t, type CurrencyBalance as u };
|