@invonetwork/web-sdk 0.1.0 → 0.2.1

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.
@@ -107,6 +107,47 @@ interface PurchaseResult {
107
107
  newBalance?: string | number | null;
108
108
  raw: Record<string, unknown>;
109
109
  }
110
+ interface PurchaseItemInput {
111
+ /** Idempotency key, unique per game. Required. A duplicate returns 409. */
112
+ clientRequestId: string;
113
+ playerEmail: string;
114
+ playerName: string;
115
+ itemId: string;
116
+ itemName: string;
117
+ /** Integer quantity, 1..1000. */
118
+ itemQuantity: number;
119
+ /** Price PER UNIT, in game-currency units (not USD). > 0 and <= 999999.99. */
120
+ unitPrice: string | number;
121
+ /** Must equal unitPrice × itemQuantity (±0.01). > 0 and <= 999999.99. */
122
+ totalPrice: string | number;
123
+ playerPhone?: string;
124
+ itemDescription?: string;
125
+ itemCategory?: string;
126
+ }
127
+ interface PurchaseItemResult {
128
+ /** "success" on a completed spend. */
129
+ status: string;
130
+ transactionId: string;
131
+ orderId: string;
132
+ /** Canonical balance AFTER the spend (game-currency units). */
133
+ newBalance: string | number | null;
134
+ previousBalance: string | number | null;
135
+ currencyName: string;
136
+ /** { total_paid, developer_revenue, platform_fee } when present. */
137
+ financialBreakdown?: Record<string, unknown>;
138
+ raw: Record<string, unknown>;
139
+ }
140
+ interface ItemHistoryQuery {
141
+ playerEmail: string;
142
+ limit?: number;
143
+ offset?: number;
144
+ }
145
+ /** Look up an item order by EXACTLY ONE of these. */
146
+ interface ItemOrderQuery {
147
+ orderId?: string;
148
+ transactionId?: string;
149
+ clientRequestId?: string;
150
+ }
110
151
  interface ApproveResult {
111
152
  status: string;
112
153
  next: string;
@@ -151,6 +192,20 @@ declare class InvoError extends Error {
151
192
  get isReceiverNotEnrolled(): boolean;
152
193
  /** True if the session/SDK token has expired and the caller should re-mint + retry. */
153
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;
154
209
  }
155
210
 
156
- 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 };
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 };
@@ -107,6 +107,47 @@ interface PurchaseResult {
107
107
  newBalance?: string | number | null;
108
108
  raw: Record<string, unknown>;
109
109
  }
110
+ interface PurchaseItemInput {
111
+ /** Idempotency key, unique per game. Required. A duplicate returns 409. */
112
+ clientRequestId: string;
113
+ playerEmail: string;
114
+ playerName: string;
115
+ itemId: string;
116
+ itemName: string;
117
+ /** Integer quantity, 1..1000. */
118
+ itemQuantity: number;
119
+ /** Price PER UNIT, in game-currency units (not USD). > 0 and <= 999999.99. */
120
+ unitPrice: string | number;
121
+ /** Must equal unitPrice × itemQuantity (±0.01). > 0 and <= 999999.99. */
122
+ totalPrice: string | number;
123
+ playerPhone?: string;
124
+ itemDescription?: string;
125
+ itemCategory?: string;
126
+ }
127
+ interface PurchaseItemResult {
128
+ /** "success" on a completed spend. */
129
+ status: string;
130
+ transactionId: string;
131
+ orderId: string;
132
+ /** Canonical balance AFTER the spend (game-currency units). */
133
+ newBalance: string | number | null;
134
+ previousBalance: string | number | null;
135
+ currencyName: string;
136
+ /** { total_paid, developer_revenue, platform_fee } when present. */
137
+ financialBreakdown?: Record<string, unknown>;
138
+ raw: Record<string, unknown>;
139
+ }
140
+ interface ItemHistoryQuery {
141
+ playerEmail: string;
142
+ limit?: number;
143
+ offset?: number;
144
+ }
145
+ /** Look up an item order by EXACTLY ONE of these. */
146
+ interface ItemOrderQuery {
147
+ orderId?: string;
148
+ transactionId?: string;
149
+ clientRequestId?: string;
150
+ }
110
151
  interface ApproveResult {
111
152
  status: string;
112
153
  next: string;
@@ -151,6 +192,20 @@ declare class InvoError extends Error {
151
192
  get isReceiverNotEnrolled(): boolean;
152
193
  /** True if the session/SDK token has expired and the caller should re-mint + retry. */
153
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;
154
209
  }
155
210
 
156
- 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 };
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 };
package/dist/index.cjs CHANGED
@@ -18,6 +18,32 @@ var InvoError = class _InvoError extends Error {
18
18
  get isTokenExpired() {
19
19
  return this.code === "SDK_TOKEN_EXPIRED";
20
20
  }
21
+ /**
22
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
23
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
24
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
25
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
26
+ */
27
+ get isInsufficientBalance() {
28
+ if (this.status !== 400) return false;
29
+ const b = this.bodyObject();
30
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
31
+ }
32
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
33
+ get isDuplicateRequest() {
34
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
35
+ }
36
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
37
+ get retryAfter() {
38
+ const v = this.bodyObject()["retry_after"];
39
+ const n = typeof v === "string" ? Number(v) : v;
40
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
41
+ }
42
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
43
+ * The `in` operator throws on primitives, so callers must go through this. */
44
+ bodyObject() {
45
+ return this.body && typeof this.body === "object" ? this.body : {};
46
+ }
21
47
  };
