@mathrunet/masamune_cloudflare_purchase_stripe 3.2.1 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/options.d.ts +1 -1
- package/dist/lib/options.js +7 -7
- package/dist/lib/options.js.map +1 -1
- package/dist/lib/purchase/helpers.js +4 -4
- package/dist/lib/purchase/helpers.js.map +1 -1
- package/dist/lib/purchase/stripe_mapping.d.ts +163 -0
- package/dist/lib/purchase/stripe_mapping.js +141 -0
- package/dist/lib/purchase/stripe_mapping.js.map +1 -0
- package/dist/lib/stripe_client.d.ts +1 -1
- package/dist/lib/stripe_client.js +1 -1
- package/dist/purchase.d.ts +1 -0
- package/dist/purchase.js +1 -0
- package/dist/purchase.js.map +1 -1
- package/dist/workers/stripe.js +68 -68
- package/dist/workers/stripe.js.map +1 -1
- package/dist/workers/stripe_webhook.js +28 -22
- package/dist/workers/stripe_webhook.js.map +1 -1
- package/dist/workers/stripe_webhook_connect.js +2 -2
- package/dist/workers/stripe_webhook_connect.js.map +1 -1
- package/dist/workers/stripe_webhook_secure.js +2 -2
- package/dist/workers/stripe_webhook_secure.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/lib/options.ts +2 -2
- package/src/lib/purchase/helpers.ts +1 -1
- package/src/lib/purchase/stripe_mapping.ts +263 -0
- package/src/lib/stripe_client.ts +1 -1
- package/src/purchase.ts +1 -0
- package/src/workers/stripe.ts +1 -1
- package/src/workers/stripe_webhook.ts +30 -23
- package/src/workers/stripe_webhook_connect.ts +1 -1
- package/src/workers/stripe_webhook_secure.ts +1 -1
- package/test/meter.test.ts +11 -2
- package/test/stripe_mapping.test.ts +184 -0
- package/test/stripe_webhook.test.ts +210 -62
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
-
import { jsonError } from "@mathrunet/masamune_cloudflare
|
|
2
|
+
import { jsonError } from "@mathrunet/masamune_cloudflare";
|
|
3
3
|
import {
|
|
4
4
|
resolveStripeClient,
|
|
5
5
|
resolveStripePurchaseStore,
|
|
@@ -9,6 +9,10 @@ import {
|
|
|
9
9
|
} from "../lib/options";
|
|
10
10
|
import { constructStripeEvent } from "../lib/stripe_client";
|
|
11
11
|
import { syncStripePayment } from "../lib/purchase/sync_payment";
|
|
12
|
+
import {
|
|
13
|
+
resolvePaymentIntentReceipt,
|
|
14
|
+
resolveSubscriptionPurchaseFields,
|
|
15
|
+
} from "../lib/purchase/stripe_mapping";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Receives and processes webhooks from Stripe (parity with the `stripeWebhook`
|
|
@@ -154,13 +158,16 @@ module.exports = (
|
|
|
154
158
|
update["errorMessage"] = null;
|
|
155
159
|
update["updatedTime"] = new Date();
|
|
156
160
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
// `PaymentIntent.charges` no longer exists; resolve the receipt via `latest_charge`.
|
|
162
|
+
const receipt = await resolvePaymentIntentReceipt({
|
|
163
|
+
stripeClient,
|
|
164
|
+
paymentIntent: payment,
|
|
165
|
+
});
|
|
166
|
+
if (receipt.receiptUrl) {
|
|
167
|
+
update["receiptUrl"] = receipt.receiptUrl;
|
|
168
|
+
}
|
|
169
|
+
if (receipt.capturedAmount) {
|
|
170
|
+
update["capturedAmount"] = receipt.capturedAmount;
|
|
164
171
|
}
|
|
165
172
|
await store.savePurchase(purchase.orderId, update);
|
|
166
173
|
return c.json({
|
|
@@ -333,17 +340,17 @@ module.exports = (
|
|
|
333
340
|
"success": "Subscription is not active.",
|
|
334
341
|
});
|
|
335
342
|
}
|
|
336
|
-
|
|
343
|
+
// Since basil the period and price live on the subscription items.
|
|
344
|
+
const fields = resolveSubscriptionPurchaseFields(subscription);
|
|
337
345
|
const id = subscription["id"];
|
|
338
346
|
const userId = subscription["metadata"]?.["userId"] as string;
|
|
339
347
|
const orderId = (subscription["metadata"]?.["orderId"] as string) ?? id;
|
|
340
|
-
const plan = subscription["plan"] as {
|
|
341
|
-
[key: string]: any
|
|
342
|
-
} ?? {};
|
|
343
348
|
|
|
344
349
|
const existing = await store.findPurchaseBySubscriptionId(id);
|
|
345
350
|
const targetOrderId = existing?.orderId ?? orderId;
|
|
346
|
-
update["expired"] =
|
|
351
|
+
update["expired"] = fields.current_period_end !== null
|
|
352
|
+
? now.getTime() >= fields.current_period_end * 1000
|
|
353
|
+
: false;
|
|
347
354
|
if (userId) {
|
|
348
355
|
update["user"] = userId;
|
|
349
356
|
}
|
|
@@ -357,20 +364,20 @@ module.exports = (
|
|
|
357
364
|
update["canceled_at"] = subscription["canceled_at"];
|
|
358
365
|
update["collection_method"] = subscription["collection_method"];
|
|
359
366
|
update["currency"] = subscription["currency"];
|
|
360
|
-
update["current_period_start"] =
|
|
361
|
-
update["current_period_end"] =
|
|
367
|
+
update["current_period_start"] = fields.current_period_start;
|
|
368
|
+
update["current_period_end"] = fields.current_period_end;
|
|
362
369
|
update["customer"] = subscription["customer"];
|
|
363
370
|
update["default_payment_method"] = subscription["default_payment_method"];
|
|
364
371
|
update["ended_at"] = subscription["ended_at"];
|
|
365
372
|
update["latest_invoice"] = subscription["latest_invoice"];
|
|
366
|
-
update["price_id"] =
|
|
367
|
-
update["active"] =
|
|
368
|
-
update["amount"] =
|
|
369
|
-
update["billing_scheme"] =
|
|
370
|
-
update["interval"] =
|
|
371
|
-
update["interval_count"] =
|
|
372
|
-
update["usage_type"] =
|
|
373
|
-
update["quantity"] =
|
|
373
|
+
update["price_id"] = fields.price_id;
|
|
374
|
+
update["active"] = fields.active;
|
|
375
|
+
update["amount"] = fields.amount;
|
|
376
|
+
update["billing_scheme"] = fields.billing_scheme;
|
|
377
|
+
update["interval"] = fields.interval;
|
|
378
|
+
update["interval_count"] = fields.interval_count;
|
|
379
|
+
update["usage_type"] = fields.usage_type;
|
|
380
|
+
update["quantity"] = fields.quantity;
|
|
374
381
|
update["start_date"] = subscription["start_date"];
|
|
375
382
|
console.log(`Subscription status is ${status}.`);
|
|
376
383
|
await store.savePurchase(targetOrderId, update, {
|
package/test/meter.test.ts
CHANGED
|
@@ -39,7 +39,14 @@ function fakeKv(store: Map<string, string>): KVNamespaceLike {
|
|
|
39
39
|
function fakeFetch(status: number, onRequest?: (init?: RequestInit) => void): typeof fetch {
|
|
40
40
|
return (async (_url: unknown, init?: RequestInit) => {
|
|
41
41
|
onRequest?.(init);
|
|
42
|
-
|
|
42
|
+
// Return a Stripe-shaped JSON error body; the SDK leaves its request
|
|
43
|
+
// timeout timer armed when an error body is not valid JSON.
|
|
44
|
+
return new Response(
|
|
45
|
+
status >= 400
|
|
46
|
+
? JSON.stringify({ error: { type: "invalid_request_error", message: "error" } })
|
|
47
|
+
: "{}",
|
|
48
|
+
{ status, headers: { "content-type": "application/json" } },
|
|
49
|
+
);
|
|
43
50
|
}) as typeof fetch;
|
|
44
51
|
}
|
|
45
52
|
|
|
@@ -68,7 +75,9 @@ describe("recordUsage", () => {
|
|
|
68
75
|
pendingRows: [{ id: "u1", units: 10 }],
|
|
69
76
|
executed,
|
|
70
77
|
store,
|
|
71
|
-
|
|
78
|
+
// Use a non-retryable error: stripe-node 22.6.x leaves the request
|
|
79
|
+
// timeout timer of a retried (5xx) attempt armed, which keeps Jest alive.
|
|
80
|
+
fetch: fakeFetch(400),
|
|
72
81
|
});
|
|
73
82
|
|
|
74
83
|
await recordUsage({
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import {
|
|
3
|
+
resolvePaymentIntentReceipt,
|
|
4
|
+
resolveSubscriptionPeriod,
|
|
5
|
+
resolveSubscriptionPurchaseFields,
|
|
6
|
+
} from "../src/lib/purchase/stripe_mapping";
|
|
7
|
+
|
|
8
|
+
function stripeStub(retrieve: (id: string) => Promise<unknown>): Stripe {
|
|
9
|
+
return { charges: { retrieve } } as unknown as Stripe;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("resolveSubscriptionPeriod", () => {
|
|
13
|
+
it("アイテム間で最も早い開始と最も遅い終了を返す", () => {
|
|
14
|
+
const period = resolveSubscriptionPeriod({
|
|
15
|
+
items: {
|
|
16
|
+
data: [
|
|
17
|
+
{ current_period_start: 200, current_period_end: 900 },
|
|
18
|
+
{ current_period_start: 100, current_period_end: 1200 },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
expect(period).toEqual({ start: 100, end: 1200 });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("アイテムに期間が無ければトップレベルの旧フィールドにフォールバックする", () => {
|
|
26
|
+
const period = resolveSubscriptionPeriod({
|
|
27
|
+
current_period_start: 10,
|
|
28
|
+
current_period_end: 20,
|
|
29
|
+
items: { data: [{ id: "si_1" }] },
|
|
30
|
+
});
|
|
31
|
+
expect(period).toEqual({ start: 10, end: 20 });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("期間が取得できなければInvalid Dateではなくnullを返す", () => {
|
|
35
|
+
expect(resolveSubscriptionPeriod({ items: { data: [] } })).toEqual({ start: null, end: null });
|
|
36
|
+
expect(resolveSubscriptionPeriod({ current_period_end: "x" })).toEqual({ start: null, end: null });
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("resolveSubscriptionPurchaseFields", () => {
|
|
41
|
+
it("items.data[0].priceから従来と同じフィールド名で値を構築する", () => {
|
|
42
|
+
const fields = resolveSubscriptionPurchaseFields({
|
|
43
|
+
items: {
|
|
44
|
+
data: [
|
|
45
|
+
{
|
|
46
|
+
current_period_start: 100,
|
|
47
|
+
current_period_end: 200,
|
|
48
|
+
quantity: 3,
|
|
49
|
+
price: {
|
|
50
|
+
id: "price_1",
|
|
51
|
+
active: true,
|
|
52
|
+
unit_amount: 1500,
|
|
53
|
+
billing_scheme: "tiered",
|
|
54
|
+
recurring: {
|
|
55
|
+
interval: "week",
|
|
56
|
+
interval_count: 2,
|
|
57
|
+
usage_type: "metered",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(fields).toEqual({
|
|
65
|
+
current_period_start: 100,
|
|
66
|
+
current_period_end: 200,
|
|
67
|
+
price_id: "price_1",
|
|
68
|
+
active: true,
|
|
69
|
+
amount: 1500,
|
|
70
|
+
billing_scheme: "tiered",
|
|
71
|
+
interval: "week",
|
|
72
|
+
interval_count: 2,
|
|
73
|
+
usage_type: "metered",
|
|
74
|
+
quantity: 3,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("priceが無ければitems.data[0].plan、次にsubscription.planへフォールバックする", () => {
|
|
79
|
+
const fromItemPlan = resolveSubscriptionPurchaseFields({
|
|
80
|
+
items: {
|
|
81
|
+
data: [{
|
|
82
|
+
plan: {
|
|
83
|
+
id: "plan_item",
|
|
84
|
+
active: false,
|
|
85
|
+
amount: 300,
|
|
86
|
+
billing_scheme: "per_unit",
|
|
87
|
+
interval: "day",
|
|
88
|
+
interval_count: 7,
|
|
89
|
+
usage_type: "licensed",
|
|
90
|
+
},
|
|
91
|
+
}],
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
expect(fromItemPlan).toMatchObject({
|
|
95
|
+
price_id: "plan_item",
|
|
96
|
+
active: false,
|
|
97
|
+
amount: 300,
|
|
98
|
+
interval: "day",
|
|
99
|
+
interval_count: 7,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const fromLegacy = resolveSubscriptionPurchaseFields({
|
|
103
|
+
quantity: 4,
|
|
104
|
+
plan: { id: "plan_legacy", amount: 700, interval: "month" },
|
|
105
|
+
});
|
|
106
|
+
expect(fromLegacy).toMatchObject({
|
|
107
|
+
price_id: "plan_legacy",
|
|
108
|
+
amount: 700,
|
|
109
|
+
interval: "month",
|
|
110
|
+
quantity: 4,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("何も無ければ全フィールドnullを返す", () => {
|
|
115
|
+
expect(resolveSubscriptionPurchaseFields({})).toEqual({
|
|
116
|
+
current_period_start: null,
|
|
117
|
+
current_period_end: null,
|
|
118
|
+
price_id: null,
|
|
119
|
+
active: null,
|
|
120
|
+
amount: null,
|
|
121
|
+
billing_scheme: null,
|
|
122
|
+
interval: null,
|
|
123
|
+
interval_count: null,
|
|
124
|
+
usage_type: null,
|
|
125
|
+
quantity: null,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("resolvePaymentIntentReceipt", () => {
|
|
131
|
+
it("latest_chargeがIDならチャージを取得する", async () => {
|
|
132
|
+
const retrieve = jest.fn(async () => ({
|
|
133
|
+
receipt_url: "https://pay.stripe.com/receipts/1",
|
|
134
|
+
amount_captured: 1000,
|
|
135
|
+
}));
|
|
136
|
+
const receipt = await resolvePaymentIntentReceipt({
|
|
137
|
+
stripeClient: stripeStub(retrieve),
|
|
138
|
+
paymentIntent: { id: "pi_1", latest_charge: "ch_1" },
|
|
139
|
+
});
|
|
140
|
+
expect(retrieve).toHaveBeenCalledWith("ch_1");
|
|
141
|
+
expect(receipt).toEqual({
|
|
142
|
+
receiptUrl: "https://pay.stripe.com/receipts/1",
|
|
143
|
+
capturedAmount: 1000,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("展開済みのlatest_chargeはAPIを呼ばずにそのまま使う", async () => {
|
|
148
|
+
const retrieve = jest.fn();
|
|
149
|
+
const receipt = await resolvePaymentIntentReceipt({
|
|
150
|
+
stripeClient: stripeStub(retrieve),
|
|
151
|
+
paymentIntent: {
|
|
152
|
+
latest_charge: { id: "ch_2", receipt_url: "https://r/2", amount_captured: 50 },
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
expect(retrieve).not.toHaveBeenCalled();
|
|
156
|
+
expect(receipt).toEqual({ receiptUrl: "https://r/2", capturedAmount: 50 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("latest_chargeが無ければ旧charges.data[0]にフォールバックする", async () => {
|
|
160
|
+
const retrieve = jest.fn();
|
|
161
|
+
const receipt = await resolvePaymentIntentReceipt({
|
|
162
|
+
stripeClient: stripeStub(retrieve),
|
|
163
|
+
paymentIntent: {
|
|
164
|
+
latest_charge: null,
|
|
165
|
+
charges: { data: [{ receipt_url: "https://r/legacy", amount_captured: 70 }] },
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
expect(retrieve).not.toHaveBeenCalled();
|
|
169
|
+
expect(receipt).toEqual({ receiptUrl: "https://r/legacy", capturedAmount: 70 });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("取得に失敗したらnullを返し例外を投げない", async () => {
|
|
173
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
174
|
+
const receipt = await resolvePaymentIntentReceipt({
|
|
175
|
+
stripeClient: stripeStub(async () => {
|
|
176
|
+
throw new Error("boom");
|
|
177
|
+
}),
|
|
178
|
+
paymentIntent: { latest_charge: "ch_x" },
|
|
179
|
+
});
|
|
180
|
+
expect(warn).toHaveBeenCalled();
|
|
181
|
+
warn.mockRestore();
|
|
182
|
+
expect(receipt).toEqual({ receiptUrl: null, capturedAmount: null });
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Stripe from "stripe";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { StripePurchaseWorkersOptions } from "../src/lib/options";
|
|
4
|
+
import { STRIPE_API_VERSION } from "../src/lib/stripe_client";
|
|
4
5
|
import { MemoryPurchaseStore } from "./helpers/mem_store";
|
|
5
6
|
|
|
6
7
|
const buildWebhook = require("../src/workers/stripe_webhook") as (
|
|
@@ -11,60 +12,91 @@ const buildWebhook = require("../src/workers/stripe_webhook") as (
|
|
|
11
12
|
|
|
12
13
|
const WEBHOOK_SECRET = "whsec_test_secret";
|
|
13
14
|
const stripeForSigning = new Stripe("sk_test_dummy", {
|
|
14
|
-
apiVersion:
|
|
15
|
+
apiVersion: STRIPE_API_VERSION,
|
|
15
16
|
});
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
const NOW = Math.floor(Date.now() / 1000);
|
|
19
|
+
const PERIOD_START = NOW - 60;
|
|
20
|
+
const PERIOD_END = NOW + 86400 * 30;
|
|
21
|
+
|
|
22
|
+
function subscriptionObject(overrides: { [key: string]: any } = {}): { [key: string]: any } {
|
|
23
|
+
return {
|
|
24
|
+
id: "sub_test_1",
|
|
25
|
+
object: "subscription",
|
|
26
|
+
status: "active",
|
|
27
|
+
customer: "cus_test_1",
|
|
28
|
+
cancel_at: null,
|
|
29
|
+
cancel_at_period_end: false,
|
|
30
|
+
canceled_at: null,
|
|
31
|
+
collection_method: "charge_automatically",
|
|
32
|
+
currency: "jpy",
|
|
33
|
+
default_payment_method: "pm_test_1",
|
|
34
|
+
ended_at: null,
|
|
35
|
+
latest_invoice: "in_test_1",
|
|
36
|
+
start_date: PERIOD_START,
|
|
37
|
+
application: null,
|
|
38
|
+
application_fee_percent: null,
|
|
39
|
+
metadata: {
|
|
40
|
+
userId: "user_1",
|
|
41
|
+
orderId: "order_1",
|
|
42
|
+
},
|
|
43
|
+
items: {
|
|
44
|
+
object: "list",
|
|
45
|
+
data: [
|
|
46
|
+
{
|
|
47
|
+
id: "si_test_1",
|
|
48
|
+
object: "subscription_item",
|
|
49
|
+
current_period_start: PERIOD_START,
|
|
50
|
+
current_period_end: PERIOD_END,
|
|
51
|
+
quantity: 1,
|
|
52
|
+
price: {
|
|
53
|
+
id: "price_test_1",
|
|
54
|
+
object: "price",
|
|
55
|
+
active: true,
|
|
56
|
+
unit_amount: 500,
|
|
57
|
+
billing_scheme: "per_unit",
|
|
58
|
+
recurring: {
|
|
59
|
+
interval: "month",
|
|
60
|
+
interval_count: 1,
|
|
61
|
+
usage_type: "licensed",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
...overrides,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function eventPayload(type: string, object: { [key: string]: any }, apiVersion: string = STRIPE_API_VERSION): string {
|
|
18
72
|
return JSON.stringify({
|
|
19
73
|
id: "evt_test_1",
|
|
20
74
|
object: "event",
|
|
21
|
-
api_version:
|
|
75
|
+
api_version: apiVersion,
|
|
22
76
|
type,
|
|
23
77
|
data: {
|
|
24
|
-
object
|
|
25
|
-
id: "sub_test_1",
|
|
26
|
-
object: "subscription",
|
|
27
|
-
status: "active",
|
|
28
|
-
customer: "cus_test_1",
|
|
29
|
-
current_period_start: Math.floor(Date.now() / 1000) - 60,
|
|
30
|
-
current_period_end: Math.floor(Date.now() / 1000) + 86400 * 30,
|
|
31
|
-
cancel_at: null,
|
|
32
|
-
cancel_at_period_end: false,
|
|
33
|
-
canceled_at: null,
|
|
34
|
-
collection_method: "charge_automatically",
|
|
35
|
-
currency: "jpy",
|
|
36
|
-
default_payment_method: "pm_test_1",
|
|
37
|
-
ended_at: null,
|
|
38
|
-
latest_invoice: "in_test_1",
|
|
39
|
-
quantity: 1,
|
|
40
|
-
start_date: Math.floor(Date.now() / 1000) - 60,
|
|
41
|
-
application: null,
|
|
42
|
-
application_fee_percent: null,
|
|
43
|
-
metadata: {
|
|
44
|
-
userId: "user_1",
|
|
45
|
-
orderId: "order_1",
|
|
46
|
-
},
|
|
47
|
-
plan: {
|
|
48
|
-
id: "price_test_1",
|
|
49
|
-
active: true,
|
|
50
|
-
amount: 500,
|
|
51
|
-
billing_scheme: "per_unit",
|
|
52
|
-
interval: "month",
|
|
53
|
-
interval_count: 1,
|
|
54
|
-
usage_type: "licensed",
|
|
55
|
-
},
|
|
56
|
-
...overrides,
|
|
57
|
-
},
|
|
78
|
+
object,
|
|
58
79
|
},
|
|
59
80
|
});
|
|
60
81
|
}
|
|
61
82
|
|
|
62
|
-
function
|
|
83
|
+
function subscriptionEvent(type: string, overrides: { [key: string]: any } = {}): string {
|
|
84
|
+
return eventPayload(type, subscriptionObject(overrides));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sign(payload: string, secret: string = WEBHOOK_SECRET): string {
|
|
88
|
+
return stripeForSigning.webhooks.generateTestHeaderString({
|
|
89
|
+
payload,
|
|
90
|
+
secret,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildApp(store: MemoryPurchaseStore, stripeClient: unknown = stripeForSigning): Hono {
|
|
63
95
|
return buildWebhook(new Hono(), {
|
|
64
96
|
secretKey: "sk_test_dummy",
|
|
65
97
|
webhookSecret: WEBHOOK_SECRET,
|
|
66
98
|
store: () => store,
|
|
67
|
-
stripeClient: () =>
|
|
99
|
+
stripeClient: () => stripeClient as Stripe,
|
|
68
100
|
}, {});
|
|
69
101
|
}
|
|
70
102
|
|
|
@@ -87,12 +119,8 @@ describe("stripeWebhook", () => {
|
|
|
87
119
|
const store = new MemoryPurchaseStore();
|
|
88
120
|
const app = buildApp(store);
|
|
89
121
|
const payload = subscriptionEvent("customer.subscription.created");
|
|
90
|
-
const signature = stripeForSigning.webhooks.generateTestHeaderString({
|
|
91
|
-
payload,
|
|
92
|
-
secret: WEBHOOK_SECRET,
|
|
93
|
-
});
|
|
94
122
|
|
|
95
|
-
const res = await post(app, payload,
|
|
123
|
+
const res = await post(app, payload, sign(payload));
|
|
96
124
|
|
|
97
125
|
expect(res.status).toBe(200);
|
|
98
126
|
const purchase = await store.getPurchase("order_1");
|
|
@@ -101,24 +129,154 @@ describe("stripeWebhook", () => {
|
|
|
101
129
|
expect(purchase?.data).toMatchObject({
|
|
102
130
|
subscription: "sub_test_1",
|
|
103
131
|
price_id: "price_test_1",
|
|
132
|
+
active: true,
|
|
104
133
|
amount: 500,
|
|
134
|
+
billing_scheme: "per_unit",
|
|
105
135
|
interval: "month",
|
|
136
|
+
interval_count: 1,
|
|
137
|
+
usage_type: "licensed",
|
|
138
|
+
quantity: 1,
|
|
139
|
+
current_period_start: PERIOD_START,
|
|
140
|
+
current_period_end: PERIOD_END,
|
|
106
141
|
expired: false,
|
|
107
142
|
customer: "cus_test_1",
|
|
108
143
|
});
|
|
109
144
|
expect((await store.findPurchaseBySubscriptionId("sub_test_1"))?.orderId).toBe("order_1");
|
|
110
145
|
});
|
|
111
146
|
|
|
112
|
-
it("
|
|
147
|
+
it("サブスクリプションアイテムの期間が終了していればexpiredをtrueにする", async () => {
|
|
113
148
|
const store = new MemoryPurchaseStore();
|
|
114
149
|
const app = buildApp(store);
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
150
|
+
const base = subscriptionObject();
|
|
151
|
+
base["items"]["data"][0]["current_period_start"] = NOW - 86400 * 31;
|
|
152
|
+
base["items"]["data"][0]["current_period_end"] = NOW - 60;
|
|
153
|
+
const payload = eventPayload("customer.subscription.updated", base);
|
|
154
|
+
|
|
155
|
+
const res = await post(app, payload, sign(payload));
|
|
156
|
+
|
|
157
|
+
expect(res.status).toBe(200);
|
|
158
|
+
const purchase = await store.getPurchase("order_1");
|
|
159
|
+
expect(purchase?.data["expired"]).toBe(true);
|
|
160
|
+
expect(purchase?.data["current_period_end"]).toBe(NOW - 60);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("旧APIバージョン(acacia)のペイロードではトップレベルのperiodとplanにフォールバックする", async () => {
|
|
164
|
+
const store = new MemoryPurchaseStore();
|
|
165
|
+
const app = buildApp(store);
|
|
166
|
+
const legacy = subscriptionObject({
|
|
167
|
+
current_period_start: PERIOD_START,
|
|
168
|
+
current_period_end: PERIOD_END,
|
|
169
|
+
quantity: 2,
|
|
170
|
+
plan: {
|
|
171
|
+
id: "price_legacy_1",
|
|
172
|
+
active: true,
|
|
173
|
+
amount: 800,
|
|
174
|
+
billing_scheme: "per_unit",
|
|
175
|
+
interval: "year",
|
|
176
|
+
interval_count: 1,
|
|
177
|
+
usage_type: "licensed",
|
|
178
|
+
},
|
|
179
|
+
items: {
|
|
180
|
+
object: "list",
|
|
181
|
+
data: [],
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
const payload = eventPayload("customer.subscription.created", legacy, "2025-02-24.acacia");
|
|
185
|
+
|
|
186
|
+
const res = await post(app, payload, sign(payload));
|
|
187
|
+
|
|
188
|
+
expect(res.status).toBe(200);
|
|
189
|
+
expect((await store.getPurchase("order_1"))?.data).toMatchObject({
|
|
190
|
+
price_id: "price_legacy_1",
|
|
191
|
+
amount: 800,
|
|
192
|
+
interval: "year",
|
|
193
|
+
quantity: 2,
|
|
194
|
+
current_period_start: PERIOD_START,
|
|
195
|
+
current_period_end: PERIOD_END,
|
|
196
|
+
expired: false,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("payment_intent.succeededでlatest_chargeを取得してreceiptUrlとcapturedAmountを保存する", async () => {
|
|
201
|
+
const store = new MemoryPurchaseStore();
|
|
202
|
+
await store.saveUser("user_1", { customer: "cus_test_1" });
|
|
203
|
+
await store.savePurchase("order_pi_1", {
|
|
204
|
+
purchaseId: "pi_test_1",
|
|
205
|
+
user: "user_1",
|
|
206
|
+
success: false,
|
|
207
|
+
});
|
|
208
|
+
const retrieve = jest.fn(async (id: string) => ({
|
|
209
|
+
id,
|
|
210
|
+
object: "charge",
|
|
211
|
+
receipt_url: "https://pay.stripe.com/receipts/test",
|
|
212
|
+
amount_captured: 1200,
|
|
213
|
+
}));
|
|
214
|
+
const stripeStub = {
|
|
215
|
+
webhooks: stripeForSigning.webhooks,
|
|
216
|
+
charges: { retrieve },
|
|
217
|
+
};
|
|
218
|
+
const app = buildApp(store, stripeStub);
|
|
219
|
+
const payload = eventPayload("payment_intent.succeeded", {
|
|
220
|
+
id: "pi_test_1",
|
|
221
|
+
object: "payment_intent",
|
|
222
|
+
customer: "cus_test_1",
|
|
223
|
+
status: "succeeded",
|
|
224
|
+
latest_charge: "ch_test_1",
|
|
119
225
|
});
|
|
120
226
|
|
|
121
|
-
const res = await post(app, payload,
|
|
227
|
+
const res = await post(app, payload, sign(payload));
|
|
228
|
+
|
|
229
|
+
expect(res.status).toBe(200);
|
|
230
|
+
expect(retrieve).toHaveBeenCalledWith("ch_test_1");
|
|
231
|
+
expect((await store.getPurchase("order_pi_1"))?.data).toMatchObject({
|
|
232
|
+
success: true,
|
|
233
|
+
capture: true,
|
|
234
|
+
receiptUrl: "https://pay.stripe.com/receipts/test",
|
|
235
|
+
capturedAmount: 1200,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("latest_chargeの取得に失敗しても購入の成功は保存する", async () => {
|
|
240
|
+
const store = new MemoryPurchaseStore();
|
|
241
|
+
await store.saveUser("user_1", { customer: "cus_test_1" });
|
|
242
|
+
await store.savePurchase("order_pi_1", {
|
|
243
|
+
purchaseId: "pi_test_1",
|
|
244
|
+
user: "user_1",
|
|
245
|
+
success: false,
|
|
246
|
+
});
|
|
247
|
+
const warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
248
|
+
const stripeStub = {
|
|
249
|
+
webhooks: stripeForSigning.webhooks,
|
|
250
|
+
charges: {
|
|
251
|
+
retrieve: jest.fn(async () => {
|
|
252
|
+
throw new Error("No such charge");
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
const app = buildApp(store, stripeStub);
|
|
257
|
+
const payload = eventPayload("payment_intent.succeeded", {
|
|
258
|
+
id: "pi_test_1",
|
|
259
|
+
object: "payment_intent",
|
|
260
|
+
customer: "cus_test_1",
|
|
261
|
+
status: "succeeded",
|
|
262
|
+
latest_charge: "ch_missing",
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const res = await post(app, payload, sign(payload));
|
|
266
|
+
warn.mockRestore();
|
|
267
|
+
|
|
268
|
+
expect(res.status).toBe(200);
|
|
269
|
+
const data = (await store.getPurchase("order_pi_1"))?.data;
|
|
270
|
+
expect(data?.["success"]).toBe(true);
|
|
271
|
+
expect(data?.["receiptUrl"]).toBeUndefined();
|
|
272
|
+
expect(data?.["capturedAmount"]).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("activeでないサブスクリプションは保存せず200を返す", async () => {
|
|
276
|
+
const store = new MemoryPurchaseStore();
|
|
277
|
+
const app = buildApp(store);
|
|
278
|
+
const payload = subscriptionEvent("customer.subscription.updated", { status: "incomplete" });
|
|
279
|
+
const res = await post(app, payload, sign(payload));
|
|
122
280
|
|
|
123
281
|
expect(res.status).toBe(200);
|
|
124
282
|
expect(await store.getPurchase("order_1")).toBeNull();
|
|
@@ -137,12 +295,7 @@ describe("stripeWebhook", () => {
|
|
|
137
295
|
ended_at: Math.floor(Date.now() / 1000),
|
|
138
296
|
canceled_at: Math.floor(Date.now() / 1000),
|
|
139
297
|
});
|
|
140
|
-
const
|
|
141
|
-
payload,
|
|
142
|
-
secret: WEBHOOK_SECRET,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const res = await post(app, payload, signature);
|
|
298
|
+
const res = await post(app, payload, sign(payload));
|
|
146
299
|
|
|
147
300
|
expect(res.status).toBe(200);
|
|
148
301
|
expect((await store.getPurchase("order_1"))?.data["expired"]).toBe(true);
|
|
@@ -152,12 +305,7 @@ describe("stripeWebhook", () => {
|
|
|
152
305
|
const store = new MemoryPurchaseStore();
|
|
153
306
|
const app = buildApp(store);
|
|
154
307
|
const payload = subscriptionEvent("customer.subscription.created");
|
|
155
|
-
const
|
|
156
|
-
payload,
|
|
157
|
-
secret: "whsec_wrong_secret",
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
const res = await post(app, payload, signature);
|
|
308
|
+
const res = await post(app, payload, sign(payload, "whsec_wrong_secret"));
|
|
161
309
|
|
|
162
310
|
expect(res.status).toBe(403);
|
|
163
311
|
expect(store.purchases.size).toBe(0);
|