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.
- package/dist/{application-Cf9SER-i.js → application-CNdzHIXk.js} +5 -7
- package/dist/{application-Cf9SER-i.js.map → application-CNdzHIXk.js.map} +1 -1
- package/dist/application.d.ts +2 -2
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +2 -2
- package/dist/better-auth/apple-client-secret.js.map +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +4 -6
- package/dist/better-auth/rate-limit.js.map +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/billing/client.d.ts +86 -0
- package/dist/billing/client.d.ts.map +1 -0
- package/dist/billing/client.js +77 -0
- package/dist/billing/client.js.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/durable-object-client.js +1 -1
- package/dist/cloudflare/durable-object-errors.d.ts +1 -1
- package/dist/cloudflare/durable-object-errors.js +1 -1
- package/dist/cloudflare/kv-client.js +1 -1
- package/dist/cloudflare/kv-errors.d.ts +1 -1
- package/dist/cloudflare/kv-errors.js +1 -1
- package/dist/cloudflare/queue.d.ts +2 -2
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +10 -12
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/r2-client.d.ts +70 -0
- package/dist/cloudflare/r2-client.d.ts.map +1 -0
- package/dist/cloudflare/r2-client.js +153 -0
- package/dist/cloudflare/r2-client.js.map +1 -0
- package/dist/cloudflare/r2-errors.d.ts +34 -0
- package/dist/cloudflare/r2-errors.d.ts.map +1 -0
- package/dist/cloudflare/r2-errors.js +90 -0
- package/dist/cloudflare/r2-errors.js.map +1 -0
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +2 -2
- package/dist/cloudflare/scheduled.js.map +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-ClSP5-V9.js → core-UUW2unJd.js} +106 -195
- package/dist/core-UUW2unJd.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +2 -2
- package/dist/http.js +3 -3
- package/dist/http.js.map +1 -1
- package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
- package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
- package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
- package/dist/index-DUFS0SJ_.d.ts.map +1 -0
- package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-CgdqU20T.js} +2 -2
- package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-CgdqU20T.js.map} +1 -1
- package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
- package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/posthog/metrics.d.ts +1 -1
- package/dist/posthog/server.d.ts +2 -2
- package/dist/posthog/server.js +1 -1
- package/dist/resend.d.ts +3 -3
- package/dist/resend.js +2 -2
- package/dist/sentry/client.d.ts +1 -1
- package/dist/sentry/client.d.ts.map +1 -1
- package/dist/sentry/client.js +10 -4
- package/dist/sentry/client.js.map +1 -1
- package/dist/sentry/server.d.ts +2 -2
- package/dist/sentry/server.js +5 -5
- package/dist/sentry/server.js.map +1 -1
- package/dist/stripe/payment-service.d.ts +52 -0
- package/dist/stripe/payment-service.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.d.ts +13 -0
- package/dist/stripe/payment-service.error.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.js +30 -0
- package/dist/stripe/payment-service.error.js.map +1 -0
- package/dist/stripe/payment-service.js +72 -0
- package/dist/stripe/payment-service.js.map +1 -0
- package/dist/stripe/payment-webhook.d.ts +18 -0
- package/dist/stripe/payment-webhook.d.ts.map +1 -0
- package/dist/stripe/payment-webhook.js +47 -0
- package/dist/stripe/payment-webhook.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +4 -4
- package/dist/tanstack.js.map +1 -1
- package/dist/testing/outbox-repository.d.ts +2 -2
- package/dist/testing/payment-service.d.ts +10 -0
- package/dist/testing/payment-service.d.ts.map +1 -0
- package/dist/testing/payment-service.js +76 -0
- package/dist/testing/payment-service.js.map +1 -0
- package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-BBmjkHgI.js} +2 -2
- package/dist/{webhook-verification-D6aGQfNi.js.map → webhook-verification-BBmjkHgI.js.map} +1 -1
- package/dist/{webhook-verification-BcQQG-vY.d.ts → webhook-verification-BuxPzdr4.d.ts} +2 -2
- package/dist/{webhook-verification-BcQQG-vY.d.ts.map → webhook-verification-BuxPzdr4.d.ts.map} +1 -1
- package/package.json +13 -6
- package/src/application/outbox-relay.ts +3 -1
- package/src/core/console-log-sink.ts +65 -0
- package/src/core/index.ts +1 -4
- package/src/core/logger.ts +80 -281
- package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
- package/src/infrastructure/better-auth/rate-limit.ts +3 -1
- package/src/infrastructure/billing/client.ts +173 -0
- package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
- package/src/infrastructure/cloudflare/r2-client.ts +162 -0
- package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
- package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
- package/src/infrastructure/http/webhook-handler.ts +1 -1
- package/src/infrastructure/sentry/client.ts +6 -3
- package/src/infrastructure/sentry/server.ts +4 -4
- package/src/infrastructure/stripe/payment-service.error.ts +42 -0
- package/src/infrastructure/stripe/payment-service.ts +129 -0
- package/src/infrastructure/stripe/payment-webhook.ts +49 -0
- package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
- package/src/infrastructure/testing/payment-service-contract.ts +91 -0
- package/dist/core-ClSP5-V9.js.map +0 -1
- 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', {
|
|
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('
|
|
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,
|
|
30
|
+
return ({ error, userId, attributes }) => {
|
|
31
31
|
if (isNotFound(error) || isRedirect(error)) return
|
|
32
|
-
|
|
33
|
-
|
|
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,
|
|
30
|
-
if (
|
|
31
|
-
Sentry.captureException(error, { extra:
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
+
}
|