@mathrunet/masamune_cloudflare_purchase_stripe 3.2.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 +3 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/dist/functions.d.ts +44 -0
- package/dist/functions.js +47 -0
- package/dist/functions.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/meter/d1_usage_event_store.d.ts +128 -0
- package/dist/lib/meter/d1_usage_event_store.js +78 -0
- package/dist/lib/meter/d1_usage_event_store.js.map +1 -0
- package/dist/lib/meter/flush_meter.d.ts +19 -0
- package/dist/lib/meter/flush_meter.js +38 -0
- package/dist/lib/meter/flush_meter.js.map +1 -0
- package/dist/lib/meter/interface.d.ts +109 -0
- package/dist/lib/meter/interface.js +3 -0
- package/dist/lib/meter/interface.js.map +1 -0
- package/dist/lib/meter/kv_usage_buffer.d.ts +56 -0
- package/dist/lib/meter/kv_usage_buffer.js +50 -0
- package/dist/lib/meter/kv_usage_buffer.js.map +1 -0
- package/dist/lib/meter/record_usage.d.ts +29 -0
- package/dist/lib/meter/record_usage.js +72 -0
- package/dist/lib/meter/record_usage.js.map +1 -0
- package/dist/lib/meter/stripe_meter_client.d.ts +76 -0
- package/dist/lib/meter/stripe_meter_client.js +74 -0
- package/dist/lib/meter/stripe_meter_client.js.map +1 -0
- package/dist/lib/options.d.ts +179 -0
- package/dist/lib/options.js +132 -0
- package/dist/lib/options.js.map +1 -0
- package/dist/lib/purchase/d1_purchase_store.d.ts +78 -0
- package/dist/lib/purchase/d1_purchase_store.js +194 -0
- package/dist/lib/purchase/d1_purchase_store.js.map +1 -0
- package/dist/lib/purchase/helpers.d.ts +36 -0
- package/dist/lib/purchase/helpers.js +64 -0
- package/dist/lib/purchase/helpers.js.map +1 -0
- package/dist/lib/purchase/interface.d.ts +182 -0
- package/dist/lib/purchase/interface.js +27 -0
- package/dist/lib/purchase/interface.js.map +1 -0
- package/dist/lib/purchase/sync_payment.d.ts +15 -0
- package/dist/lib/purchase/sync_payment.js +79 -0
- package/dist/lib/purchase/sync_payment.js.map +1 -0
- package/dist/lib/stripe_client.d.ts +55 -0
- package/dist/lib/stripe_client.js +50 -0
- package/dist/lib/stripe_client.js.map +1 -0
- package/dist/meter.d.ts +21 -0
- package/dist/meter.js +38 -0
- package/dist/meter.js.map +1 -0
- package/dist/purchase.d.ts +58 -0
- package/dist/purchase.js +48 -0
- package/dist/purchase.js.map +1 -0
- package/dist/workers/stripe.d.ts +1 -0
- package/dist/workers/stripe.js +861 -0
- package/dist/workers/stripe.js.map +1 -0
- package/dist/workers/stripe_webhook.d.ts +1 -0
- package/dist/workers/stripe_webhook.js +384 -0
- package/dist/workers/stripe_webhook.js.map +1 -0
- package/dist/workers/stripe_webhook_connect.d.ts +1 -0
- package/dist/workers/stripe_webhook_connect.js +97 -0
- package/dist/workers/stripe_webhook_connect.js.map +1 -0
- package/dist/workers/stripe_webhook_secure.d.ts +1 -0
- package/dist/workers/stripe_webhook_secure.js +82 -0
- package/dist/workers/stripe_webhook_secure.js.map +1 -0
- package/jest.config.json +19 -0
- package/package.json +56 -0
- package/src/functions.ts +48 -0
- package/src/index.ts +25 -0
- package/src/lib/meter/d1_usage_event_store.ts +176 -0
- package/src/lib/meter/flush_meter.ts +45 -0
- package/src/lib/meter/interface.ts +118 -0
- package/src/lib/meter/kv_usage_buffer.ts +79 -0
- package/src/lib/meter/record_usage.ts +87 -0
- package/src/lib/meter/stripe_meter_client.ts +117 -0
- package/src/lib/options.ts +299 -0
- package/src/lib/purchase/d1_purchase_store.ts +268 -0
- package/src/lib/purchase/helpers.ts +77 -0
- package/src/lib/purchase/interface.ts +215 -0
- package/src/lib/purchase/sync_payment.ts +95 -0
- package/src/lib/stripe_client.ts +76 -0
- package/src/meter.ts +21 -0
- package/src/purchase.ts +66 -0
- package/src/workers/stripe.ts +881 -0
- package/src/workers/stripe_webhook.ts +414 -0
- package/src/workers/stripe_webhook_connect.ts +107 -0
- package/src/workers/stripe_webhook_secure.ts +91 -0
- package/test/helpers/mem_store.ts +97 -0
- package/test/meter.test.ts +217 -0
- package/test/purchase_store.test.ts +154 -0
- package/test/stripe.test.ts +146 -0
- package/test/stripe_webhook.test.ts +174 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mergeDocumentData,
|
|
3
|
+
StripeDocumentData,
|
|
4
|
+
StripePaymentDocument,
|
|
5
|
+
StripePurchaseDocument,
|
|
6
|
+
StripePurchaseStore,
|
|
7
|
+
StripeUserDocument,
|
|
8
|
+
} from "../../src/lib/purchase/interface";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* In-memory [StripePurchaseStore] for tests.
|
|
12
|
+
*
|
|
13
|
+
* テスト用のインメモリ[StripePurchaseStore]。
|
|
14
|
+
*/
|
|
15
|
+
export class MemoryPurchaseStore implements StripePurchaseStore {
|
|
16
|
+
users = new Map<string, StripeDocumentData>();
|
|
17
|
+
payments = new Map<string, StripeDocumentData>();
|
|
18
|
+
purchases = new Map<string, { userId: string | null, data: StripeDocumentData }>();
|
|
19
|
+
|
|
20
|
+
async getUser(userId: string): Promise<StripeUserDocument | null> {
|
|
21
|
+
const data = this.users.get(userId);
|
|
22
|
+
return data ? { userId, data } : null;
|
|
23
|
+
}
|
|
24
|
+
async findUserByCustomerId(customerId: string): Promise<StripeUserDocument | null> {
|
|
25
|
+
for (const [userId, data] of this.users) {
|
|
26
|
+
if (data["customer"] === customerId) {
|
|
27
|
+
return { userId, data };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
async findUserByAccountId(accountId: string): Promise<StripeUserDocument | null> {
|
|
33
|
+
for (const [userId, data] of this.users) {
|
|
34
|
+
if (data["account"] === accountId) {
|
|
35
|
+
return { userId, data };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
async saveUser(userId: string, update: StripeDocumentData): Promise<void> {
|
|
41
|
+
this.users.set(userId, mergeDocumentData(this.users.get(userId) ?? {}, update));
|
|
42
|
+
}
|
|
43
|
+
async listPayments(userId: string): Promise<StripePaymentDocument[]> {
|
|
44
|
+
const list: StripePaymentDocument[] = [];
|
|
45
|
+
for (const [key, data] of this.payments) {
|
|
46
|
+
const [owner, paymentId] = key.split("/");
|
|
47
|
+
if (owner === userId && paymentId) {
|
|
48
|
+
list.push({ userId, paymentId, data });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return list;
|
|
52
|
+
}
|
|
53
|
+
async savePayment(userId: string, paymentId: string, update: StripeDocumentData): Promise<void> {
|
|
54
|
+
const key = `${userId}/${paymentId}`;
|
|
55
|
+
this.payments.set(key, mergeDocumentData(this.payments.get(key) ?? {}, update));
|
|
56
|
+
}
|
|
57
|
+
async deletePayment(userId: string, paymentId: string): Promise<void> {
|
|
58
|
+
this.payments.delete(`${userId}/${paymentId}`);
|
|
59
|
+
}
|
|
60
|
+
async getPurchase(orderId: string, userId?: string | undefined): Promise<StripePurchaseDocument | null> {
|
|
61
|
+
const entry = this.purchases.get(orderId);
|
|
62
|
+
if (!entry) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (userId && entry.userId !== userId) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return { orderId, userId: entry.userId, data: entry.data };
|
|
69
|
+
}
|
|
70
|
+
async findPurchaseByPurchaseId(purchaseId: string): Promise<StripePurchaseDocument | null> {
|
|
71
|
+
for (const [orderId, entry] of this.purchases) {
|
|
72
|
+
if (entry.data["purchaseId"] === purchaseId) {
|
|
73
|
+
return { orderId, userId: entry.userId, data: entry.data };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
async findPurchaseBySubscriptionId(subscriptionId: string): Promise<StripePurchaseDocument | null> {
|
|
79
|
+
for (const [orderId, entry] of this.purchases) {
|
|
80
|
+
if (entry.data["subscription"] === subscriptionId) {
|
|
81
|
+
return { orderId, userId: entry.userId, data: entry.data };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
async savePurchase(
|
|
87
|
+
orderId: string,
|
|
88
|
+
update: StripeDocumentData,
|
|
89
|
+
options: { userId?: string | undefined } = {},
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
const existing = this.purchases.get(orderId);
|
|
92
|
+
const data = mergeDocumentData(existing?.data ?? {}, update);
|
|
93
|
+
const userId = options.userId
|
|
94
|
+
?? (typeof data["user"] === "string" ? data["user"] : existing?.userId ?? null);
|
|
95
|
+
this.purchases.set(orderId, { userId, data });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { D1UsageEventStore, SqlDatabaseLike } from "../src/lib/meter/d1_usage_event_store";
|
|
2
|
+
import { flushStripeMeter } from "../src/lib/meter/flush_meter";
|
|
3
|
+
import { KVNamespaceLike, KVUsageBuffer } from "../src/lib/meter/kv_usage_buffer";
|
|
4
|
+
import { recordUsage } from "../src/lib/meter/record_usage";
|
|
5
|
+
import { StripeMeterClient } from "../src/lib/meter/stripe_meter_client";
|
|
6
|
+
|
|
7
|
+
function fakeDb(pendingRows: { id: string, units: number }[], executed: string[]): SqlDatabaseLike {
|
|
8
|
+
return {
|
|
9
|
+
prepare(sql: string) {
|
|
10
|
+
return {
|
|
11
|
+
bind(..._args: unknown[]) {
|
|
12
|
+
return {
|
|
13
|
+
async run() {
|
|
14
|
+
executed.push(sql.replace(/\s+/g, " ").trim());
|
|
15
|
+
return {};
|
|
16
|
+
},
|
|
17
|
+
async all<T>() {
|
|
18
|
+
return { results: pendingRows as unknown as T[] };
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function fakeKv(store: Map<string, string>): KVNamespaceLike {
|
|
28
|
+
return {
|
|
29
|
+
async get(key: string) {
|
|
30
|
+
const value = store.get(key);
|
|
31
|
+
return value === undefined ? null : JSON.parse(value);
|
|
32
|
+
},
|
|
33
|
+
async put(key: string, value: string) {
|
|
34
|
+
store.set(key, value);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fakeFetch(status: number, onRequest?: (init?: RequestInit) => void): typeof fetch {
|
|
40
|
+
return (async (_url: unknown, init?: RequestInit) => {
|
|
41
|
+
onRequest?.(init);
|
|
42
|
+
return new Response(status >= 400 ? "error" : "{}", { status });
|
|
43
|
+
}) as typeof fetch;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeParts(options: {
|
|
47
|
+
pendingRows: { id: string, units: number }[],
|
|
48
|
+
executed: string[],
|
|
49
|
+
store: Map<string, string>,
|
|
50
|
+
fetch: typeof fetch,
|
|
51
|
+
}) {
|
|
52
|
+
return {
|
|
53
|
+
store: new D1UsageEventStore(fakeDb(options.pendingRows, options.executed)),
|
|
54
|
+
buffer: new KVUsageBuffer(fakeKv(options.store)),
|
|
55
|
+
client: new StripeMeterClient({
|
|
56
|
+
secretKey: "sk_test_dummy",
|
|
57
|
+
eventName: "test_decisions",
|
|
58
|
+
fetch: options.fetch,
|
|
59
|
+
}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("recordUsage", () => {
|
|
64
|
+
it("Stripe flush が失敗したらバッファを保持して次回再送に回す", async () => {
|
|
65
|
+
const executed: string[] = [];
|
|
66
|
+
const store = new Map<string, string>();
|
|
67
|
+
const parts = makeParts({
|
|
68
|
+
pendingRows: [{ id: "u1", units: 10 }],
|
|
69
|
+
executed,
|
|
70
|
+
store,
|
|
71
|
+
fetch: fakeFetch(500),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await recordUsage({
|
|
75
|
+
...parts,
|
|
76
|
+
customerKey: "key_1",
|
|
77
|
+
stripeCustomerId: "cus_123",
|
|
78
|
+
endpoint: "decide",
|
|
79
|
+
units: 10,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const buffer = JSON.parse(store.get("usage:key_1") ?? "{}");
|
|
83
|
+
expect(buffer.units).toBe(10);
|
|
84
|
+
expect(executed.some((sql) => sql.includes("SET flushed_to_stripe = 1"))).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("Stripe flush が成功したらバッファをリセットし送信済みを記録する", async () => {
|
|
88
|
+
const executed: string[] = [];
|
|
89
|
+
const store = new Map<string, string>();
|
|
90
|
+
const parts = makeParts({
|
|
91
|
+
pendingRows: [{ id: "u1", units: 10 }],
|
|
92
|
+
executed,
|
|
93
|
+
store,
|
|
94
|
+
fetch: fakeFetch(200),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await recordUsage({
|
|
98
|
+
...parts,
|
|
99
|
+
customerKey: "key_1",
|
|
100
|
+
stripeCustomerId: "cus_123",
|
|
101
|
+
endpoint: "decide",
|
|
102
|
+
units: 10,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const buffer = JSON.parse(store.get("usage:key_1") ?? "{}");
|
|
106
|
+
expect(buffer.units).toBe(0);
|
|
107
|
+
expect(executed.some((sql) => sql.includes("SET flushed_to_stripe = 1"))).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("閾値未満なら flush せずバッファへ積むだけ", async () => {
|
|
111
|
+
const executed: string[] = [];
|
|
112
|
+
const store = new Map<string, string>();
|
|
113
|
+
let fetchCalled = false;
|
|
114
|
+
const parts = makeParts({
|
|
115
|
+
pendingRows: [{ id: "u1", units: 1 }],
|
|
116
|
+
executed,
|
|
117
|
+
store,
|
|
118
|
+
fetch: fakeFetch(200, () => {
|
|
119
|
+
fetchCalled = true;
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await recordUsage({
|
|
124
|
+
...parts,
|
|
125
|
+
customerKey: "key_1",
|
|
126
|
+
stripeCustomerId: "cus_123",
|
|
127
|
+
endpoint: "decide",
|
|
128
|
+
units: 1,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const buffer = JSON.parse(store.get("usage:key_1") ?? "{}");
|
|
132
|
+
expect(buffer.units).toBe(1);
|
|
133
|
+
expect(fetchCalled).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("flushStripeMeter", () => {
|
|
138
|
+
it("バッチごとに一意な identifier を送る", async () => {
|
|
139
|
+
const identifiers: string[] = [];
|
|
140
|
+
const parts = makeParts({
|
|
141
|
+
pendingRows: [{ id: "u1", units: 3 }],
|
|
142
|
+
executed: [],
|
|
143
|
+
store: new Map(),
|
|
144
|
+
fetch: fakeFetch(200, (init) => {
|
|
145
|
+
identifiers.push(new URLSearchParams(String(init?.body ?? "")).get("identifier") ?? "");
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
const options = {
|
|
149
|
+
store: parts.store,
|
|
150
|
+
client: parts.client,
|
|
151
|
+
customerKey: "key_1",
|
|
152
|
+
stripeCustomerId: "cus_123",
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(await flushStripeMeter(options)).toBe(true);
|
|
156
|
+
expect(await flushStripeMeter(options)).toBe(true);
|
|
157
|
+
expect(identifiers).toHaveLength(2);
|
|
158
|
+
expect(identifiers[0]).not.toBe(identifiers[1]);
|
|
159
|
+
expect(identifiers[0]?.startsWith("key_1:")).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("Stripe がエラーを返したら false を返し flushed を更新しない", async () => {
|
|
163
|
+
const executed: string[] = [];
|
|
164
|
+
const parts = makeParts({
|
|
165
|
+
pendingRows: [{ id: "u1", units: 3 }],
|
|
166
|
+
executed,
|
|
167
|
+
store: new Map(),
|
|
168
|
+
fetch: fakeFetch(400),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
expect(
|
|
172
|
+
await flushStripeMeter({
|
|
173
|
+
store: parts.store,
|
|
174
|
+
client: parts.client,
|
|
175
|
+
customerKey: "key_1",
|
|
176
|
+
stripeCustomerId: "cus_123",
|
|
177
|
+
}),
|
|
178
|
+
).toBe(false);
|
|
179
|
+
expect(executed.some((sql) => sql.includes("SET flushed_to_stripe = 1"))).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("secret や顧客IDが未設定なら送信せず成功扱いにする", async () => {
|
|
183
|
+
let fetchCalled = false;
|
|
184
|
+
const store = new D1UsageEventStore(fakeDb([{ id: "u1", units: 3 }], []));
|
|
185
|
+
const disabledClient = new StripeMeterClient({
|
|
186
|
+
secretKey: "",
|
|
187
|
+
eventName: "test_decisions",
|
|
188
|
+
fetch: fakeFetch(200, () => {
|
|
189
|
+
fetchCalled = true;
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(
|
|
194
|
+
await flushStripeMeter({
|
|
195
|
+
store,
|
|
196
|
+
client: disabledClient,
|
|
197
|
+
customerKey: "key_1",
|
|
198
|
+
stripeCustomerId: "cus_123",
|
|
199
|
+
}),
|
|
200
|
+
).toBe(true);
|
|
201
|
+
expect(
|
|
202
|
+
await flushStripeMeter({
|
|
203
|
+
store,
|
|
204
|
+
client: new StripeMeterClient({
|
|
205
|
+
secretKey: "sk_test_dummy",
|
|
206
|
+
eventName: "test_decisions",
|
|
207
|
+
fetch: fakeFetch(200, () => {
|
|
208
|
+
fetchCalled = true;
|
|
209
|
+
}),
|
|
210
|
+
}),
|
|
211
|
+
customerKey: "key_1",
|
|
212
|
+
stripeCustomerId: null,
|
|
213
|
+
}),
|
|
214
|
+
).toBe(true);
|
|
215
|
+
expect(fetchCalled).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { D1StripePurchaseStore } from "../src/lib/purchase/d1_purchase_store";
|
|
2
|
+
import { mergeDocumentData } from "../src/lib/purchase/interface";
|
|
3
|
+
import { SqlDatabaseLike } from "../src/lib/meter/d1_usage_event_store";
|
|
4
|
+
|
|
5
|
+
interface Row {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// D1StripePurchaseStore が発行する SELECT / INSERT / UPDATE / DELETE を
|
|
10
|
+
// メモリ上のテーブルで解釈する最小のフェイクD1。
|
|
11
|
+
function fakeDb(tables: { [table: string]: Row[] }): SqlDatabaseLike {
|
|
12
|
+
return {
|
|
13
|
+
prepare(sql: string) {
|
|
14
|
+
const normalized = sql.replace(/\s+/g, " ").trim();
|
|
15
|
+
return {
|
|
16
|
+
bind(...args: unknown[]) {
|
|
17
|
+
const run = async () => {
|
|
18
|
+
const insert = normalized.match(/^INSERT INTO (\w+) \(([^)]+)\) VALUES/);
|
|
19
|
+
if (insert) {
|
|
20
|
+
const table = insert[1] as string;
|
|
21
|
+
const columns = (insert[2] as string).split(",").map((s) => s.trim());
|
|
22
|
+
const row: Row = {};
|
|
23
|
+
columns.forEach((column, i) => {
|
|
24
|
+
row[column] = args[i];
|
|
25
|
+
});
|
|
26
|
+
(tables[table] ??= []).push(row);
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
const update = normalized.match(/^UPDATE (\w+) SET (.+) WHERE (.+)$/);
|
|
30
|
+
if (update) {
|
|
31
|
+
const table = update[1] as string;
|
|
32
|
+
const setColumns = (update[2] as string).split(",").map((s) => (s.split("=")[0] as string).trim());
|
|
33
|
+
const whereColumns = (update[3] as string).split("AND").map((s) => (s.split("=")[0] as string).trim());
|
|
34
|
+
const setValues = args.slice(0, setColumns.length);
|
|
35
|
+
const whereValues = args.slice(setColumns.length);
|
|
36
|
+
for (const row of tables[table] ?? []) {
|
|
37
|
+
if (whereColumns.every((column, i) => row[column] === whereValues[i])) {
|
|
38
|
+
setColumns.forEach((column, i) => {
|
|
39
|
+
row[column] = setValues[i];
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
const del = normalized.match(/^DELETE FROM (\w+) WHERE (.+)$/);
|
|
46
|
+
if (del) {
|
|
47
|
+
const table = del[1] as string;
|
|
48
|
+
const whereColumns = (del[2] as string).split("AND").map((s) => (s.split("=")[0] as string).trim());
|
|
49
|
+
tables[table] = (tables[table] ?? []).filter(
|
|
50
|
+
(row) => !whereColumns.every((column, i) => row[column] === args[i]),
|
|
51
|
+
);
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Unsupported SQL: ${normalized}`);
|
|
55
|
+
};
|
|
56
|
+
const all = async <T>() => {
|
|
57
|
+
const select = normalized.match(/^SELECT (.+) FROM (\w+)(?: WHERE (.+?))?( LIMIT \d+)?$/);
|
|
58
|
+
if (!select) {
|
|
59
|
+
throw new Error(`Unsupported SQL: ${normalized}`);
|
|
60
|
+
}
|
|
61
|
+
const table = select[2] as string;
|
|
62
|
+
const where = select[3];
|
|
63
|
+
let rows = tables[table] ?? [];
|
|
64
|
+
if (where) {
|
|
65
|
+
const whereColumns = where.split("AND").map((s) => (s.split("=")[0] as string).trim());
|
|
66
|
+
rows = rows.filter((row) => whereColumns.every((column, i) => row[column] === args[i]));
|
|
67
|
+
}
|
|
68
|
+
return { results: rows as unknown as T[] };
|
|
69
|
+
};
|
|
70
|
+
return { run, all };
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("mergeDocumentData", () => {
|
|
78
|
+
it("nullのキーは削除しundefinedは無視する", () => {
|
|
79
|
+
const merged = mergeDocumentData(
|
|
80
|
+
{ a: 1, b: 2, c: 3 },
|
|
81
|
+
{ a: null, b: undefined, d: 4 },
|
|
82
|
+
);
|
|
83
|
+
expect(merged).toEqual({ b: 2, c: 3, d: 4 });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("D1StripePurchaseStore", () => {
|
|
88
|
+
it("saveUserがマージ保存し検索キーカラムを同期する", async () => {
|
|
89
|
+
const tables: { [table: string]: Row[] } = {};
|
|
90
|
+
const store = new D1StripePurchaseStore(fakeDb(tables));
|
|
91
|
+
|
|
92
|
+
await store.saveUser("user_1", { user: "user_1", customer: "cus_1" });
|
|
93
|
+
await store.saveUser("user_1", { account: "acct_1", email: "a@b.c" });
|
|
94
|
+
|
|
95
|
+
const user = await store.getUser("user_1");
|
|
96
|
+
expect(user?.data).toMatchObject({
|
|
97
|
+
user: "user_1",
|
|
98
|
+
customer: "cus_1",
|
|
99
|
+
account: "acct_1",
|
|
100
|
+
email: "a@b.c",
|
|
101
|
+
});
|
|
102
|
+
expect(await store.findUserByCustomerId("cus_1")).not.toBeNull();
|
|
103
|
+
expect(await store.findUserByAccountId("acct_1")).not.toBeNull();
|
|
104
|
+
|
|
105
|
+
await store.saveUser("user_1", { customer: null });
|
|
106
|
+
expect(await store.findUserByCustomerId("cus_1")).toBeNull();
|
|
107
|
+
const after = await store.getUser("user_1");
|
|
108
|
+
expect(after?.data["customer"]).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("savePurchaseがpurchaseId/subscriptionを検索キーへ同期する", async () => {
|
|
112
|
+
const tables: { [table: string]: Row[] } = {};
|
|
113
|
+
const store = new D1StripePurchaseStore(fakeDb(tables));
|
|
114
|
+
|
|
115
|
+
await store.savePurchase("order_1", {
|
|
116
|
+
orderId: "order_1",
|
|
117
|
+
purchaseId: "pi_1",
|
|
118
|
+
user: "user_1",
|
|
119
|
+
}, { userId: "user_1" });
|
|
120
|
+
await store.savePurchase("order_2", {
|
|
121
|
+
subscription: "sub_1",
|
|
122
|
+
user: "user_1",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect((await store.findPurchaseByPurchaseId("pi_1"))?.orderId).toBe("order_1");
|
|
126
|
+
expect((await store.findPurchaseBySubscriptionId("sub_1"))?.orderId).toBe("order_2");
|
|
127
|
+
expect((await store.getPurchase("order_1", "user_1"))?.data["purchaseId"]).toBe("pi_1");
|
|
128
|
+
expect(await store.getPurchase("order_1", "other_user")).toBeNull();
|
|
129
|
+
|
|
130
|
+
await store.savePurchase("order_1", { confirm: true, error: null });
|
|
131
|
+
const purchase = await store.getPurchase("order_1");
|
|
132
|
+
expect(purchase?.data["confirm"]).toBe(true);
|
|
133
|
+
expect(purchase?.data["purchaseId"]).toBe("pi_1");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("支払い方法の保存・一覧・削除ができる", async () => {
|
|
137
|
+
const tables: { [table: string]: Row[] } = {};
|
|
138
|
+
const store = new D1StripePurchaseStore(fakeDb(tables));
|
|
139
|
+
|
|
140
|
+
await store.savePayment("user_1", "pm_1", { id: "pm_1", brand: "visa", default: true });
|
|
141
|
+
await store.savePayment("user_1", "pm_2", { id: "pm_2", brand: "mc", default: false });
|
|
142
|
+
await store.savePayment("user_1", "pm_1", { expMonth: 12 });
|
|
143
|
+
|
|
144
|
+
const payments = await store.listPayments("user_1");
|
|
145
|
+
expect(payments).toHaveLength(2);
|
|
146
|
+
expect(payments.find((p) => p.paymentId === "pm_1")?.data).toMatchObject({
|
|
147
|
+
brand: "visa",
|
|
148
|
+
expMonth: 12,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await store.deletePayment("user_1", "pm_1");
|
|
152
|
+
expect(await store.listPayments("user_1")).toHaveLength(1);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import Stripe from "stripe";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { StripePurchaseWorkersOptions } from "../src/lib/options";
|
|
4
|
+
import { MemoryPurchaseStore } from "./helpers/mem_store";
|
|
5
|
+
|
|
6
|
+
const buildStripe = require("../src/workers/stripe") as (
|
|
7
|
+
hono: Hono,
|
|
8
|
+
options: StripePurchaseWorkersOptions,
|
|
9
|
+
data: { [key: string]: any },
|
|
10
|
+
) => Hono;
|
|
11
|
+
|
|
12
|
+
function buildApp(store: MemoryPurchaseStore, stripeStub: unknown): Hono {
|
|
13
|
+
return buildStripe(new Hono(), {
|
|
14
|
+
secretKey: "sk_test_dummy_secret_key_0123456789abcdef0123456789abcdef",
|
|
15
|
+
store: () => store,
|
|
16
|
+
stripeClient: () => stripeStub as Stripe,
|
|
17
|
+
}, {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function post(app: Hono, body: { [key: string]: any }): Promise<Response> {
|
|
21
|
+
return app.request("/", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "content-type": "application/json" },
|
|
24
|
+
body: JSON.stringify(body),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("stripe (mode actions)", () => {
|
|
29
|
+
it("create_subscriptionがCheckout SessionのURLを返す", async () => {
|
|
30
|
+
const created: any[] = [];
|
|
31
|
+
const stripeStub = {
|
|
32
|
+
checkout: {
|
|
33
|
+
sessions: {
|
|
34
|
+
create: async (params: any) => {
|
|
35
|
+
created.push(params);
|
|
36
|
+
return { url: "https://checkout.stripe.com/test" };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
const app = buildApp(new MemoryPurchaseStore(), stripeStub);
|
|
42
|
+
|
|
43
|
+
const res = await post(app, {
|
|
44
|
+
mode: "create_subscription",
|
|
45
|
+
userId: "user_1",
|
|
46
|
+
orderId: "order_1",
|
|
47
|
+
productId: "price_test_1",
|
|
48
|
+
successUrl: "https://example.com/ok",
|
|
49
|
+
cancelUrl: "https://example.com/ng",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(res.status).toBe(200);
|
|
53
|
+
expect(await res.json()).toEqual({ endpoint: "https://checkout.stripe.com/test" });
|
|
54
|
+
expect(created[0]).toMatchObject({
|
|
55
|
+
mode: "subscription",
|
|
56
|
+
subscription_data: {
|
|
57
|
+
metadata: { userId: "user_1", orderId: "order_1" },
|
|
58
|
+
},
|
|
59
|
+
line_items: [{ price: "price_test_1", quantity: 1 }],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("create_subscriptionは必須パラメータ欠如で400を返す", async () => {
|
|
64
|
+
const app = buildApp(new MemoryPurchaseStore(), {});
|
|
65
|
+
|
|
66
|
+
const res = await post(app, {
|
|
67
|
+
mode: "create_subscription",
|
|
68
|
+
userId: "user_1",
|
|
69
|
+
orderId: "order_1",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(res.status).toBe(400);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("delete_subscriptionがcancel_at_period_endを設定する", async () => {
|
|
76
|
+
const updated: any[] = [];
|
|
77
|
+
const stripeStub = {
|
|
78
|
+
subscriptions: {
|
|
79
|
+
update: async (id: string, params: any) => {
|
|
80
|
+
updated.push([id, params]);
|
|
81
|
+
return { cancel_at_period_end: true };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const store = new MemoryPurchaseStore();
|
|
86
|
+
await store.savePurchase("order_1", { subscription: "sub_test_1", user: "user_1" });
|
|
87
|
+
const app = buildApp(store, stripeStub);
|
|
88
|
+
|
|
89
|
+
const res = await post(app, {
|
|
90
|
+
mode: "delete_subscription",
|
|
91
|
+
orderId: "order_1",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
expect(await res.json()).toEqual({ success: true });
|
|
96
|
+
expect(updated[0]).toEqual(["sub_test_1", { cancel_at_period_end: true }]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("delete_subscriptionは購読が見つからなければ404を返す", async () => {
|
|
100
|
+
const app = buildApp(new MemoryPurchaseStore(), {});
|
|
101
|
+
|
|
102
|
+
const res = await post(app, {
|
|
103
|
+
mode: "delete_subscription",
|
|
104
|
+
orderId: "missing_order",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(res.status).toBe(404);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("create_customer_and_paymentが新規カスタマーとsetupセッションを作る", async () => {
|
|
111
|
+
const stripeStub = {
|
|
112
|
+
customers: {
|
|
113
|
+
create: async (params: any) => ({ id: "cus_new_1", ...params }),
|
|
114
|
+
},
|
|
115
|
+
checkout: {
|
|
116
|
+
sessions: {
|
|
117
|
+
create: async (_params: any) => ({ url: "https://checkout.stripe.com/setup" }),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const store = new MemoryPurchaseStore();
|
|
122
|
+
const app = buildApp(store, stripeStub);
|
|
123
|
+
|
|
124
|
+
const res = await post(app, {
|
|
125
|
+
mode: "create_customer_and_payment",
|
|
126
|
+
userId: "user_1",
|
|
127
|
+
successUrl: "https://example.com/ok",
|
|
128
|
+
cancelUrl: "https://example.com/ng",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
expect(await res.json()).toEqual({
|
|
133
|
+
endpoint: "https://checkout.stripe.com/setup",
|
|
134
|
+
customerId: "cus_new_1",
|
|
135
|
+
});
|
|
136
|
+
expect((await store.getUser("user_1"))?.data["customer"]).toBe("cus_new_1");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("未知のmodeは404を返す", async () => {
|
|
140
|
+
const app = buildApp(new MemoryPurchaseStore(), {});
|
|
141
|
+
|
|
142
|
+
const res = await post(app, { mode: "unknown_mode" });
|
|
143
|
+
|
|
144
|
+
expect(res.status).toBe(404);
|
|
145
|
+
});
|
|
146
|
+
});
|