better-ship 0.17.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 (116) 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.js +1 -1
  23. package/dist/cloudflare/kv-errors.d.ts +1 -1
  24. package/dist/cloudflare/kv-errors.js +1 -1
  25. package/dist/cloudflare/queue.d.ts +2 -2
  26. package/dist/cloudflare/queue.d.ts.map +1 -1
  27. package/dist/cloudflare/queue.js +10 -12
  28. package/dist/cloudflare/queue.js.map +1 -1
  29. package/dist/cloudflare/r2-client.d.ts +70 -0
  30. package/dist/cloudflare/r2-client.d.ts.map +1 -0
  31. package/dist/cloudflare/r2-client.js +153 -0
  32. package/dist/cloudflare/r2-client.js.map +1 -0
  33. package/dist/cloudflare/r2-errors.d.ts +34 -0
  34. package/dist/cloudflare/r2-errors.d.ts.map +1 -0
  35. package/dist/cloudflare/r2-errors.js +90 -0
  36. package/dist/cloudflare/r2-errors.js.map +1 -0
  37. package/dist/cloudflare/scheduled.d.ts +1 -1
  38. package/dist/cloudflare/scheduled.js +2 -2
  39. package/dist/cloudflare/scheduled.js.map +1 -1
  40. package/dist/cloudflare.d.ts +3 -3
  41. package/dist/cloudflare.js +3 -3
  42. package/dist/{core-ClSP5-V9.js → core-UUW2unJd.js} +106 -195
  43. package/dist/core-UUW2unJd.js.map +1 -0
  44. package/dist/core.d.ts +2 -2
  45. package/dist/core.js +2 -2
  46. package/dist/http.d.ts +2 -2
  47. package/dist/http.js +3 -3
  48. package/dist/http.js.map +1 -1
  49. package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
  50. package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
  51. package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
  52. package/dist/index-DUFS0SJ_.d.ts.map +1 -0
  53. package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-CgdqU20T.js} +2 -2
  54. package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-CgdqU20T.js.map} +1 -1
  55. package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
  56. package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
  57. package/dist/postgres.d.ts +3 -3
  58. package/dist/postgres.js +3 -3
  59. package/dist/posthog/metrics.d.ts +1 -1
  60. package/dist/posthog/server.d.ts +2 -2
  61. package/dist/posthog/server.js +1 -1
  62. package/dist/resend.d.ts +3 -3
  63. package/dist/resend.js +2 -2
  64. package/dist/sentry/client.d.ts +1 -1
  65. package/dist/sentry/client.d.ts.map +1 -1
  66. package/dist/sentry/client.js +10 -4
  67. package/dist/sentry/client.js.map +1 -1
  68. package/dist/sentry/server.d.ts +2 -2
  69. package/dist/sentry/server.js +5 -5
  70. package/dist/sentry/server.js.map +1 -1
  71. package/dist/stripe/payment-service.d.ts +52 -0
  72. package/dist/stripe/payment-service.d.ts.map +1 -0
  73. package/dist/stripe/payment-service.error.d.ts +13 -0
  74. package/dist/stripe/payment-service.error.d.ts.map +1 -0
  75. package/dist/stripe/payment-service.error.js +30 -0
  76. package/dist/stripe/payment-service.error.js.map +1 -0
  77. package/dist/stripe/payment-service.js +72 -0
  78. package/dist/stripe/payment-service.js.map +1 -0
  79. package/dist/stripe/payment-webhook.d.ts +18 -0
  80. package/dist/stripe/payment-webhook.d.ts.map +1 -0
  81. package/dist/stripe/payment-webhook.js +47 -0
  82. package/dist/stripe/payment-webhook.js.map +1 -0
  83. package/dist/tanstack.d.ts +1 -1
  84. package/dist/tanstack.js +4 -4
  85. package/dist/tanstack.js.map +1 -1
  86. package/dist/testing/outbox-repository.d.ts +2 -2
  87. package/dist/testing/payment-service.d.ts +10 -0
  88. package/dist/testing/payment-service.d.ts.map +1 -0
  89. package/dist/testing/payment-service.js +76 -0
  90. package/dist/testing/payment-service.js.map +1 -0
  91. package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-BBmjkHgI.js} +2 -2
  92. package/dist/{webhook-verification-D6aGQfNi.js.map → webhook-verification-BBmjkHgI.js.map} +1 -1
  93. package/dist/{webhook-verification-BcQQG-vY.d.ts → webhook-verification-BuxPzdr4.d.ts} +2 -2
  94. package/dist/{webhook-verification-BcQQG-vY.d.ts.map → webhook-verification-BuxPzdr4.d.ts.map} +1 -1
  95. package/package.json +13 -6
  96. package/src/application/outbox-relay.ts +3 -1
  97. package/src/core/console-log-sink.ts +65 -0
  98. package/src/core/index.ts +1 -4
  99. package/src/core/logger.ts +80 -281
  100. package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
  101. package/src/infrastructure/better-auth/rate-limit.ts +3 -1
  102. package/src/infrastructure/billing/client.ts +173 -0
  103. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
  104. package/src/infrastructure/cloudflare/r2-client.ts +162 -0
  105. package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
  106. package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
  107. package/src/infrastructure/http/webhook-handler.ts +1 -1
  108. package/src/infrastructure/sentry/client.ts +6 -3
  109. package/src/infrastructure/sentry/server.ts +4 -4
  110. package/src/infrastructure/stripe/payment-service.error.ts +42 -0
  111. package/src/infrastructure/stripe/payment-service.ts +129 -0
  112. package/src/infrastructure/stripe/payment-webhook.ts +49 -0
  113. package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
  114. package/src/infrastructure/testing/payment-service-contract.ts +91 -0
  115. package/dist/core-ClSP5-V9.js.map +0 -1
  116. package/dist/index-DvJUcSW1.d.ts.map +0 -1
