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.
- 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.d.ts +31 -0
- package/dist/cloudflare/kv-client.d.ts.map +1 -0
- package/dist/cloudflare/kv-client.js +62 -0
- package/dist/cloudflare/kv-client.js.map +1 -0
- package/dist/cloudflare/kv-errors.d.ts +52 -0
- package/dist/cloudflare/kv-errors.d.ts.map +1 -0
- package/dist/cloudflare/kv-errors.js +144 -0
- package/dist/cloudflare/kv-errors.js.map +1 -0
- 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 +10 -5
- package/dist/cloudflare.d.ts.map +1 -1
- package/dist/cloudflare.js +21 -10
- package/dist/cloudflare.js.map +1 -1
- 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 +10 -5
- package/dist/postgres.d.ts.map +1 -1
- package/dist/postgres.js +21 -10
- package/dist/postgres.js.map +1 -1
- 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 +15 -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/d1-database-execution.ts +27 -6
- package/src/infrastructure/cloudflare/kv-client.ts +79 -0
- package/src/infrastructure/cloudflare/kv-errors.ts +157 -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/postgres/postgres-database-execution.ts +27 -6
- 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,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
|
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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,
|
|
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
|
+
}
|