@rdlabo/workers-hono-kit 0.6.4 → 0.6.6
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/http/nest-error.d.ts +149 -0
- package/dist/http/nest-error.js +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/stripe/failure.d.ts +108 -0
- package/dist/stripe/failure.js +208 -0
- package/package.json +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Context, Env } from 'hono';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
/**
|
|
4
|
+
* Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
|
|
8
|
+
* return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
|
|
9
|
+
*/
|
|
10
|
+
export declare const NEST_REASON_PHRASES: Record<number, string>;
|
|
11
|
+
/**
|
|
12
|
+
* Contextual metadata passed to an {@link ErrorReporter} when reporting an unexpected error.
|
|
13
|
+
*/
|
|
14
|
+
export interface ErrorReportContext {
|
|
15
|
+
/** Correlation id for the failing request, if one is tracked. */
|
|
16
|
+
requestId?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Signature of a function that reports an unexpected (non-HTTP) error to an external sink such as Sentry.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Wire it into {@link createNestErrorHandler} via `onUnhandledError`, e.g.
|
|
23
|
+
* `(err, c) => reporter(err, { requestId: c.get('requestId') })`. The reporting client itself is
|
|
24
|
+
* intentionally kept out of this kit; the consumer supplies the implementation.
|
|
25
|
+
*
|
|
26
|
+
* @param error - The thrown value being reported.
|
|
27
|
+
* @param context - Optional correlation context for the failing request.
|
|
28
|
+
*/
|
|
29
|
+
export type ErrorReporter = (error: unknown, context?: ErrorReportContext) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Minimal Sentry-like client for {@link createSentryErrorReporter} and {@link createQueueErrorHandler}.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Declared structurally to avoid a hard dependency on `@sentry/cloudflare`.
|
|
35
|
+
*/
|
|
36
|
+
export interface SentryExceptionReporterLike {
|
|
37
|
+
captureException(exception: unknown, captureContext?: {
|
|
38
|
+
tags?: Record<string, string>;
|
|
39
|
+
extra?: Record<string, unknown>;
|
|
40
|
+
}): void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
|
|
44
|
+
*/
|
|
45
|
+
export declare function createSentryErrorReporter(sentry: SentryExceptionReporterLike): ErrorReporter;
|
|
46
|
+
/**
|
|
47
|
+
* Minimal shape read from a value treated as an HTTP error: its status, message, and optional body.
|
|
48
|
+
*
|
|
49
|
+
* @internal
|
|
50
|
+
*/
|
|
51
|
+
interface HttpErrorLike {
|
|
52
|
+
/** HTTP status code to respond with. */
|
|
53
|
+
status: ContentfulStatusCode;
|
|
54
|
+
/** Human-readable error message placed in the response body. */
|
|
55
|
+
message: string;
|
|
56
|
+
/**
|
|
57
|
+
* Escape hatch for a fully custom response body. When present, it is rendered verbatim instead of
|
|
58
|
+
* the NestJS-shaped body.
|
|
59
|
+
*/
|
|
60
|
+
body?: unknown;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Options controlling how {@link createNestErrorHandler} shapes error responses.
|
|
64
|
+
*
|
|
65
|
+
* @typeParam E - The Hono environment type, so `onUnhandledError` receives a correctly typed context.
|
|
66
|
+
*/
|
|
67
|
+
export interface NestErrorHandlerOptions<E extends Env = Env> {
|
|
68
|
+
/** Status-to-reason-phrase map for the `error` field. Defaults to {@link NEST_REASON_PHRASES}. */
|
|
69
|
+
reasonPhrases?: Record<number, string>;
|
|
70
|
+
/**
|
|
71
|
+
* Statuses that return only `{ statusCode, message }`, omitting the `error` field. Defaults to `[401]`,
|
|
72
|
+
* matching NestJS where a generic `HttpException(msg, 401)` carries no `error`.
|
|
73
|
+
*/
|
|
74
|
+
bareStatuses?: readonly number[];
|
|
75
|
+
/**
|
|
76
|
+
* Field order of the non-bare error body. Defaults to `'statusCode-first'` (the NestJS canonical order).
|
|
77
|
+
* Use `'message-first'` to emit `{ message, error, statusCode }` when byte parity requires it.
|
|
78
|
+
*/
|
|
79
|
+
fieldOrder?: 'statusCode-first' | 'message-first';
|
|
80
|
+
/**
|
|
81
|
+
* Fallback `error` value for statuses that are neither bare nor present in `reasonPhrases`. Defaults to
|
|
82
|
+
* `undefined`, meaning the `error` field is omitted when no reason phrase is known. Set to a string such
|
|
83
|
+
* as `'Error'` to always include an `error` field, faithfully reproducing the NestJS default exception
|
|
84
|
+
* filter behavior where `error` is always present.
|
|
85
|
+
*/
|
|
86
|
+
fallbackReason?: string;
|
|
87
|
+
/**
|
|
88
|
+
* Predicate identifying which thrown values are HTTP errors. Defaults to detecting Hono's `HTTPException`.
|
|
89
|
+
* Override it (e.g. `(e) => e instanceof MyHttpError`) when the app throws a custom HTTP error type.
|
|
90
|
+
*/
|
|
91
|
+
isHttpError?: (err: unknown) => err is HttpErrorLike;
|
|
92
|
+
/**
|
|
93
|
+
* Hook invoked before an unexpected (non-HTTP) error is returned as a 500, typically used to report the
|
|
94
|
+
* error (e.g. to Sentry). Any exception thrown by this hook is swallowed so reporting cannot alter the
|
|
95
|
+
* error response.
|
|
96
|
+
*/
|
|
97
|
+
onUnhandledError?: (err: unknown, c: Context<E>) => void;
|
|
98
|
+
/**
|
|
99
|
+
* Response body for unexpected errors returned as 500. Defaults to
|
|
100
|
+
* `{ statusCode: 500, message: 'Internal server error' }`.
|
|
101
|
+
*/
|
|
102
|
+
internalServerErrorBody?: unknown;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
|
|
106
|
+
*
|
|
107
|
+
* @remarks
|
|
108
|
+
* Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
|
|
109
|
+
* - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
|
|
110
|
+
* custom `body`, that body is returned verbatim.
|
|
111
|
+
* - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
|
|
112
|
+
* - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
|
|
113
|
+
*
|
|
114
|
+
* Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
|
|
115
|
+
* {@link NestErrorHandlerOptions}, while the branching logic stays shared.
|
|
116
|
+
*
|
|
117
|
+
* @typeParam E - The Hono environment type propagated to `onUnhandledError`.
|
|
118
|
+
* @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
|
|
119
|
+
* @returns A handler suitable for `app.onError(...)`.
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* app.onError(
|
|
124
|
+
* createNestErrorHandler({
|
|
125
|
+
* fieldOrder: 'message-first',
|
|
126
|
+
* fallbackReason: 'Error',
|
|
127
|
+
* onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
|
|
128
|
+
* }),
|
|
129
|
+
* );
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
export declare function createNestErrorHandler<E extends Env = Env>(options?: NestErrorHandlerOptions<E>): (err: Error, c: Context<E>) => Response;
|
|
133
|
+
/**
|
|
134
|
+
* Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
|
|
135
|
+
*
|
|
136
|
+
* @remarks
|
|
137
|
+
* Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
|
|
138
|
+
* NestJS default 404 response so unmatched routes stay byte-identical.
|
|
139
|
+
*
|
|
140
|
+
* @param c - The Hono request context for the unmatched route.
|
|
141
|
+
* @returns A 404 JSON response.
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* app.notFound(nestNotFoundHandler);
|
|
146
|
+
* ```
|
|
147
|
+
*/
|
|
148
|
+
export declare function nestNotFoundHandler(c: Context): Response;
|
|
149
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { findMysqlDriverError, logMysqlDriverError } from './mysql-driver-error.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reason phrases attached by the NestJS default exception filter, keyed by HTTP status code.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Mirrors the `error` field values NestJS produces for common client-error statuses, so a Hono app can
|
|
7
|
+
* return byte-identical error bodies. Used as the default `reasonPhrases` map by {@link createNestErrorHandler}.
|
|
8
|
+
*/
|
|
9
|
+
export const NEST_REASON_PHRASES = {
|
|
10
|
+
400: 'Bad Request',
|
|
11
|
+
401: 'Unauthorized',
|
|
12
|
+
403: 'Forbidden',
|
|
13
|
+
404: 'Not Found',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Build an {@link ErrorReporter} that forwards unhandled errors to Sentry with an optional `request_id` tag.
|
|
17
|
+
*/
|
|
18
|
+
export function createSentryErrorReporter(sentry) {
|
|
19
|
+
return (error, context) => {
|
|
20
|
+
sentry.captureException(error, context?.requestId ? { tags: { request_id: context.requestId } } : undefined);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Structurally detect Hono's `HTTPException` without relying on `instanceof`.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* When this kit is symlinked into a consumer, the `hono` instance it resolves can differ from the
|
|
28
|
+
* consumer's `hono`, so an `HTTPException` from one copy fails an `instanceof` check against the other.
|
|
29
|
+
* Detecting the presence of a `getResponse()` method and a numeric `status` is stable across module
|
|
30
|
+
* boundaries and production bundles.
|
|
31
|
+
*
|
|
32
|
+
* @param err - The thrown value to test.
|
|
33
|
+
* @returns `true` when `err` looks like a Hono `HTTPException`.
|
|
34
|
+
*
|
|
35
|
+
* @internal
|
|
36
|
+
*/
|
|
37
|
+
const isHTTPException = (err) => err instanceof Error &&
|
|
38
|
+
typeof err.getResponse === 'function' &&
|
|
39
|
+
typeof err.status === 'number';
|
|
40
|
+
/**
|
|
41
|
+
* Create a Hono `onError` handler that maps thrown errors to NestJS-shaped error JSON.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* Reproduces the NestJS default exception filter so a Hono app returns byte-identical error bodies:
|
|
45
|
+
* - HTTP errors (by default `HTTPException`) are mapped to a NestJS-shaped body; if the error carries a
|
|
46
|
+
* custom `body`, that body is returned verbatim.
|
|
47
|
+
* - Statuses listed in `bareStatuses` (default `[401]`) omit the `error` field.
|
|
48
|
+
* - Any other (unexpected) error triggers `onUnhandledError`, is logged via `console.error`, and returns 500.
|
|
49
|
+
*
|
|
50
|
+
* Per-app differences in body field order, HTTP error type, and reporting hook are absorbed through
|
|
51
|
+
* {@link NestErrorHandlerOptions}, while the branching logic stays shared.
|
|
52
|
+
*
|
|
53
|
+
* @typeParam E - The Hono environment type propagated to `onUnhandledError`.
|
|
54
|
+
* @param options - Overrides for reason phrases, bare statuses, field order, error detection, and reporting.
|
|
55
|
+
* @returns A handler suitable for `app.onError(...)`.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* app.onError(
|
|
60
|
+
* createNestErrorHandler({
|
|
61
|
+
* fieldOrder: 'message-first',
|
|
62
|
+
* fallbackReason: 'Error',
|
|
63
|
+
* onUnhandledError: (err, c) => reportError(err, { requestId: c.get('requestId') }),
|
|
64
|
+
* }),
|
|
65
|
+
* );
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export function createNestErrorHandler(options = {}) {
|
|
69
|
+
const { reasonPhrases = NEST_REASON_PHRASES, bareStatuses = [401], fieldOrder = 'statusCode-first', isHttpError = isHTTPException, onUnhandledError, internalServerErrorBody = { statusCode: 500, message: 'Internal server error' }, fallbackReason, } = options;
|
|
70
|
+
return (err, c) => {
|
|
71
|
+
if (isHttpError(err)) {
|
|
72
|
+
// Escape hatch for a custom error body: render it verbatim.
|
|
73
|
+
if (err.body !== undefined) {
|
|
74
|
+
return c.json(err.body, err.status);
|
|
75
|
+
}
|
|
76
|
+
// reasonPhrases[status] is typed as string, but with noUncheckedIndexedAccess disabled it can be
|
|
77
|
+
// undefined at runtime. The fallbackReason fallback for unregistered statuses is intentional.
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
79
|
+
const reason = bareStatuses.includes(err.status) ? undefined : (reasonPhrases[err.status] ?? fallbackReason);
|
|
80
|
+
if (reason === undefined) {
|
|
81
|
+
return c.json({ statusCode: err.status, message: err.message }, err.status);
|
|
82
|
+
}
|
|
83
|
+
const body = fieldOrder === 'message-first'
|
|
84
|
+
? { message: err.message, error: reason, statusCode: err.status }
|
|
85
|
+
: { statusCode: err.status, message: err.message, error: reason };
|
|
86
|
+
return c.json(body, err.status);
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
onUnhandledError?.(err, c);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Reporting must never change the behavior of the error response.
|
|
93
|
+
}
|
|
94
|
+
if (findMysqlDriverError(err)) {
|
|
95
|
+
logMysqlDriverError(err, 500);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
console.error(err);
|
|
99
|
+
}
|
|
100
|
+
return c.json(internalServerErrorBody, 500);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Hono `notFound` handler that returns the canonical Express/NestJS unmatched-route 404 body.
|
|
105
|
+
*
|
|
106
|
+
* @remarks
|
|
107
|
+
* Produces `{ message: "Cannot <METHOD> <path>", error: 'Not Found', statusCode: 404 }`, matching the
|
|
108
|
+
* NestJS default 404 response so unmatched routes stay byte-identical.
|
|
109
|
+
*
|
|
110
|
+
* @param c - The Hono request context for the unmatched route.
|
|
111
|
+
* @returns A 404 JSON response.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```ts
|
|
115
|
+
* app.notFound(nestNotFoundHandler);
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
export function nestNotFoundHandler(c) {
|
|
119
|
+
return c.json({ message: `Cannot ${c.req.method} ${new URL(c.req.url).pathname}`, error: 'Not Found', statusCode: 404 }, 404);
|
|
120
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export { KVCache } from './cache/kv-cache.js';
|
|
|
47
47
|
export type { KVNamespace, KVCacheOptions } from './cache/kv-cache.js';
|
|
48
48
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
49
49
|
export type { CreateStripeClientOptions } from './stripe/client.js';
|
|
50
|
+
export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
|
|
51
|
+
export type { StripeFailureReason, PaymentFailureSource, PaymentFailureRecord, PaymentDeclinedBody, } from './stripe/failure.js';
|
|
50
52
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
51
53
|
export { sendInChunks } from './queue/send.js';
|
|
52
54
|
export type { QueueLike, QueueSendMessage } from './queue/send.js';
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ export { createSentryErrorReporter } from './http/http-error.js';
|
|
|
36
36
|
export { KVCache } from './cache/kv-cache.js';
|
|
37
37
|
// stripe
|
|
38
38
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
39
|
+
export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
|
|
39
40
|
// db
|
|
40
41
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
41
42
|
// queue
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { HTTPException } from 'hono/http-exception';
|
|
2
|
+
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
|
3
|
+
/**
|
|
4
|
+
* Normalized, provider-agnostic description of a failed Stripe payment.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* Extracted from a Stripe `PaymentIntent`, `Invoice`, or a thrown `StripeCardError` by
|
|
8
|
+
* {@link extractStripeFailureReason}. Fields are all optional because Stripe does not always populate
|
|
9
|
+
* a decline reason (e.g. a Strong Customer Authentication challenge carries no `decline_code`).
|
|
10
|
+
*/
|
|
11
|
+
export interface StripeFailureReason {
|
|
12
|
+
/** `last_payment_error.code`, e.g. `card_declined` / `authentication_required`. */
|
|
13
|
+
code?: string;
|
|
14
|
+
/** `last_payment_error.decline_code`, e.g. `insufficient_funds` / `expired_card`. */
|
|
15
|
+
declineCode?: string;
|
|
16
|
+
/** Stripe's original (English) failure message. Stored for debugging; never shown to users. */
|
|
17
|
+
message?: string;
|
|
18
|
+
/** Id of the PaymentIntent the failure originated from, when resolvable. */
|
|
19
|
+
paymentIntentId?: string;
|
|
20
|
+
/** Id of the Invoice the failure originated from, when resolvable. */
|
|
21
|
+
invoiceId?: string;
|
|
22
|
+
/** Id of the Subscription the failing invoice belongs to, when resolvable. */
|
|
23
|
+
subscriptionId?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Where a {@link PaymentFailureRecord} was captured. */
|
|
26
|
+
export type PaymentFailureSource = 'webhook.invoice.payment_failed' | 'webhook.invoice.payment_action_required' | 'reconcile' | 'checkout' | 'iap';
|
|
27
|
+
/**
|
|
28
|
+
* A payment failure as persisted to the `payment_failed.receipt` column (JSON string).
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* Only the raw {@link StripeFailureReason} is stored — the user-facing Japanese message is generated
|
|
32
|
+
* on read via {@link stripeFailureMessageJa} so that wording changes never require a data migration.
|
|
33
|
+
*/
|
|
34
|
+
export interface PaymentFailureRecord {
|
|
35
|
+
reason: StripeFailureReason;
|
|
36
|
+
source: PaymentFailureSource;
|
|
37
|
+
/** ISO 8601 timestamp of when the failure was captured. */
|
|
38
|
+
occurredAt: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Extract a normalized {@link StripeFailureReason} from a Stripe `PaymentIntent`, `Invoice`, a
|
|
42
|
+
* `{ paymentIntent, invoice }` pair, or a thrown Stripe error.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* The Stripe SDK is intentionally **not** imported — inputs are typed `unknown` and inspected by
|
|
46
|
+
* duck typing (Stripe objects carry an `object` discriminator: `'payment_intent'` / `'invoice'`).
|
|
47
|
+
* This keeps the helper immune to Stripe SDK version differences between this kit and its consumers,
|
|
48
|
+
* matching the existing `isPermanentPaymentError` approach in the payment services.
|
|
49
|
+
*
|
|
50
|
+
* @param source - A PaymentIntent, Invoice, `{ paymentIntent?, invoice? }`, or thrown Stripe error.
|
|
51
|
+
* @returns The extracted reason, or `null` when nothing identifiable could be found.
|
|
52
|
+
*/
|
|
53
|
+
export declare function extractStripeFailureReason(source: unknown): StripeFailureReason | null;
|
|
54
|
+
/**
|
|
55
|
+
* Render a {@link StripeFailureReason} as a single-sentence Japanese message safe to show to users.
|
|
56
|
+
*
|
|
57
|
+
* @remarks
|
|
58
|
+
* `decline_code` wins over `code`; anything unmapped (including `null`) falls back to a generic
|
|
59
|
+
* message. Fraud-related decline codes are deliberately masked with a generic phrase.
|
|
60
|
+
*
|
|
61
|
+
* @param reason - The extracted failure reason, or `null`.
|
|
62
|
+
* @returns A user-facing Japanese message.
|
|
63
|
+
*/
|
|
64
|
+
export declare function stripeFailureMessageJa(reason: StripeFailureReason | null): string;
|
|
65
|
+
/**
|
|
66
|
+
* Serialize a {@link PaymentFailureRecord} for storage in the `payment_failed.receipt` column.
|
|
67
|
+
*
|
|
68
|
+
* @param record - The failure record to serialize.
|
|
69
|
+
* @returns A JSON string.
|
|
70
|
+
*/
|
|
71
|
+
export declare function serializePaymentFailure(record: PaymentFailureRecord): string;
|
|
72
|
+
/**
|
|
73
|
+
* Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
|
|
74
|
+
*
|
|
75
|
+
* @param receipt - The stored JSON string, or `null`/`undefined`.
|
|
76
|
+
* @returns The parsed record, or `null` when absent or malformed.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parsePaymentFailure(receipt: string | null | undefined): PaymentFailureRecord | null;
|
|
79
|
+
/** Response body carried by {@link PaymentDeclinedError}. */
|
|
80
|
+
export interface PaymentDeclinedBody {
|
|
81
|
+
statusCode: number;
|
|
82
|
+
/** User-facing Japanese message. */
|
|
83
|
+
message: string;
|
|
84
|
+
code?: string;
|
|
85
|
+
declineCode?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* HTTP error for a synchronous card decline, carrying a user-facing Japanese message.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* Extends Hono's `HTTPException` and exposes a `body`, so `createHttpErrorHandler` returns that body
|
|
92
|
+
* verbatim (see {@link createHttpErrorHandler}). Defaults to `400` rather than the semantically
|
|
93
|
+
* correct `402` so it rides the fleet's existing client interceptor, which surfaces `4xx` bodies with
|
|
94
|
+
* a `message` to the user.
|
|
95
|
+
*/
|
|
96
|
+
export declare class PaymentDeclinedError extends HTTPException {
|
|
97
|
+
readonly body: PaymentDeclinedBody;
|
|
98
|
+
constructor(reason: StripeFailureReason | null, status?: ContentfulStatusCode);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Convert a thrown Stripe error into a {@link PaymentDeclinedError}, or `null` when it is not a card
|
|
102
|
+
* decline (the caller should re-throw so it maps to a generic 500).
|
|
103
|
+
*
|
|
104
|
+
* @param error - The value thrown by a Stripe SDK call.
|
|
105
|
+
* @param status - HTTP status for the resulting error; defaults to `400` (see {@link PaymentDeclinedError}).
|
|
106
|
+
* @returns A {@link PaymentDeclinedError}, or `null` when `error` is not a card decline.
|
|
107
|
+
*/
|
|
108
|
+
export declare function toPaymentDeclinedError(error: unknown, status?: ContentfulStatusCode): PaymentDeclinedError | null;
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { HTTPException } from 'hono/http-exception';
|
|
2
|
+
const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
|
|
3
|
+
const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
|
|
4
|
+
/** Pull `{ code, decline_code, message }` out of a Stripe error-shaped object, or `null` if empty. */
|
|
5
|
+
function readErrorShape(v) {
|
|
6
|
+
const r = asRecord(v);
|
|
7
|
+
if (!r) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const code = str(r.code);
|
|
11
|
+
const declineCode = str(r.decline_code);
|
|
12
|
+
const message = str(r.message);
|
|
13
|
+
if (!code && !declineCode && !message) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return { code, declineCode, message };
|
|
17
|
+
}
|
|
18
|
+
/** Resolve a Stripe reference (id string or expanded object with an `id`) to its id string. */
|
|
19
|
+
function refId(v) {
|
|
20
|
+
if (typeof v === 'string') {
|
|
21
|
+
return str(v);
|
|
22
|
+
}
|
|
23
|
+
return str(asRecord(v)?.id);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract a normalized {@link StripeFailureReason} from a Stripe `PaymentIntent`, `Invoice`, a
|
|
27
|
+
* `{ paymentIntent, invoice }` pair, or a thrown Stripe error.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* The Stripe SDK is intentionally **not** imported — inputs are typed `unknown` and inspected by
|
|
31
|
+
* duck typing (Stripe objects carry an `object` discriminator: `'payment_intent'` / `'invoice'`).
|
|
32
|
+
* This keeps the helper immune to Stripe SDK version differences between this kit and its consumers,
|
|
33
|
+
* matching the existing `isPermanentPaymentError` approach in the payment services.
|
|
34
|
+
*
|
|
35
|
+
* @param source - A PaymentIntent, Invoice, `{ paymentIntent?, invoice? }`, or thrown Stripe error.
|
|
36
|
+
* @returns The extracted reason, or `null` when nothing identifiable could be found.
|
|
37
|
+
*/
|
|
38
|
+
export function extractStripeFailureReason(source) {
|
|
39
|
+
const root = asRecord(source);
|
|
40
|
+
if (!root) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
let paymentIntent = null;
|
|
44
|
+
let invoice = null;
|
|
45
|
+
let thrown = null;
|
|
46
|
+
if (root.paymentIntent !== undefined || root.invoice !== undefined) {
|
|
47
|
+
// Normalized container: { paymentIntent?, invoice? }.
|
|
48
|
+
paymentIntent = asRecord(root.paymentIntent);
|
|
49
|
+
invoice = asRecord(root.invoice);
|
|
50
|
+
}
|
|
51
|
+
else if (str(root.object) === 'payment_intent') {
|
|
52
|
+
paymentIntent = root;
|
|
53
|
+
}
|
|
54
|
+
else if (str(root.object) === 'invoice') {
|
|
55
|
+
invoice = root;
|
|
56
|
+
}
|
|
57
|
+
else if (str(root.code) || str(root.decline_code) || root.type === 'StripeCardError') {
|
|
58
|
+
// A thrown Stripe error; it may carry an expanded PaymentIntent.
|
|
59
|
+
thrown = root;
|
|
60
|
+
paymentIntent = asRecord(root.payment_intent);
|
|
61
|
+
}
|
|
62
|
+
// An invoice may embed its PaymentIntent (when expanded).
|
|
63
|
+
if (invoice && !paymentIntent) {
|
|
64
|
+
paymentIntent = asRecord(invoice.payment_intent);
|
|
65
|
+
}
|
|
66
|
+
const errorShape = readErrorShape(paymentIntent?.last_payment_error) ??
|
|
67
|
+
readErrorShape(invoice?.last_finalization_error) ??
|
|
68
|
+
(thrown ? readErrorShape(thrown) : null);
|
|
69
|
+
const reason = {
|
|
70
|
+
code: errorShape?.code,
|
|
71
|
+
declineCode: errorShape?.declineCode,
|
|
72
|
+
message: errorShape?.message,
|
|
73
|
+
paymentIntentId: refId(paymentIntent?.id ?? thrown?.payment_intent),
|
|
74
|
+
invoiceId: refId(invoice?.id),
|
|
75
|
+
subscriptionId: refId(invoice?.subscription),
|
|
76
|
+
};
|
|
77
|
+
// Strong Customer Authentication: no decline_code, but the intent is stuck on `requires_action`.
|
|
78
|
+
if (!reason.code && !reason.declineCode && str(paymentIntent?.status) === 'requires_action') {
|
|
79
|
+
reason.code = 'authentication_required';
|
|
80
|
+
}
|
|
81
|
+
const hasAny = reason.code ??
|
|
82
|
+
reason.declineCode ??
|
|
83
|
+
reason.message ??
|
|
84
|
+
reason.paymentIntentId ??
|
|
85
|
+
reason.invoiceId ??
|
|
86
|
+
reason.subscriptionId;
|
|
87
|
+
return hasAny ? reason : null;
|
|
88
|
+
}
|
|
89
|
+
const GENERIC_MESSAGE_JA = '前回のカード決済に失敗しました。カード情報をご確認のうえ、再度お試しください。';
|
|
90
|
+
const FRAUD_MESSAGE_JA = 'カードがご利用いただけませんでした。カード会社にお問い合わせいただくか、別のカードをお試しください。';
|
|
91
|
+
/** decline_code → Japanese, taking precedence over {@link CODE_MESSAGES_JA}. */
|
|
92
|
+
const DECLINE_CODE_MESSAGES_JA = {
|
|
93
|
+
insufficient_funds: '残高不足のためカードが承認されませんでした。別のカードをお試しください。',
|
|
94
|
+
expired_card: 'カードの有効期限が切れています。カード情報を更新してください。',
|
|
95
|
+
incorrect_cvc: 'セキュリティコード(CVC)が正しくありません。カード情報をご確認ください。',
|
|
96
|
+
incorrect_number: 'カード番号が正しくありません。カード情報をご確認ください。',
|
|
97
|
+
card_not_supported: 'このカードはご利用いただけません。別のカードをお試しください。',
|
|
98
|
+
currency_not_supported: 'このカードは対象の通貨に対応していません。別のカードをお試しください。',
|
|
99
|
+
card_velocity_exceeded: 'ご利用限度を超えたためカードが承認されませんでした。時間をおいて再度お試しください。',
|
|
100
|
+
withdrawal_count_limit_exceeded: 'ご利用限度を超えたためカードが承認されませんでした。時間をおいて再度お試しください。',
|
|
101
|
+
processing_error: '決済処理中にエラーが発生しました。時間をおいて再度お試しください。',
|
|
102
|
+
try_again_later: '一時的な理由でカードが承認されませんでした。時間をおいて再度お試しください。',
|
|
103
|
+
// Fraud / security signals are collapsed to a generic message (Stripe recommends not revealing the reason).
|
|
104
|
+
lost_card: FRAUD_MESSAGE_JA,
|
|
105
|
+
stolen_card: FRAUD_MESSAGE_JA,
|
|
106
|
+
pickup_card: FRAUD_MESSAGE_JA,
|
|
107
|
+
do_not_honor: FRAUD_MESSAGE_JA,
|
|
108
|
+
generic_decline: FRAUD_MESSAGE_JA,
|
|
109
|
+
fraudulent: FRAUD_MESSAGE_JA,
|
|
110
|
+
};
|
|
111
|
+
/** last_payment_error.code → Japanese, used when no decline_code mapping matched. */
|
|
112
|
+
const CODE_MESSAGES_JA = {
|
|
113
|
+
card_declined: FRAUD_MESSAGE_JA,
|
|
114
|
+
expired_card: 'カードの有効期限が切れています。カード情報を更新してください。',
|
|
115
|
+
incorrect_cvc: 'セキュリティコード(CVC)が正しくありません。カード情報をご確認ください。',
|
|
116
|
+
incorrect_number: 'カード番号が正しくありません。カード情報をご確認ください。',
|
|
117
|
+
processing_error: '決済処理中にエラーが発生しました。時間をおいて再度お試しください。',
|
|
118
|
+
authentication_required: 'カード認証(3Dセキュア)が必要です。お手数ですが、もう一度お手続きをお願いします。',
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Render a {@link StripeFailureReason} as a single-sentence Japanese message safe to show to users.
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* `decline_code` wins over `code`; anything unmapped (including `null`) falls back to a generic
|
|
125
|
+
* message. Fraud-related decline codes are deliberately masked with a generic phrase.
|
|
126
|
+
*
|
|
127
|
+
* @param reason - The extracted failure reason, or `null`.
|
|
128
|
+
* @returns A user-facing Japanese message.
|
|
129
|
+
*/
|
|
130
|
+
export function stripeFailureMessageJa(reason) {
|
|
131
|
+
if (!reason) {
|
|
132
|
+
return GENERIC_MESSAGE_JA;
|
|
133
|
+
}
|
|
134
|
+
if (reason.declineCode && DECLINE_CODE_MESSAGES_JA[reason.declineCode]) {
|
|
135
|
+
return DECLINE_CODE_MESSAGES_JA[reason.declineCode];
|
|
136
|
+
}
|
|
137
|
+
if (reason.code && CODE_MESSAGES_JA[reason.code]) {
|
|
138
|
+
return CODE_MESSAGES_JA[reason.code];
|
|
139
|
+
}
|
|
140
|
+
return GENERIC_MESSAGE_JA;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Serialize a {@link PaymentFailureRecord} for storage in the `payment_failed.receipt` column.
|
|
144
|
+
*
|
|
145
|
+
* @param record - The failure record to serialize.
|
|
146
|
+
* @returns A JSON string.
|
|
147
|
+
*/
|
|
148
|
+
export function serializePaymentFailure(record) {
|
|
149
|
+
return JSON.stringify(record);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Parse a `payment_failed.receipt` value back into a {@link PaymentFailureRecord}.
|
|
153
|
+
*
|
|
154
|
+
* @param receipt - The stored JSON string, or `null`/`undefined`.
|
|
155
|
+
* @returns The parsed record, or `null` when absent or malformed.
|
|
156
|
+
*/
|
|
157
|
+
export function parsePaymentFailure(receipt) {
|
|
158
|
+
if (!receipt) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const parsed = JSON.parse(receipt);
|
|
163
|
+
const r = asRecord(parsed);
|
|
164
|
+
if (!r || !asRecord(r.reason) || typeof r.source !== 'string' || typeof r.occurredAt !== 'string') {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return parsed;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* HTTP error for a synchronous card decline, carrying a user-facing Japanese message.
|
|
175
|
+
*
|
|
176
|
+
* @remarks
|
|
177
|
+
* Extends Hono's `HTTPException` and exposes a `body`, so `createHttpErrorHandler` returns that body
|
|
178
|
+
* verbatim (see {@link createHttpErrorHandler}). Defaults to `400` rather than the semantically
|
|
179
|
+
* correct `402` so it rides the fleet's existing client interceptor, which surfaces `4xx` bodies with
|
|
180
|
+
* a `message` to the user.
|
|
181
|
+
*/
|
|
182
|
+
export class PaymentDeclinedError extends HTTPException {
|
|
183
|
+
body;
|
|
184
|
+
constructor(reason, status = 400) {
|
|
185
|
+
const message = stripeFailureMessageJa(reason);
|
|
186
|
+
super(status, { message });
|
|
187
|
+
this.body = { statusCode: status, message, code: reason?.code, declineCode: reason?.declineCode };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Convert a thrown Stripe error into a {@link PaymentDeclinedError}, or `null` when it is not a card
|
|
192
|
+
* decline (the caller should re-throw so it maps to a generic 500).
|
|
193
|
+
*
|
|
194
|
+
* @param error - The value thrown by a Stripe SDK call.
|
|
195
|
+
* @param status - HTTP status for the resulting error; defaults to `400` (see {@link PaymentDeclinedError}).
|
|
196
|
+
* @returns A {@link PaymentDeclinedError}, or `null` when `error` is not a card decline.
|
|
197
|
+
*/
|
|
198
|
+
export function toPaymentDeclinedError(error, status = 400) {
|
|
199
|
+
const r = asRecord(error);
|
|
200
|
+
if (!r) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const isCardError = r.type === 'StripeCardError' || !!str(r.decline_code) || str(r.code) === 'card_declined';
|
|
204
|
+
if (!isCardError) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
return new PaymentDeclinedError(extractStripeFailureReason(error), status);
|
|
208
|
+
}
|