@rotateprotocol/sdk 1.0.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/README.md +453 -0
- package/dist/catalog.d.ts +112 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +210 -0
- package/dist/catalog.js.map +1 -0
- package/dist/components/CheckoutForm.d.ts +86 -0
- package/dist/components/CheckoutForm.d.ts.map +1 -0
- package/dist/components/CheckoutForm.js +332 -0
- package/dist/components/CheckoutForm.js.map +1 -0
- package/dist/components/HostedCheckout.d.ts +57 -0
- package/dist/components/HostedCheckout.d.ts.map +1 -0
- package/dist/components/HostedCheckout.js +414 -0
- package/dist/components/HostedCheckout.js.map +1 -0
- package/dist/components/PaymentButton.d.ts +80 -0
- package/dist/components/PaymentButton.d.ts.map +1 -0
- package/dist/components/PaymentButton.js +210 -0
- package/dist/components/PaymentButton.js.map +1 -0
- package/dist/components/RotateProvider.d.ts +115 -0
- package/dist/components/RotateProvider.d.ts.map +1 -0
- package/dist/components/RotateProvider.js +264 -0
- package/dist/components/RotateProvider.js.map +1 -0
- package/dist/components/index.d.ts +17 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +27 -0
- package/dist/components/index.js.map +1 -0
- package/dist/embed.d.ts +85 -0
- package/dist/embed.d.ts.map +1 -0
- package/dist/embed.js +313 -0
- package/dist/embed.js.map +1 -0
- package/dist/hooks.d.ts +156 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +280 -0
- package/dist/hooks.js.map +1 -0
- package/dist/idl/rotate_connect.json +2572 -0
- package/dist/index.d.ts +505 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1197 -0
- package/dist/index.js.map +1 -0
- package/dist/marketplace.d.ts +257 -0
- package/dist/marketplace.d.ts.map +1 -0
- package/dist/marketplace.js +433 -0
- package/dist/marketplace.js.map +1 -0
- package/dist/platform.d.ts +234 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +268 -0
- package/dist/platform.js.map +1 -0
- package/dist/react.d.ts +140 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +429 -0
- package/dist/react.js.map +1 -0
- package/dist/store.d.ts +213 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +404 -0
- package/dist/store.js.map +1 -0
- package/dist/webhooks.d.ts +149 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +371 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +114 -0
- package/src/catalog.ts +299 -0
- package/src/components/CheckoutForm.tsx +608 -0
- package/src/components/HostedCheckout.tsx +675 -0
- package/src/components/PaymentButton.tsx +348 -0
- package/src/components/RotateProvider.tsx +370 -0
- package/src/components/index.ts +26 -0
- package/src/embed.ts +408 -0
- package/src/hooks.ts +518 -0
- package/src/idl/rotate_connect.json +2572 -0
- package/src/index.ts +1538 -0
- package/src/marketplace.ts +642 -0
- package/src/platform.ts +403 -0
- package/src/react.ts +459 -0
- package/src/store.ts +577 -0
- package/src/webhooks.ts +506 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rotate Store — Product Catalog, Cart & Checkout
|
|
3
|
+
*
|
|
4
|
+
* A complete store layer that sits on top of the Rotate Protocol.
|
|
5
|
+
* Products and cart state are managed client-side / in your database;
|
|
6
|
+
* only the final payment touches the blockchain.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* const store = new RotateStore(sdk, {
|
|
11
|
+
* merchantId: 1000000,
|
|
12
|
+
* platformId: 1000000,
|
|
13
|
+
* currency: 'USD',
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* store.addProduct({ id: 'tshirt-01', name: 'Logo Tee', price: 29.99, inventory: 100 });
|
|
17
|
+
* store.addProduct({ id: 'hoodie-01', name: 'Zip Hoodie', price: 59.99, inventory: 50 });
|
|
18
|
+
*
|
|
19
|
+
* const cart = store.createCart();
|
|
20
|
+
* cart.addItem('tshirt-01', 2);
|
|
21
|
+
* cart.addItem('hoodie-01', 1);
|
|
22
|
+
*
|
|
23
|
+
* const session = await cart.checkout(); // creates on-chain payment link
|
|
24
|
+
* console.log(session.linkId, session.paymentUrl);
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @packageDocumentation
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import RotateSDK, {
|
|
31
|
+
calculateFees,
|
|
32
|
+
PaymentLink,
|
|
33
|
+
CreateLinkParams,
|
|
34
|
+
CreateLinkTokenParams,
|
|
35
|
+
} from './index';
|
|
36
|
+
import { Catalog, BaseProduct, BaseProductInput, Discount, DiscountInput } from './catalog';
|
|
37
|
+
|
|
38
|
+
// ==================== TYPES ====================
|
|
39
|
+
|
|
40
|
+
export type Currency = 'SOL' | 'USDC' | 'USDT' | 'USD';
|
|
41
|
+
|
|
42
|
+
/** A product in the catalog */
|
|
43
|
+
export interface Product extends BaseProduct {}
|
|
44
|
+
|
|
45
|
+
export interface ProductInput extends BaseProductInput {}
|
|
46
|
+
|
|
47
|
+
/** A line item in a cart */
|
|
48
|
+
export interface LineItem {
|
|
49
|
+
product: Product;
|
|
50
|
+
quantity: number;
|
|
51
|
+
/** Per-item price override (for dynamic pricing / discounts) */
|
|
52
|
+
unitPrice: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Re-export Discount types from catalog for backwards compatibility
|
|
56
|
+
export type { Discount, DiscountInput } from './catalog';
|
|
57
|
+
|
|
58
|
+
/** Cart totals breakdown */
|
|
59
|
+
export interface CartTotals {
|
|
60
|
+
/** Number of unique items */
|
|
61
|
+
itemCount: number;
|
|
62
|
+
/** Total quantity across all items */
|
|
63
|
+
totalQuantity: number;
|
|
64
|
+
/** Subtotal before discounts and fees */
|
|
65
|
+
subtotal: number;
|
|
66
|
+
/** Total discount amount */
|
|
67
|
+
discountAmount: number;
|
|
68
|
+
/** Subtotal after discounts, before fees */
|
|
69
|
+
afterDiscount: number;
|
|
70
|
+
/** Buyer's fee share (protocol + platform) */
|
|
71
|
+
fees: number;
|
|
72
|
+
/** Grand total the buyer pays */
|
|
73
|
+
total: number;
|
|
74
|
+
/** What the merchant receives after seller-side fee deduction */
|
|
75
|
+
merchantReceives: number;
|
|
76
|
+
/** Line-by-line breakdown */
|
|
77
|
+
lines: Array<{
|
|
78
|
+
productId: string;
|
|
79
|
+
name: string;
|
|
80
|
+
quantity: number;
|
|
81
|
+
unitPrice: number;
|
|
82
|
+
lineTotal: number;
|
|
83
|
+
}>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Result of a checkout */
|
|
87
|
+
export interface CheckoutResult {
|
|
88
|
+
/** On-chain link ID */
|
|
89
|
+
linkId: number;
|
|
90
|
+
/** Transaction signature */
|
|
91
|
+
tx: string;
|
|
92
|
+
/** Payment URL */
|
|
93
|
+
paymentUrl: string;
|
|
94
|
+
/** QR code URL */
|
|
95
|
+
qrCodeUrl: string;
|
|
96
|
+
/** Cart totals at time of checkout */
|
|
97
|
+
totals: CartTotals;
|
|
98
|
+
/** Order reference */
|
|
99
|
+
orderRef: string;
|
|
100
|
+
/** Created timestamp */
|
|
101
|
+
createdAt: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Batch link creation result */
|
|
105
|
+
export interface BatchLinkResult {
|
|
106
|
+
productId: string;
|
|
107
|
+
linkId: number;
|
|
108
|
+
tx: string;
|
|
109
|
+
paymentUrl: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Store configuration */
|
|
113
|
+
export interface StoreConfig {
|
|
114
|
+
merchantId: number;
|
|
115
|
+
platformId: number;
|
|
116
|
+
/** Default currency for the store */
|
|
117
|
+
currency?: Currency;
|
|
118
|
+
/** Allow tips on checkout */
|
|
119
|
+
allowTips?: boolean;
|
|
120
|
+
/** Default link expiration in seconds (0 = never, default: 300 = 5min) */
|
|
121
|
+
defaultExpiresIn?: number;
|
|
122
|
+
/** Platform fee BPS for fee calculations */
|
|
123
|
+
platformFeeBps?: number;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ==================== ROTATE STORE ====================
|
|
127
|
+
|
|
128
|
+
export class RotateStore {
|
|
129
|
+
private sdk: RotateSDK;
|
|
130
|
+
private config: Required<StoreConfig>;
|
|
131
|
+
/** @internal Shared catalog logic */
|
|
132
|
+
private catalog: Catalog<Product, ProductInput>;
|
|
133
|
+
|
|
134
|
+
constructor(sdk: RotateSDK, config: StoreConfig) {
|
|
135
|
+
this.sdk = sdk;
|
|
136
|
+
this.config = {
|
|
137
|
+
merchantId: config.merchantId,
|
|
138
|
+
platformId: config.platformId,
|
|
139
|
+
currency: config.currency || 'USD',
|
|
140
|
+
allowTips: config.allowTips ?? false,
|
|
141
|
+
defaultExpiresIn: config.defaultExpiresIn ?? 300,
|
|
142
|
+
platformFeeBps: config.platformFeeBps ?? 0,
|
|
143
|
+
};
|
|
144
|
+
this.catalog = new Catalog<Product, ProductInput>((input) => {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
return { ...input, active: input.active ?? true, createdAt: now, updatedAt: now };
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ==================== PRODUCT CATALOG (delegated to Catalog) ====================
|
|
151
|
+
|
|
152
|
+
addProduct(input: ProductInput): Product { return this.catalog.addProduct(input); }
|
|
153
|
+
addProducts(inputs: ProductInput[]): Product[] { return this.catalog.addProducts(inputs); }
|
|
154
|
+
updateProduct(id: string, updates: Partial<ProductInput>): Product { return this.catalog.updateProduct(id, updates); }
|
|
155
|
+
removeProduct(id: string): boolean { return this.catalog.removeProduct(id); }
|
|
156
|
+
getProduct(id: string): Product | undefined { return this.catalog.getProduct(id); }
|
|
157
|
+
getProducts(filter?: {
|
|
158
|
+
category?: string; active?: boolean; search?: string;
|
|
159
|
+
minPrice?: number; maxPrice?: number;
|
|
160
|
+
sortBy?: 'name' | 'price' | 'createdAt'; sortOrder?: 'asc' | 'desc';
|
|
161
|
+
limit?: number; offset?: number;
|
|
162
|
+
}): Product[] { return this.catalog.getProducts(filter); }
|
|
163
|
+
getCategories(): string[] { return this.catalog.getCategories(); }
|
|
164
|
+
get productCount(): number { return this.catalog.productCount; }
|
|
165
|
+
|
|
166
|
+
// ==================== INVENTORY (delegated) ====================
|
|
167
|
+
|
|
168
|
+
isInStock(productId: string, quantity: number = 1): boolean { return this.catalog.isInStock(productId, quantity); }
|
|
169
|
+
reserveInventory(productId: string, quantity: number): void { this.catalog.reserveInventory(productId, quantity); }
|
|
170
|
+
releaseInventory(productId: string, quantity: number): void { this.catalog.releaseInventory(productId, quantity); }
|
|
171
|
+
|
|
172
|
+
// ==================== DISCOUNTS (delegated) ====================
|
|
173
|
+
|
|
174
|
+
addDiscount(input: DiscountInput): Discount { return this.catalog.addDiscount(input); }
|
|
175
|
+
getDiscount(code: string): Discount | undefined { return this.catalog.getDiscount(code); }
|
|
176
|
+
isDiscountValid(code: string, subtotal: number): boolean { return this.catalog.isDiscountValid(code, subtotal); }
|
|
177
|
+
useDiscount(code: string): void { this.catalog.useDiscount(code); }
|
|
178
|
+
|
|
179
|
+
// ==================== CART ====================
|
|
180
|
+
|
|
181
|
+
/** Create a new shopping cart */
|
|
182
|
+
createCart(): RotateCart {
|
|
183
|
+
return new RotateCart(this, this.sdk, this.config);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ==================== BATCH LINK CREATION ====================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create payment links for every active product in the catalog.
|
|
190
|
+
* Useful for generating a "Buy Now" link per product upfront.
|
|
191
|
+
* Processes sequentially to respect Solana transaction ordering.
|
|
192
|
+
*/
|
|
193
|
+
async createLinksForAllProducts(options?: {
|
|
194
|
+
expiresIn?: number;
|
|
195
|
+
productIds?: string[];
|
|
196
|
+
concurrency?: number;
|
|
197
|
+
}): Promise<BatchLinkResult[]> {
|
|
198
|
+
const products = options?.productIds
|
|
199
|
+
? options.productIds.map((id) => this.catalog.getProduct(id)).filter(Boolean) as Product[]
|
|
200
|
+
: this.getProducts({ active: true });
|
|
201
|
+
|
|
202
|
+
const results: BatchLinkResult[] = [];
|
|
203
|
+
const concurrency = options?.concurrency || 1; // sequential by default for Solana
|
|
204
|
+
|
|
205
|
+
// Process in batches
|
|
206
|
+
for (let i = 0; i < products.length; i += concurrency) {
|
|
207
|
+
const batch = products.slice(i, i + concurrency);
|
|
208
|
+
const batchResults = await Promise.all(
|
|
209
|
+
batch.map(async (product) => {
|
|
210
|
+
const amountMicro = BigInt(Math.floor(product.price * 1_000_000));
|
|
211
|
+
const expiresAt = options?.expiresIn
|
|
212
|
+
? Math.floor(Date.now() / 1000) + options.expiresIn
|
|
213
|
+
: 0;
|
|
214
|
+
|
|
215
|
+
const result = await this.sdk.createLinkUsd({
|
|
216
|
+
merchantId: this.config.merchantId,
|
|
217
|
+
platformId: this.config.platformId,
|
|
218
|
+
amount: amountMicro,
|
|
219
|
+
expiresAt,
|
|
220
|
+
allowTips: this.config.allowTips,
|
|
221
|
+
allowPartial: false,
|
|
222
|
+
orderRef: product.id,
|
|
223
|
+
description: product.name,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
productId: product.id,
|
|
228
|
+
linkId: result.linkId,
|
|
229
|
+
tx: result.tx,
|
|
230
|
+
paymentUrl: this.sdk.getPaymentUrl(result.linkId),
|
|
231
|
+
};
|
|
232
|
+
})
|
|
233
|
+
);
|
|
234
|
+
results.push(...batchResults);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ==================== SERIALIZATION ====================
|
|
241
|
+
|
|
242
|
+
/** Export the full catalog as JSON (for persistence / API) */
|
|
243
|
+
exportCatalog(): { products: Product[]; discounts: Discount[] } {
|
|
244
|
+
return {
|
|
245
|
+
products: this.catalog.exportProducts(),
|
|
246
|
+
discounts: this.catalog.exportDiscounts(),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Import a catalog from JSON */
|
|
251
|
+
importCatalog(data: { products: Product[]; discounts?: Discount[] }): void {
|
|
252
|
+
this.catalog.importProducts(data.products);
|
|
253
|
+
if (data.discounts) {
|
|
254
|
+
this.catalog.importDiscounts(data.discounts);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ==================== ROTATE CART ====================
|
|
260
|
+
|
|
261
|
+
export class RotateCart {
|
|
262
|
+
private store: RotateStore;
|
|
263
|
+
private sdk: RotateSDK;
|
|
264
|
+
private config: Required<StoreConfig>;
|
|
265
|
+
private items: Map<string, LineItem> = new Map();
|
|
266
|
+
private appliedDiscounts: string[] = [];
|
|
267
|
+
private _metadata: Record<string, string> = {};
|
|
268
|
+
|
|
269
|
+
/** @internal */
|
|
270
|
+
constructor(store: RotateStore, sdk: RotateSDK, config: Required<StoreConfig>) {
|
|
271
|
+
this.store = store;
|
|
272
|
+
this.sdk = sdk;
|
|
273
|
+
this.config = config;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ==================== LINE ITEMS ====================
|
|
277
|
+
|
|
278
|
+
/** Add a product to the cart */
|
|
279
|
+
addItem(productId: string, quantity: number = 1): LineItem {
|
|
280
|
+
if (quantity <= 0) throw new Error('Quantity must be positive');
|
|
281
|
+
|
|
282
|
+
const product = this.store.getProduct(productId);
|
|
283
|
+
if (!product) throw new Error(`Product '${productId}' not found`);
|
|
284
|
+
if (!product.active) throw new Error(`Product '${productId}' is not active`);
|
|
285
|
+
|
|
286
|
+
const existing = this.items.get(productId);
|
|
287
|
+
const newQty = (existing?.quantity || 0) + quantity;
|
|
288
|
+
|
|
289
|
+
if (!this.store.isInStock(productId, newQty)) {
|
|
290
|
+
throw new Error(`Insufficient stock for '${productId}'`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const item: LineItem = {
|
|
294
|
+
product,
|
|
295
|
+
quantity: newQty,
|
|
296
|
+
unitPrice: product.price,
|
|
297
|
+
};
|
|
298
|
+
this.items.set(productId, item);
|
|
299
|
+
return item;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Set exact quantity for a product */
|
|
303
|
+
setItemQuantity(productId: string, quantity: number): LineItem | null {
|
|
304
|
+
if (quantity <= 0) {
|
|
305
|
+
this.removeItem(productId);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const product = this.store.getProduct(productId);
|
|
310
|
+
if (!product) throw new Error(`Product '${productId}' not found`);
|
|
311
|
+
|
|
312
|
+
if (!this.store.isInStock(productId, quantity)) {
|
|
313
|
+
throw new Error(`Insufficient stock for '${productId}'`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const item: LineItem = {
|
|
317
|
+
product,
|
|
318
|
+
quantity,
|
|
319
|
+
unitPrice: product.price,
|
|
320
|
+
};
|
|
321
|
+
this.items.set(productId, item);
|
|
322
|
+
return item;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Remove a product from the cart entirely */
|
|
326
|
+
removeItem(productId: string): boolean {
|
|
327
|
+
return this.items.delete(productId);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Clear all items */
|
|
331
|
+
clear(): void {
|
|
332
|
+
this.items.clear();
|
|
333
|
+
this.appliedDiscounts = [];
|
|
334
|
+
this._metadata = {};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Get all line items */
|
|
338
|
+
getItems(): LineItem[] {
|
|
339
|
+
return Array.from(this.items.values());
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Check if cart is empty */
|
|
343
|
+
get isEmpty(): boolean {
|
|
344
|
+
return this.items.size === 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ==================== DISCOUNTS ====================
|
|
348
|
+
|
|
349
|
+
/** Apply a discount code */
|
|
350
|
+
applyDiscount(code: string): boolean {
|
|
351
|
+
const upperCode = code.toUpperCase();
|
|
352
|
+
if (this.appliedDiscounts.includes(upperCode)) return false;
|
|
353
|
+
|
|
354
|
+
const subtotal = this.calculateSubtotal();
|
|
355
|
+
if (!this.store.isDiscountValid(upperCode, subtotal)) return false;
|
|
356
|
+
|
|
357
|
+
this.appliedDiscounts.push(upperCode);
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Remove a discount code */
|
|
362
|
+
removeDiscount(code: string): boolean {
|
|
363
|
+
const idx = this.appliedDiscounts.indexOf(code.toUpperCase());
|
|
364
|
+
if (idx === -1) return false;
|
|
365
|
+
this.appliedDiscounts.splice(idx, 1);
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ==================== METADATA ====================
|
|
370
|
+
|
|
371
|
+
/** Attach metadata to the order (customer email, shipping address, notes, etc.) */
|
|
372
|
+
setMetadata(key: string, value: string): void {
|
|
373
|
+
this._metadata[key] = value;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
getMetadata(): Record<string, string> {
|
|
377
|
+
return { ...this._metadata };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ==================== TOTALS ====================
|
|
381
|
+
|
|
382
|
+
private calculateSubtotal(): number {
|
|
383
|
+
let subtotal = 0;
|
|
384
|
+
for (const item of this.items.values()) {
|
|
385
|
+
subtotal += item.unitPrice * item.quantity;
|
|
386
|
+
}
|
|
387
|
+
return Math.round(subtotal * 100) / 100; // round to cents
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private calculateDiscountAmount(subtotal: number): number {
|
|
391
|
+
let totalDiscount = 0;
|
|
392
|
+
|
|
393
|
+
for (const code of this.appliedDiscounts) {
|
|
394
|
+
const d = this.store.getDiscount(code);
|
|
395
|
+
if (!d || !d.active) continue;
|
|
396
|
+
|
|
397
|
+
if (d.productIds && d.productIds.length > 0) {
|
|
398
|
+
// Product-specific discount
|
|
399
|
+
for (const item of this.items.values()) {
|
|
400
|
+
if (!d.productIds.includes(item.product.id)) continue;
|
|
401
|
+
const lineTotal = item.unitPrice * item.quantity;
|
|
402
|
+
if (d.type === 'percent') {
|
|
403
|
+
totalDiscount += lineTotal * (d.value / 100);
|
|
404
|
+
} else {
|
|
405
|
+
totalDiscount += Math.min(d.value, lineTotal);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
// Cart-wide discount
|
|
410
|
+
if (d.type === 'percent') {
|
|
411
|
+
totalDiscount += subtotal * (d.value / 100);
|
|
412
|
+
} else {
|
|
413
|
+
totalDiscount += d.value;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return Math.min(Math.round(totalDiscount * 100) / 100, subtotal);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Calculate full cart totals */
|
|
422
|
+
getTotals(): CartTotals {
|
|
423
|
+
const lines: CartTotals['lines'] = [];
|
|
424
|
+
let subtotal = 0;
|
|
425
|
+
let totalQuantity = 0;
|
|
426
|
+
|
|
427
|
+
for (const item of this.items.values()) {
|
|
428
|
+
const lineTotal = Math.round(item.unitPrice * item.quantity * 100) / 100;
|
|
429
|
+
subtotal += lineTotal;
|
|
430
|
+
totalQuantity += item.quantity;
|
|
431
|
+
lines.push({
|
|
432
|
+
productId: item.product.id,
|
|
433
|
+
name: item.product.name,
|
|
434
|
+
quantity: item.quantity,
|
|
435
|
+
unitPrice: item.unitPrice,
|
|
436
|
+
lineTotal,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
subtotal = Math.round(subtotal * 100) / 100;
|
|
441
|
+
|
|
442
|
+
const discountAmount = this.calculateDiscountAmount(subtotal);
|
|
443
|
+
let afterDiscount = Math.round((subtotal - discountAmount) * 100) / 100;
|
|
444
|
+
if (afterDiscount < 0) afterDiscount = 0;
|
|
445
|
+
|
|
446
|
+
// Convert to micro-units for fee calculation
|
|
447
|
+
const amountMicro = Math.floor(afterDiscount * 1_000_000);
|
|
448
|
+
const feesResult = calculateFees(amountMicro, this.config.platformFeeBps);
|
|
449
|
+
|
|
450
|
+
// Convert fees back to dollars
|
|
451
|
+
const fees = feesResult.buyerFeeShare / 1_000_000;
|
|
452
|
+
const total = Math.round((afterDiscount + fees) * 100) / 100;
|
|
453
|
+
const merchantReceives = feesResult.merchantReceives / 1_000_000;
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
itemCount: this.items.size,
|
|
457
|
+
totalQuantity,
|
|
458
|
+
subtotal,
|
|
459
|
+
discountAmount,
|
|
460
|
+
afterDiscount,
|
|
461
|
+
fees: Math.round(fees * 100) / 100,
|
|
462
|
+
total: Math.round(total * 100) / 100,
|
|
463
|
+
merchantReceives: Math.round(merchantReceives * 100) / 100,
|
|
464
|
+
lines,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ==================== CHECKOUT ====================
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Finalize the cart and create an on-chain payment link.
|
|
472
|
+
* Returns everything needed to present the payment to the buyer.
|
|
473
|
+
*/
|
|
474
|
+
async checkout(options?: {
|
|
475
|
+
/** Override expiration (seconds from now, 0 = no expiry) */
|
|
476
|
+
expiresIn?: number;
|
|
477
|
+
/** Override currency */
|
|
478
|
+
currency?: Currency;
|
|
479
|
+
/** Override tips flag */
|
|
480
|
+
allowTips?: boolean;
|
|
481
|
+
/** Custom order reference (default: auto-generated) */
|
|
482
|
+
orderRef?: string;
|
|
483
|
+
/** Custom description (default: auto-generated from cart items) */
|
|
484
|
+
description?: string;
|
|
485
|
+
}): Promise<CheckoutResult> {
|
|
486
|
+
if (this.isEmpty) throw new Error('Cart is empty');
|
|
487
|
+
|
|
488
|
+
const totals = this.getTotals();
|
|
489
|
+
if (totals.afterDiscount <= 0) throw new Error('Cart total must be greater than zero');
|
|
490
|
+
|
|
491
|
+
// Validate all items are still in stock
|
|
492
|
+
for (const item of this.items.values()) {
|
|
493
|
+
if (!this.store.isInStock(item.product.id, item.quantity)) {
|
|
494
|
+
throw new Error(`'${item.product.name}' is out of stock`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const currency = options?.currency || this.config.currency;
|
|
499
|
+
const orderRef = options?.orderRef || `order_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
|
|
500
|
+
const expiresAt = (options?.expiresIn ?? this.config.defaultExpiresIn) > 0
|
|
501
|
+
? Math.floor(Date.now() / 1000) + (options?.expiresIn ?? this.config.defaultExpiresIn)
|
|
502
|
+
: 0;
|
|
503
|
+
const allowTips = options?.allowTips ?? this.config.allowTips;
|
|
504
|
+
|
|
505
|
+
// Auto-generate description from cart items (e.g. "2x Logo Tee, 1x Zip Hoodie")
|
|
506
|
+
const description = options?.description || totals.lines.map(l => `${l.quantity}x ${l.name}`).join(', ');
|
|
507
|
+
|
|
508
|
+
// Convert total to micro-units
|
|
509
|
+
const amountMicro = BigInt(Math.floor(totals.afterDiscount * 1_000_000));
|
|
510
|
+
|
|
511
|
+
let result: { tx: string; linkId: number };
|
|
512
|
+
|
|
513
|
+
if (currency === 'SOL') {
|
|
514
|
+
// SOL-denominated link (amount is in lamports)
|
|
515
|
+
const solPrice = await this.sdk.getSolPrice();
|
|
516
|
+
const lamports = BigInt(Math.floor((totals.afterDiscount / solPrice) * 1_000_000_000));
|
|
517
|
+
result = await this.sdk.createLinkSol({
|
|
518
|
+
merchantId: this.config.merchantId,
|
|
519
|
+
platformId: this.config.platformId,
|
|
520
|
+
amount: lamports,
|
|
521
|
+
expiresAt,
|
|
522
|
+
allowTips,
|
|
523
|
+
allowPartial: false,
|
|
524
|
+
orderRef,
|
|
525
|
+
description,
|
|
526
|
+
});
|
|
527
|
+
} else if (currency === 'USDC' || currency === 'USDT') {
|
|
528
|
+
result = await this.sdk.createLinkToken({
|
|
529
|
+
merchantId: this.config.merchantId,
|
|
530
|
+
platformId: this.config.platformId,
|
|
531
|
+
amount: amountMicro,
|
|
532
|
+
expiresAt,
|
|
533
|
+
allowTips,
|
|
534
|
+
allowPartial: false,
|
|
535
|
+
orderRef,
|
|
536
|
+
currency,
|
|
537
|
+
description,
|
|
538
|
+
});
|
|
539
|
+
} else {
|
|
540
|
+
// USD-denominated (buyer picks SOL / USDC / USDT at pay time)
|
|
541
|
+
result = await this.sdk.createLinkUsd({
|
|
542
|
+
merchantId: this.config.merchantId,
|
|
543
|
+
platformId: this.config.platformId,
|
|
544
|
+
amount: amountMicro,
|
|
545
|
+
expiresAt,
|
|
546
|
+
allowTips,
|
|
547
|
+
allowPartial: false,
|
|
548
|
+
orderRef,
|
|
549
|
+
description,
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Reserve inventory
|
|
554
|
+
for (const item of this.items.values()) {
|
|
555
|
+
this.store.reserveInventory(item.product.id, item.quantity);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Increment discount usage
|
|
559
|
+
for (const code of this.appliedDiscounts) {
|
|
560
|
+
this.store.useDiscount(code);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
linkId: result.linkId,
|
|
565
|
+
tx: result.tx,
|
|
566
|
+
paymentUrl: this.sdk.getPaymentUrl(result.linkId),
|
|
567
|
+
qrCodeUrl: this.sdk.getQRCodeUrl(result.linkId),
|
|
568
|
+
totals,
|
|
569
|
+
orderRef,
|
|
570
|
+
createdAt: Date.now(),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// ==================== EXPORTS ====================
|
|
576
|
+
|
|
577
|
+
export default RotateStore;
|