brainerce 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,35 +49,35 @@ yarn add brainerce
49
49
 
50
50
  Every Brainerce storefront must include **all mandatory features** below. Features auto-hide when the underlying capability is disabled, so build them all anyway; they'll appear the moment the store owner enables them.
51
51
 
52
- | Feature | SDK entry point | Mandatory |
53
- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
54
- | Product list with search, filter, pagination | `client.getProducts()`, `client.getSearchSuggestions(query)` | ✅ |
55
- | Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
56
- | Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
57
- | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
- | Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
59
- | Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
60
- | Gift card redemption at checkout | `client.applyGiftCard(checkoutId, code)`, `client.removeGiftCard(checkoutId, tenderId)`, `client.checkGiftCardBalance(code)` | conditional |
61
- | Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
62
- | Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
63
- | Login + verification branch | `client.loginCustomer()` | ✅ |
64
- | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
65
- | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
66
- | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
67
- | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
68
- | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
69
- | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
70
- | Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
71
- | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
72
- | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
73
- | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
74
- | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | ✅ |
75
- | FAQ page | `client.content.faq.get('main', locale)` | conditional |
76
- | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
77
- | Multi-language + RTL (when i18n enabled) | `client.setLocale()`, `client.getStoreDirection(locale)` | conditional |
78
- | Donation page (only when `getStoreInfo().donationsEnabled`) | `client.createDonation(input)`, `client.getDonation(id)` | conditional |
79
-
80
- **⛔ The donation page is the one row that does NOT auto-hide.** Every other conditional feature above renders nothing until the merchant configures it, which is why you build them all anyway. `createDonation` is *rejected* while donations are closed, so a donation page built for a store that has not opened them collects a donor's name, email and card details and then fails on submit. Gate that one on `getStoreInfo().donationsEnabled` and build nothing when it is false.
52
+ | Feature | SDK entry point | Mandatory |
53
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
54
+ | Product list with search, filter, pagination | `client.getProducts()`, `client.getSearchSuggestions(query)` | ✅ |
55
+ | Product detail with variant picker, stock, price | `client.getProductBySlug()` + helpers | ✅ |
56
+ | Buyer customization fields (engraving, uploads, select) | `product.customizationFields`, `client.uploadCustomizationFile()` | ✅ |
57
+ | Cart (add, update, remove, coupon, totals) | `client.addToCart()`, `getCartTotals(cart)` | ✅ |
58
+ | Inventory reservation countdown | Cart expiry timestamp from `client.getCart(cartId)` | ✅ |
59
+ | Full checkout end-to-end with payment | `setShippingAddress → selectShippingMethod → getPaymentProviders → pay → handlePaymentSuccess → waitForOrder` | ✅ |
60
+ | Gift card redemption at checkout | `client.applyGiftCard(checkoutId, code)`, `client.removeGiftCard(checkoutId, tenderId)`, `client.checkGiftCardBalance(code)` | conditional |
61
+ | Order confirmation (clear cart + wait for real order) | `client.handlePaymentSuccess()`, `client.waitForOrder()` | ✅ |
62
+ | Register + email verification flow | `client.registerCustomer()`, `client.verifyEmail()` | ✅ |
63
+ | Login + verification branch | `client.loginCustomer()` | ✅ |
64
+ | Forgot / reset password | `client.forgotPassword()`, `client.resetPassword()` | ✅ |
65
+ | OAuth sign-in buttons + callback handler | `client.getAvailableOAuthProviders()` | ✅ |
66
+ | Account area (profile + order history) | `client.getMyProfile()`, `client.updateMyProfile()`, `client.getMyOrders()` | ✅ |
67
+ | Loyalty & rewards (points balance + tiers + redeem) | `client.getLoyaltyStatus()`, `client.getAvailableRewards()`, `client.getRecommendedReward()`, `client.redeemLoyaltyReward(id)`, `client.reportSocialShare()` | conditional |
68
+ | Loyalty paid membership (premium subscription) | `client.getMembershipPlans()`, `client.getMySavedPaymentMethods()`, `client.subscribeToMembership(params)`, `client.cancelMembership()` | conditional |
69
+ | Embeddable loyalty widget (points + rewards on ANY site) | `client.getLoyaltyWidgetSession()` | conditional |
70
+ | Global header: cart count + search autocomplete | `client.smartGetCart()`, `client.getSearchSuggestions(query)` | ✅ |
71
+ | Discount banners + product badges | `client.getDiscountBanners()`, `client.getProductDiscountBadge(productId)` | ✅ |
72
+ | Product reviews on PDP + JSON-LD aggregateRating | `client.listProductReviews(id)`, `client.submitProductReview(id, …)` | ✅ |
73
+ | Customer photos on reviews | `client.uploadReviewPhoto(productId, file)`, then `imageKeys` on submit | conditional |
74
+ | Site chrome (header + footer + announcement bar) | `client.content.header.get()`, `client.content.footer.get()`, `client.content.announcement.list()` | ✅ |
75
+ | FAQ page | `client.content.faq.get('main', locale)` | conditional |
76
+ | Static pages catch-all (`/pages/[slug]`) | `client.content.page.getBySlug(slug, locale)` | conditional |
77
+ | Multi-language + RTL (when i18n enabled) | `client.setLocale()`, `client.getStoreDirection(locale)` | conditional |
78
+ | Donation page (only when `getStoreInfo().donationsEnabled`) | `client.createDonation(input)`, `client.getDonation(id)` | conditional |
79
+
80
+ **⛔ The donation page is the one row that does NOT auto-hide.** Every other conditional feature above renders nothing until the merchant configures it, which is why you build them all anyway. `createDonation` is _rejected_ while donations are closed, so a donation page built for a store that has not opened them collects a donor's name, email and card details and then fails on submit. Gate that one on `getStoreInfo().donationsEnabled` and build nothing when it is false.
81
81
 
