@happyvertical/payments 0.105.4 → 0.105.5

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.
@@ -1,5 +1,5 @@
1
- import { a as PaymentVerificationError, r as PaymentProviderError, t as PaymentConfigurationError } from "../chunks/errors-feZrwnTy.js";
2
- import { _ as normalizeUrlString, a as formEncode, b as rememberPaymentOption, f as normalizeMaxStoredPaymentOptions, g as normalizePositivePaymentAmount, h as normalizePositiveMinorUnitAmount, m as normalizeNonEmptyString, o as getFetch, p as normalizeMinorUnitAmount, t as applyExpiryToPendingStatus, u as normalizeDate, v as pollPaymentStatus, y as readJsonResponse } from "../chunks/shared-DVl6UzD9.js";
1
+ import { i as PaymentProviderError, o as PaymentVerificationError, r as PaymentNotOwnedError, t as PaymentConfigurationError } from "../chunks/errors-zSmk_AYY.js";
2
+ import { _ as normalizeUrlString, a as formEncode, b as rememberPaymentOption, f as normalizeMaxStoredPaymentOptions, g as normalizePositivePaymentAmount, h as normalizePositiveMinorUnitAmount, m as normalizeNonEmptyString, o as getFetch, p as normalizeMinorUnitAmount, t as applyExpiryToPendingStatus, u as normalizeDate, v as pollPaymentStatus, y as readJsonResponse } from "../chunks/shared-oBgOhLsa.js";
3
3
  import { createHmac, timingSafeEqual } from "node:crypto";
4
4
  //#region src/adapters/stripe.ts
5
5
  var STRIPE_BACKEND_ID = "stripe";
