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 +367 -65
- package/dist/index.d.mts +305 -13
- package/dist/index.d.ts +305 -13
- package/dist/index.js +249 -71
- package/dist/index.mjs +249 -71
- package/package.json +85 -84
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
|
|
53
|
-
|
|
|
54
|
-
| Product list with search, filter, pagination
|
|
55
|
-
| Product detail with variant picker, stock, price
|
|
56
|
-
| Buyer customization fields (engraving, uploads, select)
|
|
57
|
-
| Cart (add, update, remove, coupon, totals)
|
|
58
|
-
| Inventory reservation countdown
|
|
59
|
-
| Full checkout end-to-end with payment
|
|
60
|
-
| Gift card redemption at checkout
|
|
61
|
-
| Order confirmation (clear cart + wait for real order)
|
|
62
|
-
| Register + email verification flow
|
|
63
|
-
| Login + verification branch
|
|
64
|
-
| Forgot / reset password
|
|
65
|
-
| OAuth sign-in buttons + callback handler
|
|
66
|
-
| Account area (profile + order history)
|
|
67
|
-
| Loyalty & rewards (points balance + tiers + redeem)
|
|
68
|
-
| Loyalty paid membership (premium subscription)
|
|
69
|
-
| Embeddable loyalty widget (points + rewards on ANY site)
|
|
70
|
-
| Global header: cart count + search autocomplete
|
|
71
|
-
| Discount banners + product badges
|
|
72
|
-
| Product reviews on PDP + JSON-LD aggregateRating
|
|
73
|
-
| Customer photos on reviews
|
|
74
|
-
| Site chrome (header + footer + announcement bar)
|
|
75
|
-
| FAQ page
|
|
76
|
-
| Static pages catch-all (`/pages/[slug]`)
|
|
77
|
-
| Multi-language + RTL (when i18n enabled)
|
|
78
|
-
| Donation page (only when `getStoreInfo().donationsEnabled`) | `client.createDonation(input)`, `client.getDonation(id)`
|
|
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
|
|
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`
|
|
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`
|
|
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
|
|
5768
|
-
`
|
|
5769
|
-
`
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
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
|
|
5780
|
-
|
|
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
|
-
-
|
|
7601
|
-
-
|
|
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
|
|