82
82
  ---
83
83
 
@@ -127,6 +127,15 @@ Violating any of these causes production incidents or broken orders. Read them b
127
127
  - When `providerAmountDue` is `'0.00'` the cards cover the whole order. There is nothing for a provider to charge: skip the payment step and call `completeCheckout(checkoutId)` — it is allowed in exactly this case and produces a real paid order. **Then still clear the cart**, with `handlePaymentSuccess(checkoutId)`, exactly as you would after a payment. `completeCheckout` returns `{ orderId }`, so there is no `waitForOrder` poll to do, but skipping the cart clear leaves the shopper looking at items they have just bought.
128
128
  - A card pays only in **its own currency**. There is no conversion, so a USD card is refused on an ILS checkout like any other unusable code.
129
129
 
130
+ **Administering cards (admin `apiKey` only — see [Gift Cards (administration)](#gift-cards-administration)):**
131
+
132
+ - ⛔ **`issueGiftCard` and `reissueGiftCard` return `plaintextCode` exactly once.** The platform stores only an HMAC of it. No later call, no dashboard screen and no database query can produce it again, so an integrator that logs the response and moves on has destroyed a card that a customer paid for. Persist it or deliver it in the same code path that made the call. The one recovery that exists is retrying the SAME `Idempotency-Key` within 24 hours, which replays the identical body; miss that window and the value is stranded on a card nobody can spend.
133
+ - ⛔ **`reissueGiftCard` is not a resend.** It mints a new code, moves the whole balance onto it, and **revokes the old card** — a printed card in a customer's hand stops working the moment the call returns. Use it when a code is lost, never to "email it again".
134
+ - **A `note` is mandatory** on `issueGiftCard`, `reissueGiftCard` and `adjustGiftCardBalance` (3-500 characters), and is written to the append-only ledger permanently. It is the row a finance review reads a year later, so write the reason, not `"api"`.
135
+ - **There is no delete.** Not one route, not in bulk, not ever — the ledger is append-only and a card can carry a statutory retention life. `setGiftCardStatus(id, 'DISABLED')` is the reversible substitute. `bulkSetGiftCardStatus` takes `ACTIVE` and `DISABLED` only; `REVOKED` is refused there because revoking in bulk would strand balances with no replacement to move them to.
136
+ - **Ask for the least scope you need.** `gift_cards:issue` mints stored value and `gift_cards:adjust` rewrites a balance; neither is implied by `gift_cards:read`. This platform grants them self-serve, where Shopify makes you ask their support for the equivalent — which puts the whole weight on asking for less. ⛔ **Never mint `gift_cards:*` for a read-only integration**: the wildcard matches the resource, not the action, so one string hands a BI sync the power to mint and to rewrite balances.
137
+ - **Liability is per currency.** `getGiftCardLiability()` returns `byCurrency`, because balances in different currencies do not add up. Read the array, never the top-level figure alone, on a store that sells in more than one.
138
+
130
139
  ### Donations
131
140
 
132
141
  - NEVER route a donation through the cart or the checkout. A donation has no line item, no quantity, no shipping and no order, and it is reported separately from sales. It has its own pair, `createDonation` / `getDonation`. The tell that you have modelled it wrong is the amount: a cart cannot let a donor type one, so a "Donation $18" product is the wrong shape — and it files every gift into the merchant's sales figures.
@@ -203,7 +212,9 @@ If the store has gift cards on, the redemption field goes between step 3 and ste
203
212
 
204
213
  ### Gift card redemption flow
205
214
 
206
- Conditional: in `salesChannelId` mode, build it when `getStoreCapabilities().features.hasGiftCards` is true. That call is channel-only and `getStoreInfo()` carries no gift-card flag, so in `storeId` and admin mode there is no switch to read — build the field anyway; a code on a store without cards is just refused. It sits **inside** the checkout, after shipping is picked and **before** the payment intent, because applying a card changes what the provider is asked for.
215
+ Conditional: in `salesChannelId` mode, build it when `getStoreCapabilities().features.hasGiftCards` is true. That call is channel-only and `getStoreInfo()` carries no gift-card flag, so in `storeId` mode there is no switch to read — build the field anyway; a code on a store without cards is just refused. (Admin mode does have one: `getGiftCardLiability().enabled`. It gates **issuing**, not redemption, so it is not a reason to hide the field either.) It sits **inside** the checkout, after shipping is picked and **before** the payment intent, because applying a card changes what the provider is asked for.
216
+
217
+ This flow is redemption only. Issuing, re-issuing, adjusting and disabling cards are admin-key operations — see [Gift Cards (administration)](#gift-cards-administration).
207
218
 
208
219
  1. Offer the field on the checkout page (optionally with a "check balance" affordance):
209
220
 
@@ -3056,7 +3067,9 @@ enforcement.
3056
3067
 
3057
3068
  #### Gift Cards (a tender, not a discount)
3058
3069
 
3059
- All three modes. **Reading the switch is channel-only**, though: `getStoreCapabilities().features.hasGiftCards` exists on a `salesChannelId` client and that call throws in the other two modes. `getStoreInfo()` carries no gift-card flag, so a `storeId` or admin integration has no capability probe — build the field unconditionally there. Nothing breaks if the store has no cards: an unusable code is simply refused.
3070
+ All three modes. **Reading the switch is channel-only**, though: `getStoreCapabilities().features.hasGiftCards` exists on a `salesChannelId` client and that call throws in the other two modes. `getStoreInfo()` carries no gift-card flag, so a `storeId` integration has no capability probe — build the field unconditionally there. An admin (`apiKey`) client can read `getGiftCardLiability().enabled`, but that switch gates **issuing**, not redemption, so it is not a reason to hide the field. Nothing breaks if the store has no cards: an unusable code is simply refused.
3071
+
3072
+ Issuing and managing cards is a separate, admin-key surface: [Gift Cards (administration)](#gift-cards-administration).
3060
3073
 
3061
3074
  ```typescript
3062
3075
  // Optional pre-check before applying. Rate limited (5/min).
@@ -5764,20 +5777,27 @@ Each store has its own team with roles (`OWNER`, `MANAGER`, `STAFF`, `VIEWER`) a
5764
5777
  granular permissions, including per-sales-channel scoping. **Managing it is a dashboard
5765
5778
  operation. There is no SDK path to it, and this is deliberate.**
5766
5779
 
5767
- This section previously showed nine store-level team calls. They do not work:
5768
- `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`, `updateStoreMemberSalesChannels`,
5769
- `removeStoreMember`, `resendStoreInvitation` and `revokeStoreInvitation` call
5770
- `/api/v1/stores/:storeId/team…`, and `getMyStores` / `getMyStorePermissions` call
5771
- `/api/v1/me/stores…`. The public API exposes neither prefix, so every one of them returns
5772
- **404**.
5780
+ This section previously showed store-level team calls. **Since SDK 2.1.1 they throw a
5781
+ `BrainerceError` that says so, instead of returning a bare 404.** Ten methods are
5782
+ affected: `getStoreTeam`, `inviteStoreMember`, `updateStoreMember`,
5783
+ `updateStoreMemberSalesChannels`, `removeStoreMember`, `resendStoreInvitation`,
5784
+ `revokeStoreInvitation`, `acceptStoreInvitation`, `getMyStores` and
5785
+ `getMyStorePermissions`.
5773
5786
 
5774
5787
  **This is not a path typo waiting on a fix.** The real endpoints exist at
5775
5788
  `/api/stores/:storeId/team…`, guarded by `DashboardOnlyGuard`, which rejects API-key
5776
5789
  principals _by design_: an API key carries a `storeId` but never a `userId`, and the
5777
5790
  team service resolves a missing `userId` to an OWNER role. Failing closed at the boundary
5778
5791
  is what stops an API key from escalating its own team permissions. Pointing the SDK at
5779
- the correct path would earn a `403` instead of a `404`. Invite, re-scope and remove
5780
- members in the dashboard.
5792
+ the correct path would earn a `403` instead of a `404` which is why the SDK now raises
5793
+ that `403` locally rather than sending a request that cannot succeed. Invite, re-scope
5794
+ and remove members in the dashboard.
5795
+
5796
+ `getStoreInvitationByToken(token)` is the **one exception and it works**: the lookup
5797
+ route is `@Public()`, and it was only ever failing because the SDK asked for
5798
+ `/api/v1/store-invitations/…` instead of `/api/store-invitations/…`. That path is fixed.
5799
+ Use it to render an invitation-acceptance page; the acceptance itself must happen in the
5800
+ dashboard, because it is matched against the invited user's own email address.
5781
5801
 
5782
5802
  > **The older account-level methods are not a substitute for this.** `getTeamMembers`,
5783
5803
  > `getTeamInvitations`, `inviteTeamMember`, `resendTeamInvitation`, `revokeTeamInvitation`,
@@ -6064,6 +6084,288 @@ const { queued } = await client.aiTranslateBulk(storeId, {
6064
6084
 
6065
6085
  ---
6066
6086
 
6087
+ ### Gift Cards (administration)
6088
+
6089
+ Eight admin-key methods that issue, read, re-issue, adjust and disable stored
6090
+ value. This is the merchant half of the feature; the storefront half — apply,
6091
+ remove, check a balance — is [Gift Cards (a tender, not a discount)](#gift-cards-a-tender-not-a-discount)
6092
+ and needs no API key.
6093
+
6094
+ Every call reaches the same service the dashboard runs, so the rules below hold
6095
+ whether a person or a key is acting.
6096
+
6097
+ | Method | Route | Scope |
6098
+ | --------------------------------------------------- | --------------------------------- | ------------------ |
6099
+ | `listGiftCards(params?)` | `GET /v1/gift-cards` | `gift_cards:read` |
6100
+ | `getGiftCardLiability()` | `GET /v1/gift-cards/liability` | `gift_cards:read` |
6101
+ | `getGiftCard(giftCardId)` | `GET /v1/gift-cards/{id}` | `gift_cards:read` |
6102
+ | `issueGiftCard(data)` | `POST /v1/gift-cards` | `gift_cards:issue` |
6103
+ | `reissueGiftCard(giftCardId, note)` | `POST /v1/gift-cards/{id}/reissue` | `gift_cards:issue` |
6104
+ | `adjustGiftCardBalance(giftCardId, delta, note)` | `PATCH /v1/gift-cards/{id}/adjust` | `gift_cards:adjust` |
6105
+ | `setGiftCardStatus(giftCardId, status)` | `PATCH /v1/gift-cards/{id}/status` | `gift_cards:write` |
6106
+ | `bulkSetGiftCardStatus(giftCardIds, status)` | `PATCH /v1/gift-cards/bulk/status` | `gift_cards:write` |
6107
+
6108
+ #### ⛔ The code is returned exactly once
6109
+
6110
+ `issueGiftCard` and `reissueGiftCard` are the only two moments `plaintextCode`
6111
+ exists in readable form anywhere. The platform keeps an HMAC of it and nothing
6112
+ else, so **no API call, no dashboard screen and no database query can produce it
6113
+ again**. An integration that discards the response has destroyed a card that is
6114
+ still on the books as a liability.
6115
+
6116
+ ```typescript
6117
+ import type { IssuedGiftCardAdmin } from 'brainerce';
6118
+
6119
+ const card: IssuedGiftCardAdmin = await admin.issueGiftCard({
6120
+ amount: '200.00', // decimal STRING, greater than zero
6121
+ note: 'Goodwill for order ORD-20260902-0041, damaged in transit',
6122
+ recipientEmail: 'dana@example.com',
6123
+ });
6124
+
6125
+ await deliverToCustomer(card.plaintextCode); // ← your only chance
6126
+ // card.giftCardId, card.last4 are safe to store. card.plaintextCode is not:
6127
+ // never log it, never persist it in plaintext, never put it in an error message.
6128
+ ```
6129
+
6130
+ The **one** recovery that exists is an idempotent retry. Send an
6131
+ `Idempotency-Key` header on the issue call and re-sending the identical request
6132
+ with the same key inside 24 hours replays the whole cached response — code
6133
+ included — with `X-Idempotent-Replayed: true` on it. Outside that window there is
6134
+ nothing to replay, and the value sits on a card nobody can spend. Send the key.
6135
+
6136
+ #### Reading cards
6137
+
6138
+ ```typescript
6139
+ import type { GiftCardAdmin, GiftCardAdminDetail, GiftCardLiability } from 'brainerce';
6140
+
6141
+ const { data, meta } = await admin.listGiftCards({
6142
+ page: 1,
6143
+ limit: 50, // hard cap 100
6144
+ filter: 'withBalance', // 'all' | 'active' | 'withBalance' | 'expired' | 'disabled'
6145
+ search: 'V2D3', // last FOUR of a code, or part of a recipient email
6146
+ });
6147
+
6148
+ const card: GiftCardAdminDetail = await admin.getGiftCard(data[0].id);
6149
+ card.transactions; // the full append-only ledger, newest first
6150
+ ```
6151
+
6152
+ **`search` cannot match a full code.** Only an HMAC is stored, so there is
6153
+ nothing to search against; the last four characters and the recipient email are
6154
+ the whole of it. That is also what a merchant reads off a support email.
6155
+
6156
+ Two filters behave in ways worth knowing before you build a UI on them:
6157
+
6158
+ - `disabled` returns `DISABLED` **and** `REVOKED` cards, not just disabled ones.
6159
+ - `expired` matches on `expiresAt` being in the past regardless of status, and
6160
+ expiry is derived at read time rather than stamped by a job — so a card that
6161
+ lapsed a second ago is already in this filter, and `expired: true` on the row
6162
+ is true the moment validity ends.
6163
+
6164
+ An expired card's balance is deliberately **not** zeroed, and `expiredAt` is
6165
+ stamped only if an expiry was ever processed.
6166
+
6167
+ ```typescript
6168
+ const liability: GiftCardLiability = await admin.getGiftCardLiability();
6169
+ liability.byCurrency; // [{ currency: 'ILS', active, held, expiredNotWrittenOff }, ...]
6170
+ ```
6171
+
6172
+ This is the month-end close figure. **Read `byCurrency`** if the store sells in
6173
+ more than one: balances in different currencies do not add up, so the top-level
6174
+ `active` / `held` / `expiredNotWrittenOff` cover one currency only and are not a
6175
+ total. `expiredNotWrittenOff` is reported apart from both sides on purpose —
6176
+ whether expiry extinguishes the obligation is an open legal question, so the
6177
+ platform never folds it into either.
6178
+
6179
+ `liability.enabled` is the one admin-mode capability probe for this feature. It
6180
+ says whether the store may **issue**; redemption is not gated on it.
6181
+
6182
+ #### ⛔ Re-issue is not a resend
6183
+
6184
+ `reissueGiftCard` is the answer to a customer who lost their code. It mints a new
6185
+ code, moves the **whole** balance onto it, and **revokes the old card**:
6186
+
6187
+ ```typescript
6188
+ import type { ReissuedGiftCardAdmin } from 'brainerce';
6189
+
6190
+ const replacement: ReissuedGiftCardAdmin = await admin.reissueGiftCard(
6191
+ giftCardId,
6192
+ 'Customer lost the original code — support ticket 8812'
6193
+ );
6194
+ replacement.plaintextCode; // once, same rules as issuance
6195
+ replacement.movedAmount; // what came across from the revoked card
6196
+ replacement.deliveredTo; // email it was sent to, or null → you must hand it over
6197
+ ```
6198
+
6199
+ The old code stops working the moment this returns. If the customer is still
6200
+ holding a printed card, it is now worthless — so do not reach for this when
6201
+ someone simply wants the email again.
6202
+
6203
+ Three things it deliberately does:
6204
+
6205
+ - **Refuses while a checkout holds value on the card.** A live hold means a
6206
+ shopper is mid-payment against it; moving the balance out from under them would
6207
+ strand a provider charge already in flight. Holds are short, so waiting is the
6208
+ correct advice rather than a workaround.
6209
+ - **Carries the ORIGINAL expiry forward.** Re-issue cannot be used to restart an
6210
+ expiry clock, because the clock carries statutory notice duties.
6211
+ - **Works even when the store has gift cards switched off.** Unlike issuance it
6212
+ is not gated on that switch: it moves value that already exists, total
6213
+ liability is identical before and after, and a store that turned the feature
6214
+ off still owes every card already in a customer's hand.
6215
+
6216
+ It also refuses a card that is already `REVOKED` ("re-issue the replacement
6217
+ instead"), one with no balance left to move, and one that changed underneath the
6218
+ call. Those messages are explanatory, unlike the storefront's deliberately
6219
+ uniform refusals — surface them to the merchant.
6220
+
6221
+ #### Adjusting a balance
6222
+
6223
+ ```typescript
6224
+ const { balanceAfter } = await admin.adjustGiftCardBalance(
6225
+ giftCardId,
6226
+ '-25.00', // SIGNED decimal string: '25.00' credits, '-25.00' debits. Never zero
6227
+ 'Chargeback on the original order'
6228
+ );
6229
+ ```
6230
+
6231
+ A debit cannot take the balance below what live checkout holds have already
6232
+ reserved. That refusal names the held amount, so it is actionable: wait for the
6233
+ checkout to complete or be released, then retry.
6234
+
6235
+ #### Status, and the absence of delete
6236
+
6237
+ ```typescript
6238
+ await admin.setGiftCardStatus(giftCardId, 'DISABLED'); // 'ACTIVE' | 'DISABLED' | 'REVOKED'
6239
+ const { updated } = await admin.bulkSetGiftCardStatus(ids, 'DISABLED'); // ACTIVE | DISABLED only
6240
+ ```
6241
+
6242
+ ⛔ **There is no delete — not one route, not in bulk, not ever.** The ledger is
6243
+ append-only and a card may carry a statutory retention life. Disabling is the
6244
+ reversible substitute, and it is what "off" actually means here: it stops **new**
6245
+ holds. It deliberately does not touch a hold a checkout is already carrying,
6246
+ because pulling value out from under a shopper mid-payment would strand a
6247
+ provider charge that has already left.
6248
+
6249
+ `bulkSetGiftCardStatus` takes `ACTIVE` and `DISABLED` only. **`REVOKED` is
6250
+ refused in bulk**: revoking belongs to re-issue, which moves the balance to a
6251
+ replacement first, and revoking a page of cards would strand every balance on
6252
+ them with nowhere to go. Cards already revoked are skipped rather than
6253
+ reactivated — a revoked code put back into circulation beside its replacement
6254
+ would make the same value spendable twice — so `updated` is the honest count and
6255
+ can be lower than the number of ids you sent. Maximum **200 ids** per call.
6256
+
6257
+ #### Scopes are separable on purpose
6258
+
6259
+ `gift_cards:issue` mints stored value. `gift_cards:adjust` rewrites a balance.
6260
+ Neither is implied by `gift_cards:read`, and that separation is the whole control
6261
+ surface here: Shopify makes a merchant contact their support for the equivalent
6262
+ permission, while this platform grants it self-serve. Ask for the least your
6263
+ integration needs.
6264
+
6265
+ ⛔ **`gift_cards:*` is not "gift cards, read-only".** The wildcard matches the
6266
+ **resource**, so one string grants `read`, `write`, `issue` **and** `adjust`
6267
+ together. A reporting or BI integration that asks for it has been handed the
6268
+ power to mint money and to rewrite balances. Enumerate the scopes instead.
6269
+
6270
+ Note the underscore: the scope is `gift_cards:read`, not `gift-cards:read`. A key
6271
+ minted with the hyphen matches nothing and every call 403s with
6272
+ `INSUFFICIENT_SCOPE`, which reads like a permissions bug rather than a typo.
6273
+
6274
+ #### Rules that hold on every call
6275
+
6276
+ - **A `note` is mandatory** on issue, re-issue and adjust — 3 to 500 characters —
6277
+ and is written to the append-only ledger permanently. It is the row a finance
6278
+ review reads a year from now, so write the reason, not `"api"`.
6279
+ - **Money is decimal strings, never numbers.** `amount`, `delta`, every balance.
6280
+ A JSON number is a float, and the one thing that must not happen to stored
6281
+ value is arriving a cent short because it went through a double on the way in.
6282
+ - **There is no `currency` parameter on issue.** A card is always minted in the
6283
+ store's own currency, and a card pays only in its own currency with no
6284
+ conversion — so this API cannot issue a EUR card on an ILS store to compensate
6285
+ a foreign-currency customer. Neither half of that is configurable.
6286
+ - **Issuing is refused while gift cards are switched off for the store**, with a
6287
+ 400 that says so. The feature is off by default. Re-issue, adjust and status
6288
+ are not gated on it.
6289
+ - **All five write routes accept `Idempotency-Key`.** Send it on issue and
6290
+ re-issue especially: without one, a retried timeout mints a *second* card and a
6291
+ real liability nobody asked for.
6292
+
6293
+ #### Type definitions
6294
+
6295
+ ```typescript
6296
+ interface GiftCardAdmin {
6297
+ id: string;
6298
+ maskedCode: string; // '••••-••••-••••-••••-V2D3'
6299
+ codeLast4: string;
6300
+ initialAmount: string; // decimal strings, all of them
6301
+ balance: string; // settled value on the card
6302
+ heldAmount: string; // reserved by a checkout in progress, not available to spend
6303
+ spendable: string; // balance - heldAmount
6304
+ currency: string;
6305
+ status: 'ACTIVE' | 'DISABLED' | 'REVOKED';
6306
+ customerId: string | null;
6307
+ recipientEmail: string | null;
6308
+ expiresAt: string | null;
6309
+ expiredAt: string | null; // stamped if an expiry was processed; balance is NOT zeroed
6310
+ expired: boolean; // derived at read time
6311
+ createdAt: string;
6312
+ }
6313
+
6314
+ interface GiftCardTransaction {
6315
+ id: string;
6316
+ type: 'ISSUE' | 'REDEEM' | 'REFUND' | 'ADJUST' | 'EXPIRE';
6317
+ amount: string; // signed; negative debits the card
6318
+ balanceAfter: string;
6319
+ orderId: string | null;
6320
+ actorUserId: string | null;
6321
+ note: string | null;
6322
+ createdAt: string;
6323
+ }
6324
+
6325
+ interface GiftCardAdminDetail extends GiftCardAdmin {
6326
+ recipientName: string | null;
6327
+ orderId: string | null;
6328
+ transactions: GiftCardTransaction[]; // append-only; nothing here is ever rewritten
6329
+ }
6330
+
6331
+ interface GiftCardLiability {
6332
+ active: string;
6333
+ held: string;
6334
+ expiredNotWrittenOff: string;
6335
+ currency: string | null;
6336
+ byCurrency: Array<{
6337
+ currency: string;
6338
+ active: string;
6339
+ held: string;
6340
+ expiredNotWrittenOff: string;
6341
+ }>;
6342
+ enabled: boolean; // may the store ISSUE? Redemption is not gated on this
6343
+ }
6344
+
6345
+ interface IssuedGiftCardAdmin {
6346
+ giftCardId: string;
6347
+ plaintextCode: string; // ⚠️ returned EXACTLY ONCE
6348
+ last4: string;
6349
+ }
6350
+
6351
+ interface ReissuedGiftCardAdmin extends IssuedGiftCardAdmin {
6352
+ movedAmount: string;
6353
+ deliveredTo: string | null; // null → the merchant must hand the code over
6354
+ }
6355
+
6356
+ interface IssueGiftCardAdminDto {
6357
+ amount: string; // decimal string, greater than zero
6358
+ note: string; // REQUIRED, 3-500 chars, permanent on the ledger
6359
+ customerId?: string;
6360
+ expiresAt?: string; // ISO 8601, must be in the future. Omit for no expiry
6361
+ recipientEmail?: string;
6362
+ recipientName?: string;
6363
+ personalMessage?: string;
6364
+ }
6365
+ ```
6366
+
6367
+ ---
6368
+
6067
6369
  ## Complete Page Examples
6068
6370
 
6069
6371
  ### Home Page
@@ -7314,31 +7616,31 @@ export async function POST(req: Request) {
7314
7616
  backend validates the `events` array on create against exactly this list, so
7315
7617
  anything outside it is rejected rather than silently accepted.
7316
7618
 
7317
- | Event | Description |
7318
- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7319
- | `order.created` | New order placed (any payment status) |
7320
- | `order.updated` | Order metadata changed (status, address, items) |
7321
- | `order.paid` | Order is paid, by provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
7322
- | `order.fulfilled` | All items marked shipped/delivered |
7323
- | `order.cancelled` | Order cancelled (by merchant or customer) |
7324
- | `order.refunded` | Order fully or partially refunded |
7325
- | `customer.created` | New customer account created |
7326
- | `customer.updated` | Customer profile or contact details changed |
7327
- | `customer.deleted` | Customer account deleted |
7328
- | `product.created` | New product added to catalog |
7329
- | `product.updated` | Product attributes, variants, or pricing changed |
7330
- | `product.deleted` | Product removed from catalog |
7331
- | `inventory.updated` | Stock level changed (any reason) |
7332
- | `inventory.low` | Stock fell below the low-stock threshold |
7333
- | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
7334
- | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
7335
- | `payment.succeeded` | Payment provider confirmed funds captured |
7336
- | `payment.failed` | Payment provider rejected the transaction |
7337
- | `payment.refunded` | Refund posted to the customer |
7338
- | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
7339
- | `blog.post.updated` | Published post content changed |
7619
+ | Event | Description |
7620
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7621
+ | `order.created` | New order placed (any payment status) |
7622
+ | `order.updated` | Order metadata changed (status, address, items) |
7623
+ | `order.paid` | Order is paid, by provider capture **or** a merchant-recorded out-of-band payment (cash on delivery, bank transfer). Never assume a provider was involved; `payment.succeeded` does **not** fire for these |
7624
+ | `order.fulfilled` | All items marked shipped/delivered |
7625
+ | `order.cancelled` | Order cancelled (by merchant or customer) |
7626
+ | `order.refunded` | Order fully or partially refunded |
7627
+ | `customer.created` | New customer account created |
7628
+ | `customer.updated` | Customer profile or contact details changed |
7629
+ | `customer.deleted` | Customer account deleted |
7630
+ | `product.created` | New product added to catalog |
7631
+ | `product.updated` | Product attributes, variants, or pricing changed |
7632
+ | `product.deleted` | Product removed from catalog |
7633
+ | `inventory.updated` | Stock level changed (any reason) |
7634
+ | `inventory.low` | Stock fell below the low-stock threshold |
7635
+ | `checkout.completed` | Checkout completed (synonym of `order.created` for now) |
7636
+ | `checkout.abandoned` | Cart inactive for 1+ hours with no completion |
7637
+ | `payment.succeeded` | Payment provider confirmed funds captured |
7638
+ | `payment.failed` | Payment provider rejected the transaction |
7639
+ | `payment.refunded` | Refund posted to the customer |
7640
+ | `blog.post.published` | Post went live (manual, scheduled, or SEO Autopilot) |
7641
+ | `blog.post.updated` | Published post content changed |
7340
7642
  | `donation.paid` | A donation settled. `createDonation` returns `PENDING` and this is the only signal money actually moved, so a receipting integration waits for it. The payload carries both the donor's intended `amount` and the `chargedAmount` the provider took |
7341
- | `donation.refunded` | A settled donation was refunded. There is no `REFUNDED` donation status — this event is how you learn |
7643
+ | `donation.refunded` | A settled donation was refunded. There is no `REFUNDED` donation status — this event is how you learn |
7342
7644
 
7343
7645
  Payload shapes for each are in the
7344
7646
  [Event Catalogue](https://brainerce.com/docs/webhooks/events).
@@ -7597,8 +7899,8 @@ const handlePlaceOrder = () => {
7597
7899
  - Import types from the SDK
7598
7900
  - Handle loading states and errors
7599
7901
  - **Use toast notifications (Sonner) for user feedback on actions**
7600
- - Persist cart ID in localStorage
7601
- - Persist customer token after login
7902
+ - **Let the SDK persist the cart** it keeps the cart reference in `brainerce_session` itself. Do not write your own cart-to-localStorage code; a second copy drifts out of sync with the server
7903
+ - **NEVER put the customer token in `localStorage`** — any XSS reads it and the attacker is that customer until it expires. Hold it in memory with `setCustomerToken()`, or front the SDK with a BFF proxy (`proxyMode: true`) that keeps it in an HttpOnly cookie
7602
7904
  - **Use `isHtmlDescription(product)` helper and render HTML with `dangerouslySetInnerHTML` when it returns true**
7603
7905
  - **Wrap SDK calls in try/catch and show error toasts**
7604
7906