@vonpay/checkout-node 1.1.2 → 2.1.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/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { VonPayCheckoutConfig, CreateSessionParams, CheckoutSession, SessionStatus, DryRunResult, WebhookEvent, HealthStatus, RequestOptions, ReturnOutcome, ReturnParams, PaymentIntent, CreatePaymentIntentParams, CapturePaymentIntentParams, Capabilities, Refund, CreateRefundParams, Token, CreateTokenParams, WebhookSubscription, ListWebhookSubscriptionsParams, WebhookSubscriptionsList, WebhookEventRecord } from "./types.js";
1
+ import type { VonPayCheckoutConfig, CreateSessionParams, CheckoutSession, SessionStatus, DryRunResult, WebhookEvent, HealthStatus, RequestOptions, ReturnOutcome, ReturnParams, PaymentIntent, CreatePaymentIntentParams, CapturePaymentIntentParams, Capabilities, Refund, CreateRefundParams, Token, CreateTokenParams, WebhookSubscription, ListWebhookSubscriptionsParams, WebhookSubscriptionsList, WebhookEventRecord, Buyer, UpsertBuyerParams, UpdateBuyerParams, FindBuyerParams } from "./types.js";
2
2
  export declare const KNOWN_RETURN_SCHEMES: readonly ["v1", "v2"];
3
3
  export type ReturnScheme = (typeof KNOWN_RETURN_SCHEMES)[number];
4
4
  export declare const DEFAULT_RETURN_SCHEMES: readonly ReturnScheme[];
@@ -165,6 +165,129 @@ export declare class VonPayCheckout {
165
165
  * the rest of `/v1/*` on this engine); the SDK exposes responses
166
166
  * pass-through with no key conversion.
167
167
  */
