@hanzo/commerce 7.7.1 → 7.7.2
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/package.json +38 -9
- package/billing.ts +0 -2
- package/client.ts +0 -783
- package/components/Icons.tsx +0 -35
- package/components/add-to-cart-widget.tsx +0 -195
- package/components/buy/buy-card.tsx +0 -269
- package/components/buy/carousel-buy-card.tsx +0 -243
- package/components/buy/multi-family/all-variants-carousel.tsx +0 -259
- package/components/buy/multi-family/family-carousel/index.tsx +0 -75
- package/components/buy/multi-family/family-carousel/slide.tsx +0 -82
- package/components/buy/multi-family/family-carousel/state.ts +0 -87
- package/components/buy/multi-family/index.ts +0 -2
- package/components/buy/single-family-selector.tsx +0 -91
- package/components/buy/title-and-byline.tsx +0 -26
- package/components/cart/cart-panel/cart-line-item.tsx +0 -77
- package/components/cart/cart-panel/index.tsx +0 -157
- package/components/cart/cart-panel/promo-code.tsx +0 -105
- package/components/cart/cart-panel/total-area.tsx +0 -61
- package/components/checkout/payment-step-form/card-icon-row.tsx +0 -26
- package/components/checkout/payment-step-form/card-icons/amex.tsx +0 -32
- package/components/checkout/payment-step-form/card-icons/diners-club.tsx +0 -13
- package/components/checkout/payment-step-form/card-icons/discover.tsx +0 -25
- package/components/checkout/payment-step-form/card-icons/jcb.tsx +0 -26
- package/components/checkout/payment-step-form/card-icons/mastercard.tsx +0 -27
- package/components/checkout/payment-step-form/card-icons/visa.tsx +0 -25
- package/components/checkout/payment-step-form/cc-button.tsx +0 -18
- package/components/checkout/payment-step-form/contact-form.tsx +0 -48
- package/components/checkout/payment-step-form/crypto-icons/btc.tsx +0 -11
- package/components/checkout/payment-step-form/crypto-icons/eth.tsx +0 -20
- package/components/checkout/payment-step-form/crypto-icons/usdt.tsx +0 -13
- package/components/checkout/payment-step-form/index.tsx +0 -113
- package/components/checkout/payment-step-form/methods/bank-transfer.tsx +0 -81
- package/components/checkout/payment-step-form/methods/card.tsx +0 -253
- package/components/checkout/payment-step-form/methods/crypto.tsx +0 -226
- package/components/checkout/payment-step-form/methods/index.ts +0 -23
- package/components/checkout/shipping-step-form.tsx +0 -171
- package/components/index.ts +0 -11
- package/components/item/product-card.tsx +0 -66
- package/components/item-selector/button.tsx +0 -213
- package/components/item-selector/carousel/index.tsx +0 -196
- package/components/item-selector/carousel/slider.tsx +0 -37
- package/components/item-selector/index.ts +0 -5
- package/components/item-selector/quantity-indicator.tsx +0 -50
- package/components/node-tabs/index.tsx +0 -90
- package/components/node-tabs/node-image.tsx +0 -31
- package/index.ts +0 -55
- package/metering.test.ts +0 -199
- package/metering.ts +0 -289
- package/product-card.test.tsx +0 -104
- package/service/context.tsx +0 -45
- package/service/debug.ts +0 -41
- package/service/impls/standalone/actual-line-item.ts +0 -136
- package/service/impls/standalone/get-instance.ts +0 -64
- package/service/impls/standalone/index.ts +0 -579
- package/service/impls/standalone/order/firebase.ts +0 -5
- package/service/impls/standalone/order/index.ts +0 -58
- package/service/impls/standalone/persistence.ts +0 -33
- package/service/path-utils.ts +0 -26
- package/service/sep.ts +0 -7
- package/tsconfig.json +0 -17
- package/types/README.md +0 -2
- package/types/category-node.ts +0 -50
- package/types/checkout.ts +0 -47
- package/types/commerce-config.ts +0 -13
- package/types/commerce-service.ts +0 -128
- package/types/family.ts +0 -26
- package/types/index.ts +0 -15
- package/types/item-selector.ts +0 -97
- package/types/line-item.ts +0 -29
- package/types/multi-family-selector-props.ts +0 -20
- package/types/product.ts +0 -21
- package/types/promo.ts +0 -10
- package/types/selection-ui-specifier.ts +0 -52
- package/types/string-mutator.ts +0 -14
- package/types/token-separators.ts +0 -7
- package/util/analytics.ts +0 -21
- package/util/countries.ts +0 -196
- package/util/error.ts +0 -34
- package/util/index.ts +0 -71
- package/util/item-selector-options-accessor.ts +0 -35
- package/util/line-item-ref.ts +0 -23
- package/util/multi-family-selector-options-accessor.ts +0 -15
- package/util/obs-string-mutator.ts +0 -22
- package/util/product-media-accessor.ts +0 -58
- package/util/promo-codes.ts +0 -106
- package/util/selection-ui-specifiers.ts +0 -30
- package/util/square-payment.ts +0 -50
- package/util/use-sync-sku-param-w-current-item.ts +0 -88
package/metering.test.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Contract tests for @hanzo/commerce/metering.
|
|
3
|
-
*
|
|
4
|
-
* Self-contained: mocks global fetch and asserts the exact commerce wire
|
|
5
|
-
* contract, mirroring github.com/hanzoai/go-sdk/metering's tests so the two
|
|
6
|
-
* clients gate on the identical balance source. Runnable standalone with:
|
|
7
|
-
*
|
|
8
|
-
* npx -y -p typescript@5.9.3 -p tsx tsx pkgs/commerce/metering.test.ts
|
|
9
|
-
*
|
|
10
|
-
* It uses no test-runner dependency (the package ships none); a tiny harness
|
|
11
|
-
* below prints PASS/FAIL and exits non-zero on failure.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { Metering, identityFromHeaders } from './metering'
|
|
15
|
-
|
|
16
|
-
// ---- tiny assert harness ----
|
|
17
|
-
let failures = 0
|
|
18
|
-
let count = 0
|
|
19
|
-
function test(name: string, fn: () => Promise<void> | void) {
|
|
20
|
-
count++
|
|
21
|
-
Promise.resolve()
|
|
22
|
-
.then(fn)
|
|
23
|
-
.then(() => console.log(`ok - ${name}`))
|
|
24
|
-
.catch((err) => {
|
|
25
|
-
failures++
|
|
26
|
-
console.error(`FAIL - ${name}\n ${err?.message ?? err}`)
|
|
27
|
-
})
|
|
28
|
-
}
|
|
29
|
-
function eq(actual: unknown, expected: unknown, msg = '') {
|
|
30
|
-
if (actual !== expected) throw new Error(`${msg} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
|
|
31
|
-
}
|
|
32
|
-
function ok(cond: boolean, msg = '') {
|
|
33
|
-
if (!cond) throw new Error(msg || 'expected true')
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ---- fetch mock ----
|
|
37
|
-
type Captured = { url: string; method: string; headers: Record<string, string>; body: unknown }
|
|
38
|
-
function mockFetch(status: number, json: unknown): { restore: () => void; last: () => Captured | null } {
|
|
39
|
-
const original = globalThis.fetch
|
|
40
|
-
let last: Captured | null = null
|
|
41
|
-
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
42
|
-
const hdrs: Record<string, string> = {}
|
|
43
|
-
const h = init?.headers as Record<string, string> | undefined
|
|
44
|
-
if (h) for (const [k, v] of Object.entries(h)) hdrs[k.toLowerCase()] = v as string
|
|
45
|
-
last = {
|
|
46
|
-
url: input.toString(),
|
|
47
|
-
method: init?.method ?? 'GET',
|
|
48
|
-
headers: hdrs,
|
|
49
|
-
body: init?.body ? JSON.parse(init.body as string) : undefined,
|
|
50
|
-
}
|
|
51
|
-
return {
|
|
52
|
-
ok: status >= 200 && status < 300,
|
|
53
|
-
status,
|
|
54
|
-
statusText: `HTTP ${status}`,
|
|
55
|
-
json: async () => json,
|
|
56
|
-
text: async () => JSON.stringify(json),
|
|
57
|
-
} as Response
|
|
58
|
-
}) as typeof fetch
|
|
59
|
-
return { restore: () => { globalThis.fetch = original }, last: () => last }
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const BASE = 'http://commerce.test'
|
|
63
|
-
|
|
64
|
-
test('authorize allows when available > 0 and hits the canonical contract', async () => {
|
|
65
|
-
const fx = mockFetch(200, { user: 'hanzo/alice', currency: 'usd', balance: 5000, holds: 0, available: 5000 })
|
|
66
|
-
const meter = new Metering({ baseUrl: BASE, token: 'svc-token', org: 'hanzo' })
|
|
67
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
68
|
-
fx.restore()
|
|
69
|
-
|
|
70
|
-
ok(d.allowed, 'should allow')
|
|
71
|
-
eq(d.available, 5000, 'available')
|
|
72
|
-
const c = fx.last()!
|
|
73
|
-
eq(c.method, 'GET', 'method')
|
|
74
|
-
ok(c.url.startsWith(`${BASE}/v1/billing/balance?`), `url=${c.url}`)
|
|
75
|
-
ok(c.url.includes('user=hanzo%2Falice'), `url must url-encode user: ${c.url}`)
|
|
76
|
-
ok(c.url.includes('currency=usd'), `url must carry currency: ${c.url}`)
|
|
77
|
-
eq(c.headers['authorization'], 'Bearer svc-token', 'bearer')
|
|
78
|
-
eq(c.headers['x-iam-org-id'], 'hanzo', 'org header')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
test('authorize denies (insufficient) when available == 0', async () => {
|
|
82
|
-
const fx = mockFetch(200, { available: 0 })
|
|
83
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo' })
|
|
84
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
85
|
-
fx.restore()
|
|
86
|
-
ok(!d.allowed, 'should deny')
|
|
87
|
-
eq(d.reason, 'insufficient_balance', 'reason')
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
test('authorize fail-closed on commerce error (503/unavailable)', async () => {
|
|
91
|
-
const fx = mockFetch(500, { error: 'boom' })
|
|
92
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo' })
|
|
93
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
94
|
-
fx.restore()
|
|
95
|
-
ok(!d.allowed, 'fail-closed must deny on 500')
|
|
96
|
-
eq(d.reason, 'balance_unavailable', 'reason')
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
test('authorize fail-open allows on commerce error when configured', async () => {
|
|
100
|
-
const fx = mockFetch(503, { error: 'down' })
|
|
101
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo', failOpen: true })
|
|
102
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
103
|
-
fx.restore()
|
|
104
|
-
ok(d.allowed, 'fail-open must allow')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
test('authorize not-configured allows', async () => {
|
|
108
|
-
const meter = new Metering({}) // no baseUrl
|
|
109
|
-
ok(!meter.enabled, 'enabled should be false')
|
|
110
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
111
|
-
ok(d.allowed, 'not-configured must allow')
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
test('tier-aware authorize uses effectiveAvailable and hits /v1/billing/tier', async () => {
|
|
115
|
-
const fx = mockFetch(200, { user: 'hanzo/alice', tier: { name: 'free' }, balance: { currency: 'usd', prepaidAvailable: 0, dailyRemaining: 100, effectiveAvailable: 100 } })
|
|
116
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo', tierAware: true })
|
|
117
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
118
|
-
fx.restore()
|
|
119
|
-
ok(d.allowed, 'included allotment should allow')
|
|
120
|
-
eq(d.available, 100, 'effective available')
|
|
121
|
-
ok(fx.last()!.url.startsWith(`${BASE}/v1/billing/tier?`), `tier url=${fx.last()!.url}`)
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
test('tier-aware authorize denies when effectiveAvailable == 0', async () => {
|
|
125
|
-
const fx = mockFetch(200, { balance: { effectiveAvailable: 0, prepaidAvailable: 0, dailyRemaining: 0, currency: 'usd' } })
|
|
126
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo', tierAware: true })
|
|
127
|
-
const d = await meter.authorize({ user: 'hanzo/alice' })
|
|
128
|
-
fx.restore()
|
|
129
|
-
ok(!d.allowed && d.reason === 'insufficient_balance', 'exhausted must deny insufficient')
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
test('per-call org overrides default', async () => {
|
|
133
|
-
const fx = mockFetch(200, { available: 1 })
|
|
134
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo' })
|
|
135
|
-
await meter.authorize({ user: 'zoo/bob', org: 'zoo' })
|
|
136
|
-
fx.restore()
|
|
137
|
-
eq(fx.last()!.headers['x-iam-org-id'], 'zoo', 'per-call org override')
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test('record posts canonical payload', async () => {
|
|
141
|
-
const fx = mockFetch(201, { transactionId: 'tx_123', user: 'hanzo/alice', amount: 250, currency: 'usd', type: 'withdraw' })
|
|
142
|
-
const meter = new Metering({ baseUrl: BASE, token: 'svc-token', org: 'hanzo' })
|
|
143
|
-
const res = await meter.record({ user: 'hanzo/alice', amount: 250, provider: 'search', requestId: 'req-9', status: 'success' })
|
|
144
|
-
fx.restore()
|
|
145
|
-
|
|
146
|
-
ok(res !== null && res.transactionId === 'tx_123' && res.amount === 250, 'record result')
|
|
147
|
-
const c = fx.last()!
|
|
148
|
-
eq(c.method, 'POST', 'method')
|
|
149
|
-
eq(c.url, `${BASE}/v1/billing/usage`, 'usage url')
|
|
150
|
-
eq(c.headers['authorization'], 'Bearer svc-token', 'bearer')
|
|
151
|
-
eq(c.headers['x-iam-org-id'], 'hanzo', 'org header')
|
|
152
|
-
const b = c.body as Record<string, unknown>
|
|
153
|
-
eq(b.user, 'hanzo/alice', 'body.user')
|
|
154
|
-
eq(b.amount, 250, 'body.amount')
|
|
155
|
-
eq(b.currency, 'usd', 'body.currency defaulted')
|
|
156
|
-
eq(b.provider, 'search', 'body.provider')
|
|
157
|
-
eq(b.requestId, 'req-9', 'body.requestId')
|
|
158
|
-
ok(!('org' in b), 'org must not leak into the body')
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
test('record zero-amount is a no-op (no fetch)', async () => {
|
|
162
|
-
const fx = mockFetch(200, {})
|
|
163
|
-
const meter = new Metering({ baseUrl: BASE, token: 't', org: 'hanzo' })
|
|
164
|
-
const res = await meter.record({ user: 'hanzo/alice', amount: 0 })
|
|
165
|
-
fx.restore()
|
|
166
|
-
ok(res === null, 'zero-amount returns null')
|
|
167
|
-
ok(fx.last() === null, 'zero-amount must not call commerce')
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
test('record not-configured is a no-op', async () => {
|
|
171
|
-
const meter = new Metering({})
|
|
172
|
-
const res = await meter.record({ user: 'hanzo/alice', amount: 100 })
|
|
173
|
-
ok(res === null, 'not-configured returns null')
|
|
174
|
-
})
|
|
175
|
-
|
|
176
|
-
test('fromEnv applies defaults and reads KMS-sourced token', () => {
|
|
177
|
-
const meter = Metering.fromEnv({ COMMERCE_SERVICE_TOKEN: 'kms-token', METERING_TIER_AWARE: 'true' })
|
|
178
|
-
ok(meter.enabled, 'fromEnv default base URL enables the meter')
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
test('fromEnv honors METERING_DISABLED', () => {
|
|
182
|
-
const meter = Metering.fromEnv({ METERING_DISABLED: 'true', COMMERCE_URL: BASE })
|
|
183
|
-
ok(!meter.enabled, 'disabled must not be enabled')
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test('identityFromHeaders builds org/sub from gateway headers', () => {
|
|
187
|
-
const id = identityFromHeaders({ 'x-org-id': 'zoo', 'x-user-id': 'bob' })
|
|
188
|
-
eq(id.user, 'zoo/bob', 'user')
|
|
189
|
-
eq(id.org, 'zoo', 'org')
|
|
190
|
-
})
|
|
191
|
-
|
|
192
|
-
// summary
|
|
193
|
-
setTimeout(() => {
|
|
194
|
-
if (failures > 0) {
|
|
195
|
-
console.error(`\n${failures}/${count} tests FAILED`)
|
|
196
|
-
process.exit(1)
|
|
197
|
-
}
|
|
198
|
-
console.log(`\nall ${count} tests passed`)
|
|
199
|
-
}, 200)
|
package/metering.ts
DELETED
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @hanzo/commerce/metering
|
|
3
|
-
*
|
|
4
|
-
* The ONE way every Hanzo product meters usage to commerce — the single
|
|
5
|
-
* billing source of truth — so that everything (not only the LLM/cloud path)
|
|
6
|
-
* can be paid for. This is the TypeScript counterpart of
|
|
7
|
-
* github.com/hanzoai/go-sdk/metering; the two share an identical wire contract.
|
|
8
|
-
*
|
|
9
|
-
* It provides two operations against commerce's canonical billing API:
|
|
10
|
-
*
|
|
11
|
-
* - authorize(): a pre-request balance gate. Fail-closed by default — if the
|
|
12
|
-
* balance cannot be determined the request is denied, exactly like the
|
|
13
|
-
* gateway's prepaid-balance gate. With tierAware it consults the effective
|
|
14
|
-
* balance (prepaid + included plan allotment, e.g. the free-tier daily
|
|
15
|
-
* credit) so included usage is honored before prepaid funds.
|
|
16
|
-
*
|
|
17
|
-
* - record(): a post-request usage write that debits the user's balance.
|
|
18
|
-
*
|
|
19
|
-
* Composition over duplication: this wraps the existing {@link Commerce}
|
|
20
|
-
* client (GET /v1/billing/balance, GET /v1/billing/tier, POST /v1/billing/usage)
|
|
21
|
-
* rather than re-implementing the HTTP layer.
|
|
22
|
-
*
|
|
23
|
-
* Auth is the commerce service token (admin-scoped S2S), sent as
|
|
24
|
-
* `Authorization: Bearer <token>` plus the tenant org as `X-IAM-Org-Id`. The
|
|
25
|
-
* token is a secret and MUST come from KMS (the operator wires it into
|
|
26
|
-
* COMMERCE_SERVICE_TOKEN); this module never reads it from disk.
|
|
27
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* ```ts
|
|
30
|
-
* import { Metering } from '@hanzo/commerce/metering'
|
|
31
|
-
*
|
|
32
|
-
* const meter = Metering.fromEnv() // COMMERCE_URL + COMMERCE_SERVICE_TOKEN (KMS) + COMMERCE_SERVICE_ORG
|
|
33
|
-
*
|
|
34
|
-
* // Pre-request gate (fail-closed).
|
|
35
|
-
* const gate = await meter.authorize({ user: 'hanzo/alice' })
|
|
36
|
-
* if (!gate.allowed) {
|
|
37
|
-
* res.status(gate.reason === 'insufficient_balance' ? 402 : 503).end()
|
|
38
|
-
* return
|
|
39
|
-
* }
|
|
40
|
-
*
|
|
41
|
-
* // ... do the work, compute cost ...
|
|
42
|
-
*
|
|
43
|
-
* // Post-request record (best-effort).
|
|
44
|
-
* await meter.record({ user: 'hanzo/alice', amount: 5, provider: 'search' })
|
|
45
|
-
* ```
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
import { Commerce, CommerceApiError } from './client'
|
|
49
|
-
|
|
50
|
-
/** Canonical in-cluster commerce address (matches the gateway default). */
|
|
51
|
-
export const DEFAULT_COMMERCE_URL = 'http://commerce.hanzo.svc.cluster.local:8001'
|
|
52
|
-
|
|
53
|
-
export type MeteringConfig = {
|
|
54
|
-
/**
|
|
55
|
-
* Commerce base URL (no /v1 suffix). When empty the meter is in
|
|
56
|
-
* "not configured" mode: authorize allows and record is a no-op.
|
|
57
|
-
*/
|
|
58
|
-
baseUrl?: string
|
|
59
|
-
/** Commerce service token (admin-scoped S2S). MUST be KMS-sourced. */
|
|
60
|
-
token?: string
|
|
61
|
-
/** Default tenant org slug sent as X-IAM-Org-Id (default "hanzo"). */
|
|
62
|
-
org?: string
|
|
63
|
-
/**
|
|
64
|
-
* Gate on the tier-aware effective balance (prepaid + included plan
|
|
65
|
-
* allotment) instead of the bare prepaid balance. Default false.
|
|
66
|
-
*/
|
|
67
|
-
tierAware?: boolean
|
|
68
|
-
/**
|
|
69
|
-
* Allow-on-error. Default false (fail-closed, like the gateway). Set true
|
|
70
|
-
* only where availability outranks billing.
|
|
71
|
-
*/
|
|
72
|
-
failOpen?: boolean
|
|
73
|
-
/** Request timeout in ms. Default 5000 (the gateway's value). */
|
|
74
|
-
timeoutMs?: number
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Who to authorize / charge. user is the IAM "org/sub" identity. */
|
|
78
|
-
export type MeteringIdentity = {
|
|
79
|
-
user: string
|
|
80
|
-
org?: string
|
|
81
|
-
currency?: string
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** One usage event to record. Mirrors commerce's usageRequest one-for-one. */
|
|
85
|
-
export type UsageEvent = {
|
|
86
|
-
user: string
|
|
87
|
-
org?: string
|
|
88
|
-
/** Cost to debit, in cents. */
|
|
89
|
-
amount: number
|
|
90
|
-
currency?: string
|
|
91
|
-
model?: string
|
|
92
|
-
provider?: string
|
|
93
|
-
promptTokens?: number
|
|
94
|
-
completionTokens?: number
|
|
95
|
-
totalTokens?: number
|
|
96
|
-
requestId?: string
|
|
97
|
-
premium?: boolean
|
|
98
|
-
stream?: boolean
|
|
99
|
-
status?: string
|
|
100
|
-
clientIp?: string
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Result of a usage write. */
|
|
104
|
-
export type RecordResult = {
|
|
105
|
-
transactionId: string
|
|
106
|
-
user: string
|
|
107
|
-
amount: number
|
|
108
|
-
currency: string
|
|
109
|
-
type: string
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Outcome of an authorize() gate. */
|
|
113
|
-
export type AuthDecision = {
|
|
114
|
-
allowed: boolean
|
|
115
|
-
/** 'insufficient_balance' -> 402 ; 'balance_unavailable' -> 503 (fail-closed). */
|
|
116
|
-
reason?: 'insufficient_balance' | 'balance_unavailable'
|
|
117
|
-
/** Spendable balance in cents that the decision was based on (when known). */
|
|
118
|
-
available?: number
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Metering is the server-side hook a product uses to charge for usage. It is
|
|
123
|
-
* safe to share a single instance across requests.
|
|
124
|
-
*/
|
|
125
|
-
export class Metering {
|
|
126
|
-
private readonly commerce: Commerce | null
|
|
127
|
-
private readonly org: string
|
|
128
|
-
private readonly tierAware: boolean
|
|
129
|
-
private readonly failOpen: boolean
|
|
130
|
-
|
|
131
|
-
constructor(config: MeteringConfig = {}) {
|
|
132
|
-
const baseUrl = (config.baseUrl ?? '').replace(/\/+$/, '')
|
|
133
|
-
this.commerce = baseUrl
|
|
134
|
-
? new Commerce({ baseUrl, token: config.token, timeoutMs: config.timeoutMs ?? 5000 })
|
|
135
|
-
: null
|
|
136
|
-
this.org = (config.org ?? '').trim()
|
|
137
|
-
this.tierAware = config.tierAware ?? false
|
|
138
|
-
this.failOpen = config.failOpen ?? false
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Build a Metering instance from the canonical environment variables. This
|
|
143
|
-
* is the one-liner products use at startup. Honors METERING_DISABLED for
|
|
144
|
-
* local dev.
|
|
145
|
-
*/
|
|
146
|
-
static fromEnv(env: Record<string, string | undefined> = readEnv()): Metering {
|
|
147
|
-
if (truthy(env.METERING_DISABLED)) return new Metering({})
|
|
148
|
-
return new Metering({
|
|
149
|
-
baseUrl: (env.COMMERCE_URL ?? '').trim() || DEFAULT_COMMERCE_URL,
|
|
150
|
-
token: env.COMMERCE_SERVICE_TOKEN,
|
|
151
|
-
org: (env.COMMERCE_SERVICE_ORG ?? '').trim() || 'hanzo',
|
|
152
|
-
tierAware: truthy(env.METERING_TIER_AWARE),
|
|
153
|
-
failOpen: truthy(env.METERING_FAIL_OPEN),
|
|
154
|
-
})
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Whether a commerce base URL is configured. */
|
|
158
|
-
get enabled(): boolean {
|
|
159
|
-
return this.commerce !== null
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Pre-request balance gate. Never throws — returns a decision so callers map
|
|
164
|
-
* it to a status code. When not configured it always allows.
|
|
165
|
-
*
|
|
166
|
-
* Outcomes (matching the gateway):
|
|
167
|
-
* { allowed: true } -> allow.
|
|
168
|
-
* { allowed: false, reason: 'insufficient_balance' } -> 402 (out of funds).
|
|
169
|
-
* { allowed: false, reason: 'balance_unavailable' } -> 503 (fail-closed).
|
|
170
|
-
*/
|
|
171
|
-
async authorize(id: MeteringIdentity): Promise<AuthDecision> {
|
|
172
|
-
if (!this.commerce) return { allowed: true }
|
|
173
|
-
|
|
174
|
-
const user = (id.user ?? '').trim()
|
|
175
|
-
if (!user) {
|
|
176
|
-
return this.failOpen ? { allowed: true } : { allowed: false, reason: 'balance_unavailable' }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const headers = orgHeader(id.org ?? this.org)
|
|
180
|
-
try {
|
|
181
|
-
const available = this.tierAware
|
|
182
|
-
? (await this.commerce.getTier(user, { headers })).balance.effectiveAvailable
|
|
183
|
-
: (await this.commerce.getBalance(user, id.currency ?? 'usd', undefined, headers)).available
|
|
184
|
-
|
|
185
|
-
if (available > 0) return { allowed: true, available }
|
|
186
|
-
return { allowed: false, reason: 'insufficient_balance', available }
|
|
187
|
-
} catch {
|
|
188
|
-
// Balance unknown (commerce unreachable / non-2xx).
|
|
189
|
-
if (this.failOpen) return { allowed: true }
|
|
190
|
-
return { allowed: false, reason: 'balance_unavailable' }
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Record a usage event, debiting the user's balance. No-op when not
|
|
196
|
-
* configured or amount <= 0 (commerce treats zero-cost usage as skipped).
|
|
197
|
-
* Decoupled from gating: the work already happened, so balance is not
|
|
198
|
-
* re-checked here. Returns null on no-op.
|
|
199
|
-
*/
|
|
200
|
-
async record(usage: UsageEvent): Promise<RecordResult | null> {
|
|
201
|
-
if (!this.commerce || usage.amount <= 0) return null
|
|
202
|
-
if (!usage.user?.trim()) throw new Error('metering: record requires a user')
|
|
203
|
-
|
|
204
|
-
const headers = orgHeader(usage.org ?? this.org)
|
|
205
|
-
// Commerce's usageRequest field names map one-for-one; org travels via the
|
|
206
|
-
// header, never the body.
|
|
207
|
-
const body = {
|
|
208
|
-
user: usage.user,
|
|
209
|
-
currency: usage.currency ?? 'usd',
|
|
210
|
-
amount: usage.amount,
|
|
211
|
-
model: usage.model,
|
|
212
|
-
provider: usage.provider,
|
|
213
|
-
promptTokens: usage.promptTokens,
|
|
214
|
-
completionTokens: usage.completionTokens,
|
|
215
|
-
totalTokens: usage.totalTokens,
|
|
216
|
-
requestId: usage.requestId,
|
|
217
|
-
premium: usage.premium,
|
|
218
|
-
stream: usage.stream,
|
|
219
|
-
status: usage.status,
|
|
220
|
-
clientIp: usage.clientIp,
|
|
221
|
-
}
|
|
222
|
-
const tx = await this.commerce.addUsageRecord(body as never, undefined, headers)
|
|
223
|
-
// Commerce returns { transactionId, user, amount, currency, type }.
|
|
224
|
-
return tx as unknown as RecordResult
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Convenience for products that prefer to throw on denial. Throws a
|
|
229
|
-
* {@link CommerceApiError} with status 402 (insufficient) or 503 (unknown).
|
|
230
|
-
*/
|
|
231
|
-
async authorizeOrThrow(id: MeteringIdentity): Promise<void> {
|
|
232
|
-
const decision = await this.authorize(id)
|
|
233
|
-
if (decision.allowed) return
|
|
234
|
-
if (decision.reason === 'insufficient_balance') {
|
|
235
|
-
throw new CommerceApiError(402, 'Insufficient balance. Please add credits at console.hanzo.ai')
|
|
236
|
-
}
|
|
237
|
-
throw new CommerceApiError(503, 'Billing temporarily unavailable')
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
// Identity from gateway-minted headers (the trust boundary)
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
|
|
245
|
-
/** Gateway-minted identity headers. */
|
|
246
|
-
export const HEADER_USER_ID = 'x-user-id'
|
|
247
|
-
export const HEADER_ORG_ID = 'x-org-id'
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Build a {@link MeteringIdentity} from gateway-minted identity headers. The
|
|
251
|
-
* user is "{org}/{sub}" (commerce's iam-user form) when both are present.
|
|
252
|
-
* Accepts a header bag with string | string[] | undefined values (Node http,
|
|
253
|
-
* Next.js, fetch Headers via Object.fromEntries).
|
|
254
|
-
*/
|
|
255
|
-
export function identityFromHeaders(
|
|
256
|
-
headers: Record<string, string | string[] | undefined> | Headers,
|
|
257
|
-
): MeteringIdentity {
|
|
258
|
-
const get = (k: string): string => {
|
|
259
|
-
if (typeof (headers as Headers).get === 'function') {
|
|
260
|
-
return ((headers as Headers).get(k) ?? '').trim()
|
|
261
|
-
}
|
|
262
|
-
const v = (headers as Record<string, string | string[] | undefined>)[k]
|
|
263
|
-
return (Array.isArray(v) ? v[0] : v ?? '').trim()
|
|
264
|
-
}
|
|
265
|
-
const org = get(HEADER_ORG_ID)
|
|
266
|
-
const sub = get(HEADER_USER_ID)
|
|
267
|
-
const user = org && sub ? `${org}/${sub}` : sub
|
|
268
|
-
return { user, org }
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ---------------------------------------------------------------------------
|
|
272
|
-
// internals
|
|
273
|
-
// ---------------------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
function orgHeader(org: string): Record<string, string> | undefined {
|
|
276
|
-
const o = (org ?? '').trim()
|
|
277
|
-
return o ? { 'X-IAM-Org-Id': o } : undefined
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function truthy(v: string | undefined): boolean {
|
|
281
|
-
const s = (v ?? '').trim().toLowerCase()
|
|
282
|
-
return s === 'true' || s === '1' || s === 'yes'
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
function readEnv(): Record<string, string | undefined> {
|
|
286
|
-
// Node/edge: process.env when present, else empty (browser).
|
|
287
|
-
const g = globalThis as unknown as { process?: { env?: Record<string, string | undefined> } }
|
|
288
|
-
return g.process?.env ?? {}
|
|
289
|
-
}
|
package/product-card.test.tsx
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Annotation contract test for @hanzo/commerce ProductCard + AddToCartWidget.
|
|
3
|
-
*
|
|
4
|
-
* Proves the zero-app-config promise: rendering <ProductCard item={...}/> emits
|
|
5
|
-
* NEUTRAL, STANDARD self-description — schema.org Product/Offer microdata plus a
|
|
6
|
-
* schema.org AddAction on the add control — with no analytics code in the app.
|
|
7
|
-
* A tracker (track.js `annotate`, GA4 Merchant, any reader) captures the item
|
|
8
|
-
* and the add-to-cart intent from the DOM alone.
|
|
9
|
-
*
|
|
10
|
-
* Self-contained, mirroring metering.test.ts: no test-runner dependency. Run with:
|
|
11
|
-
*
|
|
12
|
-
* npx -y tsx pkgs/commerce/product-card.test.tsx
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import React from 'react'
|
|
16
|
-
import { renderToStaticMarkup } from 'react-dom/server'
|
|
17
|
-
|
|
18
|
-
import ProductCard from './components/item/product-card'
|
|
19
|
-
|
|
20
|
-
// ---- tiny assert harness (mirrors metering.test.ts) ----
|
|
21
|
-
let failures = 0
|
|
22
|
-
let count = 0
|
|
23
|
-
function test(name: string, fn: () => void) {
|
|
24
|
-
count++
|
|
25
|
-
try {
|
|
26
|
-
fn()
|
|
27
|
-
console.log(`ok - ${name}`)
|
|
28
|
-
} catch (err) {
|
|
29
|
-
failures++
|
|
30
|
-
console.error(`FAIL - ${name}`)
|
|
31
|
-
console.error((err as Error).message)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
function ok(cond: boolean, msg: string) {
|
|
35
|
-
if (!cond) throw new Error(msg)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// A minimal LineItem-shaped mock: getters + cart methods, no media (MediaStack
|
|
39
|
-
// renders empty). Enough to drive the microdata the card carries.
|
|
40
|
-
const item: any = {
|
|
41
|
-
id: 'x1',
|
|
42
|
-
sku: 'LXB-AU-B',
|
|
43
|
-
familyId: 'LXB',
|
|
44
|
-
familyTitle: 'Bar',
|
|
45
|
-
optionLabel: '1oz',
|
|
46
|
-
price: 99.5,
|
|
47
|
-
get title() {
|
|
48
|
-
return 'Gold Bar, 1oz'
|
|
49
|
-
},
|
|
50
|
-
get quantity() {
|
|
51
|
-
return 0
|
|
52
|
-
},
|
|
53
|
-
get isInCart() {
|
|
54
|
-
return false
|
|
55
|
-
},
|
|
56
|
-
increment() {},
|
|
57
|
-
decrement() {},
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const html = renderToStaticMarkup(<ProductCard item={item} />)
|
|
61
|
-
|
|
62
|
-
// Attribute names are matched case-insensitively: HTML parsing and setAttribute
|
|
63
|
-
// both lowercase attribute names in the HTML namespace, so React's camelCase SSR
|
|
64
|
-
// serialization (itemType="…") becomes itemtype in the live DOM that annotate
|
|
65
|
-
// queries. The DOM-capture contract is proven end-to-end in track.js's tests.
|
|
66
|
-
const H = html.toLowerCase()
|
|
67
|
-
const has = (needle: string) => H.includes(needle.toLowerCase())
|
|
68
|
-
|
|
69
|
-
test('card is a schema.org Product', () => {
|
|
70
|
-
ok(has('itemtype="https://schema.org/Product"'), 'Product itemtype present')
|
|
71
|
-
ok(has('data-slot="card"'), 'shadcn data-slot preserved')
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test('product name is machine-readable', () => {
|
|
75
|
-
ok(has('itemprop="name"'), 'name itemprop present')
|
|
76
|
-
ok(has('content="Gold Bar, 1oz"'), 'name value present')
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test('product sku is machine-readable', () => {
|
|
80
|
-
ok(has('itemprop="sku"'), 'sku itemprop present')
|
|
81
|
-
ok(has('content="LXB-AU-B"'), 'sku value present')
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('price is a schema.org Offer with currency', () => {
|
|
85
|
-
ok(has('itemtype="https://schema.org/Offer"'), 'Offer itemtype present')
|
|
86
|
-
ok(has('itemprop="price"'), 'price itemprop present')
|
|
87
|
-
ok(has('content="99.5"'), 'raw price value present')
|
|
88
|
-
ok(has('itemprop="priceCurrency"'), 'priceCurrency itemprop present')
|
|
89
|
-
ok(has('content="USD"'), 'currency value present')
|
|
90
|
-
})
|
|
91
|
-
|
|
92
|
-
test('add control is a schema.org AddAction (maps to GA4 add_to_cart)', () => {
|
|
93
|
-
ok(has('itemtype="https://schema.org/AddAction"'), 'AddAction itemtype present')
|
|
94
|
-
ok(has('itemprop="potentialAction"'), 'action linked to the Product')
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
// summary
|
|
98
|
-
setTimeout(() => {
|
|
99
|
-
if (failures > 0) {
|
|
100
|
-
console.error(`\n${failures}/${count} tests FAILED`)
|
|
101
|
-
process.exit(1)
|
|
102
|
-
}
|
|
103
|
-
console.log(`\nall ${count} tests passed`)
|
|
104
|
-
}, 200)
|
package/service/context.tsx
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
import React, {
|
|
3
|
-
createContext,
|
|
4
|
-
useContext,
|
|
5
|
-
useRef,
|
|
6
|
-
type PropsWithChildren,
|
|
7
|
-
} from 'react'
|
|
8
|
-
|
|
9
|
-
// https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18
|
|
10
|
-
import { enableStaticRendering } from 'mobx-react-lite'
|
|
11
|
-
enableStaticRendering(typeof window === "undefined")
|
|
12
|
-
|
|
13
|
-
import type CommerceService from '../types/commerce-service'
|
|
14
|
-
import type { CommerceConfig } from '../types'
|
|
15
|
-
import { getInstance } from './impls/standalone'
|
|
16
|
-
|
|
17
|
-
const CommerceContext = createContext<CommerceService | undefined>(undefined)
|
|
18
|
-
|
|
19
|
-
const useCommerce = (): CommerceService => {
|
|
20
|
-
return useContext(CommerceContext) as CommerceService
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const CommerceProvider: React.FC<PropsWithChildren & {
|
|
24
|
-
config: CommerceConfig
|
|
25
|
-
}> = ({
|
|
26
|
-
children,
|
|
27
|
-
config
|
|
28
|
-
}) => {
|
|
29
|
-
|
|
30
|
-
// TODO: Inject Promo fixture here from siteDef
|
|
31
|
-
const service = getInstance(config)
|
|
32
|
-
const valueRef = useRef<CommerceService>(service)
|
|
33
|
-
|
|
34
|
-
return (
|
|
35
|
-
<CommerceContext.Provider value={valueRef.current}>
|
|
36
|
-
{children}
|
|
37
|
-
</CommerceContext.Provider>
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export {
|
|
42
|
-
useCommerce,
|
|
43
|
-
CommerceProvider
|
|
44
|
-
}
|
|
45
|
-
|
package/service/debug.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import type { CategoryNode, CategoryNodeRole, Family, LineItem } from '../types'
|
|
2
|
-
|
|
3
|
-
// Node recursion
|
|
4
|
-
export const categoryNodeDump = (node: CategoryNode, level: number = 0): void => {
|
|
5
|
-
const spacers: string[] = []
|
|
6
|
-
for (let i = 0; i < level; i++) {
|
|
7
|
-
spacers.push('----')
|
|
8
|
-
}
|
|
9
|
-
console.log("NODE:" + spacers.join(''), node.skuToken)
|
|
10
|
-
node.subNodes?.forEach((sn: CategoryNode) => {categoryNodeDump(sn, level + 1)})
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export const selectedFamiliesDump = (result: Family[]): string => {
|
|
14
|
-
const toDisplay = result.map((c) => (c.id))
|
|
15
|
-
return JSON.stringify(toDisplay, null, 2)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const peekDump = (result : {
|
|
19
|
-
role: CategoryNodeRole
|
|
20
|
-
family: Family | undefined
|
|
21
|
-
families: Family[] | undefined
|
|
22
|
-
node: CategoryNode | undefined
|
|
23
|
-
item: LineItem | undefined
|
|
24
|
-
} | string): string => {
|
|
25
|
-
|
|
26
|
-
if (typeof result === 'string') {
|
|
27
|
-
return result
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const toDisplay = {
|
|
31
|
-
role: result.role,
|
|
32
|
-
item: result.item ? result.item.sku : 'UNDEF',
|
|
33
|
-
family: result.family ? result.family.id : 'UNDEF',
|
|
34
|
-
families: result.families ? result.families.map((f) => (f.id)) : 'UNDEF',
|
|
35
|
-
node: result.node ?
|
|
36
|
-
(result.node.skuToken + (result.node.label ? (': ' + result.node.label) : ''))
|
|
37
|
-
:
|
|
38
|
-
'UNDEF'
|
|
39
|
-
}
|
|
40
|
-
return JSON.stringify(toDisplay, null, 2)
|
|
41
|
-
}
|