@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/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/payment-failed.d.ts +25 -0
- package/dist/db/payment-failed.js +33 -0
- package/dist/http/nest-error.d.ts +149 -0
- package/dist/http/nest-error.js +120 -0
- package/dist/iap/apple.d.ts +77 -0
- package/dist/iap/apple.js +84 -0
- package/dist/iap/google.d.ts +74 -0
- package/dist/iap/google.js +75 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +7 -0
- package/dist/payment/failure.d.ts +62 -0
- package/dist/payment/failure.js +56 -0
- package/dist/payment/persistence.d.ts +24 -0
- package/dist/payment/persistence.js +32 -0
- package/dist/stripe/failure.d.ts +108 -0
- package/dist/stripe/failure.js +208 -0
- package/dist/stripe/reconcile.d.ts +34 -0
- package/dist/stripe/reconcile.js +80 -0
- package/package.json +1 -1
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
|
+
}
|