@doany-ai/sdk 0.2.7 → 0.2.9-alpha.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/dist/client.js +22 -0
- package/dist/client.types.d.ts +10 -3
- package/dist/index.d.ts +3 -1
- package/dist/modules/catalog.d.ts +8 -0
- package/dist/modules/catalog.js +29 -0
- package/dist/modules/catalog.types.d.ts +63 -0
- package/dist/modules/catalog.types.js +1 -0
- package/dist/modules/orders.d.ts +21 -0
- package/dist/modules/orders.js +330 -0
- package/dist/modules/orders.types.d.ts +125 -0
- package/dist/modules/orders.types.js +1 -0
- package/dist/modules/payments.d.ts +12 -1
- package/dist/modules/payments.js +40 -0
- package/dist/modules/payments.types.d.ts +93 -8
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -7,6 +7,8 @@ import { confirmAppUserConnection, createConnectorsModule, createUserConnectorsM
|
|
|
7
7
|
import { getAccessToken } from "./utils/auth-utils.js";
|
|
8
8
|
import { createFunctionsModule } from "./modules/functions.js";
|
|
9
9
|
import { createPaymentsModule } from "./modules/payments.js";
|
|
10
|
+
import { createCatalogModule } from "./modules/catalog.js";
|
|
11
|
+
import { createOrdersModule } from "./modules/orders.js";
|
|
10
12
|
import { createAgentsModule } from "./modules/agents.js";
|
|
11
13
|
import { createAiGatewayModule } from "./modules/ai-gateway.js";
|
|
12
14
|
import { createAppLogsModule } from "./modules/app-logs.js";
|
|
@@ -140,6 +142,8 @@ export function createClient(config) {
|
|
|
140
142
|
}),
|
|
141
143
|
integrations: createIntegrationsModule(axiosClient, appId),
|
|
142
144
|
payments: createPaymentsModule(axiosClient, appId),
|
|
145
|
+
catalog: createCatalogModule(axiosClient, appId),
|
|
146
|
+
orders: createOrdersModule(axiosClient, appId),
|
|
143
147
|
connectors: createUserConnectorsModule(axiosClient, appId),
|
|
144
148
|
auth: userAuthModule,
|
|
145
149
|
functions: createFunctionsModule(functionsAxiosClient, appId, {
|
|
@@ -202,6 +206,9 @@ export function createClient(config) {
|
|
|
202
206
|
return {
|
|
203
207
|
getCheckoutSession: full.getCheckoutSession.bind(full),
|
|
204
208
|
getSubscription: full.getSubscription.bind(full),
|
|
209
|
+
// Reading the products is not a decision for any browser, and a
|
|
210
|
+
// fulfilment function often needs what it just sold.
|
|
211
|
+
products: full.products,
|
|
205
212
|
};
|
|
206
213
|
})(),
|
|
207
214
|
functions: createFunctionsModule(serviceRoleFunctionsAxiosClient, appId, {
|
|
@@ -457,6 +464,21 @@ export function createClientFromRequest(request) {
|
|
|
457
464
|
if (runIdHeader) {
|
|
458
465
|
additionalHeaders["X-Run-Id"] = runIdHeader.slice(0, 128);
|
|
459
466
|
}
|
|
467
|
+
// Workflow diagnostics are separate from Annie's X-Run-Id attribution.
|
|
468
|
+
// The function proxy owns these headers; neither their presence nor their
|
|
469
|
+
// contents grant permissions or change billing. Match its bounded format.
|
|
470
|
+
if (serviceRoleToken) {
|
|
471
|
+
for (const name of [
|
|
472
|
+
"X-Doany-Workflow-Id",
|
|
473
|
+
"X-Doany-Workflow-Run-Id",
|
|
474
|
+
"X-Doany-Workflow-Step-Id",
|
|
475
|
+
]) {
|
|
476
|
+
const value = request.headers.get(name);
|
|
477
|
+
if (value && /^[A-Za-z0-9_.:/-]{1,128}$/.test(value)) {
|
|
478
|
+
additionalHeaders[name] = value;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
460
482
|
return createClient({
|
|
461
483
|
serverUrl: serverUrlHeader || "https://api.doany.ai",
|
|
462
484
|
appId,
|
package/dist/client.types.d.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type { SsoModule } from "./modules/sso.types.js";
|
|
|
5
5
|
import type { ConnectorsModule, UserConnectorsModule } from "./modules/connectors.types.js";
|
|
6
6
|
import type { FunctionsModule } from "./modules/functions.types.js";
|
|
7
7
|
import type { PaymentsModule } from "./modules/payments.types.js";
|
|
8
|
+
import type { CatalogModule } from "./modules/catalog.types.js";
|
|
9
|
+
import type { OrdersModule } from "./modules/orders.types.js";
|
|
8
10
|
import type { AgentsModule } from "./modules/agents.types.js";
|
|
9
11
|
import type { AiGatewayModule } from "./modules/ai-gateway.types.js";
|
|
10
12
|
import type { AppLogsModule } from "./modules/app-logs.types.js";
|
|
@@ -101,6 +103,10 @@ export interface DoanyClient {
|
|
|
101
103
|
integrations: IntegrationsModule;
|
|
102
104
|
/** {@link PaymentsModule | Payments module} for taking card payments through Stripe. */
|
|
103
105
|
payments: PaymentsModule;
|
|
106
|
+
/** {@link CatalogModule | Catalog module} for reading what the business offers. */
|
|
107
|
+
catalog: CatalogModule;
|
|
108
|
+
/** {@link OrdersModule | Orders module} for placing and reading orders. */
|
|
109
|
+
orders: OrdersModule;
|
|
104
110
|
/** Cleanup function to disconnect WebSocket connections. Call when you're done with the client. */
|
|
105
111
|
cleanup: () => void;
|
|
106
112
|
/**
|
|
@@ -150,10 +156,11 @@ export interface DoanyClient {
|
|
|
150
156
|
* service-role caller, so the ordinary client cannot resolve a mode and
|
|
151
157
|
* the read fails whatever the function forwards.
|
|
152
158
|
*
|
|
153
|
-
* Only the reads are here
|
|
154
|
-
* is
|
|
159
|
+
* Only the reads are here — the two above and the products. There
|
|
160
|
+
* is no service-role checkout: opening one is a decision that belongs to
|
|
161
|
+
* the browser that is actually there.
|
|
155
162
|
*/
|
|
156
|
-
payments: Pick<PaymentsModule, "getCheckoutSession" | "getSubscription">;
|
|
163
|
+
payments: Pick<PaymentsModule, "getCheckoutSession" | "getSubscription" | "products">;
|
|
157
164
|
/** {@link SsoModule | SSO module} for generating SSO tokens.
|
|
158
165
|
* @internal
|
|
159
166
|
*/
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,9 @@ export type { FunctionsModule, FunctionName, FunctionNameRegistry, } from "./mod
|
|
|
11
11
|
export type { AgentsModule, AgentName, AgentNameRegistry, AgentConversation, AgentMessage, AgentMessageReasoning, AgentMessageToolCall, AgentMessageUsage, AgentMessageCustomContext, AgentMessageMetadata, CreateConversationParams, } from "./modules/agents.types.js";
|
|
12
12
|
export type { AiGatewayModule, AiGatewayConnection, } from "./modules/ai-gateway.types.js";
|
|
13
13
|
export type { AppLogsModule } from "./modules/app-logs.types.js";
|
|
14
|
-
export type { PaymentsModule, CheckoutLineItem, CreateCheckoutParams, CreateCheckoutResult, CreateEmbeddedCheckoutResult, CheckoutSession, SubscriptionState, BillingPortalParams, } from "./modules/payments.types.js";
|
|
14
|
+
export type { PaymentsModule, ProductsModule, Product, ProductQuery, ProductSort, CheckoutLineItem, CreateCheckoutParams, CreateCheckoutResult, CreateEmbeddedCheckoutResult, CheckoutSession, SubscriptionState, BillingPortalParams, } from "./modules/payments.types.js";
|
|
15
|
+
export type { CatalogModule, CatalogItem, CatalogVariant, CatalogListParams, } from "./modules/catalog.types.js";
|
|
16
|
+
export type { OrdersModule, Order, OrderItem, OrderBuyer, OrderSummary, OrderStatus, FulfillmentStatus, CreateOrderParams, CreateOrderOptions, CreateOrderResult, OrderListParams, OrderAccessOptions, } from "./modules/orders.types.js";
|
|
15
17
|
export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
|
|
16
18
|
export type { ConnectorsModule, UserConnectorsModule, ConnectorApiRequest, ConnectorApiResponse, ConnectorProxyRawResponse, } from "./modules/connectors.types.js";
|
|
17
19
|
export type { CustomIntegrationsModule, CustomIntegrationCallParams, CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { AxiosInstance } from "axios";
|
|
2
|
+
import { CatalogModule } from "./catalog.types";
|
|
3
|
+
/**
|
|
4
|
+
* Creates the catalog module for the Doany SDK.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export declare function createCatalogModule(axios: AxiosInstance, appId: string): CatalogModule;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates the catalog module for the Doany SDK.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
export function createCatalogModule(axios, appId) {
|
|
7
|
+
const baseURL = `/apps/${appId}/catalog/items`;
|
|
8
|
+
return {
|
|
9
|
+
async list(params = {}) {
|
|
10
|
+
const query = {};
|
|
11
|
+
if (params.category)
|
|
12
|
+
query.category = params.category;
|
|
13
|
+
if (params.purchasable)
|
|
14
|
+
query.purchasable = "true";
|
|
15
|
+
if (params.limit)
|
|
16
|
+
query.limit = params.limit;
|
|
17
|
+
if (params.skip)
|
|
18
|
+
query.skip = params.skip;
|
|
19
|
+
if (params.sort)
|
|
20
|
+
query.sort = params.sort;
|
|
21
|
+
const data = await axios.get(baseURL, { params: query });
|
|
22
|
+
return data;
|
|
23
|
+
},
|
|
24
|
+
async get(itemId) {
|
|
25
|
+
const data = await axios.get(`${baseURL}/${encodeURIComponent(itemId)}`);
|
|
26
|
+
return data;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One way an item is sold: a name, a price and, for services, a duration.
|
|
3
|
+
*/
|
|
4
|
+
export interface CatalogVariant {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
/** In the currency's smallest unit (cents). `null` when no price is set; `0` is free. */
|
|
8
|
+
price_amount: number | null;
|
|
9
|
+
/** Three upper-case letters, e.g. `"USD"`. `null` exactly when `price_amount` is. */
|
|
10
|
+
currency: string | null;
|
|
11
|
+
duration_minutes: number | null;
|
|
12
|
+
/** The variant to preselect. */
|
|
13
|
+
is_default: boolean;
|
|
14
|
+
sort_order: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Something the business offers, with the variants it is sold in.
|
|
18
|
+
*/
|
|
19
|
+
export interface CatalogItem {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
description: string | null;
|
|
23
|
+
category: string | null;
|
|
24
|
+
image_url: string | null;
|
|
25
|
+
direct_purchase_enabled: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Whether it can be ordered right now with `doany.orders.create`. An item
|
|
28
|
+
* that is shown but not purchasable is for display only.
|
|
29
|
+
*/
|
|
30
|
+
purchasable: boolean;
|
|
31
|
+
sort_order: number;
|
|
32
|
+
/** In display order. */
|
|
33
|
+
variants: CatalogVariant[];
|
|
34
|
+
}
|
|
35
|
+
export interface CatalogListParams {
|
|
36
|
+
category?: string;
|
|
37
|
+
/** Only items that can be ordered right now. */
|
|
38
|
+
purchasable?: boolean;
|
|
39
|
+
limit?: number;
|
|
40
|
+
skip?: number;
|
|
41
|
+
/** A field name, `-` in front for descending: `sort_order` (default), `name`, `category`, `created_at`. */
|
|
42
|
+
sort?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What the business offers. Read-only: the catalog is managed by the business
|
|
46
|
+
* owner in Doany, not from the site.
|
|
47
|
+
*/
|
|
48
|
+
export interface CatalogModule {
|
|
49
|
+
/**
|
|
50
|
+
* The published items.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const items = await doany.catalog.list({ purchasable: true });
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
list(params?: CatalogListParams): Promise<CatalogItem[]>;
|
|
58
|
+
/**
|
|
59
|
+
* One published item. Rejects with status 404 when there is no such item or
|
|
60
|
+
* it is not published.
|
|
61
|
+
*/
|
|
62
|
+
get(itemId: string): Promise<CatalogItem>;
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { AxiosInstance } from "axios";
|
|
2
|
+
import { OrdersModule } from "./orders.types";
|
|
3
|
+
/** @internal Tests start from a page that has placed nothing. */
|
|
4
|
+
export declare function resetOrderAttempts(): void;
|
|
5
|
+
/**
|
|
6
|
+
* Creates the orders module for the Doany SDK.
|
|
7
|
+
*
|
|
8
|
+
* Two things are handled here so that page code does not have to:
|
|
9
|
+
*
|
|
10
|
+
* - **Retries do not double-order.** The backend requires an `Idempotency-Key`.
|
|
11
|
+
* Identical calls that overlap are one request (a double click), and a key
|
|
12
|
+
* is kept for an identical order for as long as its outcome is unknown (it
|
|
13
|
+
* failed without an answer), so a retry after a dropped connection lands on
|
|
14
|
+
* the same order — also after a page reload, for 15 minutes. Once the
|
|
15
|
+
* server has answered, the next identical order is a new one.
|
|
16
|
+
* - **A guest can reopen their order.** The order's access token is kept for
|
|
17
|
+
* the browser session and sent with `get` / `cancel` for that order.
|
|
18
|
+
*
|
|
19
|
+
* @internal
|
|
20
|
+
*/
|
|
21
|
+
export declare function createOrdersModule(axios: AxiosInstance, appId: string): OrdersModule;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Shared by every client the page creates: two clients placing the same order
|
|
2
|
+
// at once are still one attempt, and neither may release a key the other is
|
|
3
|
+
// still waiting on. Entries are named by app, actor and request.
|
|
4
|
+
const inFlight = new Map();
|
|
5
|
+
const memory = new Map();
|
|
6
|
+
/** @internal Tests start from a page that has placed nothing. */
|
|
7
|
+
export function resetOrderAttempts() {
|
|
8
|
+
inFlight.clear();
|
|
9
|
+
memory.clear();
|
|
10
|
+
}
|
|
11
|
+
function newKey() {
|
|
12
|
+
const c = globalThis.crypto;
|
|
13
|
+
if (c === null || c === void 0 ? void 0 : c.randomUUID)
|
|
14
|
+
return `order-${c.randomUUID()}`;
|
|
15
|
+
let out = "order-";
|
|
16
|
+
for (let i = 0; i < 32; i++)
|
|
17
|
+
out += Math.floor(Math.random() * 16).toString(16);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* How long an order whose outcome is unknown keeps its key across page loads.
|
|
22
|
+
* Long enough to cover "the request failed, reload, try again"; short enough
|
|
23
|
+
* that ordering the same things again later is a new order.
|
|
24
|
+
*/
|
|
25
|
+
const ATTEMPT_TTL_MS = 15 * 60 * 1000;
|
|
26
|
+
/** cyrb53: a short, stable name for a payload, so the payload itself (the buyer's
|
|
27
|
+
* details) is not what gets written as a storage key. A collision would only
|
|
28
|
+
* reuse a key for a different payload, which the backend refuses with 409. */
|
|
29
|
+
function nameOf(text) {
|
|
30
|
+
let h1 = 0xdeadbeef;
|
|
31
|
+
let h2 = 0x41c6ce57;
|
|
32
|
+
for (let i = 0; i < text.length; i++) {
|
|
33
|
+
const ch = text.charCodeAt(i);
|
|
34
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
35
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
36
|
+
}
|
|
37
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
38
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
39
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
40
|
+
}
|
|
41
|
+
/** Who the backend will take this request to be from: it keeps idempotency
|
|
42
|
+
* keys per signed-in account and per guest, so an unresolved attempt belongs
|
|
43
|
+
* to whoever made it and nobody else may reuse or clear it. */
|
|
44
|
+
function actorOf(axios) {
|
|
45
|
+
var _a, _b, _c;
|
|
46
|
+
const header = (_c = (_b = (_a = axios.defaults) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b.common) === null || _c === void 0 ? void 0 : _c["Authorization"];
|
|
47
|
+
if (typeof header !== "string" || !header.startsWith("Bearer "))
|
|
48
|
+
return "guest";
|
|
49
|
+
try {
|
|
50
|
+
const payload = header.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
51
|
+
const sub = JSON.parse(atob(payload)).sub;
|
|
52
|
+
if (typeof sub === "string" && sub)
|
|
53
|
+
return `user:${sub}`;
|
|
54
|
+
}
|
|
55
|
+
catch (_d) {
|
|
56
|
+
/* not a JWT we can read */
|
|
57
|
+
}
|
|
58
|
+
return `token:${nameOf(header)}`;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The request exactly as it will be sent, in one canonical form: keys sorted,
|
|
62
|
+
* `quantity` filled in with its default, `variant_id` lower-cased.
|
|
63
|
+
*
|
|
64
|
+
* The retry key is looked up by THIS, and this is what goes over the wire, so
|
|
65
|
+
* "same key" always means "the very same request". Anything looser breaks in
|
|
66
|
+
* one of two ways: write the same order with the keys in another order and it
|
|
67
|
+
* would get a second key (a duplicate order), or fold two requests the backend
|
|
68
|
+
* tells apart — a stray space, a blank optional field — into one and the
|
|
69
|
+
* refusal of one would release the key of the other. So whatever is
|
|
70
|
+
* normalised here is normalised in the request itself, never only in the key.
|
|
71
|
+
*/
|
|
72
|
+
function canonical(value) {
|
|
73
|
+
if (Array.isArray(value))
|
|
74
|
+
return value.map(canonical);
|
|
75
|
+
if (value && typeof value === "object") {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const key of Object.keys(value).sort()) {
|
|
78
|
+
const item = value[key];
|
|
79
|
+
if (item !== undefined)
|
|
80
|
+
out[key] = canonical(item);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
87
|
+
/** A valid name or email in the one form the backend stores it in, so editing
|
|
88
|
+
* a stray space or a capital between a failed attempt and its retry is still
|
|
89
|
+
* the same order. Anything blank or invalid is left exactly as typed: the
|
|
90
|
+
* backend refuses it, and a refused request must stay its own request. */
|
|
91
|
+
function tidyBuyer(buyer) {
|
|
92
|
+
if (!buyer || typeof buyer !== "object")
|
|
93
|
+
return buyer;
|
|
94
|
+
const out = { ...buyer };
|
|
95
|
+
// `email: null` and no email are the same thing to the backend.
|
|
96
|
+
for (const optional of ["email", "phone"]) {
|
|
97
|
+
if (out[optional] === null)
|
|
98
|
+
delete out[optional];
|
|
99
|
+
}
|
|
100
|
+
if (typeof out.name === "string" && out.name.trim())
|
|
101
|
+
out.name = out.name.trim();
|
|
102
|
+
if (typeof out.email === "string" && EMAIL.test(out.email.trim())) {
|
|
103
|
+
out.email = out.email.trim().toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function wireOf(params) {
|
|
108
|
+
const items = Array.isArray(params === null || params === void 0 ? void 0 : params.items)
|
|
109
|
+
? params.items.map((item) => item && typeof item === "object"
|
|
110
|
+
? {
|
|
111
|
+
...item,
|
|
112
|
+
variant_id: typeof item.variant_id === "string" ? item.variant_id.toLowerCase() : item.variant_id,
|
|
113
|
+
// Only a quantity that was left out: null or anything else odd
|
|
114
|
+
// goes to the backend as it is and is refused there.
|
|
115
|
+
quantity: item.quantity === undefined ? 1 : item.quantity,
|
|
116
|
+
}
|
|
117
|
+
: item)
|
|
118
|
+
: params === null || params === void 0 ? void 0 : params.items;
|
|
119
|
+
return canonical({ ...params, items, buyer: tidyBuyer(params === null || params === void 0 ? void 0 : params.buyer) });
|
|
120
|
+
}
|
|
121
|
+
function session() {
|
|
122
|
+
try {
|
|
123
|
+
return typeof window !== "undefined" ? window.sessionStorage : null;
|
|
124
|
+
}
|
|
125
|
+
catch (_a) {
|
|
126
|
+
return null; // storage can be blocked (private mode, sandboxed iframe)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Creates the orders module for the Doany SDK.
|
|
131
|
+
*
|
|
132
|
+
* Two things are handled here so that page code does not have to:
|
|
133
|
+
*
|
|
134
|
+
* - **Retries do not double-order.** The backend requires an `Idempotency-Key`.
|
|
135
|
+
* Identical calls that overlap are one request (a double click), and a key
|
|
136
|
+
* is kept for an identical order for as long as its outcome is unknown (it
|
|
137
|
+
* failed without an answer), so a retry after a dropped connection lands on
|
|
138
|
+
* the same order — also after a page reload, for 15 minutes. Once the
|
|
139
|
+
* server has answered, the next identical order is a new one.
|
|
140
|
+
* - **A guest can reopen their order.** The order's access token is kept for
|
|
141
|
+
* the browser session and sent with `get` / `cancel` for that order.
|
|
142
|
+
*
|
|
143
|
+
* @internal
|
|
144
|
+
*/
|
|
145
|
+
export function createOrdersModule(axios, appId) {
|
|
146
|
+
const baseURL = `/apps/${appId}/orders`;
|
|
147
|
+
// Keys of orders whose outcome is unknown. In memory, and mirrored to
|
|
148
|
+
// sessionStorage so that reloading the page after a failed request — the
|
|
149
|
+
// usual way people recover — still retries with the same key.
|
|
150
|
+
const fresh = (at) => typeof at === "number" && Date.now() - at < ATTEMPT_TTL_MS;
|
|
151
|
+
const attemptKey = (fingerprint) => `doany_order_attempt_${appId}_${nameOf(fingerprint)}`;
|
|
152
|
+
const undecided = {
|
|
153
|
+
get(fingerprint) {
|
|
154
|
+
var _a, _b;
|
|
155
|
+
// The window applies wherever the key is held: a tab left open is not a
|
|
156
|
+
// reason to answer a new order with an old one.
|
|
157
|
+
const held = memory.get(fingerprint);
|
|
158
|
+
if (held && fresh(held.at))
|
|
159
|
+
return held.key;
|
|
160
|
+
memory.delete(fingerprint);
|
|
161
|
+
try {
|
|
162
|
+
const raw = (_a = session()) === null || _a === void 0 ? void 0 : _a.getItem(attemptKey(fingerprint));
|
|
163
|
+
if (!raw)
|
|
164
|
+
return undefined;
|
|
165
|
+
const saved = JSON.parse(raw);
|
|
166
|
+
if (saved.key && fresh(saved.at))
|
|
167
|
+
return saved.key;
|
|
168
|
+
(_b = session()) === null || _b === void 0 ? void 0 : _b.removeItem(attemptKey(fingerprint));
|
|
169
|
+
}
|
|
170
|
+
catch (_c) {
|
|
171
|
+
/* unreadable storage is the same as nothing stored */
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
},
|
|
175
|
+
set(fingerprint, key) {
|
|
176
|
+
var _a;
|
|
177
|
+
const entry = { key, at: Date.now() };
|
|
178
|
+
memory.set(fingerprint, entry);
|
|
179
|
+
try {
|
|
180
|
+
(_a = session()) === null || _a === void 0 ? void 0 : _a.setItem(attemptKey(fingerprint), JSON.stringify(entry));
|
|
181
|
+
}
|
|
182
|
+
catch (_b) {
|
|
183
|
+
/* best effort */
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
delete(fingerprint) {
|
|
187
|
+
var _a;
|
|
188
|
+
memory.delete(fingerprint);
|
|
189
|
+
try {
|
|
190
|
+
(_a = session()) === null || _a === void 0 ? void 0 : _a.removeItem(attemptKey(fingerprint));
|
|
191
|
+
}
|
|
192
|
+
catch (_b) {
|
|
193
|
+
/* best effort */
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
// A kept token belongs to whoever placed the order. It is stored under that
|
|
198
|
+
// actor and looked up under the current one — plus `guest`, so an order placed
|
|
199
|
+
// before signing in can still be opened after. What it never does is follow
|
|
200
|
+
// the tab to another account, or stay usable once its account signed out.
|
|
201
|
+
const tokenKey = (actor, orderId) => { var _a, _b; return `doany_order_token_${appId}_${nameOf(`${(_b = (_a = axios.defaults) === null || _a === void 0 ? void 0 : _a.baseURL) !== null && _b !== void 0 ? _b : ""}|${actor}`)}_${orderId}`; };
|
|
202
|
+
function remember(actor, orderId, token) {
|
|
203
|
+
var _a;
|
|
204
|
+
if (!token)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
(_a = session()) === null || _a === void 0 ? void 0 : _a.setItem(tokenKey(actor, orderId), token);
|
|
208
|
+
}
|
|
209
|
+
catch (_b) {
|
|
210
|
+
/* best effort */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function accessHeaders(orderId, options) {
|
|
214
|
+
var _a, _b;
|
|
215
|
+
let token = options === null || options === void 0 ? void 0 : options.accessToken;
|
|
216
|
+
if (!token) {
|
|
217
|
+
try {
|
|
218
|
+
const store = session();
|
|
219
|
+
token =
|
|
220
|
+
(_b = (_a = store === null || store === void 0 ? void 0 : store.getItem(tokenKey(actorOf(axios), orderId))) !== null && _a !== void 0 ? _a : store === null || store === void 0 ? void 0 : store.getItem(tokenKey("guest", orderId))) !== null && _b !== void 0 ? _b : undefined;
|
|
221
|
+
}
|
|
222
|
+
catch (_c) {
|
|
223
|
+
token = undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return token ? { "X-Doany-Access-Token": token } : {};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
create(params, options) {
|
|
230
|
+
var _a, _b, _c, _d, _e;
|
|
231
|
+
// Read once, before anything is awaited: the request goes out as this
|
|
232
|
+
// actor, and its token must be kept for this actor even if the page has
|
|
233
|
+
// signed in as someone else by the time the answer arrives.
|
|
234
|
+
const actor = actorOf(axios);
|
|
235
|
+
const wire = wireOf(params);
|
|
236
|
+
const fingerprint = JSON.stringify([(_b = (_a = axios.defaults) === null || _a === void 0 ? void 0 : _a.baseURL) !== null && _b !== void 0 ? _b : "", appId, actor, wire]);
|
|
237
|
+
const flight = `${(_c = options === null || options === void 0 ? void 0 : options.idempotencyKey) !== null && _c !== void 0 ? _c : ""}\u0000${fingerprint}`;
|
|
238
|
+
// A second identical call while the first is still out is the same
|
|
239
|
+
// attempt (a double click): it gets the first one's answer, not a
|
|
240
|
+
// request of its own.
|
|
241
|
+
const running = inFlight.get(flight);
|
|
242
|
+
if (running)
|
|
243
|
+
return running;
|
|
244
|
+
const key = (_e = (_d = options === null || options === void 0 ? void 0 : options.idempotencyKey) !== null && _d !== void 0 ? _d : undecided.get(fingerprint)) !== null && _e !== void 0 ? _e : newKey();
|
|
245
|
+
// Written on every attempt, not only the first: the window runs from the
|
|
246
|
+
// LAST time this key went out, or a retry late in it would leave the next
|
|
247
|
+
// one without the key of an order that may just have been placed.
|
|
248
|
+
if (!(options === null || options === void 0 ? void 0 : options.idempotencyKey))
|
|
249
|
+
undecided.set(fingerprint, key);
|
|
250
|
+
// Only the attempt that put a key there may take it away: a page-keyed
|
|
251
|
+
// attempt for the same things must not release the automatic one's key.
|
|
252
|
+
const settle = () => {
|
|
253
|
+
if (!(options === null || options === void 0 ? void 0 : options.idempotencyKey) && undecided.get(fingerprint) === key) {
|
|
254
|
+
undecided.delete(fingerprint);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
const attempt = (async () => {
|
|
258
|
+
var _a;
|
|
259
|
+
try {
|
|
260
|
+
const data = (await axios.request({
|
|
261
|
+
method: "POST",
|
|
262
|
+
url: baseURL,
|
|
263
|
+
data: wire,
|
|
264
|
+
headers: { "Idempotency-Key": key },
|
|
265
|
+
}));
|
|
266
|
+
// A 2xx that does not carry an order (a proxy's page, a mismatched
|
|
267
|
+
// deployment) decides nothing: the key stays for the retry.
|
|
268
|
+
if (typeof ((_a = data === null || data === void 0 ? void 0 : data.order) === null || _a === void 0 ? void 0 : _a.id) !== "string") {
|
|
269
|
+
throw new Error("The order service answered without an order");
|
|
270
|
+
}
|
|
271
|
+
settle();
|
|
272
|
+
remember(actor, data.order.id, data.access_token);
|
|
273
|
+
return data;
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
// A 4xx is a decision (refused, invalid) — except 408 and 429, and
|
|
277
|
+
// 401, which the rest of the client also treats as "try again"
|
|
278
|
+
// (client.ts): a proxy can time out AFTER passing the request on.
|
|
279
|
+
// Everything else may have placed the order, so the same key goes
|
|
280
|
+
// out again next time; keeping a key too long costs nothing.
|
|
281
|
+
const status = error === null || error === void 0 ? void 0 : error.status;
|
|
282
|
+
if (typeof status === "number" &&
|
|
283
|
+
status >= 400 &&
|
|
284
|
+
status < 500 &&
|
|
285
|
+
![401, 408, 429].includes(status)) {
|
|
286
|
+
settle();
|
|
287
|
+
}
|
|
288
|
+
else if (!(options === null || options === void 0 ? void 0 : options.idempotencyKey)) {
|
|
289
|
+
// Still undecided: the window runs from now, however long the
|
|
290
|
+
// request itself was out.
|
|
291
|
+
undecided.set(fingerprint, key);
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
inFlight.delete(flight);
|
|
297
|
+
}
|
|
298
|
+
})();
|
|
299
|
+
inFlight.set(flight, attempt);
|
|
300
|
+
return attempt;
|
|
301
|
+
},
|
|
302
|
+
async list(params = {}) {
|
|
303
|
+
const query = {};
|
|
304
|
+
if (params.status)
|
|
305
|
+
query.status = params.status;
|
|
306
|
+
if (params.limit)
|
|
307
|
+
query.limit = params.limit;
|
|
308
|
+
if (params.skip)
|
|
309
|
+
query.skip = params.skip;
|
|
310
|
+
if (params.sort)
|
|
311
|
+
query.sort = params.sort;
|
|
312
|
+
const data = await axios.get(baseURL, { params: query });
|
|
313
|
+
return data;
|
|
314
|
+
},
|
|
315
|
+
async get(orderId, options) {
|
|
316
|
+
const data = await axios.get(`${baseURL}/${encodeURIComponent(orderId)}`, {
|
|
317
|
+
headers: accessHeaders(orderId, options),
|
|
318
|
+
});
|
|
319
|
+
return data;
|
|
320
|
+
},
|
|
321
|
+
async cancel(orderId, options) {
|
|
322
|
+
const data = await axios.request({
|
|
323
|
+
method: "POST",
|
|
324
|
+
url: `${baseURL}/${encodeURIComponent(orderId)}/cancel`,
|
|
325
|
+
headers: accessHeaders(orderId, options),
|
|
326
|
+
});
|
|
327
|
+
return data;
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
export type OrderStatus = "open" | "completed" | "canceled";
|
|
2
|
+
export type FulfillmentStatus = "pending" | "in_progress" | "completed" | "canceled";
|
|
3
|
+
/** A line of an order. Names, price and duration are as they were when it was placed. */
|
|
4
|
+
export interface OrderItem {
|
|
5
|
+
id: string;
|
|
6
|
+
variant_id: string;
|
|
7
|
+
item_name: string;
|
|
8
|
+
variant_name: string;
|
|
9
|
+
duration_minutes: number | null;
|
|
10
|
+
unit_amount: number;
|
|
11
|
+
quantity: number;
|
|
12
|
+
line_amount: number;
|
|
13
|
+
fulfillment_mode: "manual" | "calendar";
|
|
14
|
+
fulfillment_status: FulfillmentStatus | null;
|
|
15
|
+
fulfilled_at: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface OrderBuyer {
|
|
18
|
+
name: string;
|
|
19
|
+
email: string | null;
|
|
20
|
+
phone: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface Order {
|
|
23
|
+
id: string;
|
|
24
|
+
/** The number to show the customer. */
|
|
25
|
+
order_number: string;
|
|
26
|
+
status: OrderStatus;
|
|
27
|
+
currency: string;
|
|
28
|
+
/** In the currency's smallest unit (cents). */
|
|
29
|
+
total_amount: number;
|
|
30
|
+
buyer: OrderBuyer;
|
|
31
|
+
items: OrderItem[];
|
|
32
|
+
payment: {
|
|
33
|
+
/** `false` for a free order, which is `completed` as soon as it is placed. */
|
|
34
|
+
required: boolean;
|
|
35
|
+
amount_received: number;
|
|
36
|
+
amount_refunded: number;
|
|
37
|
+
};
|
|
38
|
+
created_at: string;
|
|
39
|
+
completed_at: string | null;
|
|
40
|
+
canceled_at: string | null;
|
|
41
|
+
}
|
|
42
|
+
export interface OrderSummary {
|
|
43
|
+
id: string;
|
|
44
|
+
order_number: string;
|
|
45
|
+
status: OrderStatus;
|
|
46
|
+
currency: string;
|
|
47
|
+
total_amount: number;
|
|
48
|
+
buyer: {
|
|
49
|
+
name: string;
|
|
50
|
+
};
|
|
51
|
+
created_at: string;
|
|
52
|
+
}
|
|
53
|
+
export interface CreateOrderParams {
|
|
54
|
+
/** What is being bought. Prices are not sent: the order takes them from the catalog. */
|
|
55
|
+
items: Array<{
|
|
56
|
+
variant_id: string;
|
|
57
|
+
quantity?: number;
|
|
58
|
+
}>;
|
|
59
|
+
/** Who it is for. `email` or `phone` is required; `phone` in E.164 form (`+14155550123`). */
|
|
60
|
+
buyer: {
|
|
61
|
+
name: string;
|
|
62
|
+
email?: string;
|
|
63
|
+
phone?: string;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
export interface CreateOrderOptions {
|
|
67
|
+
/**
|
|
68
|
+
* Identifies this attempt, so that a retry does not place a second order.
|
|
69
|
+
* Optional: the SDK reuses one key for an identical order that is still in
|
|
70
|
+
* flight or failed without an answer.
|
|
71
|
+
*/
|
|
72
|
+
idempotencyKey?: string;
|
|
73
|
+
}
|
|
74
|
+
export interface CreateOrderResult {
|
|
75
|
+
order: Order;
|
|
76
|
+
/**
|
|
77
|
+
* Lets someone who is not signed in open this order again. The SDK keeps it
|
|
78
|
+
* for the rest of the browser session, so `orders.get` and `orders.cancel`
|
|
79
|
+
* work without passing it; put it in a link (after `#`) to reach the order
|
|
80
|
+
* from elsewhere.
|
|
81
|
+
*/
|
|
82
|
+
access_token?: string;
|
|
83
|
+
/** `false` when the order is free and already completed. */
|
|
84
|
+
payment_required: boolean;
|
|
85
|
+
}
|
|
86
|
+
export interface OrderListParams {
|
|
87
|
+
status?: OrderStatus;
|
|
88
|
+
limit?: number;
|
|
89
|
+
skip?: number;
|
|
90
|
+
/** `created_at` (default, newest first), `total_amount`, `order_number`, `status`; `-` in front for descending. */
|
|
91
|
+
sort?: string;
|
|
92
|
+
}
|
|
93
|
+
export interface OrderAccessOptions {
|
|
94
|
+
/** The order's `access_token`, when it came from a link rather than from this browser session. */
|
|
95
|
+
accessToken?: string;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Orders placed on the site.
|
|
99
|
+
*/
|
|
100
|
+
export interface OrdersModule {
|
|
101
|
+
/**
|
|
102
|
+
* Places an order for catalog variants. Works signed in or not.
|
|
103
|
+
*
|
|
104
|
+
* Rejects with status 409 and `code` `NOT_PURCHASABLE` (with `data.variant_id`)
|
|
105
|
+
* when something in it cannot be bought, or `MIXED_CURRENCY`.
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```typescript
|
|
109
|
+
* const { order, payment_required } = await doany.orders.create({
|
|
110
|
+
* items: [{ variant_id: variant.id, quantity: 1 }],
|
|
111
|
+
* buyer: { name, email },
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
create(params: CreateOrderParams, options?: CreateOrderOptions): Promise<CreateOrderResult>;
|
|
116
|
+
/** The signed-in customer's orders, newest first. Rejects with status 401 when nobody is signed in. */
|
|
117
|
+
list(params?: OrderListParams): Promise<OrderSummary[]>;
|
|
118
|
+
/**
|
|
119
|
+
* One order. Available to the signed-in customer who placed it, or to anyone
|
|
120
|
+
* holding its access token; otherwise rejects with status 404.
|
|
121
|
+
*/
|
|
122
|
+
get(orderId: string, options?: OrderAccessOptions): Promise<Order>;
|
|
123
|
+
/** Cancels an order that is still `open`. Rejects with status 409 (`INVALID_STATE`) otherwise. */
|
|
124
|
+
cancel(orderId: string, options?: OrderAccessOptions): Promise<Order>;
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { AxiosInstance } from "axios";
|
|
2
|
-
import { PaymentsModule } from "./payments.types";
|
|
2
|
+
import { PaymentsModule, ProductsModule } from "./payments.types";
|
|
3
|
+
/**
|
|
4
|
+
* The product reads.
|
|
5
|
+
*
|
|
6
|
+
* Parameters go over the wire exactly as an entity's do (`sort`, `limit`,
|
|
7
|
+
* `skip`, `q` as JSON), because the point of this module is that code written
|
|
8
|
+
* against `entities.Product` moves over by renaming it — same arguments, same
|
|
9
|
+
* records back.
|
|
10
|
+
*
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export declare function createProductsModule(axios: AxiosInstance, appId: string): ProductsModule;
|
|
3
14
|
/**
|
|
4
15
|
* Creates the payments module for the Doany SDK.
|
|
5
16
|
*
|
package/dist/modules/payments.js
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product reads.
|
|
3
|
+
*
|
|
4
|
+
* Parameters go over the wire exactly as an entity's do (`sort`, `limit`,
|
|
5
|
+
* `skip`, `q` as JSON), because the point of this module is that code written
|
|
6
|
+
* against `entities.Product` moves over by renaming it — same arguments, same
|
|
7
|
+
* records back.
|
|
8
|
+
*
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export function createProductsModule(axios, appId) {
|
|
12
|
+
const baseURL = `/apps/${appId}/payments/products`;
|
|
13
|
+
function params(sort, limit, skip) {
|
|
14
|
+
const out = {};
|
|
15
|
+
if (sort)
|
|
16
|
+
out.sort = sort;
|
|
17
|
+
if (limit)
|
|
18
|
+
out.limit = limit;
|
|
19
|
+
if (skip)
|
|
20
|
+
out.skip = skip;
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
async list(sort, limit, skip) {
|
|
25
|
+
const data = await axios.get(baseURL, { params: params(sort, limit, skip) });
|
|
26
|
+
return data;
|
|
27
|
+
},
|
|
28
|
+
async filter(query, sort, limit, skip) {
|
|
29
|
+
const data = await axios.get(baseURL, {
|
|
30
|
+
params: { q: JSON.stringify(query), ...params(sort, limit, skip) },
|
|
31
|
+
});
|
|
32
|
+
return data;
|
|
33
|
+
},
|
|
34
|
+
async get(id) {
|
|
35
|
+
const data = await axios.get(`${baseURL}/${encodeURIComponent(id)}`);
|
|
36
|
+
return data;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
1
40
|
/**
|
|
2
41
|
* Creates the payments module for the Doany SDK.
|
|
3
42
|
*
|
|
@@ -21,6 +60,7 @@ export function createPaymentsModule(axios, appId) {
|
|
|
21
60
|
// Axios's declared return type does not reflect that, so the results below
|
|
22
61
|
// are cast through `unknown`.
|
|
23
62
|
return {
|
|
63
|
+
products: createProductsModule(axios, appId),
|
|
24
64
|
createCheckoutSession,
|
|
25
65
|
async getSubscription(subscriptionId) {
|
|
26
66
|
const data = await axios.request({
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* One thing being sold in a checkout.
|
|
3
3
|
*
|
|
4
|
-
* A line item names a record in your own app data — doany keeps your
|
|
4
|
+
* A line item names a record in your own app data — doany keeps your products
|
|
5
5
|
* there rather than as objects inside Stripe, which is why switching from test
|
|
6
6
|
* to real payments needs no migration.
|
|
7
7
|
*/
|
|
8
8
|
export type CheckoutLineItem = {
|
|
9
9
|
/**
|
|
10
|
-
* The `id` of
|
|
10
|
+
* The `id` of one of this app's products — a record from
|
|
11
|
+
* {@linkcode ProductsModule | doany.payments.products}, or from the `Product`
|
|
12
|
+
* entity on an app whose products predate it.
|
|
11
13
|
*
|
|
12
14
|
* The price, name and currency all come from that record — you cannot pass
|
|
13
15
|
* a price. This endpoint is reachable by anyone (a shop has to sell to
|
|
@@ -65,7 +67,7 @@ export type CreateCheckoutResult = {
|
|
|
65
67
|
/** `test` in the preview, `live` on a published site that has gone live. */
|
|
66
68
|
mode: "test" | "live";
|
|
67
69
|
/**
|
|
68
|
-
* What the customer is about to agree to, decided by the
|
|
70
|
+
* What the customer is about to agree to, decided by the product.
|
|
69
71
|
*
|
|
70
72
|
* `subscription` when the products carry a `recurring_interval`, `payment`
|
|
71
73
|
* otherwise. Say the right word on the button: "Subscribe" over a one-off
|
|
@@ -203,17 +205,98 @@ export type CheckoutSession = {
|
|
|
203
205
|
*/
|
|
204
206
|
access_token?: string;
|
|
205
207
|
};
|
|
208
|
+
/**
|
|
209
|
+
* One of this app's products, as {@linkcode ProductsModule} returns it.
|
|
210
|
+
*
|
|
211
|
+
* The same shape an entity record has: the platform's fields and the system
|
|
212
|
+
* fields by their entity names, and every field of the app's own (`slug`,
|
|
213
|
+
* `category`, `image_url`, …) at the top level beside them.
|
|
214
|
+
*/
|
|
215
|
+
export type Product = {
|
|
216
|
+
id: string;
|
|
217
|
+
name: string;
|
|
218
|
+
description: string | null;
|
|
219
|
+
/** Minor units — 2400 is 24.00. `null` for something shown but not sold. */
|
|
220
|
+
price_cents: number | null;
|
|
221
|
+
/** Lowercase ISO code, or `null` for the checkout's default. */
|
|
222
|
+
currency: string | null;
|
|
223
|
+
/** Present => a subscription billed this often. `null` => a one-off. */
|
|
224
|
+
recurring_interval: "week" | "month" | "year" | null;
|
|
225
|
+
/** `false` is off sale: still readable, cannot be bought. */
|
|
226
|
+
is_available: boolean;
|
|
227
|
+
created_date: string;
|
|
228
|
+
updated_date: string;
|
|
229
|
+
created_by: string | null;
|
|
230
|
+
created_by_id: string | null;
|
|
231
|
+
is_sample: boolean;
|
|
232
|
+
/**
|
|
233
|
+
* Only ever on {@linkcode ProductsModule.get}: the product was removed from
|
|
234
|
+
* the store. Kept readable because past orders and subscriptions name it.
|
|
235
|
+
*/
|
|
236
|
+
is_deleted?: true;
|
|
237
|
+
/**
|
|
238
|
+
* The app's own fields. `any`, as on an entity record, so code that read
|
|
239
|
+
* `product.slug.toLowerCase()` off `entities.Product` still type-checks.
|
|
240
|
+
*/
|
|
241
|
+
[field: string]: any;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* A field to order by, `-` first for descending: `"price_cents"`,
|
|
245
|
+
* `"-created_date"`, or one of the app's own fields.
|
|
246
|
+
*/
|
|
247
|
+
export type ProductSort = string;
|
|
248
|
+
/**
|
|
249
|
+
* Equality only — `{ slug: "starter" }`, `{ is_available: true }`. Several
|
|
250
|
+
* fields must all match. `{ field: null }` also matches a product that does not
|
|
251
|
+
* have the field. Operators (`$gt`, `$in`, …) are refused.
|
|
252
|
+
*/
|
|
253
|
+
export type ProductQuery = Record<string, string | number | boolean | null>;
|
|
254
|
+
/**
|
|
255
|
+
* Read this app's products. Read-only: products are added and priced by
|
|
256
|
+
* the app owner in the Payments panel, or by asking Annie.
|
|
257
|
+
*
|
|
258
|
+
* Same methods, arguments and results as an entity's reads, so code written
|
|
259
|
+
* against `entities.Product` moves over by renaming it. Records come back in
|
|
260
|
+
* the order they were created when no `sort` is given.
|
|
261
|
+
*/
|
|
262
|
+
export interface ProductsModule {
|
|
263
|
+
/**
|
|
264
|
+
* @example
|
|
265
|
+
* ```typescript
|
|
266
|
+
* const products = await doany.payments.products.list();
|
|
267
|
+
* const newest = await doany.payments.products.list('-created_date', 20);
|
|
268
|
+
* ```
|
|
269
|
+
*/
|
|
270
|
+
list(sort?: ProductSort, limit?: number, skip?: number): Promise<Product[]>;
|
|
271
|
+
/**
|
|
272
|
+
* @example
|
|
273
|
+
* ```typescript
|
|
274
|
+
* const [plan] = await doany.payments.products.filter({ slug: 'pro' });
|
|
275
|
+
* const onSale = await doany.payments.products.filter(
|
|
276
|
+
* { is_available: true }, 'price_cents', 10,
|
|
277
|
+
* );
|
|
278
|
+
* ```
|
|
279
|
+
*/
|
|
280
|
+
filter(query: ProductQuery, sort?: ProductSort, limit?: number, skip?: number): Promise<Product[]>;
|
|
281
|
+
/**
|
|
282
|
+
* One product by id. A removed product is still returned, with
|
|
283
|
+
* `is_deleted: true`; an id that was never a product is a 404.
|
|
284
|
+
*/
|
|
285
|
+
get(id: string): Promise<Product>;
|
|
286
|
+
}
|
|
206
287
|
/**
|
|
207
288
|
* Take card payments on your site.
|
|
208
289
|
*
|
|
209
290
|
* Money goes to the app owner's own Stripe account — doany never holds it and
|
|
210
291
|
* takes no cut. Stripe's usual per-transaction fee applies.
|
|
211
292
|
*
|
|
212
|
-
* ## Prices live
|
|
293
|
+
* ## Prices live with the product, not in your code
|
|
213
294
|
*
|
|
214
|
-
* Sellable things are
|
|
215
|
-
*
|
|
216
|
-
* looks up what
|
|
295
|
+
* Sellable things are this app's products, read with
|
|
296
|
+
* {@linkcode PaymentsModule.products}, with the price in an integer
|
|
297
|
+
* `price_cents` field. A checkout names the product; the server looks up what
|
|
298
|
+
* it costs. (An app whose products predate this keeps them in a `Product`
|
|
299
|
+
* entity; checkout reads whichever one the app uses.)
|
|
217
300
|
*
|
|
218
301
|
* ## Test and real payments
|
|
219
302
|
*
|
|
@@ -229,12 +312,14 @@ export type CheckoutSession = {
|
|
|
229
312
|
* The same code covers both. There is no key to configure and no mode to set.
|
|
230
313
|
*/
|
|
231
314
|
export interface PaymentsModule {
|
|
315
|
+
/** This app's products. See {@linkcode ProductsModule}. */
|
|
316
|
+
products: ProductsModule;
|
|
232
317
|
/**
|
|
233
318
|
* Opens a Stripe checkout and returns the URL to send the customer to.
|
|
234
319
|
*
|
|
235
320
|
* @example Sell one item
|
|
236
321
|
* ```typescript
|
|
237
|
-
* // The price comes from the
|
|
322
|
+
* // The price comes from the product, not from this call.
|
|
238
323
|
* const { url } = await doany.payments.createCheckoutSession({
|
|
239
324
|
* line_items: [{ product_id: product.id, quantity: 1 }],
|
|
240
325
|
* success_path: '/thanks',
|