@rdlabo/workers-hono-kit 0.6.4 → 0.6.8

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/index.d.ts CHANGED
@@ -47,6 +47,16 @@ 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';
52
+ export { classifyStripeReconcile } from './stripe/reconcile.js';
53
+ export type { StripeReconcileAction } from './stripe/reconcile.js';
54
+ export { paymentFailureMessageJa, iapFailureKey, UNRESOLVED_PAYMENT_STATUSES } from './payment/failure.js';
55
+ export type { PaymentFailureStatus, PaymentFailureType } from './payment/failure.js';
56
+ export { classifyAppleRenewal, verifyAppleReceipt } from './iap/apple.js';
57
+ export type { AppleRenewalState, AppleVerifyReceiptResponse, ApplePendingRenewalInfo, AppleLatestReceiptInfo, } from './iap/apple.js';
58
+ export { classifyGoogleSubscription, getGoogleSubscription, googleAccessToken } from './iap/google.js';
59
+ export type { GoogleSubscriptionState, GoogleSubscriptionPurchase, GoogleOAuthCredentials } from './iap/google.js';
50
60
  export { retryWhenDeadlock } from './db/retry.js';
51
61
  export { sendInChunks } from './queue/send.js';
52
62
  export type { QueueLike, QueueSendMessage } from './queue/send.js';
package/dist/index.js CHANGED
@@ -36,6 +36,13 @@ 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';
40
+ export { classifyStripeReconcile } from './stripe/reconcile.js';
41
+ // payment (provider-agnostic; web-standard only). reopenGuardedPaymentFailedSet is drizzle-based → './db'.
42
+ export { paymentFailureMessageJa, iapFailureKey, UNRESOLVED_PAYMENT_STATUSES } from './payment/failure.js';
43
+ // in-app purchase (Apple / Google)
44
+ export { classifyAppleRenewal, verifyAppleReceipt } from './iap/apple.js';
45
+ export { classifyGoogleSubscription, getGoogleSubscription, googleAccessToken } from './iap/google.js';
39
46
  // db
40
47
  export { retryWhenDeadlock } from './db/retry.js';
41
48
  // queue