168
+ /**
169
+ * Stored shopper profiles. `vp_sk_*` Bearer required.
170
+ *
171
+ * The same profile the checkout flows write via `buyerId` / `buyerEmail`, so
172
+ * a buyer created here is the one a later payment attaches to.
173
+ *
174
+ * ⛔ **The erase is a separate method on purpose.** `PATCH /v1/buyers/:id`
175
+ * accepts `email: null` to permanently erase the address — irreversibly, and
176
+ * it removes the copy held at the payment provider too. The API's own docs
177
+ * warn that several common JSON libraries serialise absent optional fields as
178
+ * `null`, so an ordinary update can wipe a shopper's email with nobody
179
+ * intending it.
180
+ *
181
+ * This SDK refuses to expose that shape. `update()` cannot express `null` —
182
+ * it is a type error — and {@link eraseEmail} is the only path that sends it.
183
+ * An accidental erase is therefore not writable in TypeScript.
184
+ */
185
+ /**
186
+ * ⚠️ **No idempotency key on this resource, deliberately.**
187
+ *
188
+ * The canonical spec declares `Idempotency-Key` on `POST /v1/sessions` and
189
+ * `POST /v1/refunds`, and documents the replay-mismatch contract for them. It
190
+ * declares NOTHING for either buyers route. An earlier draft of this resource
191
+ * offered the option anyway, and that was wrong: shipping an
192
+ * unconfirmed safety knob on an IRREVERSIBLE, provider-propagated deletion is
193
+ * the wrong default — a caller would reasonably read it as the same
194
+ * replay-safety guarantee the other endpoints give, and we cannot back that.
195
+ *
196
+ * Removed until server support is confirmed and the API contract declares
197
+ * it. If you need replay safety on these calls today, guard at your own call
198
+ * site.
199
+ */
200
+ buyers: {
201
+ /**
202
+ * `POST /v1/buyers` — create or update a buyer.
203
+ *
204
+ * ⚠️ **This is an UPSERT.** The write is keyed on `externalId` when
205
+ * present, else on `email`. Calling it twice with the same key UPDATES;
206
+ * it does not fail with a duplicate error. The server answers `201` when it
207
+ * created and `200` when it updated, and the body is the full stored
208
+ * profile either way — so what you read back is what was persisted
209
+ * (including `metadata` after its append-merge).
210
+ *
211
+ * At least one of `externalId` / `email` is required: a profile with no
212
+ * lookup key could never be retrieved or charged against. That is checked
213
+ * here rather than spent on a round-trip to a `400`.
214
+ */
215
+ upsert: (params: UpsertBuyerParams) => Promise<Buyer>;
216
+ /**
217
+ * `GET /v1/buyers/:id` — fetch a stored profile by id.
218
+ *
219
+ * A cross-merchant id returns an opaque 404, never a 403.
220
+ */
221
+ retrieve: (buyerId: string) => Promise<Buyer>;
222
+ /**
223
+ * `GET /v1/buyers` — exact lookup by your reference or by email.
224
+ *
225
+ * Returns the profile or `null`. There is deliberately no unfiltered
226
+ * listing in v1, so at least one key is required; when both are supplied
227
+ * `externalId` wins, matching the upsert's identity-resolution order.
228
+ *
229
+ * Unwraps the server's `{ data: [...] }` envelope, which holds zero or one
230
+ * profile — handing back a one-element array to destructure would be a
231
+ * worse API for a lookup that can never return two.
232
+ */
233
+ find: (params: FindBuyerParams) => Promise<Buyer | null>;
234
+ /**
235
+ * `PATCH /v1/buyers/:id` — update a stored profile.
236
+ *
237
+ * Omitted fields are left unchanged. `metadata` merges with append
238
+ * semantics. `externalId` is not patchable — the identity key is fixed at
239
+ * creation; upsert a new profile instead.
240
+ *
241
+ * ⛔ **This method cannot erase.** `email` is typed `string`, so passing
242
+ * `null` is a compile error, and the body is built with an explicit
243
+ * `!== undefined` test rather than a truthiness check — a truthy test would
244
+ * also drop a legitimate empty-string value and, worse, would let a `null`
245
+ * through unnoticed if the type were ever widened. Use
246
+ * {@link eraseEmail} when you actually mean to erase.
247
+ */
248
+ update: (buyerId: string, params: UpdateBuyerParams) => Promise<Buyer>;
249
+ /**
250
+ * **Permanently erase this buyer's email address.** `PATCH /v1/buyers/:id`
251
+ * with `email: null`.
252
+ *
253
+ * It removes the address from the buyer profile. Afterwards the buyer
254
+ * cannot be found by email.
255
+ *
256
+ * ⚠️ **We also ASK your payment provider to drop their copy — but a
257
+ * successful response here does not confirm that happened.** That request
258
+ * is dispatched after we answer you, is never awaited, and reports no
259
+ * failure back: a provider error, a timeout, or an open circuit breaker
260
+ * all pass silently. And for a buyer we only ever knew by email, whose
261
+ * provider record we had not separately recorded, the retraction cannot be
262
+ * performed at all — the key we would need was derived from the address
263
+ * that was just erased. Do not tell a shopper the provider copy is gone on
264
+ * the strength of a `200`.
265
+ *
266
+ * ⛔ **This is NOT a complete deletion on its own, and must not be
267
+ * described to a shopper as one.** It clears the profile's address. Other
268
+ * copies live outside this record, under their own
269
+ * retention rules, and this call does not touch them — including the
270
+ * settlement record kept for financial audit, checkout sessions from the
271
+ * recent past, and any address already forwarded to a connected store,
272
+ * which is an external system with no retraction path.
273
+ *
274
+ * ⚠️ Read that as examples, **not as an inventory.** It is the set we have
275
+ * confirmed, not a completed audit of every place a copy can exist — your
276
+ * own systems may hold one we sent you earlier. Treat this call as the
277
+ * profile half of a deletion request, not the whole answer.
278
+ *
279
+ * ⚠️ **It cannot be undone**, and a buyer identified only by email becomes
280
+ * unreachable by lookup afterwards — the record and every payment attached
281
+ * to it survive, but `find({ email })` will no longer locate it. Keep your
282
+ * own reference in `externalId` if you need to find them again.
283
+ *
284
+ * No other profile field is erasable this way; `email` is the only one the
285
+ * API declares nullable.
286
+ *
287
+ * @returns the stored profile as it is after the erase, with `email: null`.
288
+ */
289
+ eraseEmail: (buyerId: string) => Promise<Buyer>;
290
+ };
168
291
  webhookSubscriptions: {
169
292
  /**
170
293
  * `GET /v1/webhook_subscriptions` — Stripe-shaped list envelope.
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js CHANGED
@@ -9,11 +9,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
10
10
  const SDK_VERSION = pkg.version;
11
11
  const DEFAULT_BASE_URL = "https://checkout.vonpay.com";
12
- // Sent as `Von-Pay-Version` on every request. Bump alongside spec changes
13
- // that introduce new endpoints (e.g. SDK 0.6.x added capture/void/refunds/
14
- // tokens at 2026-05-05). The server treats this header as advisory routing
15
- // information; no SDK-side gating depends on it.
16
- const DEFAULT_API_VERSION = "2026-05-05";
12
+ // Sent as `Von-Pay-Version` on every request.
13
+ //
14
+ // THIS WAS "2026-05-05" AND THE API HAS NEVER ACCEPTED THAT VALUE.
15
+ // The server keeps a one-member allowlist (`SUPPORTED_VERSIONS` in checkout's
16
+ // proxy) containing only "2026-04-14", and silently substitutes it for anything
17
+ // else — no error, no warning. So every request this SDK has ever sent was
18
+ // served as 2026-04-14 while announcing something different, and the response
19
+ // header said so.
20
+ //
21
+ // The old value was not a premature version. It was the DATE THE 0.6.x SDK
22
+ // SCAFFOLD WAS WRITTEN (commit 1eb30ee, 2026-05-05), recorded as though it were
23
+ // a contract. Nothing on the server ever implemented it; the only occurrences
24
+ // of that string in checkout are comment dates.
25
+ //
26
+ // ⚠️ WHY IT MATTERED EVEN THOUGH NOTHING BROKE: a merchant copying this default
27
+ // and pinning `Von-Pay-Version: 2026-05-05` explicitly believed they had pinned
28
+ // a NEWER contract. They had pinned nothing and silently got the older one.
29
+ // Version pinning that silently no-ops is the exact failure pinning exists to
30
+ // prevent. Reported by vonpay-docs, 2026-09-08.
31
+ //
32
+ // This correction is RUNTIME-NEUTRAL: the server was already serving
33
+ // 2026-04-14: only the announced value changes, to the one actually honoured.
34
+ //
35
+ // ⛔ Do NOT bump this to a new date because a new SDK surface shipped. It is not
36
+ // a changelog. Bump it only when checkout ADDS the value to SUPPORTED_VERSIONS,
37
+ // and keep it in lock-step with the Python constant
38
+ // (`sdk/api-version-bump-both-sdks`).
39
+ const DEFAULT_API_VERSION = "2026-04-14";
17
40
  const DEFAULT_MAX_RETRIES = 2;
18
41
  const DEFAULT_TIMEOUT = 30_000;
19
42
  const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
@@ -564,6 +587,57 @@ function toCamelKeysDeep(value) {
564
587
  * sdk/metadata-keys-opaque).
565
588
  */
566
589
  function paymentIntentFromWire(raw) {
590
+ const { metadata, ...rest } = raw;
591
+ // ⛔ `buyer.metadata` IS METADATA TOO, AND IT IS NESTED.
592
+ //
593
+ // The line below runs `toCamelKeysDeep` over everything except the TOP-LEVEL
594
+ // `metadata`. When `buyer` was added (2.1.0) it arrived inside `rest`, so the
595
+ // deep converter walked into `buyer.metadata` and renamed the merchant's own
596
+ // keys: `order_id` came back as `orderId`. Reproduced, not theorised — a
597
+ // merchant looking their own order up by the key they set would miss.
598
+ //
599
+ // That is the exact defect `sdk/metadata-keys-opaque` exists to prevent, and
600
+ // it reached review because the guard was written when `metadata` appeared in
601
+ // exactly one place. Any FUTURE nested metadata needs the same treatment —
602
+ // hence extracting by walking for the key rather than hardcoding `buyer`.
603
+ const buyerRaw = rest.buyer;
604
+ const buyerMetadata = buyerRaw && typeof buyerRaw === "object" ? buyerRaw.metadata : undefined;
605
+ const camel = toCamelKeysDeep(rest);
606
+ const result = {
607
+ ...camel,
608
+ metadata: metadata ?? {},
609
+ };
610
+ // Re-attach the merchant's keys byte-for-byte, after conversion.
611
+ if (buyerMetadata !== undefined && result.buyer) {
612
+ result.buyer = {
613
+ ...result.buyer,
614
+ metadata: buyerMetadata,
615
+ };
616
+ }
617
+ return result;
618
+ }
619
+ /**
620
+ * Parse a server buyer response into the camelCase {@link Buyer} type.
621
+ *
622
+ * ⛔ WITHOUT THIS, THE RESOURCE'S OWN PRIMARY FIELD IS UNDEFINED. `request()`
623
+ * returns `response.json()` raw — it does NOT convert case — and every other
624
+ * resource here calls a `*FromWire` helper on the way out. The first version of
625
+ * `buyers` called none, so `externalId`, `createdAt`, `lastSeenAt` and every
626
+ * nested `address.*` field came back `undefined` from a correct server
627
+ * response. `externalId` is the field this SDK's own erase docs tell an
628
+ * integrator to keep hold of.
629
+ *
630
+ * Worth recording WHY this was easy to miss:
631
+ * the one committed test that read a returned field read `id`, which is spelled
632
+ * identically in both conventions and therefore passed by coincidence. A test
633
+ * that only ever asserts a convention-invariant field cannot detect a
634
+ * conversion bug.
635
+ *
636
+ * `metadata` is passed through verbatim — integrator-defined keys must NEVER be
637
+ * case-converted — they are the merchant's own keys and must round-trip
638
+ * byte-for-byte.
639
+ */
640
+ function buyerFromWire(raw) {
567
641
  const { metadata, ...rest } = raw;
568
642
  const camel = toCamelKeysDeep(rest);
569
643
  return {
@@ -1094,7 +1168,7 @@ export class VonPayCheckout {
1094
1168
  // (or any future regression) the property is simply absent, and a caller
1095
1169
  // doing an exhaustive check on a value the type says is always present
1096
1170
  // gets `undefined`. Python already defaults this; Node promised the same
1097
- // guarantee and did not enforce it (found in security review 2026-08-29).
1171
+ // guarantee and did not enforce it (found 2026-08-29).
1098
1172
  //
1099
1173
  // Unknown or missing both resolve to `"not_tracked"`, which is the
1100
1174
  // fail-safe direction and the whole point of the field: it can only mean
@@ -1299,6 +1373,258 @@ export class VonPayCheckout {
1299
1373
  * the rest of `/v1/*` on this engine); the SDK exposes responses
1300
1374
  * pass-through with no key conversion.
1301
1375
  */
1376
+ /**
1377
+ * Stored shopper profiles. `vp_sk_*` Bearer required.
1378
+ *
1379
+ * The same profile the checkout flows write via `buyerId` / `buyerEmail`, so
1380
+ * a buyer created here is the one a later payment attaches to.
1381
+ *
1382
+ * ⛔ **The erase is a separate method on purpose.** `PATCH /v1/buyers/:id`
1383
+ * accepts `email: null` to permanently erase the address — irreversibly, and
1384
+ * it removes the copy held at the payment provider too. The API's own docs
1385
+ * warn that several common JSON libraries serialise absent optional fields as
1386
+ * `null`, so an ordinary update can wipe a shopper's email with nobody
1387
+ * intending it.
1388
+ *
1389
+ * This SDK refuses to expose that shape. `update()` cannot express `null` —
1390
+ * it is a type error — and {@link eraseEmail} is the only path that sends it.
1391
+ * An accidental erase is therefore not writable in TypeScript.
1392
+ */
1393
+ /**
1394
+ * ⚠️ **No idempotency key on this resource, deliberately.**
1395
+ *
1396
+ * The canonical spec declares `Idempotency-Key` on `POST /v1/sessions` and
1397
+ * `POST /v1/refunds`, and documents the replay-mismatch contract for them. It
1398
+ * declares NOTHING for either buyers route. An earlier draft of this resource
1399
+ * offered the option anyway, and that was wrong: shipping an
1400
+ * unconfirmed safety knob on an IRREVERSIBLE, provider-propagated deletion is
1401
+ * the wrong default — a caller would reasonably read it as the same
1402
+ * replay-safety guarantee the other endpoints give, and we cannot back that.
1403
+ *
1404
+ * Removed until server support is confirmed and the API contract declares
1405
+ * it. If you need replay safety on these calls today, guard at your own call
1406
+ * site.
1407
+ */
1408
+ buyers = {
1409
+ /**
1410
+ * `POST /v1/buyers` — create or update a buyer.
1411
+ *
1412
+ * ⚠️ **This is an UPSERT.** The write is keyed on `externalId` when
1413
+ * present, else on `email`. Calling it twice with the same key UPDATES;
1414
+ * it does not fail with a duplicate error. The server answers `201` when it
1415
+ * created and `200` when it updated, and the body is the full stored
1416
+ * profile either way — so what you read back is what was persisted
1417
+ * (including `metadata` after its append-merge).
1418
+ *
1419
+ * At least one of `externalId` / `email` is required: a profile with no
1420
+ * lookup key could never be retrieved or charged against. That is checked
1421
+ * here rather than spent on a round-trip to a `400`.
1422
+ */
1423
+ upsert: async (params) => {
1424
+ // Snapshot before checking, for the same reason `update()` does — a
1425
+ // getter that answers differently on a second read must not be able to
1426
+ // change what we validated. Also means the body we send is exactly the
1427
+ // object we inspected.
1428
+ const snapshot = { ...(params ?? {}) };
1429
+ if (snapshot.externalId === undefined && snapshot.email === undefined) {
1430
+ throw new TypeError("buyers.upsert requires at least one of externalId or email — a buyer " +
1431
+ "with no lookup key can never be retrieved or charged against. " +
1432
+ "externalId is your own customer reference and is the preferred key.");
1433
+ }
1434
+ // ⚠️ Refuse a null email here too, even though only PATCH gives null the
1435
+ // erase meaning. The argument for `update()`'s runtime guard — a
1436
+ // JavaScript caller has no type system, and their serialiser may emit
1437
+ // null for a field they left out — applies identically to this call, so
1438
+ // guarding one and not the other would be arbitrary. The spec types
1439
+ // this field non-nullable, so the server would answer 400; failing here
1440
+ // with a message that names the right method is a better answer than a
1441
+ // round-trip to a validation error.
1442
+ if (snapshot.email === null) {
1443
+ throw new TypeError("buyers.upsert does not accept a null email. Omit the field to leave it " +
1444
+ "unset, or pass an address. To erase an existing buyer's email, call " +
1445
+ "buyers.eraseEmail(buyerId) — that is the only method that erases.");
1446
+ }
1447
+ const { data } = await this.request("POST", "/v1/buyers", {
1448
+ // buildSnakeBodyOpaqueMetadata, not toSnakeKeysDeep: the latter recurses
1449
+ // into `metadata` and rewrites the MERCHANT'S OWN keys — `orderId`
1450
+ // silently becomes `order_id`, which they can never match on again
1451
+ // (review rule sdk/metadata-keys-opaque). Every other resource in this
1452
+ // file uses the helper; buyers did not.
1453
+ body: buildSnakeBodyOpaqueMetadata(snapshot),
1454
+ reporterMethod: "buyers.upsert",
1455
+ });
1456
+ return buyerFromWire(data);
1457
+ },
1458
+ /**
1459
+ * `GET /v1/buyers/:id` — fetch a stored profile by id.
1460
+ *
1461
+ * A cross-merchant id returns an opaque 404, never a 403.
1462
+ */
1463
+ retrieve: async (buyerId) => {
1464
+ assertResourceId(buyerId, "buyerId");
1465
+ const { data } = await this.request("GET", `/v1/buyers/${encodeURIComponent(buyerId)}`, { reporterMethod: "buyers.retrieve" });
1466
+ return buyerFromWire(data);
1467
+ },
1468
+ /**
1469
+ * `GET /v1/buyers` — exact lookup by your reference or by email.
1470
+ *
1471
+ * Returns the profile or `null`. There is deliberately no unfiltered
1472
+ * listing in v1, so at least one key is required; when both are supplied
1473
+ * `externalId` wins, matching the upsert's identity-resolution order.
1474
+ *
1475
+ * Unwraps the server's `{ data: [...] }` envelope, which holds zero or one
1476
+ * profile — handing back a one-element array to destructure would be a
1477
+ * worse API for a lookup that can never return two.
1478
+ */
1479
+ find: async (params) => {
1480
+ if (params?.externalId === undefined && params?.email === undefined) {
1481
+ throw new TypeError("buyers.find requires at least one of externalId or email — there is " +
1482
+ "no unfiltered buyer listing in v1.");
1483
+ }
1484
+ const query = {};
1485
+ if (params.externalId !== undefined)
1486
+ query.external_id = params.externalId;
1487
+ if (params.email !== undefined)
1488
+ query.email = params.email;
1489
+ const { data } = await this.request("GET", "/v1/buyers", {
1490
+ query,
1491
+ reporterMethod: "buyers.find",
1492
+ });
1493
+ const first = data.data[0];
1494
+ return first ? buyerFromWire(first) : null;
1495
+ },
1496
+ /**
1497
+ * `PATCH /v1/buyers/:id` — update a stored profile.
1498
+ *
1499
+ * Omitted fields are left unchanged. `metadata` merges with append
1500
+ * semantics. `externalId` is not patchable — the identity key is fixed at
1501
+ * creation; upsert a new profile instead.
1502
+ *
1503
+ * ⛔ **This method cannot erase.** `email` is typed `string`, so passing
1504
+ * `null` is a compile error, and the body is built with an explicit
1505
+ * `!== undefined` test rather than a truthiness check — a truthy test would
1506
+ * also drop a legitimate empty-string value and, worse, would let a `null`
1507
+ * through unnoticed if the type were ever widened. Use
1508
+ * {@link eraseEmail} when you actually mean to erase.
1509
+ */
1510
+ update: async (buyerId, params) => {
1511
+ assertResourceId(buyerId, "buyerId");
1512
+ // ⛔ REFUSE AN EXPLICIT NULL AT RUNTIME, not only in the type.
1513
+ //
1514
+ // The type makes this unwritable in TypeScript, and that is worth having
1515
+ // — but it protects exactly the callers who were never the risk. The
1516
+ // API's own warning is about JSON libraries that serialise an ABSENT
1517
+ // optional field as `null`, and a caller in plain JavaScript has no type
1518
+ // system at all. For both of them the type is not present at runtime and
1519
+ // the erase would go through on an ordinary update.
1520
+ //
1521
+ // So the guarantee is enforced where it is actually load-bearing. There
1522
+ // is exactly one way to erase in this SDK, and it says so in its name.
1523
+ // ⛔ READ EVERY FIELD EXACTLY ONCE, BEFORE ANY OF THEM IS CHECKED.
1524
+ //
1525
+ // The first version of this guard read `params.email` three times — once
1526
+ // to test for null, twice more to build the body. That is a
1527
+ // check-then-use gap: an object whose `email` is a GETTER (or a Proxy)
1528
+ // can return `undefined` to the check and `null` to the body-builder,
1529
+ // and the erase goes through `update()` with no exception at all.
1530
+ // Reproduced against the real code, so this is not theoretical.
1531
+ //
1532
+ // That is a check-then-use gap, and it defeats the entire point of the
1533
+ // guard. Objects with lazily-evaluated properties are ordinary — ORM
1534
+ // models, framework request binders, validation wrappers — so this needs
1535
+ // no adversary, just a caller passing something that is not a plain
1536
+ // object literal. Snapshot first; decide afterwards.
1537
+ const email = params?.email;
1538
+ const name = params?.name;
1539
+ const phone = params?.phone;
1540
+ const company = params?.company;
1541
+ const address = params?.address;
1542
+ const metadata = params?.metadata;
1543
+ if (email === null) {
1544
+ throw new TypeError("buyers.update cannot erase an email address. Sending `email: null` on " +
1545
+ "this endpoint permanently erases it — including the copy held at your " +
1546
+ "payment provider — and it cannot be undone. If you meant to erase it, " +
1547
+ "call buyers.eraseEmail(buyerId). If you did not, check whether your " +
1548
+ "JSON serialiser is emitting null for fields you simply left out; " +
1549
+ "several common libraries do that by default. To leave the address " +
1550
+ "unchanged, omit the field entirely.");
1551
+ }
1552
+ const patch = {};
1553
+ // Explicit `!== undefined`, never truthiness: "" is a legitimate value to
1554
+ // send for a name, and truthiness would silently drop it.
1555
+ if (email !== undefined)
1556
+ patch.email = email;
1557
+ if (name !== undefined)
1558
+ patch.name = name;
1559
+ if (phone !== undefined)
1560
+ patch.phone = phone;
1561
+ if (company !== undefined)
1562
+ patch.company = company;
1563
+ if (address !== undefined)
1564
+ patch.address = address;
1565
+ if (metadata !== undefined)
1566
+ patch.metadata = metadata;
1567
+ // Same helper as every other resource: snake-cases `address.addressLine1`
1568
+ // and friends while leaving the merchant's own metadata keys untouched.
1569
+ const body = buildSnakeBodyOpaqueMetadata(patch);
1570
+ const { data } = await this.request("PATCH", `/v1/buyers/${encodeURIComponent(buyerId)}`, {
1571
+ body,
1572
+ reporterMethod: "buyers.update",
1573
+ });
1574
+ return buyerFromWire(data);
1575
+ },
1576
+ /**
1577
+ * **Permanently erase this buyer's email address.** `PATCH /v1/buyers/:id`
1578
+ * with `email: null`.
1579
+ *
1580
+ * It removes the address from the buyer profile. Afterwards the buyer
1581
+ * cannot be found by email.
1582
+ *
1583
+ * ⚠️ **We also ASK your payment provider to drop their copy — but a
1584
+ * successful response here does not confirm that happened.** That request
1585
+ * is dispatched after we answer you, is never awaited, and reports no
1586
+ * failure back: a provider error, a timeout, or an open circuit breaker
1587
+ * all pass silently. And for a buyer we only ever knew by email, whose
1588
+ * provider record we had not separately recorded, the retraction cannot be
1589
+ * performed at all — the key we would need was derived from the address
1590
+ * that was just erased. Do not tell a shopper the provider copy is gone on
1591
+ * the strength of a `200`.
1592
+ *
1593
+ * ⛔ **This is NOT a complete deletion on its own, and must not be
1594
+ * described to a shopper as one.** It clears the profile's address. Other
1595
+ * copies live outside this record, under their own
1596
+ * retention rules, and this call does not touch them — including the
1597
+ * settlement record kept for financial audit, checkout sessions from the
1598
+ * recent past, and any address already forwarded to a connected store,
1599
+ * which is an external system with no retraction path.
1600
+ *
1601
+ * ⚠️ Read that as examples, **not as an inventory.** It is the set we have
1602
+ * confirmed, not a completed audit of every place a copy can exist — your
1603
+ * own systems may hold one we sent you earlier. Treat this call as the
1604
+ * profile half of a deletion request, not the whole answer.
1605
+ *
1606
+ * ⚠️ **It cannot be undone**, and a buyer identified only by email becomes
1607
+ * unreachable by lookup afterwards — the record and every payment attached
1608
+ * to it survive, but `find({ email })` will no longer locate it. Keep your
1609
+ * own reference in `externalId` if you need to find them again.
1610
+ *
1611
+ * No other profile field is erasable this way; `email` is the only one the
1612
+ * API declares nullable.
1613
+ *
1614
+ * @returns the stored profile as it is after the erase, with `email: null`.
1615
+ */
1616
+ eraseEmail: async (buyerId) => {
1617
+ assertResourceId(buyerId, "buyerId");
1618
+ const { data } = await this.request("PATCH", `/v1/buyers/${encodeURIComponent(buyerId)}`, {
1619
+ // The one place in this SDK that sends an explicit null. Written as a
1620
+ // literal rather than routed through the params builder so it cannot
1621
+ // be reached by any other call path.
1622
+ body: { email: null },
1623
+ reporterMethod: "buyers.eraseEmail",
1624
+ });
1625
+ return buyerFromWire(data);
1626
+ },
1627
+ };
1302
1628
  webhookSubscriptions = {
1303
1629
  /**
1304
1630
  * `GET /v1/webhook_subscriptions` — Stripe-shaped list envelope.