@hanzo/commerce 7.5.0 → 7.6.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/billing.ts
ADDED
package/client.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hanzo/commerce/client
|
|
3
|
+
*
|
|
4
|
+
* Universal TypeScript client for the Hanzo Commerce API.
|
|
5
|
+
* Works in browser, Node.js, and edge runtimes — no backend required.
|
|
6
|
+
* Covers billing, subscriptions, payments, checkout, coupons,
|
|
7
|
+
* referrals, affiliates, usage, and plans.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { Commerce } from '@hanzo/commerce/client'
|
|
12
|
+
*
|
|
13
|
+
* const commerce = new Commerce({ baseUrl: 'https://api.hanzo.ai', token: iamToken })
|
|
14
|
+
*
|
|
15
|
+
* // Validate a coupon before checkout
|
|
16
|
+
* const coupon = await commerce.validateCoupon('LAUNCH50')
|
|
17
|
+
*
|
|
18
|
+
* // Create a checkout session
|
|
19
|
+
* const session = await commerce.createCheckoutSession({
|
|
20
|
+
* items: [{ id: 'plan_pro', quantity: 1 }],
|
|
21
|
+
* couponCode: 'LAUNCH50',
|
|
22
|
+
* referrerId: 'ref_abc123',
|
|
23
|
+
* successUrl: 'https://app.example.com/success',
|
|
24
|
+
* cancelUrl: 'https://app.example.com/cancel',
|
|
25
|
+
* })
|
|
26
|
+
* window.location.href = session.checkoutUrl
|
|
27
|
+
*
|
|
28
|
+
* // Tokenize a card (S2S — no external SDK required)
|
|
29
|
+
* const token = await commerce.tokenizeCard({
|
|
30
|
+
* number: '4242424242424242',
|
|
31
|
+
* expiryMonth: '12',
|
|
32
|
+
* expiryYear: '2027',
|
|
33
|
+
* cvc: '123',
|
|
34
|
+
* name: 'Jane Smith',
|
|
35
|
+
* })
|
|
36
|
+
*
|
|
37
|
+
* // Subscribe
|
|
38
|
+
* const sub = await commerce.subscribe({ planId: 'pro', userId: 'user_xyz' })
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Config
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export type CommerceClientConfig = {
|
|
47
|
+
/**
|
|
48
|
+
* Commerce API base URL.
|
|
49
|
+
* Defaults to https://api.hanzo.ai
|
|
50
|
+
*/
|
|
51
|
+
baseUrl?: string
|
|
52
|
+
/** @deprecated use baseUrl */
|
|
53
|
+
commerceUrl?: string
|
|
54
|
+
/** IAM access token for authenticated requests. */
|
|
55
|
+
token?: string
|
|
56
|
+
/** Request timeout in milliseconds. Default 15 000. */
|
|
57
|
+
timeoutMs?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Core types
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export type Balance = {
|
|
65
|
+
balance: number
|
|
66
|
+
holds: number
|
|
67
|
+
available: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Transaction = {
|
|
71
|
+
id?: string
|
|
72
|
+
owner?: string
|
|
73
|
+
type: 'hold' | 'hold-removed' | 'transfer' | 'deposit' | 'withdraw'
|
|
74
|
+
destinationId?: string
|
|
75
|
+
destinationKind?: string
|
|
76
|
+
sourceId?: string
|
|
77
|
+
sourceKind?: string
|
|
78
|
+
currency: string
|
|
79
|
+
amount: number
|
|
80
|
+
tags?: string[]
|
|
81
|
+
expiresAt?: string
|
|
82
|
+
metadata?: Record<string, unknown>
|
|
83
|
+
createdAt?: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export type Subscription = {
|
|
87
|
+
id?: string
|
|
88
|
+
planId?: string
|
|
89
|
+
userId?: string
|
|
90
|
+
customerId?: string
|
|
91
|
+
status?: 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid' | string
|
|
92
|
+
billingType?: 'charge_automatically' | 'send_invoice'
|
|
93
|
+
periodStart?: string
|
|
94
|
+
periodEnd?: string
|
|
95
|
+
trialStart?: string
|
|
96
|
+
trialEnd?: string
|
|
97
|
+
quantity?: number
|
|
98
|
+
createdAt?: string
|
|
99
|
+
cancelAtPeriodEnd?: boolean
|
|
100
|
+
currentPeriodEnd?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type Plan = {
|
|
104
|
+
id?: string
|
|
105
|
+
slug?: string
|
|
106
|
+
name?: string
|
|
107
|
+
description?: string
|
|
108
|
+
price?: number
|
|
109
|
+
priceMonthly?: number
|
|
110
|
+
priceAnnual?: number
|
|
111
|
+
currency?: string
|
|
112
|
+
interval?: 'monthly' | 'yearly' | string
|
|
113
|
+
intervalCount?: number
|
|
114
|
+
trialPeriodDays?: number
|
|
115
|
+
features?: string[]
|
|
116
|
+
popular?: boolean
|
|
117
|
+
contactSales?: boolean
|
|
118
|
+
metadata?: Record<string, unknown>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type Payment = {
|
|
122
|
+
id?: string
|
|
123
|
+
orderId?: string
|
|
124
|
+
userId?: string
|
|
125
|
+
amount?: number
|
|
126
|
+
amountRefunded?: number
|
|
127
|
+
fee?: number
|
|
128
|
+
currency?: string
|
|
129
|
+
status?: 'cancelled' | 'credit' | 'disputed' | 'failed' | 'fraudulent' | 'paid' | 'refunded' | 'unpaid' | string
|
|
130
|
+
captured?: boolean
|
|
131
|
+
live?: boolean
|
|
132
|
+
createdAt?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type UsageRecord = {
|
|
136
|
+
user: string
|
|
137
|
+
currency?: string
|
|
138
|
+
amount: number
|
|
139
|
+
model?: string
|
|
140
|
+
provider?: string
|
|
141
|
+
tokens?: number
|
|
142
|
+
promptTokens?: number
|
|
143
|
+
completionTokens?: number
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Coupon / Discount types
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export type CouponType = 'Percent' | 'Flat' | 'FreeShipping' | 'FreeItem'
|
|
151
|
+
|
|
152
|
+
export type Coupon = {
|
|
153
|
+
id?: string
|
|
154
|
+
code: string
|
|
155
|
+
type: CouponType
|
|
156
|
+
/** Amount: percentage (0-100) for Percent, cents for Flat */
|
|
157
|
+
amount: number
|
|
158
|
+
description?: string
|
|
159
|
+
limit?: number
|
|
160
|
+
used?: number
|
|
161
|
+
startDate?: string
|
|
162
|
+
endDate?: string
|
|
163
|
+
enabled?: boolean
|
|
164
|
+
/** Calculated discount in cents (returned by validateCoupon) */
|
|
165
|
+
discountCents?: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type CouponValidateResult = {
|
|
169
|
+
valid: boolean
|
|
170
|
+
coupon?: Coupon
|
|
171
|
+
error?: string
|
|
172
|
+
/** Discount in cents for a given subtotal */
|
|
173
|
+
discountCents?: number
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type Discount = {
|
|
177
|
+
id?: string
|
|
178
|
+
type: 'Percent' | 'Flat' | 'FreeShipping' | 'FreeItem' | 'Bulk'
|
|
179
|
+
amount: number
|
|
180
|
+
scope?: 'Product' | 'Variant' | 'Collection' | 'Store'
|
|
181
|
+
enabled?: boolean
|
|
182
|
+
startDate?: string
|
|
183
|
+
endDate?: string
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Checkout types
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
export type CheckoutItem = {
|
|
191
|
+
id: string
|
|
192
|
+
quantity?: number
|
|
193
|
+
/** Price override in cents */
|
|
194
|
+
price?: number
|
|
195
|
+
name?: string
|
|
196
|
+
description?: string
|
|
197
|
+
imageUrl?: string
|
|
198
|
+
metadata?: Record<string, unknown>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export type CheckoutSessionRequest = {
|
|
202
|
+
items: CheckoutItem[]
|
|
203
|
+
successUrl: string
|
|
204
|
+
cancelUrl: string
|
|
205
|
+
couponCode?: string
|
|
206
|
+
referrerId?: string
|
|
207
|
+
affiliateId?: string
|
|
208
|
+
currency?: string
|
|
209
|
+
customer?: {
|
|
210
|
+
email?: string
|
|
211
|
+
name?: string
|
|
212
|
+
address?: string
|
|
213
|
+
city?: string
|
|
214
|
+
zip?: string
|
|
215
|
+
}
|
|
216
|
+
metadata?: Record<string, unknown>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type CheckoutSessionResponse = {
|
|
220
|
+
checkoutUrl: string
|
|
221
|
+
sessionId: string
|
|
222
|
+
/** Original total in cents before discount */
|
|
223
|
+
originalTotal?: number
|
|
224
|
+
/** Final total in cents after discount */
|
|
225
|
+
finalTotal?: number
|
|
226
|
+
discount?: {
|
|
227
|
+
code: string
|
|
228
|
+
type: CouponType
|
|
229
|
+
amount: number
|
|
230
|
+
discountCents: number
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Card tokenization (S2S — no provider SDK needed on the frontend)
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export type CardTokenizeRequest = {
|
|
239
|
+
number: string
|
|
240
|
+
expiryMonth: string // "01"–"12"
|
|
241
|
+
expiryYear: string // "2025"–"2099"
|
|
242
|
+
cvc: string
|
|
243
|
+
name?: string
|
|
244
|
+
zip?: string
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type CardTokenizeResult = {
|
|
248
|
+
token: string
|
|
249
|
+
brand: string
|
|
250
|
+
last4: string
|
|
251
|
+
expiryMonth: string
|
|
252
|
+
expiryYear: string
|
|
253
|
+
provider: string
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Payment method types
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
export type PaymentMethodType = 'card' | 'bank_account' | 'balance' | 'crypto' | 'wire'
|
|
261
|
+
|
|
262
|
+
export type PaymentMethod = {
|
|
263
|
+
id: string
|
|
264
|
+
type: PaymentMethodType
|
|
265
|
+
isDefault?: boolean
|
|
266
|
+
customerId?: string
|
|
267
|
+
card?: {
|
|
268
|
+
brand: string
|
|
269
|
+
last4: string
|
|
270
|
+
expMonth: number
|
|
271
|
+
expYear: number
|
|
272
|
+
}
|
|
273
|
+
providerRef?: string
|
|
274
|
+
providerType?: string
|
|
275
|
+
createdAt?: string
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Referral / Affiliate types
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
export type Referral = {
|
|
283
|
+
id?: string
|
|
284
|
+
userId?: string
|
|
285
|
+
referrerId?: string
|
|
286
|
+
affiliateId?: string
|
|
287
|
+
orderId?: string
|
|
288
|
+
fee?: number
|
|
289
|
+
createdAt?: string
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export type Referrer = {
|
|
293
|
+
id?: string
|
|
294
|
+
userId?: string
|
|
295
|
+
enabled?: boolean
|
|
296
|
+
code?: string
|
|
297
|
+
referrals?: Referral[]
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export type Affiliate = {
|
|
301
|
+
id?: string
|
|
302
|
+
userId?: string
|
|
303
|
+
enabled?: boolean
|
|
304
|
+
commission?: number
|
|
305
|
+
couponId?: string
|
|
306
|
+
connectUrl?: string
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Credit grant
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
export type CreditGrant = {
|
|
314
|
+
id?: string
|
|
315
|
+
userId?: string
|
|
316
|
+
amount: number
|
|
317
|
+
currency: string
|
|
318
|
+
expiresAt?: string
|
|
319
|
+
tags?: string[]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Client
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
const DEFAULT_BASE_URL = 'https://api.hanzo.ai'
|
|
327
|
+
const DEFAULT_TIMEOUT_MS = 15_000
|
|
328
|
+
|
|
329
|
+
export class Commerce {
|
|
330
|
+
private readonly baseUrl: string
|
|
331
|
+
private token: string | undefined
|
|
332
|
+
private readonly timeoutMs: number
|
|
333
|
+
|
|
334
|
+
constructor(config: CommerceClientConfig = {}) {
|
|
335
|
+
this.baseUrl = (config.baseUrl ?? config.commerceUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
336
|
+
this.token = config.token
|
|
337
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Update the auth token (e.g. after IAM token refresh). */
|
|
341
|
+
setToken(token: string): void {
|
|
342
|
+
this.token = token
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
private async request<T>(
|
|
346
|
+
path: string,
|
|
347
|
+
opts?: {
|
|
348
|
+
method?: string
|
|
349
|
+
body?: unknown
|
|
350
|
+
token?: string
|
|
351
|
+
params?: Record<string, string>
|
|
352
|
+
},
|
|
353
|
+
): Promise<T> {
|
|
354
|
+
const url = new URL(path, this.baseUrl)
|
|
355
|
+
if (opts?.params) {
|
|
356
|
+
for (const [k, v] of Object.entries(opts.params)) {
|
|
357
|
+
url.searchParams.set(k, v)
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const controller = new AbortController()
|
|
362
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
|
|
363
|
+
|
|
364
|
+
const headers: Record<string, string> = { Accept: 'application/json' }
|
|
365
|
+
const authToken = opts?.token ?? this.token
|
|
366
|
+
if (authToken) headers['Authorization'] = `Bearer ${authToken}`
|
|
367
|
+
if (opts?.body) headers['Content-Type'] = 'application/json'
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
const res = await fetch(url.toString(), {
|
|
371
|
+
method: opts?.method ?? 'GET',
|
|
372
|
+
headers,
|
|
373
|
+
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
|
374
|
+
signal: controller.signal,
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
const text = await res.text().catch(() => '')
|
|
379
|
+
throw new CommerceApiError(res.status, `${res.statusText}: ${text}`.trim())
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return (await res.json()) as T
|
|
383
|
+
} finally {
|
|
384
|
+
clearTimeout(timer)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// -----------------------------------------------------------------------
|
|
389
|
+
// Balance
|
|
390
|
+
// -----------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
async getBalance(user: string, currency = 'usd', token?: string): Promise<Balance> {
|
|
393
|
+
return this.request<Balance>('/api/v1/billing/balance', {
|
|
394
|
+
params: { user, currency }, token,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async getAllBalances(user: string, token?: string): Promise<Record<string, Balance>> {
|
|
399
|
+
return this.request<Record<string, Balance>>('/api/v1/billing/balance/all', {
|
|
400
|
+
params: { user }, token,
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// -----------------------------------------------------------------------
|
|
405
|
+
// Usage
|
|
406
|
+
// -----------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
async addUsageRecord(record: UsageRecord, token?: string): Promise<Transaction> {
|
|
409
|
+
return this.request<Transaction>('/api/v1/billing/usage', {
|
|
410
|
+
method: 'POST', body: record, token,
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async getUsageRecords(user: string, currency = 'usd', token?: string): Promise<Transaction[]> {
|
|
415
|
+
return this.request<Transaction[]>('/api/v1/billing/usage', {
|
|
416
|
+
params: { user, currency }, token,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// -----------------------------------------------------------------------
|
|
421
|
+
// Deposits / Credits
|
|
422
|
+
// -----------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
async addDeposit(
|
|
425
|
+
params: { user: string; currency?: string; amount: number; notes?: string; tags?: string[]; expiresIn?: string },
|
|
426
|
+
token?: string,
|
|
427
|
+
): Promise<Transaction> {
|
|
428
|
+
return this.request<Transaction>('/api/v1/billing/deposit', {
|
|
429
|
+
method: 'POST', body: params, token,
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async grantStarterCredit(user: string, token?: string): Promise<Transaction> {
|
|
434
|
+
return this.request<Transaction>('/api/v1/billing/credit', {
|
|
435
|
+
method: 'POST', body: { user }, token,
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// -----------------------------------------------------------------------
|
|
440
|
+
// Plans
|
|
441
|
+
// -----------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
async getPlans(token?: string): Promise<Plan[]> {
|
|
444
|
+
return this.request<Plan[]>('/api/v1/billing/plans', { token })
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async getPlan(planId: string, token?: string): Promise<Plan | null> {
|
|
448
|
+
try {
|
|
449
|
+
return await this.request<Plan>(`/api/v1/billing/plans/${planId}`, { token })
|
|
450
|
+
} catch { return null }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// -----------------------------------------------------------------------
|
|
454
|
+
// Subscriptions
|
|
455
|
+
// -----------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
async subscribe(
|
|
458
|
+
params: { planId: string; userId?: string; customerId?: string; paymentMethodId?: string; couponCode?: string; trialDays?: number },
|
|
459
|
+
token?: string,
|
|
460
|
+
): Promise<Subscription> {
|
|
461
|
+
return this.request<Subscription>('/api/v1/billing/subscriptions', {
|
|
462
|
+
method: 'POST', body: params, token,
|
|
463
|
+
})
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async getSubscription(subscriptionId: string, token?: string): Promise<Subscription | null> {
|
|
467
|
+
try {
|
|
468
|
+
return await this.request<Subscription>(`/api/v1/billing/subscriptions/${subscriptionId}`, { token })
|
|
469
|
+
} catch { return null }
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async listSubscriptions(params?: { customerId?: string }, token?: string): Promise<Subscription[]> {
|
|
473
|
+
return this.request<Subscription[]>('/api/v1/billing/subscriptions', {
|
|
474
|
+
params: params as Record<string, string> | undefined, token,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async updateSubscription(subscriptionId: string, update: Partial<Subscription>, token?: string): Promise<Subscription> {
|
|
479
|
+
return this.request<Subscription>(`/api/v1/billing/subscriptions/${subscriptionId}`, {
|
|
480
|
+
method: 'PATCH', body: update, token,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async cancelSubscription(subscriptionId: string, immediately = false, token?: string): Promise<Subscription> {
|
|
485
|
+
return this.request<Subscription>(`/api/v1/billing/subscriptions/${subscriptionId}/cancel`, {
|
|
486
|
+
method: 'POST', body: { immediately }, token,
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async reactivateSubscription(subscriptionId: string, token?: string): Promise<Subscription> {
|
|
491
|
+
return this.request<Subscription>(`/api/v1/billing/subscriptions/${subscriptionId}/reactivate`, {
|
|
492
|
+
method: 'POST', token,
|
|
493
|
+
})
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// -----------------------------------------------------------------------
|
|
497
|
+
// Checkout sessions
|
|
498
|
+
// -----------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Create a hosted checkout session.
|
|
502
|
+
* Returns a URL to redirect the customer to for payment.
|
|
503
|
+
* Supports coupons, referral tracking, and multiple currencies.
|
|
504
|
+
*/
|
|
505
|
+
async createCheckoutSession(
|
|
506
|
+
params: CheckoutSessionRequest,
|
|
507
|
+
token?: string,
|
|
508
|
+
): Promise<CheckoutSessionResponse> {
|
|
509
|
+
return this.request<CheckoutSessionResponse>('/api/v1/checkout/sessions', {
|
|
510
|
+
method: 'POST', body: params, token,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// -----------------------------------------------------------------------
|
|
515
|
+
// Card tokenization (S2S — no external SDK required)
|
|
516
|
+
// -----------------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Tokenize a payment card server-side.
|
|
520
|
+
* No external SDK (Square.js, Stripe.js, etc.) is needed on the frontend.
|
|
521
|
+
* The card data is sent to the Hanzo Commerce API over HTTPS, which
|
|
522
|
+
* tokenizes via the configured payment provider (Stripe).
|
|
523
|
+
*/
|
|
524
|
+
async tokenizeCard(card: CardTokenizeRequest, token?: string): Promise<CardTokenizeResult> {
|
|
525
|
+
return this.request<CardTokenizeResult>('/api/v1/billing/card/tokenize', {
|
|
526
|
+
method: 'POST',
|
|
527
|
+
body: {
|
|
528
|
+
number: card.number.replace(/\s/g, ''),
|
|
529
|
+
expiry_month: card.expiryMonth,
|
|
530
|
+
expiry_year: card.expiryYear,
|
|
531
|
+
cvc: card.cvc,
|
|
532
|
+
name: card.name,
|
|
533
|
+
zip: card.zip,
|
|
534
|
+
},
|
|
535
|
+
token,
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// -----------------------------------------------------------------------
|
|
540
|
+
// Payment methods
|
|
541
|
+
// -----------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
async addPaymentMethod(
|
|
544
|
+
params: {
|
|
545
|
+
customerId: string
|
|
546
|
+
type: PaymentMethodType
|
|
547
|
+
token?: string // from tokenizeCard
|
|
548
|
+
providerRef?: string
|
|
549
|
+
providerType?: string
|
|
550
|
+
},
|
|
551
|
+
token?: string,
|
|
552
|
+
): Promise<PaymentMethod> {
|
|
553
|
+
return this.request<PaymentMethod>('/api/v1/billing/payment-methods', {
|
|
554
|
+
method: 'POST', body: params, token,
|
|
555
|
+
})
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async listPaymentMethods(customerId: string, token?: string): Promise<PaymentMethod[]> {
|
|
559
|
+
return this.request<PaymentMethod[]>('/api/v1/billing/payment-methods', {
|
|
560
|
+
params: { customerId }, token,
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async removePaymentMethod(paymentMethodId: string, token?: string): Promise<void> {
|
|
565
|
+
await this.request<void>(`/api/v1/billing/payment-methods/${paymentMethodId}`, {
|
|
566
|
+
method: 'DELETE', token,
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async setDefaultPaymentMethod(customerId: string, paymentMethodId: string, token?: string): Promise<PaymentMethod> {
|
|
571
|
+
return this.request<PaymentMethod>(`/api/v1/billing/customers/${customerId}/default-payment-method`, {
|
|
572
|
+
method: 'POST', body: { paymentMethodId }, token,
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// -----------------------------------------------------------------------
|
|
577
|
+
// Coupons / Promo codes
|
|
578
|
+
// -----------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Validate a coupon code.
|
|
582
|
+
* Optionally pass a subtotalCents to get the calculated discount amount.
|
|
583
|
+
*/
|
|
584
|
+
async validateCoupon(code: string, subtotalCents?: number, token?: string): Promise<CouponValidateResult> {
|
|
585
|
+
try {
|
|
586
|
+
const result = await this.request<Coupon>('/api/v1/coupon/validate', {
|
|
587
|
+
method: 'POST',
|
|
588
|
+
body: { code: code.toUpperCase().trim(), subtotalCents },
|
|
589
|
+
token,
|
|
590
|
+
})
|
|
591
|
+
return { valid: true, coupon: result }
|
|
592
|
+
} catch (err) {
|
|
593
|
+
const msg = err instanceof CommerceApiError ? err.message : 'Invalid coupon'
|
|
594
|
+
return { valid: false, error: msg }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Redeem a coupon for a user. Creates credit grant records.
|
|
600
|
+
*/
|
|
601
|
+
async redeemCoupon(code: string, userId: string, token?: string): Promise<CreditGrant[]> {
|
|
602
|
+
return this.request<CreditGrant[]>('/api/v1/coupon/redeem', {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
body: { code: code.toUpperCase().trim(), userId },
|
|
605
|
+
token,
|
|
606
|
+
})
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// -----------------------------------------------------------------------
|
|
610
|
+
// Referrals & Affiliates
|
|
611
|
+
// -----------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Get or create a referrer record for a user.
|
|
615
|
+
* Returns the referral code/link the user can share.
|
|
616
|
+
*/
|
|
617
|
+
async getOrCreateReferrer(userId: string, token?: string): Promise<Referrer> {
|
|
618
|
+
return this.request<Referrer>('/api/v1/referrer', {
|
|
619
|
+
method: 'POST', body: { userId }, token,
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async getReferrals(userId: string, token?: string): Promise<Referral[]> {
|
|
624
|
+
return this.request<Referral[]>(`/api/v1/user/${userId}/referrals`, { token })
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async getReferrers(userId: string, token?: string): Promise<Referrer[]> {
|
|
628
|
+
return this.request<Referrer[]>(`/api/v1/user/${userId}/referrers`, { token })
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Get affiliate details for a user.
|
|
633
|
+
*/
|
|
634
|
+
async getAffiliate(userId: string, token?: string): Promise<Affiliate | null> {
|
|
635
|
+
try {
|
|
636
|
+
return await this.request<Affiliate>(`/api/v1/user/${userId}/affiliate`, { token })
|
|
637
|
+
} catch { return null }
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Create an affiliate account for a user.
|
|
642
|
+
* After creation, user can connect their bank via the returnedconnectUrl.
|
|
643
|
+
*/
|
|
644
|
+
async createAffiliate(userId: string, token?: string): Promise<Affiliate> {
|
|
645
|
+
return this.request<Affiliate>('/api/v1/affiliate', {
|
|
646
|
+
method: 'POST', body: { userId }, token,
|
|
647
|
+
})
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async getAffiliateReferrals(affiliateId: string, token?: string): Promise<Referral[]> {
|
|
651
|
+
return this.request<Referral[]>(`/api/v1/affiliate/${affiliateId}/referrals`, { token })
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async getAffiliateOrders(affiliateId: string, token?: string): Promise<unknown[]> {
|
|
655
|
+
return this.request<unknown[]>(`/api/v1/affiliate/${affiliateId}/orders`, { token })
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async getAffiliateTransactions(affiliateId: string, token?: string): Promise<Transaction[]> {
|
|
659
|
+
return this.request<Transaction[]>(`/api/v1/affiliate/${affiliateId}/transactions`, { token })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// -----------------------------------------------------------------------
|
|
663
|
+
// Legacy checkout (order-based)
|
|
664
|
+
// -----------------------------------------------------------------------
|
|
665
|
+
|
|
666
|
+
async authorize(orderId: string, token?: string): Promise<Payment> {
|
|
667
|
+
return this.request<Payment>(`/api/v1/authorize/${orderId}`, { method: 'POST', token })
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async capture(orderId: string, token?: string): Promise<Payment> {
|
|
671
|
+
return this.request<Payment>(`/api/v1/capture/${orderId}`, { method: 'POST', token })
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async charge(orderId: string, token?: string): Promise<Payment> {
|
|
675
|
+
return this.request<Payment>(`/api/v1/charge/${orderId}`, { method: 'POST', token })
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async refund(paymentId: string, token?: string): Promise<Payment> {
|
|
679
|
+
return this.request<Payment>(`/api/v1/refund/${paymentId}`, { method: 'POST', token })
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async billingRefund(
|
|
683
|
+
params: { user: string; amount: number; originalTransactionId: string; currency?: string; notes?: string },
|
|
684
|
+
token?: string,
|
|
685
|
+
): Promise<Transaction> {
|
|
686
|
+
return this.request<Transaction>('/api/v1/billing/refund', {
|
|
687
|
+
method: 'POST', body: params, token,
|
|
688
|
+
})
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// ---------------------------------------------------------------------------
|
|
693
|
+
// Standalone factory helpers — import these for quick setup
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Create a commerce client pre-configured for api.hanzo.ai.
|
|
698
|
+
* Pass your IAM access token (read from localStorage or cookie).
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* ```ts
|
|
702
|
+
* import { hanzoCommerce } from '@hanzo/commerce/client'
|
|
703
|
+
* const commerce = hanzoCommerce(localStorage.getItem('hanzo-auth-token') ?? undefined)
|
|
704
|
+
* const plans = await commerce.getPlans()
|
|
705
|
+
* ```
|
|
706
|
+
*/
|
|
707
|
+
export function hanzoCommerce(token?: string): Commerce {
|
|
708
|
+
return new Commerce({ token })
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// ---------------------------------------------------------------------------
|
|
712
|
+
// Error
|
|
713
|
+
// ---------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
export class CommerceApiError extends Error {
|
|
716
|
+
readonly status: number
|
|
717
|
+
|
|
718
|
+
constructor(status: number, message: string) {
|
|
719
|
+
super(message)
|
|
720
|
+
this.name = 'CommerceApiError'
|
|
721
|
+
this.status = status
|
|
722
|
+
}
|
|
723
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import React, {
|
|
2
|
+
import React, { useState } from 'react'
|
|
3
3
|
import { observer } from 'mobx-react-lite'
|
|
4
4
|
|
|
5
5
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
@@ -7,7 +7,6 @@ import * as z from 'zod'
|
|
|
7
7
|
import { useForm } from 'react-hook-form'
|
|
8
8
|
|
|
9
9
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@hanzo/ui/primitives'
|
|
10
|
-
import { useAuth } from '@hanzo/auth/service'
|
|
11
10
|
|
|
12
11
|
import { useCommerce } from '../../../service/context'
|
|
13
12
|
import { sendFBEvent, sendGAEvent } from '../../../util/analytics'
|
|
@@ -26,29 +25,17 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
|
|
|
26
25
|
setOrderId
|
|
27
26
|
}) => {
|
|
28
27
|
const cmmc = useCommerce()
|
|
29
|
-
const auth = useAuth() // may be null in some cases
|
|
30
28
|
|
|
31
29
|
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus>('unpaid')
|
|
32
30
|
|
|
33
|
-
if (!auth) {
|
|
34
|
-
console.log("PAYMENT STEP FORM: auth service is null! ")
|
|
35
|
-
}
|
|
36
|
-
|
|
37
31
|
const contactForm = useForm<z.infer<typeof contactFormSchema>>({
|
|
38
|
-
resolver: zodResolver(contactFormSchema),
|
|
32
|
+
resolver: zodResolver(contactFormSchema as any),
|
|
39
33
|
defaultValues: {
|
|
40
|
-
name:
|
|
41
|
-
email:
|
|
34
|
+
name: '',
|
|
35
|
+
email: '',
|
|
42
36
|
},
|
|
43
37
|
})
|
|
44
38
|
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
if (auth?.loggedIn) {
|
|
47
|
-
contactForm.setValue('name', auth!.user?.displayName ?? '')
|
|
48
|
-
contactForm.setValue('email', auth!.user?.email ?? '')
|
|
49
|
-
}
|
|
50
|
-
}, [auth?.loggedIn])
|
|
51
|
-
|
|
52
39
|
const storePaymentInfo = async (paymentInfo: any) => {
|
|
53
40
|
const {name, email} = contactForm.getValues()
|
|
54
41
|
let id: string | undefined = undefined
|
|
@@ -111,7 +98,7 @@ const PaymentStepForm: React.FC<CheckoutStepComponentProps> = observer(({
|
|
|
111
98
|
transactionStatus={transactionStatus}
|
|
112
99
|
setTransactionStatus={setTransactionStatus}
|
|
113
100
|
storePaymentInfo={storePaymentInfo}
|
|
114
|
-
contactForm={contactForm}
|
|
101
|
+
contactForm={contactForm as any}
|
|
115
102
|
/>
|
|
116
103
|
</TabsContent>
|
|
117
104
|
))}
|
|
@@ -45,7 +45,7 @@ const ShippingStepForm: React.FC<CheckoutStepComponentProps> = ({
|
|
|
45
45
|
const cmmc = useCommerce()
|
|
46
46
|
|
|
47
47
|
const shippingForm = useForm<z.infer<typeof shippingFormSchema>>({
|
|
48
|
-
resolver: zodResolver(shippingFormSchema),
|
|
48
|
+
resolver: zodResolver(shippingFormSchema as any),
|
|
49
49
|
defaultValues: {
|
|
50
50
|
addressLine1: '',
|
|
51
51
|
addressLine2: '',
|
package/index.ts
CHANGED
|
@@ -2,12 +2,39 @@ export * from './service/context'
|
|
|
2
2
|
export * from './components'
|
|
3
3
|
// Impl-dependent, so leave w impl
|
|
4
4
|
export type { StandaloneServiceOptions as ServiceOptions } from './service/impls/standalone'
|
|
5
|
-
export {
|
|
6
|
-
useSyncSkuParamWithCurrentItem,
|
|
7
|
-
getFacetValuesMutator,
|
|
5
|
+
export {
|
|
6
|
+
useSyncSkuParamWithCurrentItem,
|
|
7
|
+
getFacetValuesMutator,
|
|
8
8
|
formatCurrencyValue,
|
|
9
9
|
ProductMediaAccessor,
|
|
10
|
-
LineItemRef
|
|
10
|
+
LineItemRef
|
|
11
11
|
} from './util'
|
|
12
12
|
|
|
13
|
-
export * from './util/selection-ui-specifiers'
|
|
13
|
+
export * from './util/selection-ui-specifiers'
|
|
14
|
+
|
|
15
|
+
// Commerce API client
|
|
16
|
+
export { Commerce, CommerceApiError, hanzoCommerce } from './client'
|
|
17
|
+
export type {
|
|
18
|
+
CommerceClientConfig,
|
|
19
|
+
Balance,
|
|
20
|
+
Transaction,
|
|
21
|
+
Subscription,
|
|
22
|
+
Plan,
|
|
23
|
+
Payment,
|
|
24
|
+
UsageRecord,
|
|
25
|
+
Coupon,
|
|
26
|
+
CouponType,
|
|
27
|
+
CouponValidateResult,
|
|
28
|
+
Discount,
|
|
29
|
+
CheckoutItem,
|
|
30
|
+
CheckoutSessionRequest,
|
|
31
|
+
CheckoutSessionResponse,
|
|
32
|
+
CardTokenizeRequest,
|
|
33
|
+
CardTokenizeResult,
|
|
34
|
+
PaymentMethod,
|
|
35
|
+
PaymentMethodType,
|
|
36
|
+
Referral,
|
|
37
|
+
Referrer,
|
|
38
|
+
Affiliate,
|
|
39
|
+
CreditGrant,
|
|
40
|
+
} from './client'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hanzo/commerce",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"description": "e-commerce framework.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"registry": "https://registry.npmjs.org/",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"license": "BSD-3-Clause",
|
|
12
12
|
"repository": {
|
|
13
13
|
"type": "git",
|
|
14
|
-
"url": "git+https://github.com/hanzoai/
|
|
14
|
+
"url": "git+https://github.com/hanzoai/ui.git",
|
|
15
15
|
"directory": "pkg/commerce"
|
|
16
16
|
},
|
|
17
17
|
"keywords": [
|
|
@@ -28,17 +28,16 @@
|
|
|
28
28
|
"exports": {
|
|
29
29
|
".": "./index.ts",
|
|
30
30
|
"./types": "./types/index.ts",
|
|
31
|
+
"./client": "./client.ts",
|
|
32
|
+
"./billing": "./billing.ts",
|
|
31
33
|
"./debug": "./service/debug.ts"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
36
|
"ethers": "^6.12.0",
|
|
35
|
-
"next-usequerystate": "^1.17.1"
|
|
36
|
-
"react-square-web-payments-sdk": "^3.2.1",
|
|
37
|
-
"square": "^35.1.0"
|
|
37
|
+
"next-usequerystate": "^1.17.1"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
|
-
"@hanzo/
|
|
41
|
-
"@hanzo/ui": "workspace:*",
|
|
40
|
+
"@hanzo/ui": ">=5.0.0",
|
|
42
41
|
"@hookform/resolvers": "^3.3.4",
|
|
43
42
|
"@radix-ui/react-radio-group": "^1.1.3",
|
|
44
43
|
"lucide-react": ">=0.456.0",
|