@@ -0,0 +1,62 @@
1
+ import type { StripeFailureReason } from '../stripe/failure.js';
2
+ /**
3
+ * Provider-agnostic status of a row in the `payment_failed` table.
4
+ *
5
+ * @remarks
6
+ * - `failed` — a charge failed (card decline / iOS billing-retry).
7
+ * - `action_required` — extra authentication pending (Stripe SCA).
8
+ * - `canceled` — the subscription was canceled or lapsed (all providers).
9
+ * - `resolved` — cleared after a later success / re-subscribe (kept for history).
10
+ */
11
+ export type PaymentFailureStatus = 'failed' | 'action_required' | 'canceled' | 'resolved';
12
+ /** Provider that produced a `payment_failed` row (matches the `type` column). */
13
+ export type PaymentFailureType = 'stripe' | 'ios' | 'android';
14
+ /**
15
+ * The statuses that still warrant a banner (everything except `resolved`).
16
+ * Use for `WHERE status IN (...)` on read / resolve queries so the set never drifts between repos.
17
+ *
18
+ * @remarks Readonly tuple — spread it for Drizzle's `inArray`: `inArray(col, [...UNRESOLVED_PAYMENT_STATUSES])`.
19
+ */
20
+ export declare const UNRESOLVED_PAYMENT_STATUSES: readonly ["failed", "action_required", "canceled"];
21
+ /**
22
+ * Render a user-facing Japanese message for a `payment_failed` row, across providers.
23
+ *
24
+ * @remarks
25
+ * Wraps {@link stripeFailureMessageJa} (decline_code → JA) and adds provider-agnostic wording for
26
+ * `canceled` (re-subscribe prompt) and for IAP `failed` (App Store / Google Play update prompt),
27
+ * which carry no Stripe `decline_code`. Message is generated on read so wording changes never
28
+ * require a data migration.
29
+ *
30
+ * @param input.status - The row status.
31
+ * @param input.type - The row `type` (stripe/ios/android); optional.
32
+ * @param input.reason - The stored Stripe reason (only meaningful for Stripe failures).
33
+ */
34
+ export declare function paymentFailureMessageJa(input: {
35
+ status: PaymentFailureStatus;
36
+ /** The row `type` (stripe/ios/android). Typed as `string` since it comes from the DB column. */
37
+ type?: string | null;
38
+ reason?: StripeFailureReason | null;
39
+ }): string;
40
+ /**
41
+ * Build the `payment_failed.recursions_id` (PRIMARY KEY) with a per-type namespace.
42
+ *
43
+ * @remarks
44
+ * - iOS: `ios:${original_transaction_id}:${expires_date_ms}`. `original_transaction_id` is stable
45
+ * across re-subscribes, so keying on it alone would let the resolved-reopen guard permanently mask
46
+ * every event after the first. Including `expires_date_ms` makes each billing cycle a distinct row,
47
+ * while the same cycle's daily reconcile (billing-retry) still converges to one row.
48
+ * - Android: `android:${orderId}` — Google issues a new orderId per subscription, so it is already
49
+ * cycle-specific; the `android:` prefix only guards against cross-type PK collisions.
50
+ *
51
+ * Stripe rows use the invoice / subscription id directly (globally unique) and need no helper.
52
+ *
53
+ * @remarks Fits the fleet's `payment_failed.recursions_id` `varchar(50)` (iOS ≤ ~38, Android ≤ ~36 chars).
54
+ */
55
+ export declare function iapFailureKey(input: {
56
+ platform: 'ios';
57
+ originalTransactionId: string;
58
+ expiresDateMs: string | number;
59
+ } | {
60
+ platform: 'android';
61
+ orderId: string;
62
+ }): string;
@@ -0,0 +1,56 @@
1
+ import { stripeFailureMessageJa } from '../stripe/failure.js';
2
+ /**
3
+ * The statuses that still warrant a banner (everything except `resolved`).
4
+ * Use for `WHERE status IN (...)` on read / resolve queries so the set never drifts between repos.
5
+ *
6
+ * @remarks Readonly tuple — spread it for Drizzle's `inArray`: `inArray(col, [...UNRESOLVED_PAYMENT_STATUSES])`.
7
+ */
8
+ export const UNRESOLVED_PAYMENT_STATUSES = ['failed', 'action_required', 'canceled'];
9
+ const CANCELED_MESSAGE_JA = 'ご登録のプランは解約されています。引き続きプレミアム機能をご利用になるには、再度ご登録ください。';
10
+ /** In-app-purchase の failed(カード無効)はプロバイダごとに更新導線が違うため文言を分ける。 */
11
+ const IAP_FAILED_MESSAGE_JA = {
12
+ ios: 'お支払いの更新に失敗しました。App Store のお支払い情報をご確認のうえ、更新してください。',
13
+ android: 'お支払いの更新に失敗しました。Google Play のお支払い情報をご確認ください。',
14
+ };
15
+ /**
16
+ * Render a user-facing Japanese message for a `payment_failed` row, across providers.
17
+ *
18
+ * @remarks
19
+ * Wraps {@link stripeFailureMessageJa} (decline_code → JA) and adds provider-agnostic wording for
20
+ * `canceled` (re-subscribe prompt) and for IAP `failed` (App Store / Google Play update prompt),
21
+ * which carry no Stripe `decline_code`. Message is generated on read so wording changes never
22
+ * require a data migration.
23
+ *
24
+ * @param input.status - The row status.
25
+ * @param input.type - The row `type` (stripe/ios/android); optional.
26
+ * @param input.reason - The stored Stripe reason (only meaningful for Stripe failures).
27
+ */
28
+ export function paymentFailureMessageJa(input) {
29
+ if (input.status === 'canceled') {
30
+ return CANCELED_MESSAGE_JA;
31
+ }
32
+ if (input.status === 'failed' && (input.type === 'ios' || input.type === 'android')) {
33
+ return IAP_FAILED_MESSAGE_JA[input.type];
34
+ }
35
+ return stripeFailureMessageJa(input.reason ?? null);
36
+ }
37
+ /**
38
+ * Build the `payment_failed.recursions_id` (PRIMARY KEY) with a per-type namespace.
39
+ *
40
+ * @remarks
41
+ * - iOS: `ios:${original_transaction_id}:${expires_date_ms}`. `original_transaction_id` is stable
42
+ * across re-subscribes, so keying on it alone would let the resolved-reopen guard permanently mask
43
+ * every event after the first. Including `expires_date_ms` makes each billing cycle a distinct row,
44
+ * while the same cycle's daily reconcile (billing-retry) still converges to one row.
45
+ * - Android: `android:${orderId}` — Google issues a new orderId per subscription, so it is already
46
+ * cycle-specific; the `android:` prefix only guards against cross-type PK collisions.
47
+ *
48
+ * Stripe rows use the invoice / subscription id directly (globally unique) and need no helper.
49
+ *
50
+ * @remarks Fits the fleet's `payment_failed.recursions_id` `varchar(50)` (iOS ≤ ~38, Android ≤ ~36 chars).
51
+ */
52
+ export function iapFailureKey(input) {
53
+ return input.platform === 'ios'
54
+ ? `ios:${input.originalTransactionId}:${input.expiresDateMs}`
55
+ : `android:${input.orderId}`;
56
+ }
@@ -0,0 +1,24 @@
1
+ import type { SQL } from 'drizzle-orm';
2
+ /**
3
+ * `payment_failed` persistence helpers shared across the fleet.
4
+ *
5
+ * @remarks
6
+ * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
+ * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
+ * consumer's DB access layer (`db.write` / `helper.query`).
9
+ */
10
+ /**
11
+ * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
+ * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
+ * paid user with a permanent failure banner.
14
+ *
15
+ * @remarks
16
+ * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
+ * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
+ * refreshed. Spread into the call:
19
+ * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
+ *
21
+ * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
+ * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
+ */
24
+ export declare function reopenGuardedPaymentFailedSet(): Record<'type' | 'userId' | 'status' | 'receipt', SQL>;
@@ -0,0 +1,32 @@
1
+ import { sql } from 'drizzle-orm';
2
+ /**
3
+ * `payment_failed` persistence helpers shared across the fleet.
4
+ *
5
+ * @remarks
6
+ * `drizzle-orm` is a **peer** of this kit (the consumer resolves a single copy). These helpers only
7
+ * build SQL fragments — the consumer's repository executes them, so it works regardless of the
8
+ * consumer's DB access layer (`db.write` / `helper.query`).
9
+ */
10
+ /**
11
+ * The `onDuplicateKeyUpdate.set` for an `insert(paymentFailed)…` that **never re-opens a `resolved`
12
+ * row** — the idempotency guard against Stripe's delayed/out-of-order webhook redelivery leaving a
13
+ * paid user with a permanent failure banner.
14
+ *
15
+ * @remarks
16
+ * `receipt` is evaluated **before** `status` (SET is left→right and later columns see already-updated
17
+ * ones), so both branch on the *pre-update* `payment_failed.status`. `type`/`user_id` are always
18
+ * refreshed. Spread into the call:
19
+ * `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
20
+ *
21
+ * Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the drizzle schema
22
+ * property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
23
+ */
24
+ export function reopenGuardedPaymentFailedSet() {
25
+ return {
26
+ type: sql `values(\`type\`)`,
27
+ userId: sql `values(\`user_id\`)`,
28
+ // resolved 行は理由も status も据え置き(再オープンしない)。status 代入より前に評価する。
29
+ receipt: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`receipt\`, values(\`receipt\`))`,
30
+ status: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`status\`, values(\`status\`))`,
31
+ };
32
+ }
@@ -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
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Stripe subscription reconcile classification.
3
+ *
4
+ * @remarks
5
+ * SDK-free / duck-typed (like `failure.ts`). Given an **expanded** subscription (its `latest_invoice`
6
+ * with the PaymentIntent resolved and the `customer`), decide what the `payment_failed` table should
7
+ * do. The consumer performs the DB writes; this only encodes the branch order.
8
+ *
9
+ * The branch order matters: subscription **termination is evaluated before `succeeded`**, so a
10
+ * voluntary cancel (cancel_at_period_end — its final invoice is paid, PaymentIntent=succeeded) is
11
+ * still recorded instead of being swallowed by the "succeeded → clear" branch.
12
+ *
13
+ * Both Stripe API shapes are accepted:
14
+ * - legacy `invoice.payment_intent` (id or expanded object)
15
+ * - v20+ `invoice.payments.data[0].payment.payment_intent`
16
+ * The PaymentIntent must be **expanded** (carry a `status`) for `clear`/dunning detection; when it is
17
+ * only an id, `canceled`/`trial` are still detected but `succeeded`/dunning fall through to `none`.
18
+ */
19
+ /**
20
+ * What a reconcile pass should do with the `payment_failed` table for a subscription.
21
+ * - `trial` — trialing (paid invoice, no PaymentIntent). Consumer upserts payment; typically clears failures.
22
+ * - `clear` — active/trialing with a succeeded charge → resolve any open failure.
23
+ * - `canceled` — subscription ended (canceled/incomplete_expired) → record a `canceled` row.
24
+ * - `failed` — dunning with `requires_payment_method` → record a `failed` row.
25
+ * - `action_required` — dunning with `requires_action` (SCA) → record an `action_required` row.
26
+ * - `none` — nothing to record (e.g. active + not-yet-charged, or PaymentIntent not expanded).
27
+ */
28
+ export type StripeReconcileAction = 'trial' | 'clear' | 'canceled' | 'failed' | 'action_required' | 'none';
29
+ /**
30
+ * Classify an expanded Stripe subscription into a {@link StripeReconcileAction}.
31
+ *
32
+ * @param subscription - The subscription with `latest_invoice` + PaymentIntent expanded (duck-typed).
33
+ */
34
+ export declare function classifyStripeReconcile(subscription: unknown): StripeReconcileAction;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Stripe subscription reconcile classification.
3
+ *
4
+ * @remarks
5
+ * SDK-free / duck-typed (like `failure.ts`). Given an **expanded** subscription (its `latest_invoice`
6
+ * with the PaymentIntent resolved and the `customer`), decide what the `payment_failed` table should
7
+ * do. The consumer performs the DB writes; this only encodes the branch order.
8
+ *
9
+ * The branch order matters: subscription **termination is evaluated before `succeeded`**, so a
10
+ * voluntary cancel (cancel_at_period_end — its final invoice is paid, PaymentIntent=succeeded) is
11
+ * still recorded instead of being swallowed by the "succeeded → clear" branch.
12
+ *
13
+ * Both Stripe API shapes are accepted:
14
+ * - legacy `invoice.payment_intent` (id or expanded object)
15
+ * - v20+ `invoice.payments.data[0].payment.payment_intent`
16
+ * The PaymentIntent must be **expanded** (carry a `status`) for `clear`/dunning detection; when it is
17
+ * only an id, `canceled`/`trial` are still detected but `succeeded`/dunning fall through to `none`.
18
+ */
19
+ const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
20
+ const str = (v) => (typeof v === 'string' && v.length > 0 ? v : undefined);
21
+ const DUNNING_STATUSES = new Set(['past_due', 'unpaid', 'incomplete']);
22
+ /** Resolve the PaymentIntent object/id from an invoice across Stripe API versions. */
23
+ function resolvePaymentIntent(invoice) {
24
+ if (!invoice) {
25
+ return { present: false };
26
+ }
27
+ // legacy: invoice.payment_intent (id string or expanded object)
28
+ const direct = invoice.payment_intent;
29
+ if (direct !== null && direct !== undefined) {
30
+ return { present: true, status: str(asRecord(direct)?.status) };
31
+ }
32
+ // v20+: invoice.payments.data[0].payment.payment_intent
33
+ const payments = asRecord(invoice.payments);
34
+ const data = Array.isArray(payments?.data) ? payments.data : [];
35
+ const pi = asRecord(asRecord(asRecord(data[0])?.payment)?.payment_intent);
36
+ if (pi) {
37
+ return { present: true, status: str(pi.status) };
38
+ }
39
+ const piId = str(asRecord(asRecord(data[0])?.payment)?.payment_intent);
40
+ return { present: piId !== undefined, status: undefined };
41
+ }
42
+ /**
43
+ * Classify an expanded Stripe subscription into a {@link StripeReconcileAction}.
44
+ *
45
+ * @param subscription - The subscription with `latest_invoice` + PaymentIntent expanded (duck-typed).
46
+ */
47
+ export function classifyStripeReconcile(subscription) {
48
+ const sub = asRecord(subscription);
49
+ if (!sub) {
50
+ return 'none';
51
+ }
52
+ const subStatus = str(sub.status);
53
+ const invoice = asRecord(sub.latest_invoice);
54
+ const invoiceStatus = str(invoice?.status);
55
+ const pi = resolvePaymentIntent(invoice);
56
+ // Termination first: evaluated before `trial` and before `succeeded`, so a canceled subscription is
57
+ // always recorded — whether its final invoice is paid (voluntary cancel, PI=succeeded), a trial
58
+ // (paid invoice, no PI), or the PaymentIntent is unexpanded. Otherwise it would be swallowed by the
59
+ // `trial` / `succeeded → clear` branches and the cancellation would never be recorded.
60
+ if (subStatus === 'canceled' || subStatus === 'incomplete_expired') {
61
+ return 'canceled';
62
+ }
63
+ if (invoiceStatus === 'paid' && !pi.present) {
64
+ return 'trial';
65
+ }
66
+ if (pi.present) {
67
+ if (pi.status === 'succeeded') {
68
+ return 'clear';
69
+ }
70
+ if (subStatus !== undefined && DUNNING_STATUSES.has(subStatus)) {
71
+ if (pi.status === 'requires_action') {
72
+ return 'action_required';
73
+ }
74
+ if (pi.status === 'requires_payment_method') {
75
+ return 'failed';
76
+ }
77
+ }
78
+ }
79
+ return 'none';
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/workers-hono-kit",
3
- "version": "0.6.4",
3
+ "version": "0.6.8",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"