@happyvertical/accounting 0.90.0 → 0.92.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,35 @@ 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` });
182
280
  }
183
281
  async void(externalId) {
184
282
  await this.provider.request("POST", `/v1/invoices/${encodeURIComponent(externalId)}/void`);
@@ -227,6 +325,79 @@ var StripePaymentOperations = class {
227
325
  async list(options = {}) {
228
326
  return (await this.provider.request("GET", "/v1/payment_intents", mapListOptions(options))).data.map(mapStripePaymentIntent);
229
327
  }
328
+ async chargeSavedPaymentMethod(input) {
329
+ assertIdempotencyKey(input.idempotencyKey);
330
+ if (!input.customerExternalId) throw new Error("Saved payment method charges require a customer");
331
+ const currency = normalizeCurrencyCode(input.currency);
332
+ const stripeAmount = minorUnitsToStripeAmount(input.amountMinor, currency);
333
+ if (stripeAmount <= 0) throw new Error("Charge amount must be a positive integer of minor units");
334
+ const chargeKey = input.idempotencyKey;
335
+ const base = {
336
+ provider: "stripe",
337
+ customerExternalId: input.customerExternalId,
338
+ amountMinor: input.amountMinor,
339
+ currency,
340
+ chargeKey
341
+ };
342
+ const existing = await this.findByChargeKey(chargeKey);
343
+ if (existing) {
344
+ assertSameCharge(existing, input.customerExternalId, stripeAmount, currency, input.paymentMethodExternalId);
345
+ return {
346
+ ...base,
347
+ ...mapChargeOutcome(existing)
348
+ };
349
+ }
350
+ let paymentMethod = input.paymentMethodExternalId;
351
+ if (!paymentMethod) {
352
+ paymentMethod = idOf((await this.provider.request("GET", `/v1/customers/${encodeURIComponent(input.customerExternalId)}`)).invoice_settings?.default_payment_method);
353
+ if (!paymentMethod) return {
354
+ ...base,
355
+ status: "failed",
356
+ failureCode: "payment_method_missing",
357
+ failureMessage: "The customer has no default payment method."
358
+ };
359
+ }
360
+ try {
361
+ const paymentIntent = await this.provider.request("POST", "/v1/payment_intents", {
362
+ amount: stripeAmount,
363
+ currency: currency.toLowerCase(),
364
+ customer: input.customerExternalId,
365
+ payment_method: paymentMethod,
366
+ off_session: true,
367
+ confirm: true,
368
+ description: input.description,
369
+ metadata: normalizeMetadata({
370
+ ...input.metadata,
371
+ [CHARGE_KEY_METADATA]: chargeKey
372
+ })
373
+ }, { idempotencyKey: `${chargeKey}:payment_intent` });
374
+ return {
375
+ ...base,
376
+ ...mapChargeOutcome(paymentIntent)
377
+ };
378
+ } catch (error) {
379
+ if (error instanceof StripeApiError && error.status === 402 && error.type === "card_error") {
380
+ const paymentIntent = isRecord(error.paymentIntent) ? error.paymentIntent : void 0;
381
+ return {
382
+ ...base,
383
+ ...paymentIntent ? mapChargeOutcome(paymentIntent) : {},
384
+ paymentMethodExternalId: idOf(paymentIntent?.payment_method) ?? paymentMethod,
385
+ status: error.code === "authentication_required" ? "requires_action" : "failed",
386
+ failureCode: error.code,
387
+ declineCode: error.declineCode,
388
+ failureMessage: error.stripeMessage,
389
+ raw: paymentIntent ? withoutClientSecret(paymentIntent) : void 0
390
+ };
391
+ }
392
+ throw error;
393
+ }
394
+ }
395
+ async findByChargeKey(chargeKey) {
396
+ return (await this.provider.request("GET", "/v1/payment_intents/search", {
397
+ query: `metadata['${CHARGE_KEY_METADATA}']:'${escapeSearchValue(chargeKey)}'`,
398
+ limit: 10
399
+ })).data.filter((intent) => intent.metadata?.[CHARGE_KEY_METADATA] === chargeKey).sort((left, right) => (left.created ?? 0) - (right.created ?? 0) || left.id.localeCompare(right.id))[0];
400
+ }
230
401
  };
231
402
  var StripeBillingOperationsImpl = class {
232
403
  provider;
@@ -234,16 +405,38 @@ var StripeBillingOperationsImpl = class {
234
405
  this.provider = provider;
235
406
  }
236
407
  async createCheckoutSession(input) {
237
- const response = await this.provider.request("POST", "/v1/checkout/sessions", mapCheckoutSessionToStripe(input), { idempotencyKey: input.idempotencyKey });
408
+ return mapCheckoutSessionResponse(await this.provider.request("POST", "/v1/checkout/sessions", mapCheckoutSessionToStripe(input), { idempotencyKey: input.idempotencyKey }));
409
+ }
410
+ async retrieveCheckoutSession(sessionExternalId) {
411
+ const session = await this.provider.request("GET", `/v1/checkout/sessions/${encodeURIComponent(sessionExternalId)}`, { expand: ["setup_intent", "payment_intent"] });
412
+ 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;
413
+ const currency = session.currency ? session.currency.toUpperCase() : void 0;
414
+ const minor = (amount) => currency && typeof amount === "number" ? stripeAmountToMinorUnitsOrUndefined(amount, currency) : void 0;
238
415
  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
416
+ ...mapCheckoutSessionResponse(session),
417
+ raw: withoutClientSecrets(session),
418
+ status: oneOf(session.status, [
419
+ "open",
420
+ "complete",
421
+ "expired"
422
+ ]),
423
+ paymentStatus: oneOf(session.payment_status, [
424
+ "paid",
425
+ "unpaid",
426
+ "no_payment_required"
427
+ ]),
428
+ paymentMethodExternalId: idOf(intent?.payment_method),
429
+ currency,
430
+ amountSubtotalMinor: minor(session.amount_subtotal),
431
+ amountTaxMinor: minor(session.total_details?.amount_tax),
432
+ amountTotalMinor: minor(session.amount_total),
433
+ metadata: { ...session.metadata || {} }
245
434
  };
246
435
  }
436
+ async setDefaultPaymentMethod(customerExternalId, paymentMethodExternalId) {
437
+ if (!customerExternalId || !paymentMethodExternalId) throw new Error("setDefaultPaymentMethod requires a customer and a payment method");
438
+ await this.provider.request("POST", `/v1/customers/${encodeURIComponent(customerExternalId)}`, { invoice_settings: { default_payment_method: paymentMethodExternalId } });
439
+ }
247
440
  async createCustomerPortalSession(input) {
248
441
  const response = await this.provider.request("POST", "/v1/billing_portal/sessions", {
249
442
  customer: input.customerExternalId,
@@ -350,7 +543,9 @@ var StripeWebhookOperations = class {
350
543
  throw new Error(`Invalid Stripe webhook payload: ${message}`);
351
544
  }
352
545
  const resource = event?.data?.object;
546
+ const payment = resource?.object === "payment_intent" && typeof resource.id === "string" ? mapWebhookPayment(resource, event?.type || void 0) : void 0;
353
547
  return {
548
+ ...payment ? { payment } : {},
354
549
  type: event?.type || "unknown",
355
550
  id: typeof event?.id === "string" ? event.id : void 0,
356
551
  provider: "stripe",
@@ -398,19 +593,19 @@ function mapCustomerToStripe(customer) {
398
593
  country: customer.billingAddress.country
399
594
  } : void 0,
400
595
  metadata: normalizeMetadata({
401
- local_id: customer.id,
402
596
  payment_terms: customer.paymentTerms,
403
- ...customer.metadata
597
+ ...customer.metadata,
598
+ local_id: customer.id
404
599
  })
405
600
  };
406
601
  if (customer.taxExempt !== void 0) stripeCustomer.tax_exempt = customer.taxExempt ? "exempt" : "none";
407
602
  return stripeCustomer;
408
603
  }
409
- function mapInvoiceToStripe(invoice) {
604
+ function mapInvoiceToStripe(invoice, collectionMethod) {
410
605
  return {
411
606
  customer: invoice.customerExternalId,
412
- collection_method: "send_invoice",
413
- due_date: invoice.dueDate,
607
+ collection_method: collectionMethod,
608
+ due_date: collectionMethod === "send_invoice" ? invoice.dueDate : void 0,
414
609
  auto_advance: false,
415
610
  pending_invoice_items_behavior: "include",
416
611
  automatic_tax: invoice.automaticTax ? { enabled: true } : void 0,
@@ -426,9 +621,10 @@ function mapInvoiceToStripe(invoice) {
426
621
  })
427
622
  };
428
623
  }
429
- function mapInvoiceUpdateToStripe(invoice) {
624
+ function mapInvoiceUpdateToStripe(invoice, collectionMethod) {
430
625
  return {
431
- due_date: invoice.dueDate,
626
+ collection_method: invoice.collectionMethod ? collectionMethod : void 0,
627
+ due_date: collectionMethod === "send_invoice" ? invoice.dueDate : void 0,
432
628
  metadata: normalizeMetadata({
433
629
  local_id: invoice.id,
434
630
  invoice_number: invoice.invoiceNumber,
@@ -439,35 +635,76 @@ function mapInvoiceUpdateToStripe(invoice) {
439
635
  };
440
636
  }
441
637
  function mapCheckoutSessionToStripe(input) {
638
+ const mode = input.mode || "subscription";
639
+ const lineItems = input.lineItems || [];
640
+ if (mode === "setup") {
641
+ if (lineItems.length > 0) throw new Error("Stripe setup-mode Checkout does not accept line items");
642
+ if (!input.currency && !input.paymentMethodTypes?.length) throw new Error("Stripe setup-mode Checkout requires currency or paymentMethodTypes");
643
+ if (input.automaticTax) throw new Error("Stripe setup-mode Checkout does not calculate tax");
644
+ } else if (lineItems.length === 0) throw new Error(`Stripe ${mode}-mode Checkout requires line items`);
645
+ if (input.setupFutureUsage && mode !== "payment") throw new Error("setupFutureUsage applies only to payment-mode Checkout");
646
+ if (input.customerUpdate && !input.customerExternalId) throw new Error("customerUpdate requires customerExternalId");
442
647
  return {
443
- mode: input.mode || "subscription",
648
+ mode,
444
649
  success_url: input.successUrl,
445
650
  cancel_url: input.cancelUrl,
446
651
  customer: input.customerExternalId,
447
652
  customer_email: input.customerExternalId ? void 0 : input.customerEmail,
448
653
  client_reference_id: input.clientReferenceId,
449
654
  allow_promotion_codes: input.allowPromotionCodes,
450
- line_items: input.lineItems.map(mapCheckoutLineItem),
655
+ currency: input.currency ? input.currency.toLowerCase() : void 0,
656
+ payment_method_types: input.paymentMethodTypes,
657
+ automatic_tax: input.automaticTax ? { enabled: true } : void 0,
658
+ billing_address_collection: input.billingAddressCollection,
659
+ customer_update: input.customerUpdate ? {
660
+ address: input.customerUpdate.address,
661
+ name: input.customerUpdate.name,
662
+ shipping: input.customerUpdate.shipping
663
+ } : void 0,
664
+ payment_intent_data: input.setupFutureUsage ? { setup_future_usage: input.setupFutureUsage } : void 0,
665
+ line_items: mode === "setup" ? void 0 : lineItems.map((lineItem) => mapCheckoutLineItem(lineItem, Boolean(input.automaticTax))),
451
666
  metadata: normalizeMetadata(input.metadata || {})
452
667
  };
453
668
  }
454
- function mapCheckoutLineItem(lineItem) {
669
+ function mapCheckoutSessionResponse(response) {
670
+ return {
671
+ externalId: response.id,
672
+ url: response.url || null,
673
+ mode: oneOf(response.mode, [
674
+ "payment",
675
+ "setup",
676
+ "subscription"
677
+ ]),
678
+ customerExternalId: idOf(response.customer),
679
+ subscriptionExternalId: idOf(response.subscription),
680
+ paymentIntentExternalId: idOf(response.payment_intent),
681
+ setupIntentExternalId: idOf(response.setup_intent),
682
+ raw: withoutClientSecrets(response)
683
+ };
684
+ }
685
+ function mapCheckoutLineItem(lineItem, automaticTax = false) {
455
686
  if (lineItem.price) return {
456
687
  price: lineItem.price,
457
688
  quantity: lineItem.quantity || 1
458
689
  };
459
690
  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" };
691
+ const { priceData } = lineItem;
692
+ const productData = priceData.product ? void 0 : { name: priceData.productName || "Subscription" };
693
+ const hasStripeAmount = priceData.unitAmount !== void 0;
694
+ const hasMinorAmount = priceData.unitAmountMinor !== void 0;
695
+ if (hasStripeAmount === hasMinorAmount) throw new Error("Stripe checkout priceData requires exactly one of unitAmount or unitAmountMinor");
696
+ const unitAmount = hasMinorAmount ? minorUnitsToStripeAmount(priceData.unitAmountMinor, normalizeCurrencyCode(priceData.currency)) : priceData.unitAmount;
461
697
  return {
462
698
  quantity: lineItem.quantity || 1,
463
699
  price_data: {
464
- currency: lineItem.priceData.currency,
465
- unit_amount: lineItem.priceData.unitAmount,
466
- product: lineItem.priceData.product,
700
+ currency: priceData.currency.toLowerCase(),
701
+ unit_amount: unitAmount,
702
+ tax_behavior: priceData.taxBehavior ?? (automaticTax ? "exclusive" : void 0),
703
+ product: priceData.product,
467
704
  product_data: productData,
468
- recurring: lineItem.priceData.recurring ? {
469
- interval: lineItem.priceData.recurring.interval,
470
- interval_count: lineItem.priceData.recurring.intervalCount
705
+ recurring: priceData.recurring ? {
706
+ interval: priceData.recurring.interval,
707
+ interval_count: priceData.recurring.intervalCount
471
708
  } : void 0
472
709
  }
473
710
  };
@@ -556,6 +793,7 @@ function mapStripeInvoiceStatus(status, dueDate) {
556
793
  case "draft": return "draft";
557
794
  case "paid": return "paid";
558
795
  case "void": return "voided";
796
+ case "uncollectible": return "uncollectible";
559
797
  case "open":
560
798
  if (dueDate && dueDate * 1e3 < Date.now()) return "overdue";
561
799
  return "sent";
@@ -715,6 +953,187 @@ function moneyToStripeMinorUnits(amount, currency) {
715
953
  function stripeMinorUnitsToMoney(amount, currency) {
716
954
  return amount / stripeCurrencyMinorUnitFactor(currency);
717
955
  }
956
+ var CHARGE_KEY_METADATA = "hv_charge_key";
957
+ function assertIdempotencyKey(key) {
958
+ if (typeof key !== "string" || key.trim() === "") throw new Error("idempotencyKey must be a non-empty string");
959
+ }
960
+ /** Escape a value for a single-quoted Stripe Search Query Language string. */
961
+ function escapeSearchValue(value) {
962
+ return value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
963
+ }
964
+ function resolveCollectionMethod(invoice) {
965
+ const method = invoice.collectionMethod ?? "send_invoice";
966
+ if (method !== "send_invoice" && method !== "charge_automatically") throw new Error(`Unsupported invoice collectionMethod '${method}'`);
967
+ return method;
968
+ }
969
+ /** The line's discount in Stripe units, validated against the line total. */
970
+ function lineDiscountToStripe(lineItem, currency) {
971
+ if (lineItem.discount === void 0 || lineItem.discount === 0) return;
972
+ if (!Number.isFinite(lineItem.discount) || lineItem.discount < 0) throw new Error(`Invoice line '${lineItem.description}' discount must be a non-negative amount`);
973
+ const discount = moneyToStripeMinorUnits(lineItem.discount, currency);
974
+ if (discount > Math.round(moneyToStripeMinorUnits(lineItem.unitPrice, currency) * lineItem.quantity)) throw new Error(`Invoice line '${lineItem.description}' discount exceeds the line total`);
975
+ return discount > 0 ? discount : void 0;
976
+ }
977
+ function lineServicePeriod(lineItem) {
978
+ const { periodStart, periodEnd } = lineItem;
979
+ if (!periodStart && !periodEnd) return;
980
+ if (!(periodStart instanceof Date) || !(periodEnd instanceof Date)) throw new Error(`Invoice line '${lineItem.description}' needs both periodStart and periodEnd`);
981
+ 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`);
982
+ return {
983
+ start: periodStart,
984
+ end: periodEnd
985
+ };
986
+ }
987
+ function mapChargeOutcome(paymentIntent) {
988
+ const error = paymentIntent.last_payment_error;
989
+ const status = mapPaymentIntentChargeStatus(paymentIntent.status, error?.code);
990
+ return {
991
+ status,
992
+ paymentExternalId: paymentIntent.id,
993
+ paymentMethodExternalId: idOf(paymentIntent.payment_method),
994
+ failureCode: status === "succeeded" ? void 0 : error?.code || void 0,
995
+ declineCode: status === "succeeded" ? void 0 : error?.decline_code || void 0,
996
+ failureMessage: status === "succeeded" ? void 0 : error?.message || void 0,
997
+ raw: withoutClientSecret(paymentIntent)
998
+ };
999
+ }
1000
+ /**
1001
+ * A PaymentIntent found by charge key after Stripe's idempotency window must
1002
+ * be the charge this call describes; a key reused for a different amount,
1003
+ * currency, or customer is refused instead of reporting the wrong charge.
1004
+ */
1005
+ function assertSameCharge(paymentIntent, customerExternalId, stripeAmount, currency, paymentMethodExternalId) {
1006
+ const customer = idOf(paymentIntent.customer);
1007
+ const paymentMethod = idOf(paymentIntent.payment_method);
1008
+ 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`);
1009
+ }
1010
+ /** Remove `client_secret` from a Stripe object and its expanded intents. */
1011
+ function withoutClientSecrets(value) {
1012
+ const copy = withoutClientSecret(value);
1013
+ for (const key of ["payment_intent", "setup_intent"]) {
1014
+ const nested = copy[key];
1015
+ if (isRecord(nested)) copy[key] = withoutClientSecret(nested);
1016
+ }
1017
+ return copy;
1018
+ }
1019
+ /**
1020
+ * Drop the intent client secret before handing objects to callers, including
1021
+ * `next_action`, whose redirect URL and SDK payload embed the same secret.
1022
+ */
1023
+ function withoutClientSecret(value) {
1024
+ const { client_secret: _secret, next_action: _nextAction, ...rest } = value;
1025
+ return rest;
1026
+ }
1027
+ function mapPaymentIntentChargeStatus(status, errorCode) {
1028
+ switch (status) {
1029
+ case "succeeded": return "succeeded";
1030
+ case "processing":
1031
+ case "requires_capture": return "processing";
1032
+ case "requires_action":
1033
+ case "requires_confirmation": return "requires_action";
1034
+ case "canceled": return "canceled";
1035
+ case "requires_payment_method": return errorCode === "authentication_required" ? "requires_action" : "failed";
1036
+ default: return "processing";
1037
+ }
1038
+ }
1039
+ function mapWebhookPayment(paymentIntent, eventType) {
1040
+ const error = paymentIntent.last_payment_error;
1041
+ let status = mapPaymentIntentChargeStatus(paymentIntent.status, error?.code);
1042
+ if (eventType === "payment_intent.succeeded") status = "succeeded";
1043
+ if (eventType === "payment_intent.canceled") status = "canceled";
1044
+ if (eventType === "payment_intent.payment_failed") status = error?.code === "authentication_required" ? "requires_action" : "failed";
1045
+ const currency = paymentIntent.currency ? paymentIntent.currency.toUpperCase() : void 0;
1046
+ const metadata = { ...paymentIntent.metadata || {} };
1047
+ return {
1048
+ status,
1049
+ paymentExternalId: paymentIntent.id,
1050
+ customerExternalId: idOf(paymentIntent.customer),
1051
+ amountMinor: currency && typeof paymentIntent.amount === "number" ? stripeAmountToMinorUnitsOrUndefined(paymentIntent.amount, currency) : void 0,
1052
+ currency,
1053
+ chargeKey: metadata[CHARGE_KEY_METADATA],
1054
+ failureCode: status === "succeeded" ? void 0 : error?.code || void 0,
1055
+ declineCode: status === "succeeded" ? void 0 : error?.decline_code || void 0,
1056
+ failureMessage: status === "succeeded" ? void 0 : error?.message || void 0,
1057
+ metadata
1058
+ };
1059
+ }
1060
+ function idOf(value) {
1061
+ if (typeof value === "string") return value || void 0;
1062
+ return value?.id || void 0;
1063
+ }
1064
+ function oneOf(value, allowed) {
1065
+ return allowed.find((candidate) => candidate === value);
1066
+ }
1067
+ function isRecord(value) {
1068
+ return typeof value === "object" && value !== null;
1069
+ }
1070
+ function normalizeCurrencyCode(currency) {
1071
+ const code = String(currency ?? "").trim().toUpperCase();
1072
+ if (!/^[A-Z]{3}$/.test(code)) throw new Error(`Currency '${currency}' must be a three-letter code`);
1073
+ return code;
1074
+ }
1075
+ /** ISO 4217 minor-unit exponents other than 2. */
1076
+ var ISO_ZERO_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
1077
+ "BIF",
1078
+ "CLP",
1079
+ "DJF",
1080
+ "GNF",
1081
+ "ISK",
1082
+ "JPY",
1083
+ "KMF",
1084
+ "KRW",
1085
+ "PYG",
1086
+ "RWF",
1087
+ "UGX",
1088
+ "UYI",
1089
+ "VND",
1090
+ "VUV",
1091
+ "XAF",
1092
+ "XOF",
1093
+ "XPF"
1094
+ ]);
1095
+ var ISO_THREE_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set([
1096
+ "BHD",
1097
+ "IQD",
1098
+ "JOD",
1099
+ "KWD",
1100
+ "LYD",
1101
+ "OMR",
1102
+ "TND"
1103
+ ]);
1104
+ var ISO_FOUR_DECIMAL_CURRENCIES = /* @__PURE__ */ new Set(["CLF", "UYW"]);
1105
+ /**
1106
+ * ISO 4217 minor-unit exponent (2 for USD, 0 for JPY and ISK), from the
1107
+ * explicit tables above rather than the runtime's `Intl`/CLDR display digits,
1108
+ * which differ for some currencies (for example RSD, IQD, MGA) and change
1109
+ * between ICU releases.
1110
+ */
1111
+ function isoMinorUnitExponent(currency) {
1112
+ if (ISO_ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
1113
+ if (ISO_THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
1114
+ if (ISO_FOUR_DECIMAL_CURRENCIES.has(currency)) return 4;
1115
+ return 2;
1116
+ }
1117
+ /**
1118
+ * Integer ISO minor units → Stripe's smallest unit. They differ where Stripe
1119
+ * keeps a two-decimal representation for a zero-decimal currency (ISK, UGX).
1120
+ */
1121
+ function minorUnitsToStripeAmount(amountMinor, currency) {
1122
+ if (!Number.isSafeInteger(amountMinor)) throw new Error(`Amount ${amountMinor} must be a safe integer number of minor units`);
1123
+ const iso = 10 ** isoMinorUnitExponent(currency);
1124
+ const stripe = stripeCurrencyMinorUnitFactor(currency);
1125
+ if (stripe >= iso) return amountMinor * (stripe / iso);
1126
+ const divisor = iso / stripe;
1127
+ if (amountMinor % divisor !== 0) throw new Error(`Amount ${amountMinor} ${currency} minor units is not representable in Stripe's currency unit`);
1128
+ return amountMinor / divisor;
1129
+ }
1130
+ function stripeAmountToMinorUnitsOrUndefined(amount, currency) {
1131
+ if (!Number.isSafeInteger(amount)) return;
1132
+ const iso = 10 ** isoMinorUnitExponent(currency);
1133
+ const stripe = stripeCurrencyMinorUnitFactor(currency);
1134
+ const minor = amount * iso / stripe;
1135
+ return Number.isSafeInteger(minor) ? minor : void 0;
1136
+ }
718
1137
  function toUnixSeconds(date) {
719
1138
  return Math.floor(date.getTime() / 1e3);
720
1139
  }
@@ -727,4 +1146,4 @@ function sleep(ms) {
727
1146
  //#endregion
728
1147
  export { StripeProvider };
729
1148
 
730
- //# sourceMappingURL=stripe-e3yn8cEJ.js.map
1149
+ //# sourceMappingURL=stripe-DvSZ3fGk.js.map