@happyvertical/accounting 0.91.0 → 0.94.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.
@@ -1,3 +1,4 @@
1
+ import { t as StripeApiError } from "./errors-Dvn2QzOB.js";
1
2
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
2
3
  //#region src/providers/stripe/index.ts
3
4
  /**
@@ -14,6 +15,7 @@ var StripeProvider = class {
14
15
  type = "stripe";
15
16
  options;
16
17
  fetchImpl;
18
+ couponIds = /* @__PURE__ */ new Map();
17
19
  customers;
18
20
  invoices;
19
21
  vendors;
@@ -42,7 +44,7 @@ var StripeProvider = class {
42
44
  }
43
45
  async request(method, endpoint, params, options = {}) {
44
46
  const timeout = this.options.timeout || 3e4;
45
- const maxRetries = this.options.maxRetries || 3;
47
+ const maxRetries = this.options.maxRetries ?? 3;
46
48
  const idempotencyKey = method === "POST" ? options.idempotencyKey ?? `sdk-${randomUUID()}` : null;
47
49
  let lastError = null;
48
50
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -68,7 +70,7 @@ var StripeProvider = class {
68
70
  });
69
71
  if (!response.ok) {
70
72
  const errorText = await response.text();
71
- const error = /* @__PURE__ */ new Error(`Stripe API error (${response.status}): ${errorText}`);
73
+ const error = new StripeApiError(response.status, errorText);
72
74
  if (response.status < 500 && response.status !== 429) throw error;
73
75
  lastError = error;
74
76
  } else {
@@ -89,6 +91,42 @@ var StripeProvider = class {
89
91
  }
90
92
  throw lastError || /* @__PURE__ */ new Error("Stripe request failed after retries");
91
93
  }