@@ -0,0 +1,162 @@
1
+ import { AppError, isAbortError } from '@/core'
2
+
3
+ import { R2Error, R2JsonError, type R2Operation } from './r2-errors.ts'
4
+
5
+ /** Values accepted by the R2 binding. */
6
+ export type R2Value = Parameters<R2Bucket['put']>[1]
7
+
8
+ /** Missing objects, failed conditions, and returned bodies are distinct outcomes. */
9
+ export type R2GetResult =
10
+ | { status: 'missing' }
11
+ | { status: 'precondition-failed'; object: R2Object }
12
+ | { status: 'found'; object: R2ObjectBody }
13
+
14
+ /** A failed condition does not establish whether an earlier attempt committed. */
15
+ export type R2PutResult = { status: 'precondition-failed' } | { status: 'stored'; object: R2Object }
16
+
17
+ /** Untrusted JSON data, with absence separate from a stored null. The application supplies its schema. */
18
+ export type R2JsonResult = { status: 'missing' } | { status: 'found'; value: unknown }
19
+
20
+ /**
21
+ * R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.
22
+ * Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.
23
+ */
24
+ export class R2Client {
25
+ constructor(private readonly bucket: R2Bucket) {}
26
+
27
+ /** Read native metadata, or return null when the object does not exist. */
28
+ head(key: string): Promise<R2Object | null> {
29
+ return callR2('head', () => this.bucket.head(key))
30
+ }
31
+
32
+ /** Return a native body without consuming it. The consumer owns later body-read failures. */
33
+ async get(key: string, options?: R2GetOptions): Promise<R2GetResult> {
34
+ const object = await callR2('get', () => this.bucket.get(key, options))
35
+ if (object === null) return { status: 'missing' }
36
+ if (!('body' in object)) return { status: 'precondition-failed', object }
37
+ return { status: 'found', object }
38
+ }
39
+
40
+ /** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */
41
+ async getJson(key: string): Promise<R2JsonResult> {
42
+ const text = await callR2('getJson', async () => {
43
+ const object = await this.bucket.get(key)
44
+ return object === null ? null : await object.text()
45
+ })
46
+ if (text === null) return { status: 'missing' }
47
+ try {
48
+ return { status: 'found', value: JSON.parse(text) }
49
+ } catch (cause) {
50
+ throw new R2JsonError({ operation: 'decode', cause })
51
+ }
52
+ }
53
+
54
+ /** Write once, including conditional writes. Pass streams directly to the binding. */
55
+ async put(key: string, body: R2Value, options?: R2PutOptions): Promise<R2PutResult> {
56
+ const object = await callR2('put', () => this.bucket.put(key, body, options))
57
+ return object === null ? { status: 'precondition-failed' } : { status: 'stored', object }
58
+ }
59
+
60
+ /** Serialize once and default the content type to application/json without changing caller options. */
61
+ async putJson(
62
+ key: string,
63
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This serialization boundary accepts any value and rejects values without a JSON representation.
64
+ value: unknown,
65
+ options?: R2PutOptions,
66
+ ): Promise<R2PutResult> {
67
+ let text: string | undefined
68
+ try {
69
+ text = JSON.stringify(value)
70
+ } catch (cause) {
71
+ throw new R2JsonError({ operation: 'encode', cause })
72
+ }
73
+ if (text === undefined) throw new R2JsonError({ operation: 'encode', cause: undefined })
74
+ return this.put(key, text, { ...options, httpMetadata: jsonMetadata(options?.httpMetadata) })
75
+ }
76
+
77
+ /** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */
78
+ delete(keys: string | string[]): Promise<void> {
79
+ return callR2('delete', () => this.bucket.delete(keys))
80
+ }
81
+
82
+ /** Return one native page with the requested metadata, cursor, and delimiter results. */
83
+ list(options?: R2ListOptions): Promise<R2Objects> {
84
+ return callR2('list', () => this.bucket.list(options))
85
+ }
86
+
87
+ /** Create once. A failed response can leave an upload whose ID the caller never received. */
88
+ async createMultipartUpload(
89
+ key: string,
90
+ options?: R2MultipartOptions,
91
+ ): Promise<R2MultipartClient> {
92
+ const upload = await callR2('createMultipartUpload', () =>
93
+ this.bucket.createMultipartUpload(key, options),
94
+ )
95
+ return new R2MultipartClient(upload)
96
+ }
97
+
98
+ /** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */
99
+ resumeMultipartUpload(key: string, uploadId: string): R2MultipartClient {
100
+ return new R2MultipartClient(this.bucket.resumeMultipartUpload(key, uploadId))
101
+ }
102
+ }
103
+
104
+ /** An upload handle that binds its key and ID once. Every operation makes one attempt. */
105
+ export class R2MultipartClient {
106
+ constructor(private readonly upload: R2MultipartUpload) {}
107
+
108
+ /** The destination object key. */
109
+ get key(): string {
110
+ return this.upload.key
111
+ }
112
+
113
+ /** The provider ID to persist when the application needs to resume this upload. */
114
+ get uploadId(): string {
115
+ return this.upload.uploadId
116
+ }
117
+
118
+ /** Upload one part without buffering or replaying its body. */
119
+ uploadPart(
120
+ partNumber: number,
121
+ body: Exclude<R2Value, null>,
122
+ options?: R2UploadPartOptions,
123
+ ): Promise<R2UploadedPart> {
124
+ return callR2('uploadPart', () => this.upload.uploadPart(partNumber, body, options))
125
+ }
126
+
127
+ /** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */
128
+ complete(parts: ReadonlyArray<R2UploadedPart>): Promise<R2Object> {
129
+ return callR2('complete', () =>
130
+ this.upload.complete([...parts].sort((a, b) => a.partNumber - b.partNumber)),
131
+ )
132
+ }
133
+
134
+ /** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */
135
+ async abort(): Promise<void> {
136
+ try {
137
+ await callR2('abort', () => this.upload.abort())
138
+ } catch (cause) {
139
+ if (cause instanceof R2Error && cause.operation === 'abort' && cause.r2?.code === 10024)
140
+ return
141
+ throw cause
142
+ }
143
+ }
144
+ }
145
+
146
+ async function callR2<T>(operation: R2Operation, work: () => Promise<T>): Promise<T> {
147
+ try {
148
+ return await work()
149
+ } catch (cause) {
150
+ if (isAbortError(cause) || cause instanceof AppError) throw cause
151
+ throw new R2Error({ operation, cause })
152
+ }
153
+ }
154
+
155
+ function jsonMetadata(metadata: R2HTTPMetadata | Headers | undefined): R2HTTPMetadata | Headers {
156
+ if (metadata instanceof Headers) {
157
+ const headers = new Headers(metadata)
158
+ if (!headers.has('content-type')) headers.set('content-type', 'application/json')
159
+ return headers
160
+ }
161
+ return { ...metadata, contentType: metadata?.contentType ?? 'application/json' }
162
+ }
@@ -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
@@ -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
+ }