@rdlabo/workers-hono-kit 0.6.6 → 0.6.9
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/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 +9 -1
- package/dist/index.js +6 -0
- package/dist/payment/failure.d.ts +63 -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 +18 -1
- package/dist/stripe/reconcile.d.ts +34 -0
- package/dist/stripe/reconcile.js +80 -0
- package/package.json +1 -1
package/dist/db/index.d.ts
CHANGED
|
@@ -24,3 +24,4 @@ export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-c
|
|
|
24
24
|
export type { HonoDrizzleConfigOptions, ResolvedDbSecret } from './orm-config.js';
|
|
25
25
|
export { baselineMigrations, readBaselineEntry } from './migrate.js';
|
|
26
26
|
export type { BaselineMigrationsOptions, BaselineResult, BaselineEntry } from './migrate.js';
|
|
27
|
+
export { reopenGuardedPaymentFailedSet } from './payment-failed.js';
|
package/dist/db/index.js
CHANGED
|
@@ -18,3 +18,4 @@ export { coerceDecimalNumber, decimalNumberParams } from './decimal.js';
|
|
|
18
18
|
export { jstTimestamp, jstDatetime, jstDate, decimalNumber, jstOnUpdateNow } from './columns.js';
|
|
19
19
|
export { DRIZZLE_ORM_OPTIONS, honoDrizzleConfig, resolveDbSecret } from './orm-config.js';
|
|
20
20
|
export { baselineMigrations, readBaselineEntry } from './migrate.js';
|
|
21
|
+
export { reopenGuardedPaymentFailedSet } from './payment-failed.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SQL } from 'drizzle-orm';
|
|
2
|
+
/**
|
|
3
|
+
* `payment_failed` persistence helpers (MySQL / Drizzle).
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Lives in the `./db` subpath because it imports `drizzle-orm` (a peer); the web-standard root export
|
|
7
|
+
* must stay free of ORM deps. The helper only builds SQL fragments — the consumer's repository
|
|
8
|
+
* executes them, so it works regardless of the 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` (Drizzle emits the SET clause in the object's insertion
|
|
17
|
+
* order and MySQL evaluates it left→right, so later columns would otherwise see the already-updated
|
|
18
|
+
* `status`). Both branch on the *pre-update* `payment_failed.status`; `type`/`user_id` are always
|
|
19
|
+
* refreshed. Spread into the call:
|
|
20
|
+
* `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
|
|
21
|
+
*
|
|
22
|
+
* Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the Drizzle schema
|
|
23
|
+
* property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
|
|
24
|
+
*/
|
|
25
|
+
export declare function reopenGuardedPaymentFailedSet(): Record<'type' | 'userId' | 'status' | 'receipt', SQL>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
/**
|
|
3
|
+
* `payment_failed` persistence helpers (MySQL / Drizzle).
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* Lives in the `./db` subpath because it imports `drizzle-orm` (a peer); the web-standard root export
|
|
7
|
+
* must stay free of ORM deps. The helper only builds SQL fragments — the consumer's repository
|
|
8
|
+
* executes them, so it works regardless of the 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` (Drizzle emits the SET clause in the object's insertion
|
|
17
|
+
* order and MySQL evaluates it left→right, so later columns would otherwise see the already-updated
|
|
18
|
+
* `status`). Both branch on the *pre-update* `payment_failed.status`; `type`/`user_id` are always
|
|
19
|
+
* refreshed. Spread into the call:
|
|
20
|
+
* `insert(paymentFailed).values(...).onDuplicateKeyUpdate({ set: reopenGuardedPaymentFailedSet() })`.
|
|
21
|
+
*
|
|
22
|
+
* Assumes the canonical column names (`type`, `user_id`, `status`, `receipt`) and the Drizzle schema
|
|
23
|
+
* property names (`type`, `userId`, `status`, `receipt`) used fleet-wide.
|
|
24
|
+
*/
|
|
25
|
+
export function reopenGuardedPaymentFailedSet() {
|
|
26
|
+
return {
|
|
27
|
+
type: sql `values(\`type\`)`,
|
|
28
|
+
userId: sql `values(\`user_id\`)`,
|
|
29
|
+
// resolved 行は理由も status も据え置き(再オープンしない)。status 代入より前に評価する。
|
|
30
|
+
receipt: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`receipt\`, values(\`receipt\`))`,
|
|
31
|
+
status: sql `IF(\`payment_failed\`.\`status\` = 'resolved', \`payment_failed\`.\`status\`, values(\`status\`))`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store (StoreKit / verifyReceipt) helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provider-agnostic and SDK-free: inputs are duck-typed `unknown` JSON (Apple ships no SDK), matching
|
|
6
|
+
* the `stripe/failure.ts` approach. `classifyAppleRenewal` maps a verifyReceipt response to a renewal
|
|
7
|
+
* state; the consumer decides how to persist it (billing_retry → `failed`, lapsed → `canceled`,
|
|
8
|
+
* active → resolve). `verifyAppleReceipt` performs the production→sandbox verification call.
|
|
9
|
+
*/
|
|
10
|
+
/** Apple `pending_renewal_info[]` entry (fields consumed here). */
|
|
11
|
+
export interface ApplePendingRenewalInfo {
|
|
12
|
+
/** Links this renewal info to a subscription group / receipt (used to pick the matching entry). */
|
|
13
|
+
original_transaction_id?: string;
|
|
14
|
+
/** `'1'` while Apple is retrying billing because the payment method failed (= card invalid). */
|
|
15
|
+
is_in_billing_retry_period?: string;
|
|
16
|
+
/** `'0'` = auto-renew turned off (will lapse at period end). */
|
|
17
|
+
auto_renew_status?: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
/** Apple `latest_receipt_info[]` entry (fields consumed here). */
|
|
21
|
+
export interface AppleLatestReceiptInfo {
|
|
22
|
+
original_transaction_id?: string;
|
|
23
|
+
/** Subscription expiry as epoch-ms string (stable regardless of the `expires_date` string format). */
|
|
24
|
+
expires_date_ms?: string;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
/** Apple `verifyReceipt` response (fields consumed here). */
|
|
28
|
+
export interface AppleVerifyReceiptResponse {
|
|
29
|
+
status?: number;
|
|
30
|
+
environment?: string;
|
|
31
|
+
latest_receipt_info?: AppleLatestReceiptInfo[];
|
|
32
|
+
pending_renewal_info?: ApplePendingRenewalInfo[];
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Renewal state derived from an Apple verifyReceipt response.
|
|
37
|
+
* - `billing_retry` — Apple is retrying a failed payment (card invalid) → persist as `failed`.
|
|
38
|
+
* - `lapsed` — auto-renew off and expired → persist as `canceled`.
|
|
39
|
+
* - `active` — a valid (non-expired) subscription → resolve any open failure.
|
|
40
|
+
* - `unknown` — indeterminate (no receipt, or expired with auto-renew on and no retry) → no-op.
|
|
41
|
+
*/
|
|
42
|
+
export type AppleRenewalState = 'billing_retry' | 'lapsed' | 'active' | 'unknown';
|
|
43
|
+
/**
|
|
44
|
+
* Classify an Apple verifyReceipt response into an {@link AppleRenewalState}.
|
|
45
|
+
*
|
|
46
|
+
* @remarks
|
|
47
|
+
* The most recent renewal (max `expires_date_ms`) is used regardless of array order. Sandbox is NOT
|
|
48
|
+
* filtered here — the consumer should skip `environment === 'Sandbox'` before persisting to a
|
|
49
|
+
* production table (see {@link AppleVerifyReceiptResponse.environment}).
|
|
50
|
+
*
|
|
51
|
+
* @param verify - The verifyReceipt response (duck-typed).
|
|
52
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
53
|
+
* @returns The state plus the latest `original_transaction_id` / `expires_date_ms` (for the row key).
|
|
54
|
+
*/
|
|
55
|
+
export declare function classifyAppleRenewal(verify: unknown, now: number): {
|
|
56
|
+
state: AppleRenewalState;
|
|
57
|
+
originalTransactionId?: string;
|
|
58
|
+
expiresDateMs?: string;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Verify an App Store receipt against Apple, falling back from production to sandbox.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Mirrors the fleet's long-standing verify flow: POST to the production endpoint, and if
|
|
65
|
+
* `status !== 0` retry against sandbox. Returns `null` when both reject. The `password` (shared
|
|
66
|
+
* secret) is per-app and must be injected. `fetchImpl` is injectable for tests.
|
|
67
|
+
*
|
|
68
|
+
* @remarks Caveat: any non-zero `status` (including Apple's *retryable* 21005/21009) collapses to
|
|
69
|
+
* `null`, so a transient Apple outage is indistinguishable from a genuinely invalid receipt. Do not
|
|
70
|
+
* treat `null` as "subscription gone" without other signals. Also throws if a response is not JSON.
|
|
71
|
+
*
|
|
72
|
+
* @returns The parsed response, or `null` for an invalid (or unverifiable) receipt.
|
|
73
|
+
*/
|
|
74
|
+
export declare function verifyAppleReceipt(receipt: string, opts: {
|
|
75
|
+
password: string;
|
|
76
|
+
fetchImpl?: typeof fetch;
|
|
77
|
+
}): Promise<AppleVerifyReceiptResponse | null>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple App Store (StoreKit / verifyReceipt) helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Provider-agnostic and SDK-free: inputs are duck-typed `unknown` JSON (Apple ships no SDK), matching
|
|
6
|
+
* the `stripe/failure.ts` approach. `classifyAppleRenewal` maps a verifyReceipt response to a renewal
|
|
7
|
+
* state; the consumer decides how to persist it (billing_retry → `failed`, lapsed → `canceled`,
|
|
8
|
+
* active → resolve). `verifyAppleReceipt` performs the production→sandbox verification call.
|
|
9
|
+
*/
|
|
10
|
+
const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
|
|
11
|
+
const toArray = (v) => (Array.isArray(v) ? v : []);
|
|
12
|
+
/**
|
|
13
|
+
* Classify an Apple verifyReceipt response into an {@link AppleRenewalState}.
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* The most recent renewal (max `expires_date_ms`) is used regardless of array order. Sandbox is NOT
|
|
17
|
+
* filtered here — the consumer should skip `environment === 'Sandbox'` before persisting to a
|
|
18
|
+
* production table (see {@link AppleVerifyReceiptResponse.environment}).
|
|
19
|
+
*
|
|
20
|
+
* @param verify - The verifyReceipt response (duck-typed).
|
|
21
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
22
|
+
* @returns The state plus the latest `original_transaction_id` / `expires_date_ms` (for the row key).
|
|
23
|
+
*/
|
|
24
|
+
export function classifyAppleRenewal(verify, now) {
|
|
25
|
+
const v = asRecord(verify);
|
|
26
|
+
// 消耗型など expires_date_ms 欠損エントリを除外してから最新(max expires)を選ぶ。
|
|
27
|
+
// 欠損値を Number() すると NaN で比較が常に false になり、先頭の欠損行が最後まで残って
|
|
28
|
+
// 誤って 'active'(isExpired=false)を返すため、有効な expires を持つ行だけを対象にする。
|
|
29
|
+
const infos = toArray(v?.latest_receipt_info).filter((e) => Number.isFinite(Number(e.expires_date_ms)));
|
|
30
|
+
const latest = infos.reduce((acc, cur) => {
|
|
31
|
+
if (!acc) {
|
|
32
|
+
return cur;
|
|
33
|
+
}
|
|
34
|
+
return Number(cur.expires_date_ms) > Number(acc.expires_date_ms) ? cur : acc;
|
|
35
|
+
}, undefined);
|
|
36
|
+
const originalTransactionId = latest?.original_transaction_id;
|
|
37
|
+
if (!latest || !originalTransactionId) {
|
|
38
|
+
return { state: 'unknown' };
|
|
39
|
+
}
|
|
40
|
+
// 複数サブスク商品時に別商品の renewal info を読まないよう、latest と同じ original_transaction_id の
|
|
41
|
+
// pending エントリを優先(無ければ先頭にフォールバック)。
|
|
42
|
+
const pendingArr = toArray(v?.pending_renewal_info);
|
|
43
|
+
const pending = pendingArr.find((p) => p.original_transaction_id === originalTransactionId) ?? pendingArr.at(0);
|
|
44
|
+
const expiresMs = Number(latest.expires_date_ms);
|
|
45
|
+
const isExpired = Number.isFinite(expiresMs) && expiresMs < now;
|
|
46
|
+
const base = { originalTransactionId, expiresDateMs: latest.expires_date_ms };
|
|
47
|
+
if (pending?.is_in_billing_retry_period === '1') {
|
|
48
|
+
return { state: 'billing_retry', ...base };
|
|
49
|
+
}
|
|
50
|
+
if (pending?.auto_renew_status === '0' && isExpired) {
|
|
51
|
+
return { state: 'lapsed', ...base };
|
|
52
|
+
}
|
|
53
|
+
if (!isExpired) {
|
|
54
|
+
return { state: 'active', ...base };
|
|
55
|
+
}
|
|
56
|
+
return { state: 'unknown', ...base };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Verify an App Store receipt against Apple, falling back from production to sandbox.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Mirrors the fleet's long-standing verify flow: POST to the production endpoint, and if
|
|
63
|
+
* `status !== 0` retry against sandbox. Returns `null` when both reject. The `password` (shared
|
|
64
|
+
* secret) is per-app and must be injected. `fetchImpl` is injectable for tests.
|
|
65
|
+
*
|
|
66
|
+
* @remarks Caveat: any non-zero `status` (including Apple's *retryable* 21005/21009) collapses to
|
|
67
|
+
* `null`, so a transient Apple outage is indistinguishable from a genuinely invalid receipt. Do not
|
|
68
|
+
* treat `null` as "subscription gone" without other signals. Also throws if a response is not JSON.
|
|
69
|
+
*
|
|
70
|
+
* @returns The parsed response, or `null` for an invalid (or unverifiable) receipt.
|
|
71
|
+
*/
|
|
72
|
+
export async function verifyAppleReceipt(receipt, opts) {
|
|
73
|
+
const body = JSON.stringify({ 'receipt-data': receipt, password: opts.password });
|
|
74
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
75
|
+
const post = (url) => doFetch(url, { method: 'POST', body }).then((r) => r.json());
|
|
76
|
+
let verify = await post('https://buy.itunes.apple.com/verifyReceipt');
|
|
77
|
+
if (verify.status !== 0) {
|
|
78
|
+
verify = await post('https://sandbox.itunes.apple.com/verifyReceipt');
|
|
79
|
+
if (verify.status !== 0) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return verify;
|
|
84
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Play (Android Publisher) subscription helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* SDK-free and duck-typed like {@link ./apple.ts}. `classifyGoogleSubscription` maps a
|
|
6
|
+
* `purchases.subscriptions.get` response (or its 410 error) to a state; the consumer persists it
|
|
7
|
+
* (canceled/gone → `canceled`, active → resolve). `getGoogleSubscription` / `googleAccessToken`
|
|
8
|
+
* perform the OAuth + Android Publisher calls.
|
|
9
|
+
*/
|
|
10
|
+
/** Android Publisher `purchases.subscriptions.get` response (fields consumed here). */
|
|
11
|
+
export interface GoogleSubscriptionPurchase {
|
|
12
|
+
startTimeMillis?: string;
|
|
13
|
+
expiryTimeMillis?: string;
|
|
14
|
+
/** `false` once the user turns off auto-renew (will cancel at period end). */
|
|
15
|
+
autoRenewing?: boolean;
|
|
16
|
+
/** Present once canceled (0=user / 1=system / 2=replaced / 3=developer). */
|
|
17
|
+
cancelReason?: number;
|
|
18
|
+
/** Set when Google returns an error body (e.g. `{ code: 410 }` for a long-expired token). */
|
|
19
|
+
error?: {
|
|
20
|
+
code?: number;
|
|
21
|
+
message?: string;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Subscription state derived from a Google subscriptions.get response.
|
|
28
|
+
* - `canceled` — expired and will not renew (autoRenewing=false or a cancelReason) → persist `canceled`.
|
|
29
|
+
* - `gone` — Google 410 "no longer available" (long-churned) → persist `canceled` (best-effort).
|
|
30
|
+
* - `active` — a valid (non-expired) subscription → resolve any open failure.
|
|
31
|
+
* - `unknown` — indeterminate / transient error → no-op.
|
|
32
|
+
*
|
|
33
|
+
* @remarks Google's account-hold / grace-period (payment failed but auto-renew still on — the Android
|
|
34
|
+
* analogue of Apple's `billing_retry`) is currently reported as `active`/`unknown`, not a distinct
|
|
35
|
+
* "failed" state. If that distinction is needed later, add it to this union in a minor release (adding
|
|
36
|
+
* a member is semi-breaking for exhaustive `switch` consumers).
|
|
37
|
+
*/
|
|
38
|
+
export type GoogleSubscriptionState = 'canceled' | 'gone' | 'active' | 'unknown';
|
|
39
|
+
/**
|
|
40
|
+
* Classify a Google subscriptions.get response into a {@link GoogleSubscriptionState}.
|
|
41
|
+
*
|
|
42
|
+
* @param purchase - The response body (duck-typed); may be `{ error: { code } }`.
|
|
43
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
44
|
+
*/
|
|
45
|
+
export declare function classifyGoogleSubscription(purchase: unknown, now: number): {
|
|
46
|
+
state: GoogleSubscriptionState;
|
|
47
|
+
};
|
|
48
|
+
/** OAuth service-account credentials for the Android Publisher API (per-app; inject, never hardcode in kit). */
|
|
49
|
+
export interface GoogleOAuthCredentials {
|
|
50
|
+
client_id: string;
|
|
51
|
+
client_secret: string;
|
|
52
|
+
refresh_token: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Exchange a refresh token for an Android Publisher access token.
|
|
56
|
+
*
|
|
57
|
+
* @param creds - The per-app OAuth credentials.
|
|
58
|
+
* @param fetchImpl - Injectable fetch (tests).
|
|
59
|
+
*/
|
|
60
|
+
export declare function googleAccessToken(creds: GoogleOAuthCredentials, fetchImpl?: typeof fetch): Promise<string>;
|
|
61
|
+
/**
|
|
62
|
+
* Fetch a subscription purchase from the Android Publisher API.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* Returns the raw JSON (including `{ error: { code } }` bodies for 4xx such as 410) so the caller can
|
|
66
|
+
* pass it to {@link classifyGoogleSubscription}. `packageName` / `subscriptionId` are per-app.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getGoogleSubscription(opts: {
|
|
69
|
+
packageName: string;
|
|
70
|
+
subscriptionId: string;
|
|
71
|
+
purchaseToken: string;
|
|
72
|
+
accessToken: string;
|
|
73
|
+
fetchImpl?: typeof fetch;
|
|
74
|
+
}): Promise<GoogleSubscriptionPurchase>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Play (Android Publisher) subscription helpers.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* SDK-free and duck-typed like {@link ./apple.ts}. `classifyGoogleSubscription` maps a
|
|
6
|
+
* `purchases.subscriptions.get` response (or its 410 error) to a state; the consumer persists it
|
|
7
|
+
* (canceled/gone → `canceled`, active → resolve). `getGoogleSubscription` / `googleAccessToken`
|
|
8
|
+
* perform the OAuth + Android Publisher calls.
|
|
9
|
+
*/
|
|
10
|
+
const asRecord = (v) => typeof v === 'object' && v !== null ? v : null;
|
|
11
|
+
/**
|
|
12
|
+
* Classify a Google subscriptions.get response into a {@link GoogleSubscriptionState}.
|
|
13
|
+
*
|
|
14
|
+
* @param purchase - The response body (duck-typed); may be `{ error: { code } }`.
|
|
15
|
+
* @param now - Current epoch-ms (`Date.now()`); injected for determinism/testing.
|
|
16
|
+
*/
|
|
17
|
+
export function classifyGoogleSubscription(purchase, now) {
|
|
18
|
+
const p = asRecord(purchase);
|
|
19
|
+
if (!p) {
|
|
20
|
+
return { state: 'unknown' };
|
|
21
|
+
}
|
|
22
|
+
const err = asRecord(p.error);
|
|
23
|
+
if (err) {
|
|
24
|
+
// 410 = "The subscription purchase is no longer available for query" (expired too long) = churned.
|
|
25
|
+
return { state: err.code === 410 ? 'gone' : 'unknown' };
|
|
26
|
+
}
|
|
27
|
+
const expiryMs = Number(p.expiryTimeMillis);
|
|
28
|
+
const isExpired = Number.isFinite(expiryMs) && expiryMs < now;
|
|
29
|
+
const willNotRenew = p.autoRenewing === false || p.cancelReason !== undefined;
|
|
30
|
+
if (isExpired && willNotRenew) {
|
|
31
|
+
return { state: 'canceled' };
|
|
32
|
+
}
|
|
33
|
+
if (!isExpired) {
|
|
34
|
+
return { state: 'active' };
|
|
35
|
+
}
|
|
36
|
+
return { state: 'unknown' };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Exchange a refresh token for an Android Publisher access token.
|
|
40
|
+
*
|
|
41
|
+
* @param creds - The per-app OAuth credentials.
|
|
42
|
+
* @param fetchImpl - Injectable fetch (tests).
|
|
43
|
+
*/
|
|
44
|
+
export async function googleAccessToken(creds, fetchImpl) {
|
|
45
|
+
const body = new URLSearchParams({
|
|
46
|
+
client_id: creds.client_id,
|
|
47
|
+
client_secret: creds.client_secret,
|
|
48
|
+
grant_type: 'refresh_token',
|
|
49
|
+
refresh_token: creds.refresh_token,
|
|
50
|
+
}).toString();
|
|
51
|
+
const res = (await (fetchImpl ?? fetch)('https://accounts.google.com/o/oauth2/token', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
54
|
+
body,
|
|
55
|
+
}).then((r) => r.json()));
|
|
56
|
+
if (!res.access_token) {
|
|
57
|
+
// 静かに '' を返すと後続が error body → 'unknown' に劣化し、refresh token 失効(invalid_grant)を
|
|
58
|
+
// 見逃す。呼び出し側に可視化するため throw する。
|
|
59
|
+
throw new Error('googleAccessToken: no access_token in token response (refresh_token expired?)');
|
|
60
|
+
}
|
|
61
|
+
return res.access_token;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Fetch a subscription purchase from the Android Publisher API.
|
|
65
|
+
*
|
|
66
|
+
* @remarks
|
|
67
|
+
* Returns the raw JSON (including `{ error: { code } }` bodies for 4xx such as 410) so the caller can
|
|
68
|
+
* pass it to {@link classifyGoogleSubscription}. `packageName` / `subscriptionId` are per-app.
|
|
69
|
+
*/
|
|
70
|
+
export async function getGoogleSubscription(opts) {
|
|
71
|
+
const url = `https://www.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent(opts.packageName)}` +
|
|
72
|
+
`/purchases/subscriptions/${encodeURIComponent(opts.subscriptionId)}/tokens/${encodeURIComponent(opts.purchaseToken)}`;
|
|
73
|
+
// access_token はクエリに載せず Authorization ヘッダで送る(URL ログ/プロキシへの秘匿情報漏洩を防ぐ)。
|
|
74
|
+
return (opts.fetchImpl ?? fetch)(url, { headers: { authorization: `Bearer ${opts.accessToken}` } }).then((r) => r.json());
|
|
75
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -48,7 +48,15 @@ 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
50
|
export { extractStripeFailureReason, stripeFailureMessageJa, serializePaymentFailure, parsePaymentFailure, PaymentDeclinedError, toPaymentDeclinedError, } from './stripe/failure.js';
|
|
51
|
-
export type { StripeFailureReason, PaymentFailureSource, PaymentFailureRecord, PaymentDeclinedBody, } from './stripe/failure.js';
|
|
51
|
+
export type { StripeFailureReason, PaymentFailureSource, PaymentFailureRecord, PaymentFailureReason, IapFailureReason, 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';
|
|
52
60
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
53
61
|
export { sendInChunks } from './queue/send.js';
|
|
54
62
|
export type { QueueLike, QueueSendMessage } from './queue/send.js';
|
package/dist/index.js
CHANGED
|
@@ -37,6 +37,12 @@ export { KVCache } from './cache/kv-cache.js';
|
|
|
37
37
|
// stripe
|
|
38
38
|
export { createStripeClient, verifyStripeWebhook } from './stripe/client.js';
|
|
39
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';
|
|
40
46
|
// db
|
|
41
47
|
export { retryWhenDeadlock } from './db/retry.js';
|
|
42
48
|
// queue
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PaymentFailureReason } 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?: PaymentFailureReason | null;
|
|
39
|
+
}): string;
|
|
40
|
+
/**
|
|
41
|
+
* Build the provider-native `payment_failed.recursions_id`.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* - 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: `${orderId}` — Google issues a new orderId per subscription, so it is already cycle-specific.
|
|
49
|
+
*
|
|
50
|
+
* Stripe rows use the invoice / subscription id directly (globally unique) and need no helper.
|
|
51
|
+
*
|
|
52
|
+
* The provider is stored separately in the `type` column. Provider-native ID formats are disjoint in practice
|
|
53
|
+
* (Apple numeric pair / Google `GPA.*` / Stripe `in_*` or `sub_*`), so duplicating `type` in the primary-key value
|
|
54
|
+
* is unnecessary.
|
|
55
|
+
*/
|
|
56
|
+
export declare function iapFailureKey(input: {
|
|
57
|
+
platform: 'ios';
|
|
58
|
+
originalTransactionId: string;
|
|
59
|
+
expiresDateMs: string | number;
|
|
60
|
+
} | {
|
|
61
|
+
platform: 'android';
|
|
62
|
+
orderId: string;
|
|
63
|
+
}): 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
|
+
const stripeReason = input.reason && !('provider' in input.reason) ? input.reason : null;
|
|
36
|
+
return stripeFailureMessageJa(stripeReason);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build the provider-native `payment_failed.recursions_id`.
|
|
40
|
+
*
|
|
41
|
+
* @remarks
|
|
42
|
+
* - iOS: `${original_transaction_id}:${expires_date_ms}`. `original_transaction_id` is stable
|
|
43
|
+
* across re-subscribes, so keying on it alone would let the resolved-reopen guard permanently mask
|
|
44
|
+
* every event after the first. Including `expires_date_ms` makes each billing cycle a distinct row,
|
|
45
|
+
* while the same cycle's daily reconcile (billing-retry) still converges to one row.
|
|
46
|
+
* - Android: `${orderId}` — Google issues a new orderId per subscription, so it is already cycle-specific.
|
|
47
|
+
*
|
|
48
|
+
* Stripe rows use the invoice / subscription id directly (globally unique) and need no helper.
|
|
49
|
+
*
|
|
50
|
+
* The provider is stored separately in the `type` column. Provider-native ID formats are disjoint in practice
|
|
51
|
+
* (Apple numeric pair / Google `GPA.*` / Stripe `in_*` or `sub_*`), so duplicating `type` in the primary-key value
|
|
52
|
+
* is unnecessary.
|
|
53
|
+
*/
|
|
54
|
+
export function iapFailureKey(input) {
|
|
55
|
+
return input.platform === 'ios' ? `${input.originalTransactionId}:${input.expiresDateMs}` : 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
|
+
}
|
package/dist/stripe/failure.d.ts
CHANGED
|
@@ -22,6 +22,23 @@ export interface StripeFailureReason {
|
|
|
22
22
|
/** Id of the Subscription the failing invoice belongs to, when resolvable. */
|
|
23
23
|
subscriptionId?: string;
|
|
24
24
|
}
|
|
25
|
+
/** Normalized reason persisted for an App Store / Google Play subscription failure. */
|
|
26
|
+
export interface IapFailureReason {
|
|
27
|
+
/** Provider discriminator. */
|
|
28
|
+
provider: 'ios' | 'android';
|
|
29
|
+
/** Stable machine-readable classification. */
|
|
30
|
+
code: 'billing_retry' | 'auto_renew_off' | 'subscription_canceled' | 'subscription_gone';
|
|
31
|
+
/** Provider response status code when present (Apple verifyReceipt status / Google error code). */
|
|
32
|
+
statusCode?: number;
|
|
33
|
+
/** Apple auto-renew status (`'0'` means disabled). */
|
|
34
|
+
autoRenewStatus?: string;
|
|
35
|
+
/** Apple billing-retry status (`'1'` means retrying a failed renewal). */
|
|
36
|
+
billingRetryStatus?: string;
|
|
37
|
+
/** Google cancellation reason code (0=user, 1=system, 2=replaced, 3=developer). */
|
|
38
|
+
cancelReason?: number;
|
|
39
|
+
}
|
|
40
|
+
/** Provider-specific diagnostic reason stored in `payment_failed.receipt`. */
|
|
41
|
+
export type PaymentFailureReason = StripeFailureReason | IapFailureReason;
|
|
25
42
|
/** Where a {@link PaymentFailureRecord} was captured. */
|
|
26
43
|
export type PaymentFailureSource = 'webhook.invoice.payment_failed' | 'webhook.invoice.payment_action_required' | 'reconcile' | 'checkout' | 'iap';
|
|
27
44
|
/**
|
|
@@ -32,7 +49,7 @@ export type PaymentFailureSource = 'webhook.invoice.payment_failed' | 'webhook.i
|
|
|
32
49
|
* on read via {@link stripeFailureMessageJa} so that wording changes never require a data migration.
|
|
33
50
|
*/
|
|
34
51
|
export interface PaymentFailureRecord {
|
|
35
|
-
reason:
|
|
52
|
+
reason: PaymentFailureReason;
|
|
36
53
|
source: PaymentFailureSource;
|
|
37
54
|
/** ISO 8601 timestamp of when the failure was captured. */
|
|
38
55
|
occurredAt: string;
|
|
@@ -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
|
+
}
|