better-ship 0.16.0 → 0.18.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.
Files changed (129) hide show
  1. package/dist/{application-Cf9SER-i.js → application-CNdzHIXk.js} +5 -7
  2. package/dist/{application-Cf9SER-i.js.map → application-CNdzHIXk.js.map} +1 -1
  3. package/dist/application.d.ts +2 -2
  4. package/dist/application.js +1 -1
  5. package/dist/better-auth/apple-client-secret.d.ts +1 -1
  6. package/dist/better-auth/apple-client-secret.js +2 -2
  7. package/dist/better-auth/apple-client-secret.js.map +1 -1
  8. package/dist/better-auth/auth-client.js +1 -1
  9. package/dist/better-auth/rate-limit.d.ts +1 -1
  10. package/dist/better-auth/rate-limit.js +4 -6
  11. package/dist/better-auth/rate-limit.js.map +1 -1
  12. package/dist/better-auth.js +1 -1
  13. package/dist/billing/client.d.ts +86 -0
  14. package/dist/billing/client.d.ts.map +1 -0
  15. package/dist/billing/client.js +77 -0
  16. package/dist/billing/client.js.map +1 -0
  17. package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
  18. package/dist/cloudflare/auth-secondary-storage.js +1 -1
  19. package/dist/cloudflare/durable-object-client.js +1 -1
  20. package/dist/cloudflare/durable-object-errors.d.ts +1 -1
  21. package/dist/cloudflare/durable-object-errors.js +1 -1
  22. package/dist/cloudflare/kv-client.d.ts +31 -0
  23. package/dist/cloudflare/kv-client.d.ts.map +1 -0
  24. package/dist/cloudflare/kv-client.js +62 -0
  25. package/dist/cloudflare/kv-client.js.map +1 -0
  26. package/dist/cloudflare/kv-errors.d.ts +52 -0
  27. package/dist/cloudflare/kv-errors.d.ts.map +1 -0
  28. package/dist/cloudflare/kv-errors.js +144 -0
  29. package/dist/cloudflare/kv-errors.js.map +1 -0
  30. package/dist/cloudflare/queue.d.ts +2 -2
  31. package/dist/cloudflare/queue.d.ts.map +1 -1
  32. package/dist/cloudflare/queue.js +10 -12
  33. package/dist/cloudflare/queue.js.map +1 -1
  34. package/dist/cloudflare/r2-client.d.ts +70 -0
  35. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  36. package/dist/cloudflare/r2-client.js +153 -0
  37. package/dist/cloudflare/r2-client.js.map +1 -0
  38. package/dist/cloudflare/r2-errors.d.ts +34 -0
  39. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  40. package/dist/cloudflare/r2-errors.js +90 -0
  41. package/dist/cloudflare/r2-errors.js.map +1 -0
  42. package/dist/cloudflare/scheduled.d.ts +1 -1
  43. package/dist/cloudflare/scheduled.js +2 -2
  44. package/dist/cloudflare/scheduled.js.map +1 -1
  45. package/dist/cloudflare.d.ts +10 -5
  46. package/dist/cloudflare.d.ts.map +1 -1
  47. package/dist/cloudflare.js +21 -10
  48. package/dist/cloudflare.js.map +1 -1
  49. package/dist/{core-ClSP5-V9.js → core-UUW2unJd.js} +106 -195
  50. package/dist/core-UUW2unJd.js.map +1 -0
  51. package/dist/core.d.ts +2 -2
  52. package/dist/core.js +2 -2
  53. package/dist/http.d.ts +2 -2
  54. package/dist/http.js +3 -3
  55. package/dist/http.js.map +1 -1
  56. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  57. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  58. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  59. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  60. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-CgdqU20T.js} +2 -2
  61. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-CgdqU20T.js.map} +1 -1
  62. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  63. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  64. package/dist/postgres.d.ts +10 -5
  65. package/dist/postgres.d.ts.map +1 -1
  66. package/dist/postgres.js +21 -10
  67. package/dist/postgres.js.map +1 -1
  68. package/dist/posthog/metrics.d.ts +1 -1
  69. package/dist/posthog/server.d.ts +2 -2
  70. package/dist/posthog/server.js +1 -1
  71. package/dist/resend.d.ts +3 -3
  72. package/dist/resend.js +2 -2
  73. package/dist/sentry/client.d.ts +1 -1
  74. package/dist/sentry/client.d.ts.map +1 -1
  75. package/dist/sentry/client.js +10 -4
  76. package/dist/sentry/client.js.map +1 -1
  77. package/dist/sentry/server.d.ts +2 -2
  78. package/dist/sentry/server.js +5 -5
  79. package/dist/sentry/server.js.map +1 -1
  80. package/dist/stripe/payment-service.d.ts +52 -0
  81. package/dist/stripe/payment-service.d.ts.map +1 -0
  82. package/dist/stripe/payment-service.error.d.ts +13 -0
  83. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  84. package/dist/stripe/payment-service.error.js +30 -0
  85. package/dist/stripe/payment-service.error.js.map +1 -0
  86. package/dist/stripe/payment-service.js +72 -0
  87. package/dist/stripe/payment-service.js.map +1 -0
  88. package/dist/stripe/payment-webhook.d.ts +18 -0
  89. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  90. package/dist/stripe/payment-webhook.js +47 -0
  91. package/dist/stripe/payment-webhook.js.map +1 -0
  92. package/dist/tanstack.d.ts +1 -1
  93. package/dist/tanstack.js +4 -4
  94. package/dist/tanstack.js.map +1 -1
  95. package/dist/testing/outbox-repository.d.ts +2 -2
  96. package/dist/testing/payment-service.d.ts +10 -0
  97. package/dist/testing/payment-service.d.ts.map +1 -0
  98. package/dist/testing/payment-service.js +76 -0
  99. package/dist/testing/payment-service.js.map +1 -0
  100. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-BBmjkHgI.js} +2 -2
  101. package/dist/{webhook-verification-D6aGQfNi.js.map → webhook-verification-BBmjkHgI.js.map} +1 -1
  102. package/dist/{webhook-verification-BcQQG-vY.d.ts → webhook-verification-BuxPzdr4.d.ts} +2 -2
  103. package/dist/{webhook-verification-BcQQG-vY.d.ts.map → webhook-verification-BuxPzdr4.d.ts.map} +1 -1
  104. package/package.json +15 -6
  105. package/src/application/outbox-relay.ts +3 -1
  106. package/src/core/console-log-sink.ts +65 -0
  107. package/src/core/index.ts +1 -4
  108. package/src/core/logger.ts +80 -281
  109. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  110. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  111. package/src/infrastructure/billing/client.ts +173 -0
  112. package/src/infrastructure/cloudflare/d1-database-execution.ts +27 -6
  113. package/src/infrastructure/cloudflare/kv-client.ts +79 -0
  114. package/src/infrastructure/cloudflare/kv-errors.ts +157 -0
  115. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  116. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  117. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  118. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  119. package/src/infrastructure/http/webhook-handler.ts +1 -1
  120. package/src/infrastructure/postgres/postgres-database-execution.ts +27 -6
  121. package/src/infrastructure/sentry/client.ts +6 -3
  122. package/src/infrastructure/sentry/server.ts +4 -4
  123. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  124. package/src/infrastructure/stripe/payment-service.ts +129 -0
  125. package/src/infrastructure/stripe/payment-webhook.ts +49 -0
  126. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  127. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  128. package/dist/core-ClSP5-V9.js.map +0 -1
  129. package/dist/index-DvJUcSW1.d.ts.map +0 -1
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod'
2
+
3
+ import { AppError, classifyBoundaryError, isAbortError, type FailureClassification } from '@/core'
4
+
5
+ /** The storage operation that failed. */
6
+ export type R2Operation =
7
+ | 'head'
8
+ | 'get'
9
+ | 'getJson'
10
+ | 'put'
11
+ | 'delete'
12
+ | 'list'
13
+ | 'createMultipartUpload'
14
+ | 'uploadPart'
15
+ | 'complete'
16
+ | 'abort'
17
+
18
+ /** Provider diagnostics from the outer failure, without request bodies or credentials. */
19
+ export type R2Failure = {
20
+ readonly code: number
21
+ readonly action?: string | undefined
22
+ }
23
+
24
+ const R2FailureSchema = z.object({
25
+ code: z.number().int().positive(),
26
+ action: z.string().optional(),
27
+ })
28
+
29
+ // R2 codes describe failures; the caller owns retry decisions and repeat safety.
30
+ const classifications: ReadonlyMap<number, FailureClassification> = new Map([
31
+ [10001, 'transient'],
32
+ [10043, 'transient'],
33
+ [10054, 'transient'],
34
+ [10058, 'transient'],
35
+ [10013, 'transient'],
36
+ [10002, 'terminal'],
37
+ [10003, 'terminal'],
38
+ [10018, 'terminal'],
39
+ [10035, 'terminal'],
40
+ [10042, 'terminal'],
41
+ [10005, 'terminal'],
42
+ [10006, 'terminal'],
43
+ [10008, 'terminal'],
44
+ [10009, 'terminal'],
45
+ [10073, 'terminal'],
46
+ [10007, 'terminal'],
47
+ [10020, 'terminal'],
48
+ [100100, 'terminal'],
49
+ [10012, 'terminal'],
50
+ [10069, 'terminal'],
51
+ [10033, 'terminal'],
52
+ [10014, 'terminal'],
53
+ [10037, 'terminal'],
54
+ [10039, 'terminal'],
55
+ [10031, 'terminal'],
56
+ [10011, 'terminal'],
57
+ [10024, 'terminal'],
58
+ [10025, 'terminal'],
59
+ [10048, 'terminal'],
60
+ ])
61
+
62
+ function extractR2Failure(cause: unknown): R2Failure | undefined {
63
+ if (!(cause instanceof Error)) return undefined
64
+ const parsed = R2FailureSchema.safeParse(cause)
65
+ if (parsed.success) return parsed.data
66
+ // Cloudflare documents the numeric suffix for bindings that omit structured fields.
67
+ const match = /\((\d{5,6})\)\s*$/.exec(cause.message)
68
+ return match?.[1] === undefined ? undefined : { code: Number(match[1]) }
69
+ }
70
+
71
+ function classifyR2Code(cause: unknown): FailureClassification | undefined {
72
+ const failure = extractR2Failure(cause)
73
+ return failure === undefined ? undefined : (classifications.get(failure.code) ?? 'unknown')
74
+ }
75
+
76
+ function classifyR2Error(cause: unknown): FailureClassification {
77
+ if (isAbortError(cause)) return 'terminal'
78
+ if (cause instanceof AppError) return cause.classification
79
+ return classifyBoundaryError({ cause, classifyBoundary: classifyR2Code, includeCloudflare: true })
80
+ }
81
+
82
+ /** A raw binding failure after one attempt. Classification does not establish whether a mutation committed. */
83
+ export class R2Error extends AppError {
84
+ readonly _tag = 'R2Error'
85
+ readonly classification: FailureClassification
86
+ readonly operation: R2Operation
87
+ readonly r2?: R2Failure | undefined
88
+ override readonly cause: unknown
89
+
90
+ constructor(input: { operation: R2Operation; cause: unknown }) {
91
+ super(`R2 ${input.operation} failed`, { cause: input.cause })
92
+ this.operation = input.operation
93
+ this.cause = input.cause
94
+ this.r2 = extractR2Failure(input.cause)
95
+ this.classification = classifyR2Error(input.cause)
96
+ }
97
+ }
98
+
99
+ /** JSON serialization or syntax failed. Binding and body-read failures use R2Error. */
100
+ export class R2JsonError extends AppError {
101
+ readonly _tag = 'R2JsonError'
102
+ readonly classification = 'terminal'
103
+ readonly operation: 'encode' | 'decode'
104
+ override readonly cause: unknown
105
+
106
+ constructor(input: { operation: 'encode' | 'decode'; cause: unknown }) {
107
+ super(`R2 JSON ${input.operation} failed`, { cause: input.cause })
108
+ this.operation = input.operation
109
+ this.cause = input.cause
110
+ }
111
+ }
@@ -45,7 +45,7 @@ export function defineSchedules<const Schedules extends Readonly<Record<string,
45
45
  handler: (runs) => async (controller, deps) => {
46
46
  const { cron } = controller
47
47
  if (!schema.validate(cron)) {
48
- logger.error('scheduled_unknown_cron', { details: { cron } })
48
+ logger.error('scheduled_unknown_cron', { cron })
49
49
  return
50
50
  }
51
51
  await runs[cron](deps, new Date(controller.scheduledTime))
@@ -25,7 +25,7 @@ export class WebhookHandler<Event> {
25
25
  }
26
26
 
27
27
  private reject(error: WebhookVerificationError): Response {
28
- this.logger.warn('Webhook rejected', { reason: error.reason })
28
+ this.logger.warn('webhook_rejected', { reason: error.reason })
29
29
  const status =
30
30
  error.reason === 'missing_headers' || error.reason === 'invalid_signature'
31
31
  ? StatusCodes.UNAUTHORIZED
@@ -1,13 +1,34 @@
1
+ import { Result } from 'better-result'
2
+
3
+ import { DatabaseError } from '@/core'
4
+
1
5
  import { classifyPostgresError } from './classify-postgres-error.ts'
2
6
 
3
- /** Execute once without retries; convert recognized Postgres failures and preserve other exceptions unchanged. */
7
+ /**
8
+ * Convert database failures and preserve other exceptions. The default executes once.
9
+ * Opt-in transient retries allow three attempts with full jitter and backoff caps of 250 ms and 500 ms.
10
+ */
4
11
  export async function executeDatabase<T>(
5
12
  operation: string,
6
13
  work: () => PromiseLike<T>,
14
+ options: { readonly retry?: 'never' | 'transient' } = {},
7
15
  ): Promise<T> {
8
- try {
9
- return await work()
10
- } catch (cause) {
11
- throw classifyPostgresError(cause, operation) ?? cause
12
- }
16
+ const result = await Result.tryPromise(
17
+ {
18
+ try: async () => await work(),
19
+ catch: (cause) => classifyPostgresError(cause, operation) ?? cause,
20
+ },
21
+ {
22
+ retry: {
23
+ times: options.retry === 'transient' ? 2 : 0,
24
+ delayMs: 250,
25
+ backoff: 'exponential',
26
+ jitter: true,
27
+ shouldRetry: (error) =>
28
+ error instanceof DatabaseError && error.classification === 'transient',
29
+ },
30
+ },
31
+ )
32
+ if (result.isErr()) throw result.error
33
+ return result.value
13
34
  }
@@ -27,10 +27,13 @@ export function createSentryClientOptions(options: SentryClientOptions): Sentry.
27
27
 
28
28
  /** Forward logged browser errors once, excluding TanStack navigation control flow. */
29
29
  export function createSentryLoggerErrorHook(): LoggerErrorHook {
30
- return ({ error, context }) => {
30
+ return ({ error, userId, attributes }) => {
31
31
  if (isNotFound(error) || isRedirect(error)) return
32
- const captured = error instanceof Error ? error : new Error('Logger received a non-Error value')
33
- Sentry.captureException(captured, { extra: context })
32
+ if (userId !== undefined) {
33
+ Sentry.captureException(error, { extra: attributes, user: { id: userId } })
34
+ return
35
+ }
36
+ Sentry.captureException(error, { extra: attributes })
34
37
  }
35
38
  }
36
39
 
@@ -26,12 +26,12 @@ export function createSentryOptions(options: SentryServerOptions): Sentry.Cloudf
26
26
 
27
27
  /** Register with setLoggerErrorHook once at startup; an explicit user applies only to this report. */
28
28
  export function createSentryLoggerErrorHook(): LoggerErrorHook {
29
- return ({ error, distinctId, context }) => {
30
- if (distinctId !== undefined) {
31
- Sentry.captureException(error, { extra: context, user: { id: distinctId } })
29
+ return ({ error, userId, attributes }) => {
30
+ if (userId !== undefined) {
31
+ Sentry.captureException(error, { extra: attributes, user: { id: userId } })
32
32
  return
33
33
  }
34
- Sentry.captureException(error, { extra: context })
34
+ Sentry.captureException(error, { extra: attributes })
35
35
  }
36
36
  }
37
37
 
@@ -0,0 +1,42 @@
1
+ import Stripe from 'stripe'
2
+
3
+ import { AppError, type FailureClassification } from '@/core'
4
+
5
+ /** A final Stripe request failure. The caller logs once; native details remain in cause. */
6
+ export class PaymentServiceError extends AppError {
7
+ readonly _tag = 'PaymentServiceError'
8
+ readonly classification: FailureClassification
9
+
10
+ /** Classify the final SDK error without scheduling another payment attempt. */
11
+ constructor(
12
+ readonly operation: string,
13
+ cause: Stripe.errors.StripeError,
14
+ ) {
15
+ super(`Stripe request failed: ${operation}`, { cause })
16
+ this.classification = classifyStripeError(cause)
17
+ }
18
+ }
19
+
20
+ function classifyStripeError(error: Stripe.errors.StripeError): FailureClassification {
21
+ if (error.headers?.['stripe-should-retry'] === 'false') return 'terminal'
22
+ const status = error.statusCode
23
+ if (status !== undefined && status >= 400 && status < 500 && status !== 409 && status !== 429)
24
+ return 'terminal'
25
+ if (
26
+ error instanceof Stripe.errors.StripeAuthenticationError ||
27
+ error instanceof Stripe.errors.StripePermissionError ||
28
+ error instanceof Stripe.errors.StripeInvalidRequestError ||
29
+ error instanceof Stripe.errors.StripeIdempotencyError
30
+ )
31
+ return 'terminal'
32
+ if (error.headers?.['stripe-should-retry'] === 'true') return 'transient'
33
+ if (status === 409 || status === 429 || (status !== undefined && status >= 500))
34
+ return 'transient'
35
+ if (
36
+ error instanceof Stripe.errors.StripeConnectionError ||
37
+ error instanceof Stripe.errors.StripeAPIError ||
38
+ error instanceof Stripe.errors.StripeRateLimitError
39
+ )
40
+ return 'transient'
41
+ return 'unknown'
42
+ }
@@ -0,0 +1,129 @@
1
+ import Stripe from 'stripe'
2
+
3
+ import { PaymentServiceError } from './payment-service.error.ts'
4
+
5
+ /** Supply the same key and parameters when repeating one durable payment attempt. */
6
+ export type PaymentRequestOptions = Stripe.RequestOptions &
7
+ Required<Pick<Stripe.RequestOptions, 'idempotencyKey'>>
8
+
9
+ /** Native Checkout parameters; the service selects payment or setup mode. */
10
+ export type CheckoutInput = Omit<Stripe.Checkout.SessionCreateParams, 'mode'>
11
+
12
+ /** A saved customer method; the service confirms the payment off-session. */
13
+ export type SavedMethodCharge = Omit<Stripe.PaymentIntentCreateParams, 'confirm' | 'off_session'> &
14
+ Required<Pick<Stripe.PaymentIntentCreateParams, 'customer' | 'payment_method'>>
15
+
16
+ /** Native customer response, including Stripe request metadata. */
17
+ export type CustomerResult = Awaited<ReturnType<Stripe['customers']['create']>>
18
+
19
+ /** Native Checkout response. Its URL can be null; this does not confirm payment. */
20
+ export type CheckoutResult = Awaited<ReturnType<Stripe['checkout']['sessions']['create']>>
21
+
22
+ /** Native PaymentIntent response. Applications decide what each status permits. */
23
+ export type PaymentResult = Awaited<ReturnType<Stripe['paymentIntents']['create']>>
24
+
25
+ /**
26
+ * Stripe payment operations, with native types and stable keys for mutations.
27
+ * Rejects with PaymentServiceError or native StripeCardError; unexpected defects propagate.
28
+ */
29
+ export interface IPaymentService {
30
+ /** Create a customer; the application owns persistence and reuse of the customer link. */
31
+ createCustomer(
32
+ input: Stripe.CustomerCreateParams,
33
+ options: PaymentRequestOptions,
34
+ ): Promise<CustomerResult>
35
+ /** Create one-time payment checkout; application fulfillment requires verified payment confirmation. */
36
+ createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>
37
+ /** Create checkout in setup mode. The application records consent and verifies the saved method's owner. */
38
+ setupPaymentMethod(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult>
39
+ /** Confirm a saved method off-session. Native card errors can require customer authentication. */
40
+ chargeSavedMethod(
41
+ input: SavedMethodCharge,
42
+ options: PaymentRequestOptions,
43
+ ): Promise<PaymentResult>
44
+ /** Retrieve an existing payment without starting another payment attempt. */
45
+ getPayment(
46
+ paymentId: string,
47
+ params?: Stripe.PaymentIntentRetrieveParams,
48
+ options?: Stripe.RequestOptions,
49
+ ): Promise<PaymentResult>
50
+ }
51
+
52
+ /**
53
+ * Ported from Typist's StripeClient request boundary; the native SDK now owns retries.
54
+ * Each request defaults to two SDK retries. Native request options can override retries and timeout.
55
+ */
56
+ export class StripePaymentService implements IPaymentService {
57
+ /** Inject a native SDK instance; its transport, credentials, and API version remain caller-owned. */
58
+ constructor(private readonly stripe: Stripe) {}
59
+
60
+ /** Create a customer with a stable application key and preserve the native response. */
61
+ createCustomer(
62
+ input: Stripe.CustomerCreateParams,
63
+ options: PaymentRequestOptions,
64
+ ): Promise<CustomerResult> {
65
+ return this.request('customers.create', () =>
66
+ this.stripe.customers.create(input, { maxNetworkRetries: 2, ...options }),
67
+ )
68
+ }
69
+
70
+ /** Force payment mode after caller parameters; return the native Checkout Session. */
71
+ createCheckout(input: CheckoutInput, options: PaymentRequestOptions): Promise<CheckoutResult> {
72
+ return this.request('checkout.sessions.create', () =>
73
+ this.stripe.checkout.sessions.create(
74
+ { ...input, mode: 'payment' },
75
+ { maxNetworkRetries: 2, ...options },
76
+ ),
77
+ )
78
+ }
79
+
80
+ /** Force setup mode after caller parameters; this operation does not charge the customer. */
81
+ setupPaymentMethod(
82
+ input: CheckoutInput,
83
+ options: PaymentRequestOptions,
84
+ ): Promise<CheckoutResult> {
85
+ return this.request('checkout.sessions.create', () =>
86
+ this.stripe.checkout.sessions.create(
87
+ { ...input, mode: 'setup' },
88
+ { maxNetworkRetries: 2, ...options },
89
+ ),
90
+ )
91
+ }
92
+
93
+ /** Force confirmation and off-session use; preserve native card failures for application handling. */
94
+ chargeSavedMethod(
95
+ input: SavedMethodCharge,
96
+ options: PaymentRequestOptions,
97
+ ): Promise<PaymentResult> {
98
+ return this.request('paymentIntents.create', () =>
99
+ this.stripe.paymentIntents.create(
100
+ { ...input, confirm: true, off_session: true },
101
+ { maxNetworkRetries: 2, ...options },
102
+ ),
103
+ )
104
+ }
105
+
106
+ /** Preserve every native status and response field when reconciling an existing payment. */
107
+ getPayment(
108
+ paymentId: string,
109
+ params?: Stripe.PaymentIntentRetrieveParams,
110
+ options?: Stripe.RequestOptions,
111
+ ): Promise<PaymentResult> {
112
+ return this.request('paymentIntents.retrieve', () =>
113
+ this.stripe.paymentIntents.retrieve(paymentId, params, { maxNetworkRetries: 2, ...options }),
114
+ )
115
+ }
116
+
117
+ private async request<T>(operation: string, work: () => Promise<T>): Promise<T> {
118
+ try {
119
+ return await work()
120
+ } catch (cause) {
121
+ if (
122
+ !(cause instanceof Stripe.errors.StripeError) ||
123
+ cause instanceof Stripe.errors.StripeCardError
124
+ )
125
+ throw cause
126
+ throw new PaymentServiceError(operation, cause)
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,49 @@
1
+ import Stripe from 'stripe'
2
+
3
+ import {
4
+ WebhookVerificationError,
5
+ type WebhookVerification,
6
+ type WebhookVerifier,
7
+ } from '../http/webhook-verification.ts'
8
+
9
+ /**
10
+ * Authenticate raw Stripe webhook bodies with the SDK's timestamp tolerance and Web Crypto.
11
+ * Returns native events; application conversion owns event selection and payload validation.
12
+ */
13
+ export class StripeWebhookVerifier implements WebhookVerifier<Stripe.Event> {
14
+ /** Supply the endpoint's signing secret. Tolerance defaults to Stripe's five-minute window. */
15
+ constructor(
16
+ private readonly stripe: Stripe,
17
+ private readonly signingSecret: string,
18
+ private readonly tolerance?: number,
19
+ ) {}
20
+
21
+ /** Reject signature and JSON failures. Operational failures propagate without logging. */
22
+ async verify(body: string, headers: Headers): Promise<WebhookVerification<Stripe.Event>> {
23
+ const signature = headers.get('stripe-signature')
24
+ if (!signature)
25
+ return { outcome: 'rejected', error: new WebhookVerificationError('missing_headers') }
26
+ try {
27
+ const event = await this.stripe.webhooks.constructEventAsync(
28
+ body,
29
+ signature,
30
+ this.signingSecret,
31
+ this.tolerance,
32
+ Stripe.createSubtleCryptoProvider(),
33
+ )
34
+ return { outcome: 'event', event }
35
+ } catch (cause) {
36
+ if (cause instanceof Stripe.errors.StripeSignatureVerificationError)
37
+ return {
38
+ outcome: 'rejected',
39
+ error: new WebhookVerificationError('invalid_signature', cause),
40
+ }
41
+ if (cause instanceof SyntaxError)
42
+ return {
43
+ outcome: 'rejected',
44
+ error: new WebhookVerificationError('invalid_payload', cause),
45
+ }
46
+ throw cause
47
+ }
48
+ }
49
+ }
@@ -14,11 +14,12 @@ export function resolveFailure<Entries>(
14
14
  const resolved = table.resolve(cause)
15
15
  if (resolved !== null) {
16
16
  logger.warn('handled_error', {
17
- details: { tag: resolved.payload._tag, status: resolved.status },
17
+ tag: resolved.payload._tag,
18
+ status: resolved.status,
18
19
  })
19
20
  return resolved
20
21
  }
21
22
  const classification = cause instanceof AppError ? cause.classification : 'unknown'
22
- logger.error('unexpected_error', { error: cause, details: { classification } })
23
+ logger.error('unexpected_error', { error: cause, classification })
23
24
  return table.fallback(cause)
24
25
  }
@@ -0,0 +1,91 @@
1
+ import { deepStrictEqual, equal, rejects } from 'node:assert/strict'
2
+
3
+ import Stripe from 'stripe'
4
+
5
+ import type { IPaymentService } from '../stripe/payment-service.ts'
6
+
7
+ /**
8
+ * Test a Stripe payment adapter through native fetch injection without live payments.
9
+ * Each case constructs a fresh SDK instance and adapter; no Vitest dependency is required.
10
+ */
11
+ export function paymentServiceContract(
12
+ test: (name: string, run: () => Promise<void>) => void,
13
+ create: (stripe: Stripe) => IPaymentService,
14
+ ): void {
15
+ test('payment checkout selects payment mode and retains the supplied key', async () => {
16
+ const fixture = paymentFixture(create)
17
+ await fixture.service.createCheckout(
18
+ { customer: 'cus_contract' },
19
+ { idempotencyKey: 'checkout:contract' },
20
+ )
21
+ equal(fixture.requests[0]?.body.get('mode'), 'payment')
22
+ equal(fixture.requests[0]?.headers.get('idempotency-key'), 'checkout:contract')
23
+ })
24
+
25
+ test('payment method setup selects setup mode', async () => {
26
+ const fixture = paymentFixture(create)
27
+ await fixture.service.setupPaymentMethod(
28
+ { customer: 'cus_contract' },
29
+ { idempotencyKey: 'setup:contract' },
30
+ )
31
+ equal(fixture.requests[0]?.body.get('mode'), 'setup')
32
+ })
33
+
34
+ test('saved method payments confirm off-session', async () => {
35
+ const fixture = paymentFixture(create)
36
+ await fixture.service.chargeSavedMethod(
37
+ { amount: 1000, currency: 'usd', customer: 'cus_contract', payment_method: 'pm_contract' },
38
+ { idempotencyKey: 'charge:contract' },
39
+ )
40
+ equal(fixture.requests[0]?.body.get('confirm'), 'true')
41
+ equal(fixture.requests[0]?.body.get('off_session'), 'true')
42
+ })
43
+
44
+ test('payment reads preserve native statuses and response fields', async () => {
45
+ const fixture = paymentFixture(create)
46
+ const payment = await fixture.service.getPayment('pi_contract')
47
+ deepStrictEqual(
48
+ { id: payment.id, status: payment.status },
49
+ { id: 'pi_contract', status: 'requires_capture' },
50
+ )
51
+ })
52
+
53
+ test('card failures retain the native error contract', async () => {
54
+ const response = Response.json(
55
+ {
56
+ error: {
57
+ type: 'card_error',
58
+ code: 'authentication_required',
59
+ message: 'Authentication required',
60
+ },
61
+ },
62
+ { status: 402 },
63
+ )
64
+ const fixture = paymentFixture(create, response)
65
+ await rejects(
66
+ fixture.service.chargeSavedMethod(
67
+ { amount: 1000, currency: 'usd', customer: 'cus_contract', payment_method: 'pm_contract' },
68
+ { idempotencyKey: 'charge:contract' },
69
+ ),
70
+ Stripe.errors.StripeCardError,
71
+ )
72
+ })
73
+ }
74
+
75
+ function paymentFixture(
76
+ create: (stripe: Stripe) => IPaymentService,
77
+ response = Response.json({ id: 'pi_contract', status: 'requires_capture' }),
78
+ ) {
79
+ const requests: { headers: Headers; body: URLSearchParams }[] = []
80
+ const stripe = new Stripe('sk_test_contract', {
81
+ httpClient: Stripe.createFetchHttpClient(async (input, init) => {
82
+ const request = new Request(input, init)
83
+ requests.push({
84
+ headers: request.headers,
85
+ body: new URLSearchParams(new TextDecoder().decode(await request.arrayBuffer())),
86
+ })
87
+ return response.clone()
88
+ }),
89
+ })
90
+ return { service: create(stripe), requests }
91
+ }