@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/options.d.ts +1 -1
  6. package/dist/lib/options.js +7 -7
  7. package/dist/lib/options.js.map +1 -1
  8. package/dist/lib/purchase/helpers.js +4 -4
  9. package/dist/lib/purchase/helpers.js.map +1 -1
  10. package/dist/lib/purchase/stripe_mapping.d.ts +163 -0
  11. package/dist/lib/purchase/stripe_mapping.js +141 -0
  12. package/dist/lib/purchase/stripe_mapping.js.map +1 -0
  13. package/dist/lib/stripe_client.d.ts +1 -1
  14. package/dist/lib/stripe_client.js +1 -1
  15. package/dist/purchase.d.ts +1 -0
  16. package/dist/purchase.js +1 -0
  17. package/dist/purchase.js.map +1 -1
  18. package/dist/workers/stripe.js +68 -68
  19. package/dist/workers/stripe.js.map +1 -1
  20. package/dist/workers/stripe_webhook.js +28 -22
  21. package/dist/workers/stripe_webhook.js.map +1 -1
  22. package/dist/workers/stripe_webhook_connect.js +2 -2
  23. package/dist/workers/stripe_webhook_connect.js.map +1 -1
  24. package/dist/workers/stripe_webhook_secure.js +2 -2
  25. package/dist/workers/stripe_webhook_secure.js.map +1 -1
  26. package/package.json +3 -3
  27. package/src/index.ts +1 -0
  28. package/src/lib/options.ts +2 -2
  29. package/src/lib/purchase/helpers.ts +1 -1
  30. package/src/lib/purchase/stripe_mapping.ts +263 -0
  31. package/src/lib/stripe_client.ts +1 -1
  32. package/src/purchase.ts +1 -0
  33. package/src/workers/stripe.ts +1 -1
  34. package/src/workers/stripe_webhook.ts +30 -23
  35. package/src/workers/stripe_webhook_connect.ts +1 -1
  36. package/src/workers/stripe_webhook_secure.ts +1 -1
  37. package/test/meter.test.ts +11 -2
  38. package/test/stripe_mapping.test.ts +184 -0
  39. 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/dist/lib/src/http_error";
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
- if (payment["charges"] && payment["charges"]["data"] && payment["charges"]["data"].length > 0 && payment["charges"]["data"][0]) {
158
- if (payment["charges"]["data"][0]["receipt_url"]) {
159
- update["receiptUrl"] = payment["charges"]["data"][0]["receipt_url"];
160
- }
161
- if (payment["charges"]["data"][0]["amount_captured"]) {
162
- update["capturedAmount"] = payment["charges"]["data"][0]["amount_captured"];
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
- const endDate = new Date(subscription["current_period_end"] as number * 1000);
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"] = now >= endDate;
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"] = subscription["current_period_start"];
361
- update["current_period_end"] = subscription["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"] = plan["id"];
367
- update["active"] = plan["active"];
368
- update["amount"] = plan["amount"];
369
- update["billing_scheme"] = plan["billing_scheme"];
370
- update["interval"] = plan["interval"];
371
- update["interval_count"] = plan["interval_count"];
372
- update["usage_type"] = plan["usage_type"];
373
- update["quantity"] = subscription["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, {
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import { jsonError } from "@mathrunet/masamune_cloudflare/dist/lib/src/http_error";
2
+ import { jsonError } from "@mathrunet/masamune_cloudflare";
3
3
  import {
4
4
  resolveStripeClient,
5
5
  resolveStripePurchaseStore,
@@ -1,5 +1,5 @@
1
1
  import { Hono } from "hono";
2
- import { jsonError } from "@mathrunet/masamune_cloudflare/dist/lib/src/http_error";
2
+ import { jsonError } from "@mathrunet/masamune_cloudflare";
3
3
  import "@mathrunet/masamune";
4
4
  import {
5
5
  resolveStripeClient,
@@ -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
- return new Response(status >= 400 ? "error" : "{}", { status });
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
- fetch: fakeFetch(500),
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: "2025-02-24.acacia",
15
+ apiVersion: STRIPE_API_VERSION,
15
16
  });
16
17
 
17
- function subscriptionEvent(type: string, overrides: { [key: string]: any } = {}): string {
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: "2025-02-24.acacia",
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 buildApp(store: MemoryPurchaseStore): Hono {
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: () => stripeForSigning,
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, signature);
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("activeでないサブスクリプションは保存せず200を返す", async () => {
147
+ it("サブスクリプションアイテムの期間が終了していればexpiredをtrueにする", async () => {
113
148
  const store = new MemoryPurchaseStore();
114
149
  const app = buildApp(store);
115
- const payload = subscriptionEvent("customer.subscription.updated", { status: "incomplete" });
116
- const signature = stripeForSigning.webhooks.generateTestHeaderString({
117
- payload,
118
- secret: WEBHOOK_SECRET,
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, signature);
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 signature = stripeForSigning.webhooks.generateTestHeaderString({
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 signature = stripeForSigning.webhooks.generateTestHeaderString({
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);