94
+ /**
95
+ * @internal
96
+ * A reusable amount-off coupon for a line discount, created on first use.
97
+ * Its id is derived from the currency and amount, so every replay and every
98
+ * invoice with the same discount shares one coupon instead of creating one
99
+ * per line.
100
+ */
101
+ async amountOffCoupon(currency, amount) {
102
+ const normalized = currency.toLowerCase();
103
+ const id = `hv_amount_off_${normalized}_${amount}`;
104
+ const cached = this.couponIds.get(id);
105
+ if (cached) return cached;
106
+ const path = `/v1/coupons/${encodeURIComponent(id)}`;
107
+ let coupon;
108
+ try {
109
+ coupon = await this.request("GET", path);
110
+ } catch (error) {
111
+ if (!(error instanceof StripeApiError) || error.status !== 404) throw error;
112
+ try {
113
+ coupon = await this.request("POST", "/v1/coupons", {
114
+ id,
115
+ amount_off: amount,
116
+ currency: normalized,
117
+ duration: "forever",
118
+ name: "Discount",
119
+ metadata: { hv_purpose: "invoice_line_discount" }
120
+ });
121
+ } catch (createError) {
122
+ if (!(createError instanceof StripeApiError) || createError.code !== "resource_already_exists") throw createError;
123
+ coupon = await this.request("GET", path);
124
+ }
125
+ }
126
+ if (coupon.amount_off !== amount || (coupon.currency || "").toLowerCase() !== normalized || coupon.percent_off || coupon.valid === false) throw new Error(`Stripe coupon ${id} exists but is not a valid ${amount} ${normalized} amount-off coupon`);
127
+ this.couponIds.set(id, coupon.id);
128
+ return coupon.id;
129
+ }
92
130
  buildUrl(endpoint, params) {
93
131
  const baseUrl = this.options.apiBaseUrl || "https://api.stripe.com";
94
132
  const url = new URL(endpoint, baseUrl);
@@ -102,12 +140,40 @@ var StripeCustomerOperations = class {
102
140
  this.provider = provider;
103
141
  }
104
142
  async push(customer) {
143
+ if (customer.idempotencyKey !== void 0) {
144
+ assertIdempotencyKey(customer.idempotencyKey);
145
+ const existing = await this.findByLocalId(customer.id);
146
+ if (existing) return {
147
+ action: "created",
148
+ externalId: existing.id,
149
+ syncedAt: /* @__PURE__ */ new Date()
150
+ };
151
+ }
105
152
  return {
106
153
  action: "created",
107
- externalId: (await this.provider.request("POST", "/v1/customers", mapCustomerToStripe(customer))).id,
154
+ externalId: (await this.provider.request("POST", "/v1/customers", mapCustomerToStripe(customer), { idempotencyKey: customer.idempotencyKey ? `${customer.idempotencyKey}:customer` : void 0 })).id,
108
155
  syncedAt: /* @__PURE__ */ new Date()
109
156
  };
110
157
  }
158
+ /**
159
+ * The oldest customer tagged with this local id. Stripe search is
160
+ * eventually consistent (normally under a minute); inside that lag the
161
+ * idempotency key, retained for at least 24 hours, covers the replay.
162
+ */
163
+ async findByLocalId(localId) {
164
+ const matches = [];
165
+ let page;
166
+ do {
167
+ const response = await this.provider.request("GET", "/v1/customers/search", {
168
+ query: `metadata['local_id']:'${escapeSearchValue(localId)}'`,
169
+ limit: 100,
170
+ page
171
+ });
172
+ matches.push(...response.data.filter((customer) => customer.metadata?.local_id === localId));
173
+ page = response.has_more ? response.next_page || void 0 : void 0;
174
+ } while (page);
175
+ return matches.sort((left, right) => (left.created ?? 0) - (right.created ?? 0) || left.id.localeCompare(right.id))[0];
176
+ }
111
177
  async pull(externalId) {
112
178
  return mapStripeCustomer(await this.provider.request("GET", `/v1/customers/${encodeURIComponent(externalId)}`));
113
179
  }
@@ -131,6 +197,14 @@ var StripeInvoiceOperations = class {
131
197
  }
132
198
  async push(invoice) {
133
199
  if (!invoice.customerExternalId) throw new Error("Stripe invoices require customerExternalId");
200
+ const currency = invoice.currency || "usd";
201
+ const collectionMethod = resolveCollectionMethod(invoice);
202
+ const lines = invoice.lineItems.map((lineItem) => ({
203
+ lineItem,
204
+ unitAmount: moneyToStripeMinorUnits(lineItem.unitPrice, currency),
205
+ discount: lineDiscountToStripe(lineItem, currency),
206
+ period: lineServicePeriod(lineItem)
207
+ }));
134
208
  const existingInvoice = invoice.idempotencyKey ? await this.findInvoiceByLocalId(invoice.customerExternalId, invoice.id) : void 0;
135
209
  if (existingInvoice) return {
136
210
  action: "created",
@@ -138,14 +212,18 @@ var StripeInvoiceOperations = class {
138
212
  syncedAt: /* @__PURE__ */ new Date()
139
213
  };
140
214
  const existingLineItemIndexes = invoice.idempotencyKey ? await this.findPendingLineItemIndexes(invoice.customerExternalId, invoice.id) : /* @__PURE__ */ new Set();
141
- for (const [index, lineItem] of invoice.lineItems.entries()) {
215
+ for (const [index, line] of lines.entries()) {
142
216
  if (existingLineItemIndexes.has(index)) continue;
217
+ const { lineItem, unitAmount, discount, period } = line;
218
+ const coupon = discount ? await this.provider.amountOffCoupon(currency, discount) : void 0;
143
219
  await this.provider.request("POST", "/v1/invoiceitems", {
144
220
  customer: invoice.customerExternalId,
145
- currency: invoice.currency || "usd",
221
+ currency,
146
222
  description: lineItem.description,
147
223
  quantity: lineItem.quantity,
148
- unit_amount_decimal: String(moneyToStripeMinorUnits(lineItem.unitPrice, invoice.currency || "usd")),
224
+ unit_amount_decimal: String(unitAmount),
225
+ period,
226
+ discounts: coupon ? [{ coupon }] : void 0,
149
227
  tax_behavior: invoice.automaticTax ? "exclusive" : void 0,
150
228
  metadata: normalizeMetadata({
151
229
  local_invoice_id: invoice.id,
@@ -158,7 +236,7 @@ var StripeInvoiceOperations = class {
158
236
  }
159
237
  return {
160
238
  action: "created",
161
- externalId: (await this.provider.request("POST", "/v1/invoices", mapInvoiceToStripe(invoice), { idempotencyKey: invoice.idempotencyKey ? `${invoice.idempotencyKey}:invoice` : void 0 })).id,
239
+ externalId: (await this.provider.request("POST", "/v1/invoices", mapInvoiceToStripe(invoice, collectionMethod), { idempotencyKey: invoice.idempotencyKey ? `${invoice.idempotencyKey}:invoice` : void 0 })).id,
162
240
  syncedAt: /* @__PURE__ */ new Date()
163
241
  };
164
242
  }
@@ -170,15 +248,59 @@ var StripeInvoiceOperations = class {
170
248
  }
171
249
  async sync(invoice) {
172
250
  if (!invoice.externalId) return this.push(invoice);
173
- await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(invoice.externalId)}`, mapInvoiceUpdateToStripe(invoice));
251
+ await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(invoice.externalId)}`, mapInvoiceUpdateToStripe(invoice, resolveCollectionMethod(invoice)));
174
252
  return {
175
253
  action: "updated",
176
254
  externalId: invoice.externalId,
177
255
  syncedAt: /* @__PURE__ */ new Date()
178
256
  };
179
257
  }
258
+ /**
259
+ * `send_invoice` invoices are emailed through Stripe's send endpoint.
260
+ * `charge_automatically` invoices are finalized with automatic collection
261
+ * on, so Stripe charges the customer's default payment method and applies
262
+ * its retry settings; the outcome arrives as `invoice.paid` or
263
+ * `invoice.payment_failed`.
264
+ */
180
265
  async send(externalId) {
181
- await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(externalId)}/send`);
266
+ const path = `/v1/invoices/${encodeURIComponent(externalId)}`;
267
+ const invoice = await this.provider.request("GET", path);
268
+ if (invoice.collection_method !== "charge_automatically") {
269
+ await this.provider.request("POST", `${path}/send`);
270
+ return;
271
+ }
272
+ if (invoice.status === "draft") {
273
+ await this.provider.request("POST", `${path}/finalize`, { auto_advance: true }, { idempotencyKey: `${externalId}:finalize` });
274
+ return;
275
+ }
276
+ if (invoice.status === "open" && invoice.auto_advance === false) await this.provider.request("POST", path, { auto_advance: true }, { idempotencyKey: `${externalId}:auto_advance` });
277
+ }
278
+ async markUncollectible(externalId) {
279
+ await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(externalId)}/mark_uncollectible`, void 0, { idempotencyKey: `${externalId}:mark_uncollectible` });
280
+ }
281
+ /**
282
+ * Close an invoice paid on another rail (`paid_out_of_band`). An invoice
283
+ * already marked paid out of band, or paid with nothing collected, is left
284
+ * as is; one Stripe collected money for itself throws (a second collection needs a refund, not a silent close).
285
+ * A draft is finalized first without automatic collection, and refused if
286
+ * finalization raised the amount due. A void invoice throws; an
287
+ * uncollectible one can still be paid, as in Stripe.
288
+ */
289
+ async markPaidOutOfBand(externalId) {
290
+ const path = `/v1/invoices/${encodeURIComponent(externalId)}`;
291
+ let invoice = await this.provider.request("GET", path);
292
+ if (invoice.status === "paid") {
293
+ if (invoice.paid_out_of_band === true || !(invoice.amount_paid ?? 0)) return;
294
+ throw new Error(`Stripe invoice ${externalId} was already paid through Stripe; it was not marked paid out of band.`);
295
+ }
296
+ if (invoice.status === "void") throw new Error(`Stripe invoice ${externalId} is void and cannot be marked paid.`);
297
+ if (invoice.status === "draft") {
298
+ const draftDue = invoice.amount_remaining ?? invoice.total ?? 0;
299
+ invoice = await this.provider.request("POST", `${path}/finalize`, { auto_advance: false }, { idempotencyKey: `${externalId}:finalize_out_of_band` });
300
+ if (invoice.status === "paid") return;
301
+ if ((invoice.amount_remaining ?? 0) > draftDue) throw new Error(`Stripe invoice ${externalId} grew from ${draftDue} to ${invoice.amount_remaining} on finalization; not marking it paid out of band.`);
302
+ }
303
+ await this.provider.request("POST", `${path}/pay`, { paid_out_of_band: true }, { idempotencyKey: `${externalId}:paid_out_of_band` });
182
304
  }
183
305
  async void(externalId) {
184
306
  await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(externalId)}/void`);
@@ -227,6 +349,79 @@ var StripePaymentOperations = class {
227
349
  async list(options = {}) {
228
350
  return (await this.provider.request("GET", "/v1/payment_intents", mapListOptions(options))).data.map(mapStripePaymentIntent);
229
351
  }
352
+ async chargeSavedPaymentMethod(input) {
353
+ assertIdempotencyKey(input.idempotencyKey);
354
+ if (!input.customerExternalId) throw new Error("Saved payment method charges require a customer");
355
+ const currency = normalizeCurrencyCode(input.currency);
356
+ const stripeAmount = minorUnitsToStripeAmount(input.amountMinor, currency);
357
+ if (stripeAmount <= 0) throw new Error("Charge amount must be a positive integer of minor units");
358
+ const chargeKey = input.idempotencyKey;
359
+ const base = {
360
+ provider: "stripe",
361
+ customerExternalId: input.customerExternalId,
362
+ amountMinor: input.amountMinor,
363
+ currency,
364
+ chargeKey
365
+ };
366
+ const existing = await this.findByChargeKey(chargeKey);
367
+ if (existing) {
368
+ assertSameCharge(existing, input.customerExternalId, stripeAmount, currency, input.paymentMethodExternalId);
369
+ return {
370
+ ...base,
371
+ ...mapChargeOutcome(existing)
372
+ };
373
+ }
374
+ let paymentMethod = input.paymentMethodExternalId;
375
+ if (!paymentMethod) {
376
+ paymentMethod = idOf((await this.provider.request("GET", `/v1/customers/${encodeURIComponent(input.customerExternalId)}`)).invoice_settings?.default_payment_method);
377
+ if (!paymentMethod) return {
378
+ ...base,
379
+ status: "failed",
380
+ failureCode: "payment_method_missing",
381
+ failureMessage: "The customer has no default payment method."
382
+ };
383
+ }
384
+ try {
385
+ const paymentIntent = await this.provider.request("POST", "/v1/payment_intents", {
386
+ amount: stripeAmount,
387
+ currency: currency.toLowerCase(),
388
+ customer: input.customerExternalId,
389
+ payment_method: paymentMethod,
390
+ off_session: true,
391
+ confirm: true,
392
+ description: input.description,
393
+ metadata: normalizeMetadata({
394
+ ...input.metadata,
395
+ [CHARGE_KEY_METADATA]: chargeKey
396
+ })
397
+ }, { idempotencyKey: `${chargeKey}:payment_intent` });
398
+ return {
399
+ ...base,
400
+ ...mapChargeOutcome(paymentIntent)
401
+ };
402
+ } catch (error) {
403
+ if (error instanceof StripeApiError && error.status === 402 && error.type === "card_error") {
404
+ const paymentIntent = isRecord(error.paymentIntent) ? error.paymentIntent : void 0;
405
+ return {
406
+ ...base,
407
+ ...paymentIntent ? mapChargeOutcome(paymentIntent) : {},
408
+ paymentMethodExternalId: idOf(paymentIntent?.payment_method) ?? paymentMethod,
409
+ status: error.code === "authentication_required" ? "requires_action" : "failed",
410
+ failureCode: error.code,
411
+ declineCode: error.declineCode,
412
+ failureMessage: error.stripeMessage,
413
+ raw: paymentIntent ? withoutClientSecret(paymentIntent) : void 0
414
+ };
415
+ }
416
+ throw error;
417
+ }
418
+ }
419
+ async findByChargeKey(chargeKey) {
420
+ return (await this.provider.request("GET", "/v1/payment_intents/search", {
421
+ query: `metadata['${CHARGE_KEY_METADATA}']:'${escapeSearchValue(chargeKey)}'`,
422
+ limit: 10
423
+ })).data.filter((intent) => intent.metadata?.[CHARGE_KEY_METADATA] === chargeKey).sort((left, right) => (left.created ?? 0) - (right.created ?? 0) || left.id.localeCompare(right.id))[0];
424
+ }
230
425
  };
231
426
  var StripeBillingOperationsImpl = class {
232
427
  provider;
@@ -234,16 +429,38 @@ var StripeBillingOperationsImpl = class {
234
429
  this.provider = provider;
235
430
  }
236
431
  async createCheckoutSession(input) {
237
- const response = await this.provider.request("POST", "/v1/checkout/sessions", mapCheckoutSessionToStripe(input), { idempotencyKey: input.idempotencyKey });
432
+ return mapCheckoutSessionResponse(await this.provider.request("POST", "/v1/checkout/sessions", mapCheckoutSessionToStripe(input), { idempotencyKey: input.idempotencyKey }));
433
+ }
434
+ async retrieveCheckoutSession(sessionExternalId) {
435
+ const session = await this.provider.request("GET", `/v1/checkout/sessions/${encodeURIComponent(sessionExternalId)}`, { expand: ["setup_intent", "payment_intent"] });
436
+ const intent = typeof session.setup_intent === "object" && session.setup_intent ? session.setup_intent : typeof session.payment_intent === "object" && session.payment_intent ? session.payment_intent : void 0;
437
+ const currency = session.currency ? session.currency.toUpperCase() : void 0;
438
+ const minor = (amount) => currency && typeof amount === "number" ? stripeAmountToMinorUnitsOrUndefined(amount, currency) : void 0;
238
439
  return {
239
- externalId: response.id,
240
- url: response.url || null,
241
- customerExternalId: response.customer || void 0,
242
- subscriptionExternalId: response.subscription || void 0,
243
- paymentIntentExternalId: response.payment_intent || void 0,
244
- raw: response
440
+ ...mapCheckoutSessionResponse(session),
441
+ raw: withoutClientSecrets(session),
442
+ status: oneOf(session.status, [
443
+ "open",
444
+ "complete",
445
+ "expired"
446
+ ]),
447
+ paymentStatus: oneOf(session.payment_status, [
448
+ "paid",
449
+ "unpaid",
450
+ "no_payment_required"
451
+ ]),
452
+ paymentMethodExternalId: idOf(intent?.payment_method),
453
+ currency,
454
+ amountSubtotalMinor: minor(session.amount_subtotal),
455
+ amountTaxMinor: minor(session.total_details?.amount_tax),
456
+ amountTotalMinor: minor(session.amount_total),
457
+ metadata: { ...session.metadata || {} }
245
458
  };
246
459
  }
460
+ async setDefaultPaymentMethod(customerExternalId, paymentMethodExternalId) {
461
+ if (!customerExternalId || !paymentMethodExternalId) throw new Error("setDefaultPaymentMethod requires a customer and a payment method");
462
+ await this.provider.request("POST", `/v1/customers/${encodeURIComponent(customerExternalId)}`, { invoice_settings: { default_payment_method: paymentMethodExternalId } });
463
+ }
247
464
  async createCustomerPortalSession(input) {
248
465
  const response = await this.provider.request("POST", "/v1/billing_portal/sessions", {
249
466
  customer: input.customerExternalId,
@@ -350,7 +567,9 @@ var StripeWebhookOperations = class {
350
567
  throw new Error(`Invalid Stripe webhook payload: ${message}`);
351
568
  }
352
569
  const resource = event?.data?.object;
570
+ const payment = resource?.object === "payment_intent" && typeof resource.id === "string" ? mapWebhookPayment(resource, event?.type || void 0) : void 0;
353
571
  return {
572
+ ...payment ? { payment } : {},
354
573
  type: event?.type || "unknown",
355
574
  id: typeof event?.id === "string" ? event.id : void 0,
356
575
  provider: "stripe",
@@ -398,19 +617,19 @@ function mapCustomerToStripe(customer) {
398
617
  country: customer.billingAddress.country
399
618
  } : void 0,
400
619
  metadata: normalizeMetadata({
401
- local_id: customer.id,
402
620
  payment_terms: customer.paymentTerms,
403
- ...customer.metadata
621
+ ...customer.metadata,
622
+ local_id: customer.id
404
623
  })
405
624
  };
406
625
  if (customer.taxExempt !== void 0) stripeCustomer.tax_exempt = customer.taxExempt ? "exempt" : "none";
407
626
  return stripeCustomer;
408
627
  }
409
- function mapInvoiceToStripe(invoice) {
628
+ function mapInvoiceToStripe(invoice, collectionMethod) {
410
629
  return {
411
630
  customer: invoice.customerExternalId,
412
- collection_method: "send_invoice",
413
- due_date: invoice.dueDate,
631
+ collection_method: collectionMethod,
632
+ due_date: collectionMethod === "send_invoice" ? invoice.dueDate : void 0,
414
633
  auto_advance: false,
415
634
  pending_invoice_items_behavior: "include",
416
635
  automatic_tax: invoice.automaticTax ? { enabled: true } : void 0,
@@ -426,9 +645,10 @@ function mapInvoiceToStripe(invoice) {
426
645
  })
427
646
  };
428
647
  }
429
- function mapInvoiceUpdateToStripe(invoice) {
648
+ function mapInvoiceUpdateToStripe(invoice, collectionMethod) {
430
649
  return {
431
- due_date: invoice.dueDate,
650
+ collection_method: invoice.collectionMethod ? collectionMethod : void 0,
651
+ due_date: collectionMethod === "send_invoice" ? invoice.dueDate : void 0,
432
652
  metadata: normalizeMetadata({
433
653
  local_id: invoice.id,
434
654
  invoice_number: invoice.invoiceNumber,
@@ -439,35 +659,76 @@ function mapInvoiceUpdateToStripe(invoice) {
439
659
  };
440
660
  }
441
661
  function mapCheckoutSessionToStripe(input) {
662
+ const mode = input.mode || "subscription";
663
+ const lineItems = input.lineItems || [];
664
+ if (mode === "setup") {
665
+ if (lineItems.length > 0) throw new Error("Stripe setup-mode Checkout does not accept line items");
666
+ if (!input.currency && !input.paymentMethodTypes?.length) throw new Error("Stripe setup-mode Checkout requires currency or paymentMethodTypes");
667
+ if (input.automaticTax) throw new Error("Stripe setup-mode Checkout does not calculate tax");
668
+ } else if (lineItems.length === 0) throw new Error(`Stripe ${mode}-mode Checkout requires line items`);
669
+ if (input.setupFutureUsage && mode !== "payment") throw new Error("setupFutureUsage applies only to payment-mode Checkout");
670
+ if (input.customerUpdate && !input.customerExternalId) throw new Error("customerUpdate requires customerExternalId");
442
671
  return {
443
- mode: input.mode || "subscription",
672
+ mode,
444
673
  success_url: input.successUrl,
445
674
  cancel_url: input.cancelUrl,
446
675
  customer: input.customerExternalId,
447
676
  customer_email: input.customerExternalId ? void 0 : input.customerEmail,
448
677
  client_reference_id: input.clientReferenceId,
449
678
  allow_promotion_codes: input.allowPromotionCodes,
450
- line_items: input.lineItems.map(mapCheckoutLineItem),
679
+ currency: input.currency ? input.currency.toLowerCase() : void 0,
680
+ payment_method_types: input.paymentMethodTypes,
681
+ automatic_tax: input.automaticTax ? { enabled: true } : void 0,
682
+ billing_address_collection: input.billingAddressCollection,
683
+ customer_update: input.customerUpdate ? {
684
+ address: input.customerUpdate.address,
685
+ name: input.customerUpdate.name,
686
+ shipping: input.customerUpdate.shipping
687
+ } : void 0,
688
+ payment_intent_data: input.setupFutureUsage ? { setup_future_usage: input.setupFutureUsage } : void 0,
689
+ line_items: mode === "setup" ? void 0 : lineItems.map((lineItem) => mapCheckoutLineItem(lineItem, Boolean(input.automaticTax))),
451
690
  metadata: normalizeMetadata(input.metadata || {})
452
691
  };
453
692
  }
454
- function mapCheckoutLineItem(lineItem) {
693
+ function mapCheckoutSessionResponse(response) {
694
+ return {
695
+ externalId: response.id,
696
+ url: response.url || null,
697
+ mode: oneOf(response.mode, [
698
+ "payment",
699
+ "setup",
700
+ "subscription"
701
+ ]),
702
+ customerExternalId: idOf(response.customer),
703
+ subscriptionExternalId: idOf(response.subscription),
704
+ paymentIntentExternalId: idOf(response.payment_intent),
705
+ setupIntentExternalId: idOf(response.setup_intent),
706
+ raw: withoutClientSecrets(response)
707
+ };
708
+ }
709
+ function mapCheckoutLineItem(lineItem, automaticTax = false) {
455
710
  if (lineItem.price) return {
456
711
  price: lineItem.price,
457
712
  quantity: lineItem.quantity || 1
458
713
  };
459
714
  if (!lineItem.priceData) throw new Error("Stripe checkout line item requires price or priceData");
460
- const productData = lineItem.priceData.product ? void 0 : { name: lineItem.priceData.productName || "Subscription" };
715
+ const { priceData } = lineItem;
716
+ const productData = priceData.product ? void 0 : { name: priceData.productName || "Subscription" };
717
+ const hasStripeAmount = priceData.unitAmount !== void 0;
718
+ const hasMinorAmount = priceData.unitAmountMinor !== void 0;
719
+ if (hasStripeAmount === hasMinorAmount) throw new Error("Stripe checkout priceData requires exactly one of unitAmount or unitAmountMinor");
720
+ const unitAmount = hasMinorAmount ? minorUnitsToStripeAmount(priceData.unitAmountMinor, normalizeCurrencyCode(priceData.currency)) : priceData.unitAmount;
461
721
  return {
462
722
  quantity: lineItem.quantity || 1,
463
723
  price_data: {
464
- currency: lineItem.priceData.currency,
465
- unit_amount: lineItem.priceData.unitAmount,
466
- product: lineItem.priceData.product,
724
+ currency: priceData.currency.toLowerCase(),
725
+ unit_amount: unitAmount,
726
+ tax_behavior: priceData.taxBehavior ?? (automaticTax ? "exclusive" : void 0),
727
+ product: priceData.product,
467
728
  product_data: productData,
468
- recurring: lineItem.priceData.recurring ? {
469
- interval: lineItem.priceData.recurring.interval,
470
- interval_count: lineItem.priceData.recurring.intervalCount
729
+ recurring: priceData.recurring ? {
730
+ interval: priceData.recurring.interval,
731
+ interval_count: priceData.recurring.intervalCount
471
732
  } : void 0
472
733
  }
473
734
  };
@@ -520,6 +781,7 @@ function mapStripeInvoice(invoice) {
520
781
  balance: stripeMinorUnitsToMoney(invoice.amount_remaining || 0, invoice.currency),
521
782
  status: mapStripeInvoiceStatus(invoice.status, invoice.due_date),
522
783
  currency: invoice.currency || "usd",
784
+ paidOutOfBand: invoice.paid_out_of_band === true,
523
785
  raw: invoice
524
786
  };
525
787
  }
@@ -556,6 +818,7 @@ function mapStripeInvoiceStatus(status, dueDate) {
556
818
  case "draft": return "draft";
557
819
  case "paid": return "paid";
558
820
  case "void": return "voided";
821
+ case "uncollectible": return "uncollectible";
559
822
  case "open":
560
823
  if (dueDate && dueDate * 1e3 < Date.now()) return "overdue";
561
824
  return "sent";
@@ -715,6 +978,187 @@ function moneyToStripeMinorUnits(amount, currency) {
715
978
  function stripeMinorUnitsToMoney(amount, currency) {
716
979
  return amount / stripeCurrencyMinorUnitFactor(currency);
717
980
  }
981
+ var CHARGE_KEY_METADATA = "hv_charge_key";
982
+ function assertIdempotencyKey(key) {
983
+ if (typeof key !== "string" || key.trim() === "") throw new Error("idempotencyKey must be a non-empty string");
984
+ }
985
+ /** Escape a value for a single-quoted Stripe Search Query Language string. */
986
+ function escapeSearchValue(value) {
987
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
988
+ }
989
+ function resolveCollectionMethod(invoice) {
990
+ const method = invoice.collectionMethod ?? "send_invoice";
991
+ if (method !== "send_invoice" && method !== "charge_automatically") throw new Error(`Unsupported invoice collectionMethod '${method}'`);
992
+ return method;
993
+ }
994
+ /** The line's discount in Stripe units, validated against the line total. */
995
+ function lineDiscountToStripe(lineItem, currency) {
996
+ if (lineItem.discount === void 0 || lineItem.discount === 0) return;
997
+ if (!Number.isFinite(lineItem.discount) || lineItem.discount < 0) throw new Error(`Invoice line '${lineItem.description}' discount must be a non-negative amount`);
998
+ const discount = moneyToStripeMinorUnits(lineItem.discount, currency);
999
+ if (discount > Math.round(moneyToStripeMinorUnits(lineItem.unitPrice, currency) * lineItem.quantity)) throw new Error(`Invoice line '${lineItem.description}' discount exceeds the line total`);
1000
+ return discount > 0 ? discount : void 0;
1001
+ }
1002
+ function lineServicePeriod(lineItem) {
1003
+ const { periodStart, periodEnd } = lineItem;
1004
+ if (!periodStart && !periodEnd) return;
1005
+ if (!(periodStart instanceof Date) || !(periodEnd instanceof Date)) throw new Error(`Invoice line '${lineItem.description}' needs both periodStart and periodEnd`);
1006
+ if (Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime()) || periodEnd.getTime() < periodStart.getTime()) throw new Error(`Invoice line '${lineItem.description}' has an invalid service period`);
1007
+ return {
1008
+ start: periodStart,
1009
+ end: periodEnd
1010
+ };
1011
+ }
1012
+ function mapChargeOutcome(paymentIntent) {
1013
+ const error = paymentIntent.last_payment_error;
1014
+ const status = mapPaymentIntentChargeStatus(paymentIntent.status, error?.code);
1015
+ return {
1016
+ status,
1017
+ paymentExternalId: paymentIntent.id,
1018
+ paymentMethodExternalId: idOf(paymentIntent.payment_method),
1019
+ failureCode: status === "succeeded" ? void 0 : error?.code || void 0,
1020
+ declineCode: status === "succeeded" ? void 0 : error?.decline_code || void 0,
1021
+ failureMessage: status === "succeeded" ? void 0 : error?.message || void 0,
1022
+ raw: withoutClientSecret(paymentIntent)
1023
+ };
1024
+ }
1025
+ /**
1026
+ * A PaymentIntent found by charge key after Stripe's idempotency window must
1027
+ * be the charge this call describes; a key reused for a different amount,
1028
+ * currency, or customer is refused instead of reporting the wrong charge.
1029
+ */
1030
+ function assertSameCharge(paymentIntent, customerExternalId, stripeAmount, currency, paymentMethodExternalId) {
1031
+ const customer = idOf(paymentIntent.customer);
1032
+ const paymentMethod = idOf(paymentIntent.payment_method);
1033
+ if (paymentIntent.amount !== stripeAmount || (paymentIntent.currency || "").toUpperCase() !== currency || customer !== void 0 && customer !== customerExternalId || paymentMethodExternalId !== void 0 && paymentMethod !== void 0 && paymentMethod !== paymentMethodExternalId) throw new Error(`idempotencyKey was already used for a different charge (${paymentIntent.id}); use a new key for a new charge`);
1034
+ }
1035
+ /** Remove `client_secret` from a Stripe object and its expanded intents. */
1036
+ function withoutClientSecrets(value) {
1037
+ const copy = withoutClientSecret(value);
1038
+ for (const key of ["payment_intent", "setup_intent"]) {
1039
+ const nested = copy[key];
1040
+ if (isRecord(nested)) copy[key] = withoutClientSecret(nested);
1041
+ }
1042
+ return copy;
1043
+ }
1044
+ /**
1045
+ * Drop the intent client secret before handing objects to callers, including
1046
+ * `next_action`, whose redirect URL and SDK payload embed the same secret.
1047
+ */
1048
+ function withoutClientSecret(value) {
1049
+ const { client_secret: _secret, next_action: _nextAction, ...rest } = value;
1050
+ return rest;
1051
+ }
1052
+ function mapPaymentIntentChargeStatus(status, errorCode) {
1053
+ switch (status) {
1054
+ case "succeeded": return "succeeded";
1055
+ case "processing":
1056
+ case "requires_capture": return "processing";
1057
+ case "requires_action":
1058
+ case "requires_confirmation": return "requires_action";
1059
+ case "canceled": return "canceled";
1060
+ case "requires_payment_method": return errorCode === "authentication_required" ? "requires_action" : "failed";
1061
+ default: return "processing";
1062
+ }
1063
+ }
1064
+ function mapWebhookPayment(paymentIntent, eventType) {
1065
+ const error = paymentIntent.last_payment_error;
1066
+ let status = mapPaymentIntentChargeStatus(paymentIntent.status, error?.code);
1067
+ if (eventType === "payment_intent.succeeded") status = "succeeded";
1068
+ if (eventType === "payment_intent.canceled") status = "canceled";
1069
+ if (eventType === "payment_intent.payment_failed") status = error?.code === "authentication_required" ? "requires_action" : "failed";
1070
+ const currency = paymentIntent.currency ? paymentIntent.currency.toUpperCase() : void 0;
1071
+ const metadata = { ...paymentIntent.metadata || {} };
1072
+ return {
1073
+ status,
1074
+ paymentExternalId: paymentIntent.id,
1075
+ customerExternalId: idOf(paymentIntent.customer),
1076
+ amountMinor: currency && typeof paymentIntent.amount === "number" ? stripeAmountToMinorUnitsOrUndefined(paymentIntent.amount, currency) : void 0,
1077
+ currency,
1078
+ chargeKey: metadata[CHARGE_KEY_METADATA],
1079
+ failureCode: status === "succeeded" ? void 0 : error?.code || void 0,
1080
+ declineCode: status === "succeeded" ? void 0 : error?.decline_code || void 0,
1081
+ failureMessage: status === "succeeded" ? void 0 : error?.message || void 0,
1082
+ metadata
1083
+ };
1084
+ }
1085
+ function idOf(value) {
1086
+ if (typeof value === "string") return value || void 0;
1087
+ return value?.id || void 0;
1088
+ }
1089
+ function oneOf(value, allowed) {
1090
+ return allowed.find((candidate) => candidate === value);
1091
+ }
1092
+ function isRecord(value) {
1093
+ return typeof value === "object" && value !== null;
1094
+ }
1095
+ function normalizeCurrencyCode(currency) {
1096
+ const code = String(currency ?? "").trim().toUpperCase();
1097
+ if (!/^[A-Z]{3}$/.test(code)) throw new Error(`Currency '${currency}' must be a three-letter code`);
1098
+ return code;
1099
+ }
1100
+ /** ISO 4217 minor-unit exponents other than 2. */
1101
+ var ISO_ZERO_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
1102
+ "BIF",
1103
+ "CLP",
1104
+ "DJF",
1105
+ "GNF",
1106
+ "ISK",
1107
+ "JPY",
1108
+ "KMF",
1109
+ "KRW",
1110
+ "PYG",
1111
+ "RWF",
1112
+ "UGX",
1113
+ "UYI",
1114
+ "VND",
1115
+ "VUV",
1116
+ "XAF",
1117
+ "XOF",
1118
+ "XPF"
1119
+ ]);
1120
+ var ISO_THREE_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
1121
+ "BHD",
1122
+ "IQD",
1123
+ "JOD",
1124
+ "KWD",
1125
+ "LYD",
1126
+ "OMR",
1127
+ "TND"
1128
+ ]);
1129
+ var ISO_FOUR_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set(["CLF", "UYW"]);
1130
+ /**
1131
+ * ISO 4217 minor-unit exponent (2 for USD, 0 for JPY and ISK), from the
1132
+ * explicit tables above rather than the runtime's `Intl`/CLDR display digits,
1133
+ * which differ for some currencies (for example RSD, IQD, MGA) and change
1134
+ * between ICU releases.
1135
+ */
1136
+ function isoMinorUnitExponent(currency) {
1137
+ if (ISO_ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
1138
+ if (ISO_THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
1139
+ if (ISO_FOUR_DECIMAL_CURRENCIES.has(currency)) return 4;
1140
+ return 2;
1141
+ }
1142
+ /**
1143
+ * Integer ISO minor units → Stripe's smallest unit. They differ where Stripe
1144
+ * keeps a two-decimal representation for a zero-decimal currency (ISK, UGX).
1145
+ */
1146
+ function minorUnitsToStripeAmount(amountMinor, currency) {
1147
+ if (!Number.isSafeInteger(amountMinor)) throw new Error(`Amount ${amountMinor} must be a safe integer number of minor units`);
1148
+ const iso = 10 ** isoMinorUnitExponent(currency);
1149
+ const stripe = stripeCurrencyMinorUnitFactor(currency);
1150
+ if (stripe >= iso) return amountMinor * (stripe / iso);
1151
+ const divisor = iso / stripe;
1152
+ if (amountMinor % divisor !== 0) throw new Error(`Amount ${amountMinor} ${currency} minor units is not representable in Stripe's currency unit`);
1153
+ return amountMinor / divisor;
1154
+ }
1155
+ function stripeAmountToMinorUnitsOrUndefined(amount, currency) {
1156
+ if (!Number.isSafeInteger(amount)) return;
1157
+ const iso = 10 ** isoMinorUnitExponent(currency);
1158
+ const stripe = stripeCurrencyMinorUnitFactor(currency);
1159
+ const minor = amount * iso / stripe;
1160
+ return Number.isSafeInteger(minor) ? minor : void 0;
1161
+ }
718
1162
  function toUnixSeconds(date) {
719
1163
  return Math.floor(date.getTime() / 1e3);
720
1164
  }
@@ -727,4 +1171,4 @@ function sleep(ms) {
727
1171
  //#endregion
728
1172
  export { StripeProvider };
729
1173
 
730
- //# sourceMappingURL=stripe-e3yn8cEJ.js.map
1174
+ //# sourceMappingURL=stripe-Dc061lPD.js.map