22
48
  function errorFromResponse(status, body) {
23
49
  let message = `INVO request failed (HTTP ${status})`;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-B7rVID2r.cjs';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.cjs';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.cjs';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.cjs';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-B7rVID2r.js';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.js';
1
+ import { C as ClientConfig, A as ApproveResult, a as ConfirmReceiptResult, L as LinkDeviceResult } from './errors-DV5QsftP.js';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.js';
3
3
 
4
4
  declare class InvoClient {
5
5
  private readonly http;
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { assertSecureBaseUrl, Http, InvoError } from './chunk-KUQVVH2P.js';
2
- export { InvoError } from './chunk-KUQVVH2P.js';
1
+ import { assertSecureBaseUrl, Http, InvoError } from './chunk-A44O4KC3.js';
2
+ export { InvoError } from './chunk-A44O4KC3.js';
3
3
 
4
4
  // src/shared/webauthn.ts
5
5
  function b64urlToBuffer(value) {
package/dist/server.cjs CHANGED
@@ -18,6 +18,32 @@ var InvoError = class _InvoError extends Error {
18
18
  get isTokenExpired() {
19
19
  return this.code === "SDK_TOKEN_EXPIRED";
20
20
  }
21
+ /**
22
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
23
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
24
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
25
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
26
+ */
27
+ get isInsufficientBalance() {
28
+ if (this.status !== 400) return false;
29
+ const b = this.bodyObject();
30
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
31
+ }
32
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
33
+ get isDuplicateRequest() {
34
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
35
+ }
36
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
37
+ get retryAfter() {
38
+ const v = this.bodyObject()["retry_after"];
39
+ const n = typeof v === "string" ? Number(v) : v;
40
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
41
+ }
42
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
43
+ * The `in` operator throws on primitives, so callers must go through this. */
44
+ bodyObject() {
45
+ return this.body && typeof this.body === "object" ? this.body : {};
46
+ }
21
47
  };
22
48
  function errorFromResponse(status, body) {
23
49
  let message = `INVO request failed (HTTP ${status})`;
@@ -121,29 +147,35 @@ var Http = class {
121
147
  // src/server.ts
122
148
  var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
123
149
  var MAX_USD_AMOUNT = 999.99;
124
- function invalidAmount(usdAmount, why) {
150
+ var MAX_ITEM_PRICE = 999999.99;
151
+ function invalidInput(label, value, why) {
125
152
  return new InvoError({
126
- message: `usdAmount ${why} (got ${JSON.stringify(usdAmount)}).`,
153
+ message: `${label} ${why} (got ${JSON.stringify(value)}).`,
127
154
  code: "INVALID_INPUT",
128
155
  status: 0
129
156
  });
130
157
  }
131
- function assertUsdAmount(usdAmount) {
158
+ var PLAIN_DECIMAL = /^\d+(\.\d{1,2})?$/;
159
+ function parseMoney(value, max, label) {
132
160
  let n;
133
- if (typeof usdAmount === "number") {
134
- n = usdAmount;
135
- if (Number.isFinite(n) && Math.abs(n * 100 - Math.round(n * 100)) > 1e-9) {
136
- throw invalidAmount(usdAmount, "must have at most 2 decimal places");
161
+ if (typeof value === "number") {
162
+ if (!Number.isFinite(value) || !PLAIN_DECIMAL.test(String(value))) {
163
+ throw invalidInput(label, value, "must be a positive decimal with at most 2 places");
137
164
  }
165
+ n = value;
138
166
  } else {
139
- if (!/^\d+(\.\d{1,2})?$/.test(usdAmount)) {
140
- throw invalidAmount(usdAmount, "must be a plain decimal USD value with at most 2 places");
167
+ if (!PLAIN_DECIMAL.test(value)) {
168
+ throw invalidInput(label, value, "must be a plain decimal value with at most 2 places");
141
169
  }
142
- n = Number(usdAmount);
170
+ n = Number(value);
143
171
  }
144
- if (!Number.isFinite(n) || n <= 0 || n > MAX_USD_AMOUNT) {
145
- throw invalidAmount(usdAmount, `must be > 0 and <= ${MAX_USD_AMOUNT}`);
172
+ if (!Number.isFinite(n) || n <= 0 || n > max) {
173
+ throw invalidInput(label, value, `must be > 0 and <= ${max}`);
146
174
  }
175
+ return n;
176
+ }
177
+ function assertUsdAmount(usdAmount) {
178
+ parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
147
179
  }
148
180
  function requireField(value, field, raw) {
149
181
  const s = value == null ? "" : String(value);
@@ -322,6 +354,104 @@ var InvoServer = class {
322
354
  this.auth
323
355
  );
324
356
  }
357
+ /**
358
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
359
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
360
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
361
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
362
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
363
+ * item to your inventory off the `item.purchased` webhook, not just this response.
364
+ */
365
+ async purchaseItem(input) {
366
+ const required = [
367
+ ["clientRequestId", input.clientRequestId],
368
+ ["playerEmail", input.playerEmail],
369
+ ["playerName", input.playerName],
370
+ ["itemId", input.itemId],
371
+ ["itemName", input.itemName]
372
+ ];
373
+ for (const [k, v] of required) {
374
+ if (typeof v !== "string" || !v.trim()) throw invalidInput(k, v, "is required");
375
+ }
376
+ if (!Number.isInteger(input.itemQuantity) || input.itemQuantity < 1 || input.itemQuantity > 1e3) {
377
+ throw invalidInput("itemQuantity", input.itemQuantity, "must be an integer 1..1000");
378
+ }
379
+ const unit = parseMoney(input.unitPrice, MAX_ITEM_PRICE, "unitPrice");
380
+ const total = parseMoney(input.totalPrice, MAX_ITEM_PRICE, "totalPrice");
381
+ const expectedCents = Math.round(unit * input.itemQuantity * 100);
382
+ const totalCents = Math.round(total * 100);
383
+ if (Math.abs(totalCents - expectedCents) > 1) {
384
+ throw invalidInput(
385
+ "totalPrice",
386
+ input.totalPrice,
387
+ `must equal unitPrice \xD7 itemQuantity (\xB10.01): ${unit} \xD7 ${input.itemQuantity}`
388
+ );
389
+ }
390
+ const body = {
391
+ client_request_id: input.clientRequestId,
392
+ player_email: input.playerEmail,
393
+ player_name: input.playerName,
394
+ item_id: input.itemId,
395
+ item_name: input.itemName,
396
+ item_quantity: input.itemQuantity,
397
+ unit_price: input.unitPrice,
398
+ total_price: input.totalPrice
399
+ };
400
+ if (input.playerPhone) body["player_phone"] = input.playerPhone;
401
+ if (input.itemDescription) body["item_description"] = input.itemDescription;
402
+ if (input.itemCategory) body["item_category"] = input.itemCategory;
403
+ const raw = await this.http.post(
404
+ "/api/item-purchases/purchase-item",
405
+ body,
406
+ this.auth
407
+ );
408
+ return {
409
+ status: String(raw["status"] ?? ""),
410
+ transactionId: requireField(raw["transaction_id"], "transaction_id", raw),
411
+ orderId: requireField(raw["order_id"], "order_id", raw),
412
+ newBalance: raw["new_balance"] ?? null,
413
+ previousBalance: raw["previous_balance"] ?? null,
414
+ currencyName: String(raw["currency_name"] ?? ""),
415
+ financialBreakdown: raw["financial_breakdown"],
416
+ raw
417
+ };
418
+ }
419
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
420
+ async getItemPurchaseHistory(query) {
421
+ if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
422
+ throw invalidInput("playerEmail", query.playerEmail, "is required");
423
+ }
424
+ const q = new URLSearchParams();
425
+ q.set("player_email", query.playerEmail);
426
+ if (query.limit != null) q.set("limit", String(query.limit));
427
+ if (query.offset != null) q.set("offset", String(query.offset));
428
+ return this.http.get(
429
+ `/api/item-purchases/player-purchase-history?${q.toString()}`,
430
+ this.auth
431
+ );
432
+ }
433
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
434
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
435
+ async getItemOrderDetails(query) {
436
+ const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
437
+ (v) => typeof v === "string" && v.trim()
438
+ );
439
+ if (provided.length !== 1) {
440
+ throw new InvoError({
441
+ message: "getItemOrderDetails requires EXACTLY ONE of orderId, transactionId, or clientRequestId.",
442
+ code: "INVALID_INPUT",
443
+ status: 0
444
+ });
445
+ }
446
+ const q = new URLSearchParams();
447
+ if (query.orderId) q.set("order_id", query.orderId);
448
+ if (query.transactionId) q.set("transaction_id", query.transactionId);
449
+ if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
450
+ return this.http.get(
451
+ `/api/item-purchases/order-details?${q.toString()}`,
452
+ this.auth
453
+ );
454
+ }
325
455
  toInitiateResult(raw) {
326
456
  const vm = raw["verification_method"];
327
457
  const guardian = raw["guardian_approval"];
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult } from './errors-B7rVID2r.cjs';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.cjs';
1
+ import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult, i as PurchaseItemInput, j as PurchaseItemResult, k as ItemHistoryQuery, l as ItemOrderQuery } from './errors-DV5QsftP.cjs';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.cjs';
3
3
 
4
4
  declare class InvoServer {
5
5
  private readonly http;
@@ -36,7 +36,21 @@ declare class InvoServer {
36
36
  orderId?: string;
37
37
  transactionId?: string;
38
38
  }): Promise<Record<string, unknown>>;
39
+ /**
40
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
41
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
42
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
43
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
44
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
45
+ * item to your inventory off the `item.purchased` webhook, not just this response.
46
+ */
47
+ purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
48
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
49
+ getItemPurchaseHistory(query: ItemHistoryQuery): Promise<Record<string, unknown>>;
50
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
51
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
52
+ getItemOrderDetails(query: ItemOrderQuery): Promise<Record<string, unknown>>;
39
53
  private toInitiateResult;
40
54
  }
41
55
 
42
- export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, PlayerToken, PurchaseInput, PurchaseResult, ServerConfig };
56
+ export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult } from './errors-B7rVID2r.js';
2
- export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-B7rVID2r.js';
1
+ import { S as ServerConfig, P as PlayerToken, b as InitiateSendInput, c as InitiateResult, d as InitiateTransferInput, e as CreateCheckoutInput, f as CreateCheckoutResult, g as PurchaseInput, h as PurchaseResult, i as PurchaseItemInput, j as PurchaseItemResult, k as ItemHistoryQuery, l as ItemOrderQuery } from './errors-DV5QsftP.js';
2
+ export { I as InvoError, R as Rail, V as VerificationMethod } from './errors-DV5QsftP.js';
3
3
 
4
4
  declare class InvoServer {
5
5
  private readonly http;
@@ -36,7 +36,21 @@ declare class InvoServer {
36
36
  orderId?: string;
37
37
  transactionId?: string;
38
38
  }): Promise<Record<string, unknown>>;
39
+ /**
40
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
41
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
42
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
43
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
44
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
45
+ * item to your inventory off the `item.purchased` webhook, not just this response.
46
+ */
47
+ purchaseItem(input: PurchaseItemInput): Promise<PurchaseItemResult>;
48
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
49
+ getItemPurchaseHistory(query: ItemHistoryQuery): Promise<Record<string, unknown>>;
50
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
51
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
52
+ getItemOrderDetails(query: ItemOrderQuery): Promise<Record<string, unknown>>;
39
53
  private toInitiateResult;
40
54
  }
41
55
 
42
- export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, PlayerToken, PurchaseInput, PurchaseResult, ServerConfig };
56
+ export { CreateCheckoutInput, CreateCheckoutResult, InitiateResult, InitiateSendInput, InitiateTransferInput, InvoServer, ItemHistoryQuery, ItemOrderQuery, PlayerToken, PurchaseInput, PurchaseItemInput, PurchaseItemResult, PurchaseResult, ServerConfig };
package/dist/server.js CHANGED
@@ -1,32 +1,38 @@
1
- import { assertSecureBaseUrl, Http, InvoError } from './chunk-KUQVVH2P.js';
2
- export { InvoError } from './chunk-KUQVVH2P.js';
1
+ import { assertSecureBaseUrl, Http, InvoError } from './chunk-A44O4KC3.js';
2
+ export { InvoError } from './chunk-A44O4KC3.js';
3
3
 
4
4
  // src/server.ts
5
5
  var DEFAULT_UA = "invonetwork-web-sdk/0.1.0 (+https://invo.network)";
6
6
  var MAX_USD_AMOUNT = 999.99;
7
- function invalidAmount(usdAmount, why) {
7
+ var MAX_ITEM_PRICE = 999999.99;
8
+ function invalidInput(label, value, why) {
8
9
  return new InvoError({
9
- message: `usdAmount ${why} (got ${JSON.stringify(usdAmount)}).`,
10
+ message: `${label} ${why} (got ${JSON.stringify(value)}).`,
10
11
  code: "INVALID_INPUT",
11
12
  status: 0
12
13
  });
13
14
  }
14
- function assertUsdAmount(usdAmount) {
15
+ var PLAIN_DECIMAL = /^\d+(\.\d{1,2})?$/;
16
+ function parseMoney(value, max, label) {
15
17
  let n;
16
- if (typeof usdAmount === "number") {
17
- n = usdAmount;
18
- if (Number.isFinite(n) && Math.abs(n * 100 - Math.round(n * 100)) > 1e-9) {
19
- throw invalidAmount(usdAmount, "must have at most 2 decimal places");
18
+ if (typeof value === "number") {
19
+ if (!Number.isFinite(value) || !PLAIN_DECIMAL.test(String(value))) {
20
+ throw invalidInput(label, value, "must be a positive decimal with at most 2 places");
20
21
  }
22
+ n = value;
21
23
  } else {
22
- if (!/^\d+(\.\d{1,2})?$/.test(usdAmount)) {
23
- throw invalidAmount(usdAmount, "must be a plain decimal USD value with at most 2 places");
24
+ if (!PLAIN_DECIMAL.test(value)) {
25
+ throw invalidInput(label, value, "must be a plain decimal value with at most 2 places");
24
26
  }
25
- n = Number(usdAmount);
27
+ n = Number(value);
26
28
  }
27
- if (!Number.isFinite(n) || n <= 0 || n > MAX_USD_AMOUNT) {
28
- throw invalidAmount(usdAmount, `must be > 0 and <= ${MAX_USD_AMOUNT}`);
29
+ if (!Number.isFinite(n) || n <= 0 || n > max) {
30
+ throw invalidInput(label, value, `must be > 0 and <= ${max}`);
29
31
  }
32
+ return n;
33
+ }
34
+ function assertUsdAmount(usdAmount) {
35
+ parseMoney(usdAmount, MAX_USD_AMOUNT, "usdAmount");
30
36
  }
31
37
  function requireField(value, field, raw) {
32
38
  const s = value == null ? "" : String(value);
@@ -205,6 +211,104 @@ var InvoServer = class {
205
211
  this.auth
206
212
  );
207
213
  }
214
+ /**
215
+ * Buy an in-game item by SPENDING the player's existing game currency (§4.8).
216
+ * No real money, no payment rail, no passkey — it's a balance debit, authenticated
217
+ * server-side by the game secret. Idempotent on clientRequestId (a duplicate throws
218
+ * a 409 InvoError; check err.isDuplicateRequest). Insufficient balance throws a 400
219
+ * (err.isInsufficientBalance; required_amount/current_balance on err.body). Grant the
220
+ * item to your inventory off the `item.purchased` webhook, not just this response.
221
+ */
222
+ async purchaseItem(input) {
223
+ const required = [
224
+ ["clientRequestId", input.clientRequestId],
225
+ ["playerEmail", input.playerEmail],
226
+ ["playerName", input.playerName],
227
+ ["itemId", input.itemId],
228
+ ["itemName", input.itemName]
229
+ ];
230
+ for (const [k, v] of required) {
231
+ if (typeof v !== "string" || !v.trim()) throw invalidInput(k, v, "is required");
232
+ }
233
+ if (!Number.isInteger(input.itemQuantity) || input.itemQuantity < 1 || input.itemQuantity > 1e3) {
234
+ throw invalidInput("itemQuantity", input.itemQuantity, "must be an integer 1..1000");
235
+ }
236
+ const unit = parseMoney(input.unitPrice, MAX_ITEM_PRICE, "unitPrice");
237
+ const total = parseMoney(input.totalPrice, MAX_ITEM_PRICE, "totalPrice");
238
+ const expectedCents = Math.round(unit * input.itemQuantity * 100);
239
+ const totalCents = Math.round(total * 100);
240
+ if (Math.abs(totalCents - expectedCents) > 1) {
241
+ throw invalidInput(
242
+ "totalPrice",
243
+ input.totalPrice,
244
+ `must equal unitPrice \xD7 itemQuantity (\xB10.01): ${unit} \xD7 ${input.itemQuantity}`
245
+ );
246
+ }
247
+ const body = {
248
+ client_request_id: input.clientRequestId,
249
+ player_email: input.playerEmail,
250
+ player_name: input.playerName,
251
+ item_id: input.itemId,
252
+ item_name: input.itemName,
253
+ item_quantity: input.itemQuantity,
254
+ unit_price: input.unitPrice,
255
+ total_price: input.totalPrice
256
+ };
257
+ if (input.playerPhone) body["player_phone"] = input.playerPhone;
258
+ if (input.itemDescription) body["item_description"] = input.itemDescription;
259
+ if (input.itemCategory) body["item_category"] = input.itemCategory;
260
+ const raw = await this.http.post(
261
+ "/api/item-purchases/purchase-item",
262
+ body,
263
+ this.auth
264
+ );
265
+ return {
266
+ status: String(raw["status"] ?? ""),
267
+ transactionId: requireField(raw["transaction_id"], "transaction_id", raw),
268
+ orderId: requireField(raw["order_id"], "order_id", raw),
269
+ newBalance: raw["new_balance"] ?? null,
270
+ previousBalance: raw["previous_balance"] ?? null,
271
+ currencyName: String(raw["currency_name"] ?? ""),
272
+ financialBreakdown: raw["financial_breakdown"],
273
+ raw
274
+ };
275
+ }
276
+ /** Paginated item-purchase history for a player (§4.8 companion read). */
277
+ async getItemPurchaseHistory(query) {
278
+ if (typeof query.playerEmail !== "string" || !query.playerEmail.trim()) {
279
+ throw invalidInput("playerEmail", query.playerEmail, "is required");
280
+ }
281
+ const q = new URLSearchParams();
282
+ q.set("player_email", query.playerEmail);
283
+ if (query.limit != null) q.set("limit", String(query.limit));
284
+ if (query.offset != null) q.set("offset", String(query.offset));
285
+ return this.http.get(
286
+ `/api/item-purchases/player-purchase-history?${q.toString()}`,
287
+ this.auth
288
+ );
289
+ }
290
+ /** Look up one item order by EXACTLY ONE of orderId | transactionId | clientRequestId
291
+ * (§4.8). Use clientRequestId for saga/recovery: "did this purchase actually complete?" */
292
+ async getItemOrderDetails(query) {
293
+ const provided = [query.orderId, query.transactionId, query.clientRequestId].filter(
294
+ (v) => typeof v === "string" && v.trim()
295
+ );
296
+ if (provided.length !== 1) {
297
+ throw new InvoError({
298
+ message: "getItemOrderDetails requires EXACTLY ONE of orderId, transactionId, or clientRequestId.",
299
+ code: "INVALID_INPUT",
300
+ status: 0
301
+ });
302
+ }
303
+ const q = new URLSearchParams();
304
+ if (query.orderId) q.set("order_id", query.orderId);
305
+ if (query.transactionId) q.set("transaction_id", query.transactionId);
306
+ if (query.clientRequestId) q.set("client_request_id", query.clientRequestId);
307
+ return this.http.get(
308
+ `/api/item-purchases/order-details?${q.toString()}`,
309
+ this.auth
310
+ );
311
+ }
208
312
  toInitiateResult(raw) {
209
313
  const vm = raw["verification_method"];
210
314
  const guardian = raw["guardian_approval"];