@cobrastyle/adapter-brink 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cobrastyle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Brink Storefront Adapter
3
+ *
4
+ * Implements only the features Brink owns:
5
+ * - cart (session-bound shopper API)
6
+ * - checkout.placeOrder (management API import)
7
+ * - customer.getOrders / getOrder (management API)
8
+ *
9
+ * Other features (product, category, search, customer auth) intentionally
10
+ * absent — route those to a different adapter (e.g. `enad`) via
11
+ * `features` / `methods` in storefront.config.ts.
12
+ *
13
+ * IMPORTANT INVARIANTS
14
+ * - The `cartId` parameter on cart methods IS the Brink shopper session
15
+ * token. We never have a "fetch cart by db id" — Brink's session token
16
+ * IS the bearer credential for that session.
17
+ * - The session token can rotate (e.g. on `/sessions/migrate` or
18
+ * `/checkout/start`). When that happens, we return a Cart whose `id`
19
+ * reflects the new token — the caller MUST update the cookie.
20
+ * - Money: Brink uses minor units; the Cart contract uses major. Mappers
21
+ * convert. Inputs to Brink (e.g. shipping fee in placeOrder body)
22
+ * multiply major × 100.
23
+ */
24
+ import type { PartialStorefrontAdapter, CustomerOrdersResult, CustomerOrder } from '@cobrastyle/shared-types';
25
+ export declare const brinkAdapter: PartialStorefrontAdapter;
26
+ /**
27
+ * Look up orders by email directly against the Brink management API.
28
+ * Use this from a storefront.config.ts `methods.customer.getOrders` override
29
+ * to inject the email from your auth adapter.
30
+ */
31
+ export declare function getBrinkOrdersByEmail(email: string, page?: number, pageSize?: number): Promise<CustomerOrdersResult>;
32
+ /**
33
+ * Look up orders by merchant reference (typically a B2B organisation id).
34
+ */
35
+ export declare function getBrinkOrdersByMerchantRef(ref: string): Promise<CustomerOrder[]>;
@@ -0,0 +1,299 @@
1
+ import { getConfig, getCartStateStore } from './config';
2
+ import { shopperFetch } from './client/shopper';
3
+ import { managementFetch } from './client/management';
4
+ import { BrinkFetchError } from './client/errors';
5
+ import { mapShopperCart } from './mappers/cart';
6
+ import { mapBrinkOrder } from './mappers/order';
7
+ import { buildBrinkOrderBody } from './mappers/place-order';
8
+ // ─── Helpers ────────────────────────────────────────────────────────────────
9
+ async function readSession(token) {
10
+ return shopperFetch({
11
+ endpoint: '/shopper/sessions',
12
+ method: 'GET',
13
+ token,
14
+ });
15
+ }
16
+ async function startSession() {
17
+ const cfg = getConfig();
18
+ const body = {
19
+ storeGroupId: cfg.defaultStoreGroupId,
20
+ countryCode: cfg.defaultCountryCode,
21
+ languageCode: cfg.defaultLanguageCode,
22
+ };
23
+ return shopperFetch({
24
+ endpoint: '/shopper/sessions/start',
25
+ method: 'POST',
26
+ body,
27
+ });
28
+ }
29
+ async function buildCart(session, cartIdHint) {
30
+ // Brink rotates the token on some endpoints, so we always trust the one
31
+ // returned in the response — but if the session was just refreshed
32
+ // through GET /shopper/sessions (where there is no token in the body),
33
+ // the caller-provided token is still valid.
34
+ const cartId = session.token || cartIdHint || '';
35
+ const store = getCartStateStore();
36
+ const side = await store.get(cartId);
37
+ return mapShopperCart(session.cart, side, cartId);
38
+ }
39
+ async function patchSide(cartId, patch) {
40
+ const store = getCartStateStore();
41
+ const current = (await store.get(cartId)) ?? {};
42
+ await store.set(cartId, { ...current, ...patch });
43
+ }
44
+ function generateImportId() {
45
+ // RFC 4122 v4 if available, otherwise a sortable random id.
46
+ if (typeof globalThis.crypto !== 'undefined' &&
47
+ typeof globalThis.crypto.randomUUID === 'function') {
48
+ return globalThis.crypto.randomUUID();
49
+ }
50
+ return `imp_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
51
+ }
52
+ // ─── Adapter ────────────────────────────────────────────────────────────────
53
+ export const brinkAdapter = {
54
+ cart: {
55
+ create: async () => {
56
+ const session = await startSession();
57
+ return buildCart(session);
58
+ },
59
+ get: async (cartId) => {
60
+ try {
61
+ const session = await readSession(cartId);
62
+ return buildCart(session, cartId);
63
+ }
64
+ catch (err) {
65
+ // 401/403/404 all mean "this token is no longer a valid session".
66
+ // Return null so the storefront can clear the cookie and start fresh.
67
+ if (err instanceof BrinkFetchError &&
68
+ (err.statusCode === 401 ||
69
+ err.statusCode === 403 ||
70
+ err.statusCode === 404)) {
71
+ return null;
72
+ }
73
+ throw err;
74
+ }
75
+ },
76
+ addItem: async (cartId, input) => {
77
+ // Brink only accepts productVariantId. We accept either an explicit
78
+ // variant id (preferred) or fall back to the sku field if the caller
79
+ // is treating the SKU as the variant id.
80
+ const productVariantId = input.productVariantId ?? input.sku;
81
+ if (!productVariantId) {
82
+ throw new Error('Brink addItem requires productVariantId (pass via AddToCartInput.productVariantId, or use the sku field as the variant id).');
83
+ }
84
+ const body = {
85
+ productVariantId,
86
+ quantity: input.quantity,
87
+ options: input.selectedOptions
88
+ ? Object.fromEntries(input.selectedOptions.map((v, i) => [`option_${i}`, v]))
89
+ : undefined,
90
+ };
91
+ const session = await shopperFetch({
92
+ endpoint: '/shopper/sessions/items',
93
+ method: 'POST',
94
+ token: cartId,
95
+ body,
96
+ });
97
+ return buildCart(session, cartId);
98
+ },
99
+ updateItem: async (cartId, input) => {
100
+ const session = await shopperFetch({
101
+ endpoint: `/shopper/sessions/items/${encodeURIComponent(input.itemUid)}`,
102
+ method: 'PUT',
103
+ token: cartId,
104
+ body: { quantity: input.quantity },
105
+ });
106
+ return buildCart(session, cartId);
107
+ },
108
+ removeItem: async (cartId, itemUid) => {
109
+ const session = await shopperFetch({
110
+ endpoint: `/shopper/sessions/items/${encodeURIComponent(itemUid)}`,
111
+ method: 'DELETE',
112
+ token: cartId,
113
+ });
114
+ return buildCart(session, cartId);
115
+ },
116
+ applyCoupon: async (cartId, input) => {
117
+ const session = await shopperFetch({
118
+ endpoint: '/shopper/sessions/discount-codes',
119
+ method: 'POST',
120
+ token: cartId,
121
+ body: { discountCode: input.couponCode },
122
+ });
123
+ return buildCart(session, cartId);
124
+ },
125
+ removeCoupon: async (cartId, couponCode) => {
126
+ const session = await shopperFetch({
127
+ endpoint: `/shopper/sessions/discount-codes/${encodeURIComponent(couponCode)}`,
128
+ method: 'DELETE',
129
+ token: cartId,
130
+ });
131
+ return buildCart(session, cartId);
132
+ },
133
+ setEmail: async (cartId, email) => {
134
+ await patchSide(cartId, { email });
135
+ const session = await readSession(cartId);
136
+ return buildCart(session, cartId);
137
+ },
138
+ },
139
+ checkout: {
140
+ setShippingAddress: async (cartId, input) => {
141
+ const address = {
142
+ ...input.address,
143
+ country: input.address.countryCode,
144
+ };
145
+ await patchSide(cartId, { shippingAddress: address });
146
+ const session = await readSession(cartId);
147
+ return buildCart(session, cartId);
148
+ },
149
+ setBillingAddress: async (cartId, input) => {
150
+ const address = {
151
+ ...input.address,
152
+ country: input.address.countryCode,
153
+ };
154
+ await patchSide(cartId, { billingAddress: address });
155
+ const session = await readSession(cartId);
156
+ return buildCart(session, cartId);
157
+ },
158
+ setShippingMethod: async (cartId, input) => {
159
+ // Pull the chosen option from the brink session (canonical source) so
160
+ // we capture price/title from Brink rather than trusting the caller.
161
+ const session = await readSession(cartId);
162
+ const option = session.cart.shippingOptions?.find((o) => o.id === input.methodCode || o.id === input.carrierCode);
163
+ if (!option) {
164
+ throw new Error(`Shipping method "${input.methodCode}" not available on this cart`);
165
+ }
166
+ await patchSide(cartId, {
167
+ shippingMethod: {
168
+ carrierCode: option.id,
169
+ carrierTitle: option.displayName || option.name,
170
+ methodCode: option.id,
171
+ methodTitle: option.displayName || option.name,
172
+ amount: (option.salePriceAmount || option.basePriceAmount) / 100,
173
+ currency: session.cart.currencyCode,
174
+ },
175
+ });
176
+ return buildCart(session, cartId);
177
+ },
178
+ setPaymentMethod: async (cartId, input) => {
179
+ await patchSide(cartId, {
180
+ paymentMethod: { code: input.code, title: input.code },
181
+ });
182
+ const session = await readSession(cartId);
183
+ return buildCart(session, cartId);
184
+ },
185
+ placeOrder: async (cartId) => {
186
+ const store = getCartStateStore();
187
+ try {
188
+ const [session, side] = await Promise.all([
189
+ readSession(cartId),
190
+ store.get(cartId),
191
+ ]);
192
+ if (!side?.shippingAddress || !side?.billingAddress) {
193
+ return {
194
+ success: false,
195
+ error: 'Cannot place order: shipping/billing address not set. Call setShippingAddress + setBillingAddress first.',
196
+ };
197
+ }
198
+ const body = buildBrinkOrderBody({
199
+ session,
200
+ side,
201
+ importId: generateImportId(),
202
+ date: new Date().toISOString(),
203
+ });
204
+ const result = await managementFetch({
205
+ endpoint: '/order/imports/orders',
206
+ method: 'POST',
207
+ body,
208
+ });
209
+ await store.clear(cartId);
210
+ return {
211
+ success: true,
212
+ orderId: result.orderId,
213
+ orderNumber: result.orderReference,
214
+ };
215
+ }
216
+ catch (err) {
217
+ return {
218
+ success: false,
219
+ error: err instanceof Error ? err.message : 'Failed to place order',
220
+ };
221
+ }
222
+ },
223
+ },
224
+ customer: {
225
+ // Stub the auth surface — Brink doesn't own customer auth. Route auth
226
+ // to enad (or magento2) via storefront.config.ts. These are present
227
+ // because TypeScript requires CustomerAdapter to declare them, but they
228
+ // throw if accidentally invoked (which won't happen if routing is set
229
+ // up correctly).
230
+ login: async () => {
231
+ throw new Error('Brink does not implement customer.login — route customer auth to a different adapter.');
232
+ },
233
+ register: async () => {
234
+ throw new Error('Brink does not implement customer.register — route customer auth to a different adapter.');
235
+ },
236
+ logout: async () => { },
237
+ get: async () => null,
238
+ update: async () => {
239
+ throw new Error('Brink does not implement customer.update — route customer auth to a different adapter.');
240
+ },
241
+ changePassword: async () => false,
242
+ getOrders: async (_token, page = 1, pageSize = 10) => {
243
+ // Brink lookup is by email or merchant-ref. The caller passes a
244
+ // session token (auth-adapter token), so we need the email — which
245
+ // is on the customer record. The recommended wiring is a method-level
246
+ // override in storefront.config.ts that resolves the email first
247
+ // (see README). As a fallback we error out cleanly.
248
+ throw new Error('Brink getOrders requires a customer email. Override customer.getOrders in storefront.config.ts to resolve the email from your auth adapter, then call brinkAdapter via the override (see README).');
249
+ },
250
+ getOrder: async (_token, orderId) => {
251
+ try {
252
+ const order = await managementFetch({
253
+ endpoint: `/order/orders/${encodeURIComponent(orderId)}`,
254
+ method: 'GET',
255
+ });
256
+ return mapBrinkOrder(order);
257
+ }
258
+ catch (err) {
259
+ if (err instanceof BrinkFetchError &&
260
+ (err.statusCode === 404 || err.statusCode === 403)) {
261
+ return null;
262
+ }
263
+ throw err;
264
+ }
265
+ },
266
+ },
267
+ };
268
+ // ─── Standalone helpers exposed for storefront overrides ────────────────────
269
+ /**
270
+ * Look up orders by email directly against the Brink management API.
271
+ * Use this from a storefront.config.ts `methods.customer.getOrders` override
272
+ * to inject the email from your auth adapter.
273
+ */
274
+ export async function getBrinkOrdersByEmail(email, page = 1, pageSize = 10) {
275
+ const result = await managementFetch({
276
+ endpoint: `/order/orders/email/${encodeURIComponent(email)}`,
277
+ method: 'GET',
278
+ });
279
+ const items = (result.orderSummaries ?? []).map(mapBrinkOrder);
280
+ return {
281
+ items: items.slice((page - 1) * pageSize, page * pageSize),
282
+ totalCount: items.length,
283
+ pageInfo: {
284
+ currentPage: page,
285
+ pageSize,
286
+ totalPages: Math.max(1, Math.ceil(items.length / pageSize)),
287
+ },
288
+ };
289
+ }
290
+ /**
291
+ * Look up orders by merchant reference (typically a B2B organisation id).
292
+ */
293
+ export async function getBrinkOrdersByMerchantRef(ref) {
294
+ const result = await managementFetch({
295
+ endpoint: `/order/orders/merchant-ref1/${encodeURIComponent(ref)}`,
296
+ method: 'GET',
297
+ });
298
+ return (result.orderSummaries ?? []).map(mapBrinkOrder);
299
+ }
@@ -0,0 +1,8 @@
1
+ import type { BrinkErrorBody } from '../types';
2
+ export declare class BrinkFetchError extends Error {
3
+ readonly statusCode: number;
4
+ readonly requestId?: string;
5
+ readonly errors?: string[];
6
+ readonly body?: BrinkErrorBody;
7
+ constructor(message: string, statusCode: number, body?: BrinkErrorBody);
8
+ }
@@ -0,0 +1,14 @@
1
+ export class BrinkFetchError extends Error {
2
+ statusCode;
3
+ requestId;
4
+ errors;
5
+ body;
6
+ constructor(message, statusCode, body) {
7
+ super(message);
8
+ this.name = 'BrinkFetchError';
9
+ this.statusCode = statusCode;
10
+ this.body = body;
11
+ this.requestId = body?.requestId;
12
+ this.errors = body?.errors;
13
+ }
14
+ }
@@ -0,0 +1,7 @@
1
+ interface ManagementFetchOpts {
2
+ endpoint: string;
3
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
4
+ body?: unknown;
5
+ }
6
+ export declare function managementFetch<T>(opts: ManagementFetchOpts, attempt?: number): Promise<T>;
7
+ export {};
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Management API client. Authenticates with an OAuth2 access token + a
3
+ * static `x-api-key` header. On 401/403 the token is invalidated and the
4
+ * request is retried once with a fresh token.
5
+ */
6
+ import { getConfig } from '../config';
7
+ import { BrinkFetchError } from './errors';
8
+ import { getOrRefresh, invalidate } from './oauth';
9
+ export async function managementFetch(opts, attempt = 0) {
10
+ const cfg = getConfig();
11
+ const token = await getOrRefresh(attempt > 0);
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ Authorization: token,
15
+ 'x-api-key': cfg.managementApiKey ?? 'BrinkCommerceDefaultApiKey',
16
+ };
17
+ const res = await fetch(`${cfg.managementApiUrl}${opts.endpoint}`, {
18
+ method: opts.method,
19
+ headers,
20
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
21
+ cache: 'no-store',
22
+ });
23
+ if (res.status === 204)
24
+ return {};
25
+ if ((res.status === 401 || res.status === 403) && attempt === 0) {
26
+ invalidate();
27
+ return managementFetch(opts, attempt + 1);
28
+ }
29
+ const json = (await res.json().catch(() => ({})));
30
+ if (!res.ok) {
31
+ const err = json;
32
+ throw new BrinkFetchError(err.error || err.message || `Brink management API error (${res.status})`, res.status, err);
33
+ }
34
+ return json;
35
+ }
@@ -0,0 +1,2 @@
1
+ export declare function getOrRefresh(forceRefresh?: boolean): Promise<string>;
2
+ export declare function invalidate(): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * OAuth2 client_credentials token cache for the Brink management API.
3
+ *
4
+ * SCOPE: in-process module-level cache. Each serverless instance refreshes
5
+ * once on cold start. For multi-instance high-throughput production, swap
6
+ * this for a shared cache (Vercel Edge Config / KV / Redis). The replacement
7
+ * point is `getOrRefresh()` — keep the same signature.
8
+ */
9
+ import { getConfig } from '../config';
10
+ import { BrinkFetchError } from './errors';
11
+ let cached = null;
12
+ let inflight = null;
13
+ const SAFETY_WINDOW_MS = 60_000;
14
+ export async function getOrRefresh(forceRefresh = false) {
15
+ const now = Date.now();
16
+ if (!forceRefresh &&
17
+ cached &&
18
+ cached.expiresAt - SAFETY_WINDOW_MS > now) {
19
+ return cached.accessToken;
20
+ }
21
+ // Coalesce concurrent refresh attempts onto a single network call.
22
+ if (inflight)
23
+ return inflight;
24
+ inflight = (async () => {
25
+ try {
26
+ const token = await fetchToken();
27
+ cached = {
28
+ accessToken: token.access_token,
29
+ expiresAt: Date.now() + token.expires_in * 1000,
30
+ };
31
+ return token.access_token;
32
+ }
33
+ finally {
34
+ inflight = null;
35
+ }
36
+ })();
37
+ return inflight;
38
+ }
39
+ export function invalidate() {
40
+ cached = null;
41
+ }
42
+ async function fetchToken() {
43
+ const cfg = getConfig();
44
+ const body = new URLSearchParams({
45
+ client_id: cfg.managementClientId,
46
+ client_secret: cfg.managementClientSecret,
47
+ grant_type: 'client_credentials',
48
+ scope: cfg.managementScope,
49
+ });
50
+ const res = await fetch(`${cfg.oauthUrl}/oauth2/token`, {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
53
+ body: body.toString(),
54
+ cache: 'no-store',
55
+ });
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => '');
58
+ throw new BrinkFetchError(`OAuth2 token request failed: ${text || res.statusText}`, res.status);
59
+ }
60
+ return (await res.json());
61
+ }
@@ -0,0 +1,8 @@
1
+ interface ShopperFetchOpts {
2
+ endpoint: string;
3
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
4
+ token?: string;
5
+ body?: unknown;
6
+ }
7
+ export declare function shopperFetch<T>(opts: ShopperFetchOpts): Promise<T>;
8
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shopper API client. All requests authenticate with the session token,
3
+ * which our adapter treats as the cart id. The token is rotated by Brink on
4
+ * `/sessions/migrate` and `/checkout/start` — the response contains a fresh
5
+ * `token` field which the caller is responsible for persisting back into
6
+ * the cart cookie.
7
+ */
8
+ import { getConfig } from '../config';
9
+ import { BrinkFetchError } from './errors';
10
+ export async function shopperFetch(opts) {
11
+ const cfg = getConfig();
12
+ const headers = {
13
+ 'Content-Type': 'application/json',
14
+ };
15
+ if (opts.token)
16
+ headers.Authorization = opts.token;
17
+ const res = await fetch(`${cfg.shopperApiUrl}${opts.endpoint}`, {
18
+ method: opts.method,
19
+ headers,
20
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
21
+ cache: 'no-store',
22
+ });
23
+ if (res.status === 204)
24
+ return {};
25
+ const json = (await res.json().catch(() => ({})));
26
+ if (!res.ok) {
27
+ const err = json;
28
+ throw new BrinkFetchError(err.error || err.message || `Brink shopper API error (${res.status})`, res.status, err);
29
+ }
30
+ return json;
31
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Brink adapter configuration.
3
+ *
4
+ * Brink exposes two distinct API surfaces with different auth:
5
+ * - **Shopper API** (`shopperApiUrl`) — session-bound cart/checkout.
6
+ * Authenticated with the session token, which IS the cart id.
7
+ * - **Management API** (`managementApiUrl`) — order import, lookup,
8
+ * history. Authenticated with an OAuth2 client_credentials access token
9
+ * plus a static `x-api-key`.
10
+ *
11
+ * The OAuth2 token is minted from `oauthUrl` using `managementClientId` /
12
+ * `managementClientSecret` / `managementScope`.
13
+ */
14
+ export interface BrinkConfig {
15
+ /** Base URL for shopper API (sessions/items/etc.). e.g. https://api.brinkcommerce.com */
16
+ shopperApiUrl: string;
17
+ /** Base URL for management API (orders/imports). */
18
+ managementApiUrl: string;
19
+ /** OAuth2 token endpoint host. */
20
+ oauthUrl: string;
21
+ /** OAuth2 client_credentials inputs. */
22
+ managementClientId: string;
23
+ managementClientSecret: string;
24
+ managementScope: string;
25
+ /** Static x-api-key header for the management API. */
26
+ managementApiKey?: string;
27
+ /** Defaults used when starting a fresh shopper session. */
28
+ defaultStoreGroupId: string;
29
+ defaultCountryCode: string;
30
+ defaultLanguageCode: string;
31
+ defaultCurrencyCode: string;
32
+ /**
33
+ * Pluggable side-channel store for cart-level state that Brink does not
34
+ * persist on its session (shipping/billing address, selected shipping &
35
+ * payment methods). Indexed by Brink session token (= our cart id).
36
+ *
37
+ * Must be provided in serverless environments. Defaults to an in-memory
38
+ * Map which only works for single-process dev.
39
+ */
40
+ cartStateStore?: CartStateStore;
41
+ }
42
+ export interface CartSideState {
43
+ shippingAddress?: import('@cobrastyle/shared-types').ShippingAddress;
44
+ billingAddress?: import('@cobrastyle/shared-types').BillingAddress;
45
+ shippingMethod?: import('@cobrastyle/shared-types').SelectedShippingMethod;
46
+ paymentMethod?: import('@cobrastyle/shared-types').SelectedPaymentMethod;
47
+ email?: string;
48
+ }
49
+ export interface CartStateStore {
50
+ get(cartId: string): Promise<CartSideState | null>;
51
+ set(cartId: string, state: CartSideState): Promise<void>;
52
+ clear(cartId: string): Promise<void>;
53
+ }
54
+ export declare function setConfig(next: BrinkConfig): void;
55
+ export declare function getConfig(): BrinkConfig;
56
+ export declare function getCartStateStore(): CartStateStore;
package/dist/config.js ADDED
@@ -0,0 +1,37 @@
1
+ let config = null;
2
+ export function setConfig(next) {
3
+ config = next;
4
+ }
5
+ export function getConfig() {
6
+ if (!config) {
7
+ throw new Error('Brink adapter not configured. Call setConfig() with shopper/management URLs and OAuth2 credentials.');
8
+ }
9
+ return config;
10
+ }
11
+ // In-memory fallback. Single-process only — fine for `pnpm dev`, breaks the
12
+ // moment you deploy to multiple instances. The app's adapter loader
13
+ // should always pass a cookie-backed store.
14
+ class InMemoryCartStateStore {
15
+ store = new Map();
16
+ async get(cartId) {
17
+ return this.store.get(cartId) ?? null;
18
+ }
19
+ async set(cartId, state) {
20
+ this.store.set(cartId, state);
21
+ }
22
+ async clear(cartId) {
23
+ this.store.delete(cartId);
24
+ }
25
+ }
26
+ let inMemoryWarned = false;
27
+ export function getCartStateStore() {
28
+ const cfg = getConfig();
29
+ if (cfg.cartStateStore)
30
+ return cfg.cartStateStore;
31
+ if (!inMemoryWarned) {
32
+ inMemoryWarned = true;
33
+ console.warn('[Brink] Using in-memory cart state store. Provide config.cartStateStore for serverless.');
34
+ }
35
+ return defaultStore;
36
+ }
37
+ const defaultStore = new InMemoryCartStateStore();
@@ -0,0 +1,5 @@
1
+ export { brinkAdapter, brinkAdapter as default, getBrinkOrdersByEmail, getBrinkOrdersByMerchantRef, } from './adapter';
2
+ export { setConfig, getConfig } from './config';
3
+ export type { BrinkConfig, CartStateStore, CartSideState } from './config';
4
+ export { BrinkFetchError } from './client/errors';
5
+ export * as mappers from './mappers/cart';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { brinkAdapter, brinkAdapter as default, getBrinkOrdersByEmail, getBrinkOrdersByMerchantRef, } from './adapter';
2
+ export { setConfig, getConfig } from './config';
3
+ export { BrinkFetchError } from './client/errors';
4
+ export * as mappers from './mappers/cart';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * ShopperCart → Cart. Brink amounts are minor units (e.g. 12345 = 123.45 SEK);
3
+ * the Cart contract is in major units, so all money is divided by 100 here.
4
+ *
5
+ * Side state (addresses, selected methods) is not part of the brink session
6
+ * and must be merged in by the adapter from the configured cart state store.
7
+ */
8
+ import type { Cart } from '@cobrastyle/shared-types';
9
+ import type { ShopperCart } from '../types';
10
+ import type { CartSideState } from '../config';
11
+ declare const minor: (n: number | undefined | null) => number;
12
+ export declare function mapShopperCart(shopper: ShopperCart, side: CartSideState | null, cartId: string): Cart;
13
+ export { minor };
@@ -0,0 +1,86 @@
1
+ const minor = (n) => typeof n === 'number' ? n / 100 : 0;
2
+ export function mapShopperCart(shopper, side, cartId) {
3
+ const currency = shopper.currencyCode || 'SEK';
4
+ const items = shopper.items.map((i) => mapItem(i, currency));
5
+ const appliedCoupons = shopper.discountCodes.map((c) => ({
6
+ code: c.code,
7
+ }));
8
+ const availableShippingMethods = (shopper.shippingOptions ?? []).map((o) => mapShippingOption(o, currency));
9
+ return {
10
+ id: cartId,
11
+ items,
12
+ totals: mapTotals(shopper, items, currency),
13
+ appliedCoupons,
14
+ shippingAddresses: side?.shippingAddress ? [side.shippingAddress] : [],
15
+ billingAddress: side?.billingAddress,
16
+ selectedShippingMethod: side?.shippingMethod,
17
+ selectedPaymentMethod: side?.paymentMethod,
18
+ availableShippingMethods,
19
+ availablePaymentMethods: [],
20
+ email: side?.email,
21
+ };
22
+ }
23
+ function mapItem(item, currency) {
24
+ const unit = minor(item.salePriceAmount || item.basePriceAmount);
25
+ const rowTotal = minor(item.totalPriceAmount - item.totalTaxAmount);
26
+ const rowTotalIncludingTax = minor(item.totalPriceAmount);
27
+ return {
28
+ id: item.id,
29
+ uid: item.id,
30
+ product: {
31
+ id: item.productParentId,
32
+ sku: item.productVariantId,
33
+ name: item.displayName || item.name,
34
+ urlKey: item.slug,
35
+ thumbnail: {
36
+ url: item.imageUrl,
37
+ label: item.displayName || item.name,
38
+ },
39
+ },
40
+ quantity: item.quantity,
41
+ prices: {
42
+ price: unit,
43
+ rowTotal,
44
+ rowTotalIncludingTax,
45
+ currency,
46
+ },
47
+ configurableOptions: item.options
48
+ ? Object.entries(item.options).map(([k, v]) => ({
49
+ optionLabel: k,
50
+ valueLabel: v,
51
+ }))
52
+ : undefined,
53
+ };
54
+ }
55
+ function mapTotals(shopper, items, currency) {
56
+ const subtotal = items.reduce((s, i) => s + i.prices.rowTotal, 0);
57
+ const subtotalIncludingTax = items.reduce((s, i) => s + i.prices.rowTotalIncludingTax, 0);
58
+ return {
59
+ subtotal,
60
+ subtotalIncludingTax,
61
+ grandTotal: minor(shopper.totals.grandTotal),
62
+ discounts: shopper.totals.discountTotal > 0
63
+ ? [{ amount: minor(shopper.totals.discountTotal), label: 'Discount' }]
64
+ : [],
65
+ taxes: [
66
+ {
67
+ amount: minor(shopper.totals.taxTotal),
68
+ label: 'VAT',
69
+ rate: 0,
70
+ },
71
+ ],
72
+ shipping: minor(shopper.totals.shippingTotal),
73
+ currency,
74
+ };
75
+ }
76
+ function mapShippingOption(o, currency) {
77
+ return {
78
+ carrierCode: o.id,
79
+ carrierTitle: o.displayName || o.name,
80
+ methodCode: o.id,
81
+ methodTitle: o.displayName || o.name,
82
+ amount: minor(o.salePriceAmount || o.basePriceAmount),
83
+ currency,
84
+ };
85
+ }
86
+ export { minor };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Map Brink management-API Order → CustomerOrder for the storefront's
3
+ * "my orders" UI. Money fields divided by 100 (minor → major units).
4
+ */
5
+ import type { CustomerOrder } from '@cobrastyle/shared-types';
6
+ import type { BrinkOrder } from '../types';
7
+ export declare function mapBrinkOrder(order: BrinkOrder): CustomerOrder;
@@ -0,0 +1,35 @@
1
+ import { minor } from './cart';
2
+ export function mapBrinkOrder(order) {
3
+ const items = order.orderLines.map((l) => ({
4
+ id: l.id,
5
+ productName: l.displayName || l.name,
6
+ sku: l.productVariantId,
7
+ quantity: l.quantity,
8
+ price: minor(l.salePriceAmount || l.basePriceAmount),
9
+ thumbnail: l.imageUrl,
10
+ }));
11
+ const shippingAddress = {
12
+ firstname: order.shippingAddress.givenName,
13
+ lastname: order.shippingAddress.familyName,
14
+ street: [
15
+ order.shippingAddress.streetAddress,
16
+ order.shippingAddress.streetAddress2,
17
+ ].filter(Boolean),
18
+ city: order.shippingAddress.city,
19
+ region: order.shippingAddress.stateOrProvince,
20
+ postcode: order.shippingAddress.postalCode,
21
+ country: order.shippingAddress.country,
22
+ countryCode: order.shippingAddress.country,
23
+ telephone: order.shippingAddress.telephoneNumber,
24
+ };
25
+ return {
26
+ id: order.id,
27
+ orderNumber: order.reference,
28
+ createdAt: order.date,
29
+ status: order.status?.orderStates?.[0] ?? 'unknown',
30
+ total: minor(order.totals.grandTotal),
31
+ currency: order.currencyCode,
32
+ items,
33
+ shippingAddress,
34
+ };
35
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Build a Brink management-API CreateBrinkOrder body from a shopper session
3
+ * + accumulated side state (addresses, methods, email).
4
+ *
5
+ * NOTE: this is a B2B-flavoured "direct import" path. It uses
6
+ * `BrinkZeroPayment` because no payment provider is wired up yet; the order
7
+ * lands in Brink as a finished, pre-paid record. Wire a real payment
8
+ * provider (Walley/Klarna/Adyen/etc.) before going live to consumers.
9
+ */
10
+ import type { CartSideState } from '../config';
11
+ import type { ShopperSessionResponse, CreateBrinkOrderBody } from '../types';
12
+ export interface BuildOrderInputs {
13
+ session: ShopperSessionResponse;
14
+ side: CartSideState;
15
+ importId: string;
16
+ /** ISO 8601 date string. Defaults to now() at the caller. */
17
+ date: string;
18
+ channelType?: string;
19
+ }
20
+ export declare function buildBrinkOrderBody(input: BuildOrderInputs): CreateBrinkOrderBody;
@@ -0,0 +1,97 @@
1
+ export function buildBrinkOrderBody(input) {
2
+ const { session, side, importId, date } = input;
3
+ const cart = session.cart;
4
+ if (!side.shippingAddress) {
5
+ throw new Error('Cannot place order: shipping address not set');
6
+ }
7
+ if (!side.billingAddress) {
8
+ throw new Error('Cannot place order: billing address not set');
9
+ }
10
+ return {
11
+ importId,
12
+ date,
13
+ countryCode: cart.countryCode,
14
+ languageCode: cart.languageCode,
15
+ currencyCode: cart.currencyCode,
16
+ storeGroupId: cart.storeGroupId,
17
+ payment: {
18
+ providerId: 'brink-zero',
19
+ providerName: 'BrinkZeroPayment',
20
+ reference: importId,
21
+ method: 'zero',
22
+ },
23
+ isTaxIncludedInPrice: cart.isTaxIncludedInPrice,
24
+ channelType: input.channelType ?? 'WEB',
25
+ isTaxExemptionEligible: false,
26
+ isTaxExempt: false,
27
+ taxationCountry: cart.countryCode,
28
+ shippingAddress: mapAddress(side.shippingAddress, side.email),
29
+ billingAddress: mapAddress(side.billingAddress, side.email),
30
+ shipping: mapShipping(side.shippingMethod),
31
+ shippingFees: side.shippingMethod
32
+ ? [
33
+ {
34
+ id: side.shippingMethod.methodCode,
35
+ name: side.shippingMethod.methodTitle,
36
+ taxGroupId: 'default',
37
+ // 25% VAT in basis points (2500 = 25.00%) — adjust per market.
38
+ taxPercentage: 2500,
39
+ taxPercentageDecimals: 2,
40
+ basePriceAmount: Math.round(side.shippingMethod.amount * 100),
41
+ salePriceAmount: Math.round(side.shippingMethod.amount * 100),
42
+ discountAmount: 0,
43
+ taxAmount: 0,
44
+ },
45
+ ]
46
+ : undefined,
47
+ orderLines: cart.items.map(mapOrderLine),
48
+ };
49
+ }
50
+ function mapAddress(addr, email) {
51
+ return {
52
+ country: addr.countryCode || addr.country,
53
+ telephoneNumber: addr.telephone,
54
+ stateOrProvince: addr.region ?? '',
55
+ streetAddress: addr.street[0] ?? '',
56
+ streetAddress2: addr.street[1] ?? '',
57
+ city: addr.city,
58
+ givenName: addr.firstname,
59
+ familyName: addr.lastname,
60
+ postalCode: addr.postcode,
61
+ email: email ?? '',
62
+ };
63
+ }
64
+ function mapShipping(method) {
65
+ if (!method) {
66
+ return {
67
+ providerId: 'manual',
68
+ providerName: 'Manual',
69
+ reference: 'manual',
70
+ };
71
+ }
72
+ return {
73
+ providerId: method.carrierCode,
74
+ providerName: method.carrierTitle,
75
+ reference: method.methodCode,
76
+ };
77
+ }
78
+ function mapOrderLine(item) {
79
+ return {
80
+ id: item.id,
81
+ productVariantId: item.productVariantId,
82
+ productParentId: item.productParentId,
83
+ name: item.displayName || item.name,
84
+ taxPercentage: item.taxPercentage,
85
+ taxPercentageDecimals: item.taxPercentageDecimals,
86
+ taxGroupId: item.taxGroupId,
87
+ quantity: item.quantity,
88
+ imageUrl: item.imageUrl,
89
+ basePriceAmount: item.basePriceAmount,
90
+ salePriceAmount: item.salePriceAmount,
91
+ totalDiscountAmount: item.totalDiscountAmount,
92
+ totalPriceAmount: item.totalPriceAmount,
93
+ totalTaxAmount: item.totalTaxAmount,
94
+ options: item.options,
95
+ customAttributes: item.customAttributes,
96
+ };
97
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Brink wire types — copied from @enadhq/commerce/dist/brink/types so this
3
+ * package has zero runtime dependency on the SDK. If Brink updates the API
4
+ * shape, regenerate from upstream `dist/brink/types/{api,session,order}.d.ts`.
5
+ *
6
+ * All money fields are in **minor units** (öre/cents). Multiply by 100 going
7
+ * out, divide by 100 coming in.
8
+ */
9
+ export interface ShopperCartItem {
10
+ id: string;
11
+ productVariantId: string;
12
+ productParentId: string;
13
+ quantity: number;
14
+ name: string;
15
+ displayName: string;
16
+ description: string;
17
+ taxGroupId: string;
18
+ basePriceAmount: number;
19
+ salePriceAmount: number;
20
+ discountAmount: number;
21
+ totalPriceAmount: number;
22
+ totalDiscountAmount: number;
23
+ taxPercentage: number;
24
+ taxPercentageDecimals: number;
25
+ totalTaxAmount: number;
26
+ imageUrl: string;
27
+ slug: string;
28
+ options?: Record<string, string>;
29
+ customAttributes?: Record<string, string>;
30
+ }
31
+ export interface ShopperCartTotals {
32
+ subTotal: number;
33
+ taxTotal: number;
34
+ grandTotal: number;
35
+ discountTotal: number;
36
+ shippingTotal: number;
37
+ }
38
+ export interface ShopperShippingOption {
39
+ id: string;
40
+ name: string;
41
+ displayName: string;
42
+ taxGroupId: string;
43
+ taxPercentage: number;
44
+ taxPercentageDecimals: number;
45
+ basePriceAmount: number;
46
+ salePriceAmount: number;
47
+ discountAmount: number;
48
+ taxAmount: number;
49
+ }
50
+ export interface ShopperDiscountCode {
51
+ code: string;
52
+ applyLast: boolean;
53
+ isExclusive: boolean;
54
+ }
55
+ export interface ShopperCart {
56
+ id: string;
57
+ storeGroupId: string;
58
+ countryCode: string;
59
+ currencyCode: string;
60
+ languageCode: string;
61
+ isTaxIncludedInPrice: boolean;
62
+ discountAmount: number;
63
+ discountCodes: ShopperDiscountCode[];
64
+ items: ShopperCartItem[];
65
+ shippingOptions?: ShopperShippingOption[];
66
+ totals: ShopperCartTotals;
67
+ }
68
+ export interface ShopperSessionResponse {
69
+ token: string;
70
+ cart: ShopperCart;
71
+ locked: boolean;
72
+ attributes?: Record<string, Record<string, string>>;
73
+ }
74
+ export interface RequestSessionStart {
75
+ storeGroupId: string;
76
+ countryCode: string;
77
+ languageCode: string;
78
+ }
79
+ export interface RequestAddItem {
80
+ productVariantId: string;
81
+ quantity: number;
82
+ options?: Record<string, string>;
83
+ }
84
+ export interface OAuth2TokenResponse {
85
+ access_token: string;
86
+ expires_in: number;
87
+ token_type: 'Bearer';
88
+ }
89
+ export interface CreateBrinkOrderBody {
90
+ importId: string;
91
+ date: string;
92
+ countryCode: string;
93
+ languageCode: string;
94
+ currencyCode: string;
95
+ storeGroupId: string;
96
+ payment: {
97
+ providerId: string;
98
+ providerName: string;
99
+ reference: string;
100
+ method: string;
101
+ };
102
+ isTaxIncludedInPrice: boolean;
103
+ channelType: string;
104
+ isTaxExemptionEligible?: boolean;
105
+ isTaxExempt?: boolean;
106
+ taxationCountry: string;
107
+ shippingAddress: BrinkOrderAddressInput;
108
+ billingAddress: BrinkOrderAddressInput;
109
+ shipping: {
110
+ providerId: string;
111
+ providerName: string;
112
+ reference: string;
113
+ };
114
+ shippingFees?: BrinkShippingFeeInput[];
115
+ orderLines: BrinkOrderLineInput[];
116
+ attributes?: {
117
+ merchantReference1?: string;
118
+ merchantReference2?: string;
119
+ company?: {
120
+ registrationNumber?: string;
121
+ taxId?: string;
122
+ name?: string;
123
+ reference?: string;
124
+ };
125
+ };
126
+ }
127
+ export interface BrinkOrderAddressInput {
128
+ country: string;
129
+ telephoneNumber: string;
130
+ stateOrProvince: string;
131
+ streetAddress: string;
132
+ streetAddress2: string;
133
+ city: string;
134
+ givenName: string;
135
+ familyName: string;
136
+ postalCode: string;
137
+ email: string;
138
+ }
139
+ export interface BrinkShippingFeeInput {
140
+ id: string;
141
+ name: string;
142
+ taxGroupId: string;
143
+ taxPercentage: number;
144
+ taxPercentageDecimals: number;
145
+ basePriceAmount: number;
146
+ salePriceAmount: number;
147
+ discountAmount: number;
148
+ taxAmount: number;
149
+ }
150
+ export interface BrinkOrderLineInput {
151
+ id: string;
152
+ productVariantId: string;
153
+ productParentId?: string;
154
+ name: string;
155
+ taxPercentage: number;
156
+ taxPercentageDecimals: number;
157
+ taxGroupId: string;
158
+ quantity: number;
159
+ imageUrl: string;
160
+ basePriceAmount: number;
161
+ salePriceAmount: number;
162
+ totalDiscountAmount: number;
163
+ totalPriceAmount: number;
164
+ totalTaxAmount: number;
165
+ options?: Record<string, string>;
166
+ customAttributes?: Record<string, string>;
167
+ }
168
+ export interface CreateBrinkOrderResponse {
169
+ finished: string;
170
+ orderId: string;
171
+ orderReference: string;
172
+ }
173
+ export interface BrinkOrder {
174
+ id: string;
175
+ reference: string;
176
+ date: string;
177
+ countryCode: string;
178
+ currencyCode: string;
179
+ languageCode: string;
180
+ storeGroupId: string;
181
+ channelType: string;
182
+ isTaxIncludedInPrice: boolean;
183
+ orderLines: ShopperCartItem[];
184
+ shippingAddress: BrinkOrderAddressInput & {
185
+ pcc?: string;
186
+ };
187
+ billingAddress: BrinkOrderAddressInput & {
188
+ pcc?: string;
189
+ };
190
+ totals: {
191
+ subTotal: number;
192
+ taxTotal: number;
193
+ discountTotal: number;
194
+ shippingTotal: number;
195
+ giftCardTotal?: number;
196
+ grandTotal: number;
197
+ };
198
+ status?: {
199
+ orderStates: string[];
200
+ };
201
+ }
202
+ export interface BrinkOrders {
203
+ orderSummaries: BrinkOrder[];
204
+ }
205
+ export interface BrinkErrorBody {
206
+ error?: string;
207
+ message?: string;
208
+ statusCode?: number;
209
+ requestId?: string;
210
+ errors?: string[];
211
+ productVariantIDs?: string[];
212
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Brink wire types — copied from @enadhq/commerce/dist/brink/types so this
3
+ * package has zero runtime dependency on the SDK. If Brink updates the API
4
+ * shape, regenerate from upstream `dist/brink/types/{api,session,order}.d.ts`.
5
+ *
6
+ * All money fields are in **minor units** (öre/cents). Multiply by 100 going
7
+ * out, divide by 100 coming in.
8
+ */
9
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@cobrastyle/adapter-brink",
3
+ "version": "1.0.1",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "dependencies": {
16
+ "@cobrastyle/shared-types": "1.0.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^20.0.0",
20
+ "typescript": "^5.3.0"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/eComero/cobrastyle-express-next.git",
26
+ "directory": "packages/adapters/brink"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc --project tsconfig.build.json",
33
+ "type-check": "tsc --noEmit"
34
+ }
35
+ }