@@ -18,6 +18,7 @@ var StripeAdapter = class {
18
18
  supportedCurrencies;
19
19
  checkoutMode;
20
20
  webhookSecret;
21
+ connectWebhookSecret;
21
22
  stripeAccount;
22
23
  maxStoredPaymentOptions;
23
24
  maxStoredWebhookEventIds;
@@ -26,10 +27,8 @@ var StripeAdapter = class {
26
27
  constructor(options) {
27
28
  this.options = options;
28
29
  if (typeof options.secretKey !== "string" || !options.secretKey.trim()) throw new PaymentConfigurationError("StripeAdapter requires secretKey.");
29
- if (options.webhookSecret !== void 0) {
30
- if (typeof options.webhookSecret !== "string") throw new PaymentConfigurationError("StripeAdapter webhookSecret must be a string when configured.");
31
- if (!options.webhookSecret.trim()) throw new PaymentConfigurationError("StripeAdapter webhookSecret must not be empty when configured.");
32
- }
30
+ assertOptionalSecret(options.webhookSecret, "webhookSecret");
31
+ assertOptionalSecret(options.connectWebhookSecret, "connectWebhookSecret");
33
32
  if (options.apiBaseUrl !== void 0) {
34
33
  if (typeof options.apiBaseUrl !== "string") throw new PaymentConfigurationError("StripeAdapter apiBaseUrl must be a string when configured.");
35
34
  if (!options.apiBaseUrl.trim()) throw new PaymentConfigurationError("StripeAdapter apiBaseUrl must not be empty when configured.");
@@ -45,6 +44,7 @@ var StripeAdapter = class {
45
44
  this.checkoutMode = options.checkoutMode ?? "payment";
46
45
  if (this.checkoutMode !== "payment") throw new PaymentConfigurationError("StripeAdapter only supports payment Checkout mode.");
47
46
  this.webhookSecret = options.webhookSecret?.trim();
47
+ this.connectWebhookSecret = options.connectWebhookSecret?.trim();
48
48
  this.stripeAccount = normalizeOptionalStripeAccount(options.stripeAccount, "StripeAdapter stripeAccount");
49
49
  this.capabilities = {
50
50
  id: STRIPE_BACKEND_ID,
@@ -382,33 +382,165 @@ var StripeAdapter = class {
382
382
  raw: session
383
383
  };
384
384
  }
385
+ /**
386
+ * Verify and parse a delivery to the platform webhook endpoint. Events that
387
+ * carry a connected `account` are rejected: they belong to the Connect
388
+ * endpoint and must never be processed as platform-account events.
389
+ */
385
390
  parseWebhookEvent(payload, signature) {
386
- if (!this.webhookSecret) throw new PaymentConfigurationError("StripeAdapter parseWebhookEvent requires webhookSecret.");
391
+ const event = this.verifyWebhookEvent(payload, signature, "platform");
392
+ this.recordWebhookEvent(event);
393
+ return event;
394
+ }
395
+ /**
396
+ * Verify a Stripe Connect endpoint delivery with `connectWebhookSecret`,
397
+ * then parse it. The signature is checked before the payload is parsed, and
398
+ * an event without a connected `account` is rejected. When this adapter is
399
+ * scoped to a `stripeAccount`, events for any other account are rejected.
400
+ * The payload, including `quoteId`, is controlled by the connected account:
401
+ * bind `quoteId` to `account` before acting on it.
402
+ */
403
+ parseConnectWebhookEvent(payload, signature) {
404
+ const event = this.verifyWebhookEvent(payload, signature, "connect");
405
+ this.recordWebhookEvent(event);
406
+ return event;
407
+ }
408
+ /**
409
+ * Verify a webhook delivery, then map its connected account to an owner
410
+ * (for example a tenant) with the host's resolver. An account the resolver
411
+ * does not recognize is rejected with {@link PaymentNotOwnedError}; there is
412
+ * no fallback to the platform account. The event is recorded for duplicate
413
+ * detection only after it resolves, so a retried delivery that failed to
414
+ * resolve is not reported as a duplicate.
415
+ *
416
+ * Security: a connected account controls the objects it creates, so a
417
+ * Connect event's `quoteId` (and every other payload field) is chosen by
418
+ * that account. Resolving the account proves only who sent the event. The
419
+ * host must check that `event.quoteId` belongs to the resolved owner (for
420
+ * example inside `resolveAccount`, which receives the event, or before
421
+ * settling) and must never settle another owner's quote from it.
422
+ */
423
+ async resolveConnectWebhookEvent(payload, signature, options) {
424
+ if (typeof options?.resolveAccount !== "function") throw new PaymentConfigurationError("Stripe resolveConnectWebhookEvent requires a resolveAccount function.");
425
+ const endpoint = options.endpoint ?? "connect";
426
+ if (endpoint !== "connect" && endpoint !== "platform") throw new PaymentConfigurationError("Stripe resolveConnectWebhookEvent endpoint must be connect or platform.");
427
+ const event = this.verifyWebhookEvent(payload, signature, endpoint);
428
+ const account = endpoint === "connect" ? event.account : event.connect?.type === "application_fee.created" || event.connect?.type === "application_fee.refunded" ? event.connect.applicationFee.account : void 0;
429
+ if (account === void 0) throw new PaymentNotOwnedError(`Stripe webhook event ${event.id} (${event.type}) has no connected account to route by.`);
430
+ this.assertAdapterAccount(account);
431
+ const owner = await options.resolveAccount(account, event);
432
+ if (owner === void 0 || owner === null) throw new PaymentNotOwnedError(`Stripe connected account ${account} is not mapped to an owner.`);
433
+ this.recordWebhookEvent(event);
434
+ return {
435
+ event,
436
+ account,
437
+ owner
438
+ };
439
+ }
440
+ /**
441
+ * Create a Stripe Connect **Standard** account. The account holder owns the
442
+ * Stripe relationship and dashboard; the platform only connects to it.
443
+ * Always issued on the platform account, never on a connected account.
444
+ */
445
+ async createStandardAccount(input = {}) {
446
+ const country = input.country === void 0 ? void 0 : normalizeStripeCountry(input.country);
447
+ const email = input.email === void 0 ? void 0 : normalizeNonEmptyString(input.email, "Stripe account email");
448
+ return mapStripeConnectAccount(await this.stripeRequest("/accounts", {
449
+ method: "POST",
450
+ idempotencyKey: input.idempotencyKey,
451
+ body: formEncode({
452
+ type: "standard",
453
+ country,
454
+ email,
455
+ ...Object.fromEntries([["business_type", input.businessType]]),
456
+ ...flattenStripeMetadata(input.metadata)
457
+ })
458
+ }), "Stripe account response");
459
+ }
460
+ /**
461
+ * Create a single-use Account Link that sends the account holder through
462
+ * Stripe-hosted onboarding. `refreshUrl` is visited when the link expires or
463
+ * is reused, and should mint a new link; `returnUrl` does not mean
464
+ * onboarding finished, so read {@link getAccountCapabilities} afterwards.
465
+ */
466
+ async createOnboardingLink(accountId, returnUrl, refreshUrl, options = {}) {
467
+ const account = normalizeStripeConnectedAccountId(accountId);
468
+ const link = await this.stripeRequest("/account_links", {
469
+ method: "POST",
470
+ idempotencyKey: options.idempotencyKey,
471
+ body: formEncode({
472
+ account,
473
+ type: "account_onboarding",
474
+ collect: options.collect,
475
+ ...Object.fromEntries([["return_url", normalizeUrlString(returnUrl, "Stripe returnUrl")], ["refresh_url", normalizeUrlString(refreshUrl, "Stripe refreshUrl")]])
476
+ })
477
+ });
478
+ const url = normalizeOptionalProviderString(readProviderString(link, "url"));
479
+ if (!url) throw new PaymentProviderError("Stripe Account Link response did not include a url.");
480
+ const expiresAtSeconds = readSafeInteger(link, "expires_at");
481
+ return {
482
+ url,
483
+ expiresAt: expiresAtSeconds === void 0 ? void 0 : /* @__PURE__ */ new Date(expiresAtSeconds * 1e3),
484
+ raw: link
485
+ };
486
+ }
487
+ /**
488
+ * Read a connected account's onboarding and capability state
489
+ * (`charges_enabled`, `details_submitted`, `payouts_enabled`, per-capability
490
+ * status, and outstanding requirements).
491
+ */
492
+ async getAccountCapabilities(accountId) {
493
+ const account = normalizeStripeConnectedAccountId(accountId);
494
+ const status = mapStripeConnectAccount(await this.stripeRequest(`/accounts/${encodeURIComponent(account)}`), "Stripe account response");
495
+ if (status.accountId !== account) throw new PaymentVerificationError(`Stripe account response ${status.accountId} did not match ${account}.`);
496
+ return status;
497
+ }
498
+ verifyWebhookEvent(payload, signature, endpoint) {
499
+ const secret = endpoint === "connect" ? this.connectWebhookSecret : this.webhookSecret;
500
+ if (!secret) throw new PaymentConfigurationError(endpoint === "connect" ? "StripeAdapter parseConnectWebhookEvent requires connectWebhookSecret." : "StripeAdapter parseWebhookEvent requires webhookSecret.");
387
501
  if (!signature) throw new PaymentProviderError("Missing Stripe webhook signature.");
388
- verifyStripeWebhookSignature(payload, signature, this.webhookSecret);
502
+ verifyStripeWebhookSignature(payload, signature, secret);
389
503
  const event = parseStripeWebhookPayload(payload);
390
504
  const id = readRequiredWebhookString(event, "id", "Stripe webhook event id");
391
505
  const type = readRequiredWebhookString(event, "type", "Stripe webhook event type");
392
- const duplicate = this.seenWebhookEventIds.has(id);
393
- if (!duplicate) {
394
- this.seenWebhookEventIds.add(id);
395
- while (this.seenWebhookEventIds.size > this.maxStoredWebhookEventIds) {
396
- const oldest = this.seenWebhookEventIds.values().next().value;
397
- if (oldest === void 0) break;
398
- this.seenWebhookEventIds.delete(oldest);
399
- }
400
- }
506
+ const rawAccount = event.account;
507
+ let account;
508
+ if (endpoint === "connect") {
509
+ account = typeof rawAccount === "string" ? rawAccount.trim() : void 0;
510
+ if (!account || !STRIPE_CONNECTED_ACCOUNT_ID.test(account)) throw new PaymentVerificationError("Stripe Connect webhook event did not include a valid connected account.");
511
+ this.assertAdapterAccount(account);
512
+ } else if (rawAccount !== void 0 && rawAccount !== null) throw new PaymentVerificationError("Stripe connected-account event received on the platform webhook endpoint; verify it with parseConnectWebhookEvent.");
401
513
  const data = event.data?.object;
514
+ const connect = mapStripeConnectEventDetail(type, data);
515
+ if (account !== void 0 && connect?.type === "account.updated" && connect.account.accountId !== account) throw new PaymentVerificationError("Stripe account.updated webhook object did not match the event account.");
402
516
  return {
403
517
  id,
404
518
  type,
405
519
  status: mapStripeWebhookStatus(type, data),
406
520
  quoteId: normalizeOptionalWebhookString((type.startsWith("checkout.session.") ? readString(data, "client_reference_id") : void 0) ?? readString(data?.metadata ?? {}, "quoteId")),
407
521
  providerPaymentId: normalizeOptionalWebhookString(readString(data, "id")),
408
- duplicate,
522
+ duplicate: this.seenWebhookEventIds.has(id),
523
+ ...account === void 0 ? {} : { account },
524
+ ...connect === void 0 ? {} : { connect },
409
525
  raw: event
410
526
  };
411
527
  }
528
+ recordWebhookEvent(event) {
529
+ if (this.seenWebhookEventIds.has(event.id)) {
530
+ event.duplicate = true;
531
+ return;
532
+ }
533
+ event.duplicate = false;
534
+ this.seenWebhookEventIds.add(event.id);
535
+ while (this.seenWebhookEventIds.size > this.maxStoredWebhookEventIds) {
536
+ const oldest = this.seenWebhookEventIds.values().next().value;
537
+ if (oldest === void 0) break;
538
+ this.seenWebhookEventIds.delete(oldest);
539
+ }
540
+ }
541
+ assertAdapterAccount(account) {
542
+ if (this.stripeAccount !== void 0 && account !== this.stripeAccount) throw new PaymentNotOwnedError(`Stripe connected account ${account} does not match this adapter's account scope.`);
543
+ }
412
544
  async stripeRequest(path, init = {}) {
413
545
  const headers = new Headers(init.headers);
414
546
  headers.set("Authorization", `Bearer ${this.secretKey}`);
@@ -442,6 +574,80 @@ var StripeAdapter = class {
442
574
  for (const option of this.optionsByQuote.values()) if (option.providerPaymentId === sessionId) return option;
443
575
  }
444
576
  };
577
+ function assertOptionalSecret(value, name) {
578
+ if (value === void 0) return;
579
+ if (typeof value !== "string") throw new PaymentConfigurationError(`StripeAdapter ${name} must be a string when configured.`);
580
+ if (!value.trim()) throw new PaymentConfigurationError(`StripeAdapter ${name} must not be empty when configured.`);
581
+ }
582
+ var STRIPE_CONNECTED_ACCOUNT_ID = /^acct_[A-Za-z0-9]+$/;
583
+ function normalizeStripeConnectedAccountId(value) {
584
+ const account = typeof value === "string" ? value.trim() : void 0;
585
+ if (!account || !STRIPE_CONNECTED_ACCOUNT_ID.test(account)) throw new PaymentConfigurationError("Stripe connected account id must look like acct_...");
586
+ return account;
587
+ }
588
+ function normalizeStripeCountry(value) {
589
+ const country = typeof value === "string" ? value.trim().toUpperCase() : "";
590
+ if (!/^[A-Z]{2}$/.test(country)) throw new PaymentConfigurationError("Stripe account country must be a two-letter ISO country code.");
591
+ return country;
592
+ }
593
+ function readStringArray(value, key) {
594
+ const item = value?.[key];
595
+ return Array.isArray(item) ? item.filter((entry) => typeof entry === "string") : [];
596
+ }
597
+ function mapStripeConnectAccount(account, context) {
598
+ const accountId = readProviderString(account, "id")?.trim();
599
+ if (!account || !accountId || !STRIPE_CONNECTED_ACCOUNT_ID.test(accountId)) throw new PaymentProviderError(`${context} did not include an account id.`);
600
+ const capabilities = {};
601
+ for (const [name, state] of Object.entries(readObject(account, "capabilities") ?? {})) if (typeof state === "string") capabilities[name] = state;
602
+ const requirements = readObject(account, "requirements");
603
+ const disabledReason = normalizeOptionalProviderString(readProviderString(requirements, "disabled_reason"));
604
+ const defaultCurrency = readProviderString(account, "default_currency");
605
+ return {
606
+ accountId,
607
+ type: normalizeOptionalProviderString(readProviderString(account, "type")),
608
+ chargesEnabled: account.charges_enabled === true,
609
+ detailsSubmitted: account.details_submitted === true,
610
+ payoutsEnabled: account.payouts_enabled === true,
611
+ capabilities,
612
+ requirements: {
613
+ currentlyDue: readStringArray(requirements, "currently_due"),
614
+ eventuallyDue: readStringArray(requirements, "eventually_due"),
615
+ pastDue: readStringArray(requirements, "past_due"),
616
+ ...disabledReason === void 0 ? {} : { disabledReason }
617
+ },
618
+ country: normalizeOptionalProviderString(readProviderString(account, "country")),
619
+ defaultCurrency: defaultCurrency?.toUpperCase(),
620
+ raw: account
621
+ };
622
+ }
623
+ function mapStripeApplicationFee(fee) {
624
+ const id = normalizeOptionalProviderString(readProviderString(fee, "id"));
625
+ if (!fee || !id) throw new PaymentProviderError("Stripe application_fee webhook object did not include an id.");
626
+ const account = normalizeOptionalProviderString(readProviderString(fee, "account"));
627
+ if (account !== void 0 && !STRIPE_CONNECTED_ACCOUNT_ID.test(account)) throw new PaymentProviderError("Stripe application_fee webhook object has an invalid account id.");
628
+ const livemode = fee.livemode;
629
+ return {
630
+ id,
631
+ account,
632
+ amount: readSafeInteger(fee, "amount"),
633
+ amountRefunded: readSafeInteger(fee, "amount_refunded"),
634
+ currency: readStripeResultCurrency(fee),
635
+ refunded: fee.refunded === true,
636
+ charge: normalizeOptionalProviderString(readProviderString(fee, "charge") ?? readProviderString(readObject(fee, "charge"), "id")),
637
+ livemode: typeof livemode === "boolean" ? livemode : void 0,
638
+ raw: fee
639
+ };
640
+ }
641
+ function mapStripeConnectEventDetail(type, object) {
642
+ if (type === "account.updated") return {
643
+ type,
644
+ account: mapStripeConnectAccount(object, "Stripe account.updated webhook object")
645
+ };
646
+ if (type === "application_fee.created" || type === "application_fee.refunded") return {
647
+ type,
648
+ applicationFee: mapStripeApplicationFee(object)
649
+ };
650
+ }
445
651
  function readStripeOptionAccount(option) {
446
652
  const value = option?.metadata?.stripeAccount;
447
653
  return typeof value === "string" ? value : void 0;