@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.
Files changed (88) hide show
  1. package/package.json +38 -9
  2. package/billing.ts +0 -2
  3. package/client.ts +0 -783
  4. package/components/Icons.tsx +0 -35
  5. package/components/add-to-cart-widget.tsx +0 -195
  6. package/components/buy/buy-card.tsx +0 -269
  7. package/components/buy/carousel-buy-card.tsx +0 -243
  8. package/components/buy/multi-family/all-variants-carousel.tsx +0 -259
  9. package/components/buy/multi-family/family-carousel/index.tsx +0 -75
  10. package/components/buy/multi-family/family-carousel/slide.tsx +0 -82
  11. package/components/buy/multi-family/family-carousel/state.ts +0 -87
  12. package/components/buy/multi-family/index.ts +0 -2
  13. package/components/buy/single-family-selector.tsx +0 -91
  14. package/components/buy/title-and-byline.tsx +0 -26
  15. package/components/cart/cart-panel/cart-line-item.tsx +0 -77
  16. package/components/cart/cart-panel/index.tsx +0 -157
  17. package/components/cart/cart-panel/promo-code.tsx +0 -105
  18. package/components/cart/cart-panel/total-area.tsx +0 -61
  19. package/components/checkout/payment-step-form/card-icon-row.tsx +0 -26
  20. package/components/checkout/payment-step-form/card-icons/amex.tsx +0 -32
  21. package/components/checkout/payment-step-form/card-icons/diners-club.tsx +0 -13
  22. package/components/checkout/payment-step-form/card-icons/discover.tsx +0 -25
  23. package/components/checkout/payment-step-form/card-icons/jcb.tsx +0 -26
  24. package/components/checkout/payment-step-form/card-icons/mastercard.tsx +0 -27
  25. package/components/checkout/payment-step-form/card-icons/visa.tsx +0 -25
  26. package/components/checkout/payment-step-form/cc-button.tsx +0 -18
  27. package/components/checkout/payment-step-form/contact-form.tsx +0 -48
  28. package/components/checkout/payment-step-form/crypto-icons/btc.tsx +0 -11
  29. package/components/checkout/payment-step-form/crypto-icons/eth.tsx +0 -20
  30. package/components/checkout/payment-step-form/crypto-icons/usdt.tsx +0 -13
  31. package/components/checkout/payment-step-form/index.tsx +0 -113
  32. package/components/checkout/payment-step-form/methods/bank-transfer.tsx +0 -81
  33. package/components/checkout/payment-step-form/methods/card.tsx +0 -253
  34. package/components/checkout/payment-step-form/methods/crypto.tsx +0 -226
  35. package/components/checkout/payment-step-form/methods/index.ts +0 -23
  36. package/components/checkout/shipping-step-form.tsx +0 -171
  37. package/components/index.ts +0 -11
  38. package/components/item/product-card.tsx +0 -66
  39. package/components/item-selector/button.tsx +0 -213
  40. package/components/item-selector/carousel/index.tsx +0 -196
  41. package/components/item-selector/carousel/slider.tsx +0 -37
  42. package/components/item-selector/index.ts +0 -5
  43. package/components/item-selector/quantity-indicator.tsx +0 -50
  44. package/components/node-tabs/index.tsx +0 -90
  45. package/components/node-tabs/node-image.tsx +0 -31
  46. package/index.ts +0 -55
  47. package/metering.test.ts +0 -199
  48. package/metering.ts +0 -289
  49. package/product-card.test.tsx +0 -104
  50. package/service/context.tsx +0 -45
  51. package/service/debug.ts +0 -41
  52. package/service/impls/standalone/actual-line-item.ts +0 -136
  53. package/service/impls/standalone/get-instance.ts +0 -64
  54. package/service/impls/standalone/index.ts +0 -579
  55. package/service/impls/standalone/order/firebase.ts +0 -5
  56. package/service/impls/standalone/order/index.ts +0 -58
  57. package/service/impls/standalone/persistence.ts +0 -33
  58. package/service/path-utils.ts +0 -26
  59. package/service/sep.ts +0 -7
  60. package/tsconfig.json +0 -17
  61. package/types/README.md +0 -2
  62. package/types/category-node.ts +0 -50
  63. package/types/checkout.ts +0 -47
  64. package/types/commerce-config.ts +0 -13
  65. package/types/commerce-service.ts +0 -128
  66. package/types/family.ts +0 -26
  67. package/types/index.ts +0 -15
  68. package/types/item-selector.ts +0 -97
  69. package/types/line-item.ts +0 -29
  70. package/types/multi-family-selector-props.ts +0 -20
  71. package/types/product.ts +0 -21
  72. package/types/promo.ts +0 -10
  73. package/types/selection-ui-specifier.ts +0 -52
  74. package/types/string-mutator.ts +0 -14
  75. package/types/token-separators.ts +0 -7
  76. package/util/analytics.ts +0 -21
  77. package/util/countries.ts +0 -196
  78. package/util/error.ts +0 -34
  79. package/util/index.ts +0 -71
  80. package/util/item-selector-options-accessor.ts +0 -35
  81. package/util/line-item-ref.ts +0 -23
  82. package/util/multi-family-selector-options-accessor.ts +0 -15
  83. package/util/obs-string-mutator.ts +0 -22
  84. package/util/product-media-accessor.ts +0 -58
  85. package/util/promo-codes.ts +0 -106
  86. package/util/selection-ui-specifiers.ts +0 -30
  87. package/util/square-payment.ts +0 -50
  88. 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
- }
@@ -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)
@@ -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
- }