@odla-ai/chapter 0.3.0 → 0.5.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/dist/index.cjs +254 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +269 -1
- package/dist/index.d.ts +269 -1
- package/dist/index.js +254 -3
- package/dist/index.js.map +1 -1
- package/dist/ui/index.d.ts +169 -2
- package/dist/ui/index.js +597 -1
- package/dist/ui/index.js.map +1 -1
- package/dist/worker/index.cjs +759 -146
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +7 -4
- package/dist/worker/index.d.ts +7 -4
- package/dist/worker/index.js +758 -147
- package/dist/worker/index.js.map +1 -1
- package/package.json +6 -1
package/dist/worker/index.cjs
CHANGED
|
@@ -23,8 +23,9 @@ __export(worker_exports, {
|
|
|
23
23
|
chapterWorker: () => chapterWorker
|
|
24
24
|
});
|
|
25
25
|
module.exports = __toCommonJS(worker_exports);
|
|
26
|
+
|
|
27
|
+
// src/worker-context.ts
|
|
26
28
|
var import_db = require("@odla-ai/db");
|
|
27
|
-
var import_crm2 = require("@odla-ai/crm");
|
|
28
29
|
var import_jose = require("jose");
|
|
29
30
|
|
|
30
31
|
// src/auth.ts
|
|
@@ -44,6 +45,81 @@ async function getVaultSecret(db, name) {
|
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// src/worker-context.ts
|
|
49
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
50
|
+
function createWorkerContext(options) {
|
|
51
|
+
const { chapter } = options;
|
|
52
|
+
const auth = chapter.auth;
|
|
53
|
+
const crmBase = options.crmBasePath ?? "/api/crm";
|
|
54
|
+
let publicConfigCache = null;
|
|
55
|
+
const jwksByIssuer = /* @__PURE__ */ new Map();
|
|
56
|
+
async function getPublicConfig(env) {
|
|
57
|
+
if (publicConfigCache && Date.now() - publicConfigCache.at < 5 * 6e4) return publicConfigCache.value;
|
|
58
|
+
const res = await fetch(`${env.ODLA_PLATFORM}/registry/apps/${env.ODLA_APP_ID}/public-config?env=${env.ODLA_ENV}`);
|
|
59
|
+
if (!res.ok) throw new Error(`public-config fetch failed: ${res.status}`);
|
|
60
|
+
const value = await res.json();
|
|
61
|
+
publicConfigCache = { value, at: Date.now() };
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
async function verifyUser(req, env) {
|
|
65
|
+
const header = req.headers.get("authorization") ?? "";
|
|
66
|
+
if (!header.startsWith("Bearer ")) return null;
|
|
67
|
+
const token = header.slice(7);
|
|
68
|
+
const { issuer } = await getPublicConfig(env);
|
|
69
|
+
if (!issuer) return null;
|
|
70
|
+
let jwks = jwksByIssuer.get(issuer);
|
|
71
|
+
if (!jwks) {
|
|
72
|
+
jwks = (0, import_jose.createRemoteJWKSet)(new URL(`${issuer}/.well-known/jwks.json`));
|
|
73
|
+
jwksByIssuer.set(issuer, jwks);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const { payload } = await (0, import_jose.jwtVerify)(token, jwks, { issuer });
|
|
77
|
+
if (!payload.sub) return null;
|
|
78
|
+
return {
|
|
79
|
+
userId: payload.sub,
|
|
80
|
+
email: typeof payload.email === "string" ? payload.email : void 0,
|
|
81
|
+
payload
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function makeDb(env) {
|
|
88
|
+
return (0, import_db.initAdmin)({ appId: env.ODLA_TENANT, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_ENDPOINT });
|
|
89
|
+
}
|
|
90
|
+
async function isAdminEmail(db, email) {
|
|
91
|
+
if (!email) return false;
|
|
92
|
+
const { admins } = await db.query({ admins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
|
|
93
|
+
return Array.isArray(admins) && admins.length > 0;
|
|
94
|
+
}
|
|
95
|
+
async function isSuperAdminEmail(db, email) {
|
|
96
|
+
if (!auth.superAdmins || !email) return false;
|
|
97
|
+
const { superAdmins } = await db.query({ superAdmins: { $: { where: { email: email.toLowerCase() }, limit: 1 } } });
|
|
98
|
+
return Array.isArray(superAdmins) && superAdmins.length > 0;
|
|
99
|
+
}
|
|
100
|
+
async function roleFor(db, u) {
|
|
101
|
+
if (auth.source === "claim") return roleFromClaim(u.payload, auth);
|
|
102
|
+
return await isAdminEmail(db, u.email) ? auth.adminRole : auth.ladder[0];
|
|
103
|
+
}
|
|
104
|
+
async function isAdmin(db, u) {
|
|
105
|
+
if (auth.source === "claim") return isAdminRole(roleFromClaim(u.payload, auth), auth);
|
|
106
|
+
return isAdminEmail(db, u.email);
|
|
107
|
+
}
|
|
108
|
+
function crmSender(env) {
|
|
109
|
+
if (!env.SEND_EMAIL || !env.EMAIL_FROM) return void 0;
|
|
110
|
+
const binding = env.SEND_EMAIL;
|
|
111
|
+
return {
|
|
112
|
+
async send(payload) {
|
|
113
|
+
return binding.send(payload);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { chapter, auth, crmBase, getPublicConfig, verifyUser, makeDb, isAdminEmail, isSuperAdminEmail, roleFor, isAdmin, crmSender };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/worker-routes.ts
|
|
121
|
+
var import_crm2 = require("@odla-ai/crm");
|
|
122
|
+
|
|
47
123
|
// src/member.ts
|
|
48
124
|
async function submitApplication(db, chapter, fields, opts) {
|
|
49
125
|
const app = chapter.application;
|
|
@@ -113,170 +189,707 @@ async function projectSharedRecord(deps, person) {
|
|
|
113
189
|
return { recordId: created.id };
|
|
114
190
|
}
|
|
115
191
|
|
|
116
|
-
// src/
|
|
117
|
-
var
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return
|
|
192
|
+
// src/scheduling.ts
|
|
193
|
+
var SCHEDULING_DEFAULTS = {
|
|
194
|
+
slotMinutes: 45,
|
|
195
|
+
days: [1, 2, 3, 4, 5],
|
|
196
|
+
startHour: 9,
|
|
197
|
+
endHour: 17,
|
|
198
|
+
timezone: "America/Los_Angeles",
|
|
199
|
+
minNoticeHours: 24,
|
|
200
|
+
windowDays: 14,
|
|
201
|
+
summaryTemplate: "Introduction call with {{firstName}} {{lastName}}"
|
|
202
|
+
};
|
|
203
|
+
function isValidTimeZone(tz) {
|
|
204
|
+
try {
|
|
205
|
+
new Intl.DateTimeFormat(void 0, { timeZone: tz });
|
|
206
|
+
return true;
|
|
207
|
+
} catch {
|
|
208
|
+
return false;
|
|
133
209
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
210
|
+
}
|
|
211
|
+
function resolveScheduling(config) {
|
|
212
|
+
const d = config ?? {};
|
|
213
|
+
const c = {
|
|
214
|
+
slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
|
|
215
|
+
days: d.days ?? SCHEDULING_DEFAULTS.days,
|
|
216
|
+
startHour: d.startHour ?? SCHEDULING_DEFAULTS.startHour,
|
|
217
|
+
endHour: d.endHour ?? SCHEDULING_DEFAULTS.endHour,
|
|
218
|
+
timezone: d.timezone ?? SCHEDULING_DEFAULTS.timezone,
|
|
219
|
+
minNoticeHours: d.minNoticeHours ?? SCHEDULING_DEFAULTS.minNoticeHours,
|
|
220
|
+
windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
|
|
221
|
+
summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
|
|
222
|
+
};
|
|
223
|
+
const fail2 = (msg) => {
|
|
224
|
+
throw new Error(`scheduling: ${msg}`);
|
|
225
|
+
};
|
|
226
|
+
if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) fail2("slotMinutes must be 15\u2013240");
|
|
227
|
+
if (!(c.windowDays >= 1 && c.windowDays <= 62)) fail2("windowDays must be 1\u201362 (FreeBusy caps at 62)");
|
|
228
|
+
if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) fail2("minNoticeHours must be 0\u2013336");
|
|
229
|
+
if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) fail2("require 0 \u2264 startHour < endHour \u2264 24");
|
|
230
|
+
const days = [...c.days];
|
|
231
|
+
if (!days.length || !days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
|
|
232
|
+
fail2("days must be a non-empty list of weekday integers 0\u20136");
|
|
233
|
+
}
|
|
234
|
+
if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) fail2(`invalid IANA timezone "${c.timezone}"`);
|
|
235
|
+
if (typeof c.summaryTemplate !== "string") fail2("summaryTemplate must be a string");
|
|
236
|
+
return { ...c, days };
|
|
237
|
+
}
|
|
238
|
+
var BOOKABLE_STATUSES = ["submitted", "paid_pending_vetting", "call_scheduled"];
|
|
239
|
+
function canBookFrom(status) {
|
|
240
|
+
return BOOKABLE_STATUSES.includes(status);
|
|
241
|
+
}
|
|
242
|
+
function slotWindow(now, windowDays) {
|
|
243
|
+
return { from: now, to: now + windowDays * 864e5 };
|
|
244
|
+
}
|
|
245
|
+
function endForSlot(startAt, slotMinutes) {
|
|
246
|
+
return startAt + slotMinutes * 6e4;
|
|
247
|
+
}
|
|
248
|
+
function isSlotAvailable(slots, startAt) {
|
|
249
|
+
return slots.some((s) => s.startAt === startAt);
|
|
250
|
+
}
|
|
251
|
+
function renderSummary(template, app) {
|
|
252
|
+
return template.replace("{{firstName}}", app.firstName ?? "").replace("{{lastName}}", app.lastName ?? "");
|
|
253
|
+
}
|
|
254
|
+
function bookingDecision(existing) {
|
|
255
|
+
const eventId = existing?.googleEventId ?? null;
|
|
256
|
+
return { reschedule: Boolean(eventId), eventId };
|
|
257
|
+
}
|
|
258
|
+
function introIdempotencyKey(applicationId) {
|
|
259
|
+
return `application:${applicationId}:intro`;
|
|
260
|
+
}
|
|
261
|
+
function meetingCreateRow(i) {
|
|
262
|
+
return {
|
|
263
|
+
id: i.meetingId,
|
|
264
|
+
applicationId: i.applicationId,
|
|
265
|
+
groupId: i.groupId,
|
|
266
|
+
startAt: i.startAt,
|
|
267
|
+
endAt: i.endAt,
|
|
268
|
+
timezone: i.timezone,
|
|
269
|
+
status: "scheduled",
|
|
270
|
+
googleEventId: i.googleEventId,
|
|
271
|
+
...i.meetUrl ? { meetUrl: i.meetUrl } : {},
|
|
272
|
+
...i.htmlLink ? { htmlLink: i.htmlLink } : {},
|
|
273
|
+
drift: "none",
|
|
274
|
+
createdAt: i.createdAt
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function meetingRescheduleUpdate(startAt, endAt) {
|
|
278
|
+
return { startAt, endAt, drift: "none" };
|
|
279
|
+
}
|
|
280
|
+
function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
281
|
+
return {
|
|
282
|
+
meetingAt: startAt,
|
|
283
|
+
...htmlLink ? { meetingLink: htmlLink } : {},
|
|
284
|
+
...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/session.ts
|
|
289
|
+
function applicationSummary(app) {
|
|
290
|
+
return {
|
|
291
|
+
id: app.id,
|
|
292
|
+
firstName: app.firstName ?? null,
|
|
293
|
+
lastName: app.lastName ?? null,
|
|
294
|
+
email: app.email ?? null,
|
|
295
|
+
status: app.status,
|
|
296
|
+
createdAt: app.createdAt ?? null,
|
|
297
|
+
meetingLink: app.meetingLink ?? null,
|
|
298
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded",
|
|
299
|
+
renewalAt: app.renewalAt ?? null,
|
|
300
|
+
canceled: app.canceled === true
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
304
|
+
const summary = applicationSummary(app);
|
|
305
|
+
let meetingAt = app.meetingAt ?? null;
|
|
306
|
+
let meetUrl = null;
|
|
307
|
+
let timezone = defaultTimezone;
|
|
308
|
+
if (meeting) {
|
|
309
|
+
timezone = meeting.timezone ?? timezone;
|
|
310
|
+
if (meeting.status === "scheduled") {
|
|
311
|
+
meetingAt = meeting.startAt ?? null;
|
|
312
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
313
|
+
} else {
|
|
314
|
+
meetingAt = null;
|
|
144
315
|
}
|
|
316
|
+
}
|
|
317
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/worker-routes.ts
|
|
321
|
+
async function memberSessionApplication(db, chapterId, email) {
|
|
322
|
+
const apps = (await db.query({ applications: { $: { where: { email }, order: { createdAt: "desc" }, limit: 1 } } })).applications;
|
|
323
|
+
const app = Array.isArray(apps) ? apps[0] : void 0;
|
|
324
|
+
if (!app) return null;
|
|
325
|
+
const meetings = (await db.query({ meetings: { $: { where: { applicationId: app.id, status: "scheduled" }, order: { createdAt: "desc" }, limit: 1 } } })).meetings;
|
|
326
|
+
const meeting = Array.isArray(meetings) ? meetings[0] : void 0;
|
|
327
|
+
const groups = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups;
|
|
328
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
329
|
+
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
330
|
+
return memberApplication(app, meeting, timezone);
|
|
331
|
+
}
|
|
332
|
+
var handleConfig = async (_req, url, env, ctx) => {
|
|
333
|
+
if (url.pathname !== "/api/config") return null;
|
|
334
|
+
try {
|
|
335
|
+
const { clerkPublishableKey } = await ctx.getPublicConfig(env);
|
|
336
|
+
return json({ clerkPublishableKey: clerkPublishableKey ?? null, env: env.ODLA_ENV });
|
|
337
|
+
} catch {
|
|
338
|
+
return json({ clerkPublishableKey: null, env: env.ODLA_ENV });
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
var handleMe = async (req, url, env, ctx) => {
|
|
342
|
+
if (url.pathname !== "/api/me") return null;
|
|
343
|
+
const u = await ctx.verifyUser(req, env);
|
|
344
|
+
if (!u) return json({ authorized: false }, 401);
|
|
345
|
+
const db = ctx.makeDb(env);
|
|
346
|
+
const role = await ctx.roleFor(db, u);
|
|
347
|
+
const superAdmin = await ctx.isSuperAdminEmail(db, u.email);
|
|
348
|
+
const base = { authorized: isAdminRole(role, ctx.auth), role, superAdmin, email: u.email ?? null };
|
|
349
|
+
if (ctx.chapter.mode !== "chapter" || !u.email) return json(base);
|
|
350
|
+
const application = await memberSessionApplication(db, ctx.chapter.id, u.email);
|
|
351
|
+
return json({ ...base, application });
|
|
352
|
+
};
|
|
353
|
+
var handleCrm = async (req, url, env, ctx) => {
|
|
354
|
+
const crmBase = ctx.crmBase;
|
|
355
|
+
if (url.pathname !== crmBase && !url.pathname.startsWith(crmBase + "/")) return null;
|
|
356
|
+
const db = ctx.makeDb(env);
|
|
357
|
+
const routes = (0, import_crm2.createCrmRoutes)({
|
|
358
|
+
crm: ctx.chapter.crm,
|
|
359
|
+
db,
|
|
360
|
+
authorize: async (r) => {
|
|
361
|
+
const u = await ctx.verifyUser(r, env);
|
|
362
|
+
if (!u || !await ctx.isAdmin(db, u)) return null;
|
|
363
|
+
return u.email ? { userId: u.userId, email: u.email } : { userId: u.userId };
|
|
364
|
+
},
|
|
365
|
+
sender: ctx.crmSender(env),
|
|
366
|
+
from: env.EMAIL_FROM,
|
|
367
|
+
envName: env.ODLA_ENV,
|
|
368
|
+
baseUrl: url.origin,
|
|
369
|
+
basePath: crmBase
|
|
370
|
+
});
|
|
371
|
+
const res = await routes(req);
|
|
372
|
+
if (res) return res;
|
|
373
|
+
return json({ error: "not found" }, 404);
|
|
374
|
+
};
|
|
375
|
+
var handleNetworkShared = async (req, url, env, ctx) => {
|
|
376
|
+
if (req.method !== "POST" || url.pathname !== "/api/network/shared") return null;
|
|
377
|
+
const db = ctx.makeDb(env);
|
|
378
|
+
const secret = await getVaultSecret(db, "network_share_secret");
|
|
379
|
+
const provided = (req.headers.get("authorization") ?? "").replace(/^Bearer /, "");
|
|
380
|
+
if (!secret || provided.length !== secret.length || provided !== secret) {
|
|
381
|
+
return json({ error: "unauthorized" }, 401);
|
|
382
|
+
}
|
|
383
|
+
let person;
|
|
384
|
+
try {
|
|
385
|
+
person = JSON.parse(await req.text());
|
|
386
|
+
} catch {
|
|
387
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
388
|
+
}
|
|
389
|
+
if (typeof person.email !== "string" || typeof person.hubRecordId !== "string") {
|
|
390
|
+
return json({ error: "email and hubRecordId are required" }, 400);
|
|
391
|
+
}
|
|
392
|
+
const { recordId } = await projectSharedRecord(
|
|
393
|
+
{ crm: ctx.chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
394
|
+
person
|
|
395
|
+
);
|
|
396
|
+
return json({ recordId });
|
|
397
|
+
};
|
|
398
|
+
var handleMember = async (req, url, env, ctx) => {
|
|
399
|
+
const chapter = ctx.chapter;
|
|
400
|
+
if (chapter.mode !== "chapter") return null;
|
|
401
|
+
if (req.method === "GET" && url.pathname === "/api/join-config") {
|
|
402
|
+
const db = ctx.makeDb(env);
|
|
403
|
+
const groupId = url.searchParams.get("group") ?? chapter.id;
|
|
404
|
+
const { groups } = await db.query({ groups: { $: { where: { id: groupId }, limit: 1 } } });
|
|
405
|
+
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
406
|
+
if (!group) return json({ error: "not found" }, 404);
|
|
407
|
+
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
408
|
+
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
409
|
+
return json(joinConfig(group, paymentsReady));
|
|
410
|
+
}
|
|
411
|
+
if (req.method === "POST" && url.pathname === "/api/applications") {
|
|
412
|
+
const raw = await req.text();
|
|
413
|
+
if (raw.length > chapter.application.bodyCap) return json({ error: "request body too large" }, 413);
|
|
414
|
+
let parsed;
|
|
145
415
|
try {
|
|
146
|
-
|
|
147
|
-
if (!payload.sub) return null;
|
|
148
|
-
return {
|
|
149
|
-
userId: payload.sub,
|
|
150
|
-
email: typeof payload.email === "string" ? payload.email : void 0,
|
|
151
|
-
payload
|
|
152
|
-
};
|
|
416
|
+
parsed = JSON.parse(raw);
|
|
153
417
|
} catch {
|
|
154
|
-
return
|
|
418
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
155
419
|
}
|
|
420
|
+
const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
|
|
421
|
+
const result = await submitApplication(ctx.makeDb(env), chapter, parsed, {
|
|
422
|
+
submissionId,
|
|
423
|
+
groupId: chapter.id,
|
|
424
|
+
now: Date.now(),
|
|
425
|
+
newId: () => crypto.randomUUID()
|
|
426
|
+
});
|
|
427
|
+
if (!result.ok) return json({ error: result.error }, 400);
|
|
428
|
+
return json({ id: result.id, duplicate: result.duplicate, status: result.status });
|
|
156
429
|
}
|
|
157
|
-
|
|
158
|
-
|
|
430
|
+
return null;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// src/worker-routes-schedule.ts
|
|
434
|
+
var import_calendar = require("@odla-ai/calendar");
|
|
435
|
+
function errCode(err) {
|
|
436
|
+
if (err && typeof err === "object") {
|
|
437
|
+
const code = err.code;
|
|
438
|
+
if (typeof code === "string") return code;
|
|
159
439
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
440
|
+
return "calendar_unavailable";
|
|
441
|
+
}
|
|
442
|
+
function makeCalendar(env) {
|
|
443
|
+
return (0, import_calendar.initCalendar)({ appId: env.ODLA_APP_ID, env: env.ODLA_ENV, adminToken: env.ODLA_API_KEY, endpoint: env.ODLA_PLATFORM });
|
|
444
|
+
}
|
|
445
|
+
async function firstRow(db, ns, q) {
|
|
446
|
+
const res = await db.query({ [ns]: { $: q } });
|
|
447
|
+
const rows = res[ns];
|
|
448
|
+
return Array.isArray(rows) ? rows[0] : void 0;
|
|
449
|
+
}
|
|
450
|
+
async function computeSlots(cal, cfg) {
|
|
451
|
+
const { from, to } = slotWindow(Date.now(), cfg.windowDays);
|
|
452
|
+
const fb = await cal.availability.freeBusy({ timeMin: from, timeMax: to });
|
|
453
|
+
return (0, import_calendar.computeBookableSlots)(fb.busy, {
|
|
454
|
+
from: fb.timeMin,
|
|
455
|
+
to: fb.timeMax,
|
|
456
|
+
timezone: cfg.timezone,
|
|
457
|
+
slotMinutes: cfg.slotMinutes,
|
|
458
|
+
businessHours: { days: [...cfg.days], startHour: cfg.startHour, endHour: cfg.endHour },
|
|
459
|
+
minNoticeMs: cfg.minNoticeHours * 36e5
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
async function bookSlot(req, env, ctx) {
|
|
463
|
+
let body;
|
|
464
|
+
try {
|
|
465
|
+
body = JSON.parse(await req.text());
|
|
466
|
+
} catch {
|
|
467
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
164
468
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
469
|
+
const applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
|
|
470
|
+
const startAt = Number(body.startAt);
|
|
471
|
+
if (!applicationId || !Number.isFinite(startAt)) return json({ error: "applicationId and startAt required" }, 400);
|
|
472
|
+
const db = ctx.makeDb(env);
|
|
473
|
+
const app = await firstRow(db, "applications", { where: { id: applicationId }, limit: 1 });
|
|
474
|
+
if (!app) return json({ error: "not found" }, 404);
|
|
475
|
+
const status = String(app.status ?? "");
|
|
476
|
+
if (!canBookFrom(status)) return json({ error: `cannot book from status "${status}"` }, 409);
|
|
477
|
+
const group = await firstRow(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
478
|
+
if (!group) return json({ error: "group not found" }, 500);
|
|
479
|
+
const cfg = resolveScheduling(group.schedulingJson);
|
|
480
|
+
const endAt = endForSlot(startAt, cfg.slotMinutes);
|
|
481
|
+
const cal = makeCalendar(env);
|
|
482
|
+
let slots;
|
|
483
|
+
try {
|
|
484
|
+
slots = await computeSlots(cal, cfg);
|
|
485
|
+
} catch (err) {
|
|
486
|
+
return json({ error: "scheduling unavailable", code: errCode(err) }, 503);
|
|
169
487
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
488
|
+
if (!isSlotAvailable(slots, startAt)) return json({ error: "slot no longer available", code: "calendar_slot_unavailable" }, 409);
|
|
489
|
+
const summary = renderSummary(cfg.summaryTemplate, { firstName: app.firstName, lastName: app.lastName });
|
|
490
|
+
const existing = await firstRow(db, "meetings", {
|
|
491
|
+
where: { applicationId, status: "scheduled" },
|
|
492
|
+
order: { createdAt: "desc" },
|
|
493
|
+
limit: 1
|
|
494
|
+
});
|
|
495
|
+
const decision = bookingDecision(existing);
|
|
496
|
+
let meetUrl = null;
|
|
497
|
+
let htmlLink = null;
|
|
498
|
+
let meetingOp;
|
|
499
|
+
try {
|
|
500
|
+
if (decision.reschedule && decision.eventId) {
|
|
501
|
+
await cal.actions.reschedule(decision.eventId, { startAt, endAt });
|
|
502
|
+
meetUrl = existing?.meetUrl ?? null;
|
|
503
|
+
htmlLink = existing?.htmlLink ?? null;
|
|
504
|
+
meetingOp = { t: "update", ns: "meetings", id: String(existing?.id), attrs: meetingRescheduleUpdate(startAt, endAt) };
|
|
505
|
+
} else {
|
|
506
|
+
const { booking } = await cal.actions.create(
|
|
507
|
+
{ summary, startAt, endAt, attendees: [String(app.email)], timezone: cfg.timezone, meet: true },
|
|
508
|
+
{ idempotencyKey: introIdempotencyKey(applicationId) }
|
|
509
|
+
);
|
|
510
|
+
meetUrl = booking.meetUrl ?? null;
|
|
511
|
+
htmlLink = booking.htmlLink ?? null;
|
|
512
|
+
const meetingId = crypto.randomUUID();
|
|
513
|
+
meetingOp = {
|
|
514
|
+
t: "update",
|
|
515
|
+
ns: "meetings",
|
|
516
|
+
id: meetingId,
|
|
517
|
+
attrs: meetingCreateRow({
|
|
518
|
+
meetingId,
|
|
519
|
+
applicationId,
|
|
520
|
+
groupId: String(group.id),
|
|
521
|
+
startAt,
|
|
522
|
+
endAt,
|
|
523
|
+
timezone: cfg.timezone,
|
|
524
|
+
googleEventId: booking.eventId,
|
|
525
|
+
meetUrl: booking.meetUrl,
|
|
526
|
+
htmlLink: booking.htmlLink,
|
|
527
|
+
createdAt: Date.now()
|
|
528
|
+
})
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
} catch (err) {
|
|
532
|
+
const code = errCode(err);
|
|
533
|
+
if (code === "calendar_slot_unavailable") return json({ error: "slot no longer available", code }, 409);
|
|
534
|
+
return json({ error: "booking failed", code }, 502);
|
|
173
535
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
536
|
+
const appOp = { t: "update", ns: "applications", id: applicationId, attrs: applicationBookingUpdate(status, startAt, htmlLink) };
|
|
537
|
+
await db.transact([meetingOp, appOp]);
|
|
538
|
+
return json({ ok: true, startAt, endAt, meetUrl, rescheduled: decision.reschedule });
|
|
539
|
+
}
|
|
540
|
+
var handleSchedule = async (req, url, env, ctx) => {
|
|
541
|
+
if (ctx.chapter.mode !== "chapter") return null;
|
|
542
|
+
if (req.method === "GET" && url.pathname === "/api/schedule/slots") {
|
|
543
|
+
const db = ctx.makeDb(env);
|
|
544
|
+
const group = await firstRow(db, "groups", { where: { id: url.searchParams.get("group") ?? ctx.chapter.id }, limit: 1 });
|
|
545
|
+
if (!group) return json({ error: "not found" }, 404);
|
|
546
|
+
const cfg = resolveScheduling(group.schedulingJson);
|
|
547
|
+
try {
|
|
548
|
+
const slots = await computeSlots(makeCalendar(env), cfg);
|
|
549
|
+
return json({ schedulingReady: true, timezone: cfg.timezone, slotMinutes: cfg.slotMinutes, slots });
|
|
550
|
+
} catch (err) {
|
|
551
|
+
return json({ schedulingReady: false, code: errCode(err) });
|
|
552
|
+
}
|
|
177
553
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const binding = env.SEND_EMAIL;
|
|
181
|
-
return { async send(payload) {
|
|
182
|
-
return binding.send(payload);
|
|
183
|
-
} };
|
|
554
|
+
if (req.method === "POST" && url.pathname === "/api/schedule/book") {
|
|
555
|
+
return bookSlot(req, env, ctx);
|
|
184
556
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
557
|
+
return null;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/payments.ts
|
|
561
|
+
function parseSigHeader(header) {
|
|
562
|
+
const parts = {};
|
|
563
|
+
for (const p of header.split(",")) {
|
|
564
|
+
const [k, v] = p.split("=", 2);
|
|
565
|
+
if (k && v !== void 0) parts[k] = v;
|
|
566
|
+
}
|
|
567
|
+
return { t: parts.t, v1: parts.v1 };
|
|
568
|
+
}
|
|
569
|
+
function toHex(buf) {
|
|
570
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
571
|
+
}
|
|
572
|
+
function timingSafeEqual(a, b) {
|
|
573
|
+
if (a.length !== b.length) return false;
|
|
574
|
+
let diff = 0;
|
|
575
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
576
|
+
return diff === 0;
|
|
577
|
+
}
|
|
578
|
+
async function verifyStripeSignature(payload, header, secret, opts = {}) {
|
|
579
|
+
const { t, v1 } = parseSigHeader(header);
|
|
580
|
+
if (!t || !v1) return false;
|
|
581
|
+
const ts = Number(t);
|
|
582
|
+
if (!Number.isFinite(ts)) return false;
|
|
583
|
+
const nowSec = (opts.now ?? Date.now()) / 1e3;
|
|
584
|
+
const tolerance = opts.toleranceSec ?? 300;
|
|
585
|
+
if (Math.abs(nowSec - ts) > tolerance) return false;
|
|
586
|
+
const enc = new TextEncoder();
|
|
587
|
+
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
588
|
+
const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${t}.${payload}`));
|
|
589
|
+
return timingSafeEqual(toHex(mac), v1);
|
|
590
|
+
}
|
|
591
|
+
function stripeForm(params) {
|
|
592
|
+
const out = new URLSearchParams();
|
|
593
|
+
for (const [k, v] of Object.entries(params)) {
|
|
594
|
+
if (v === void 0 || v === null) continue;
|
|
595
|
+
if (typeof v === "object") {
|
|
596
|
+
for (const [k2, v2] of Object.entries(v)) {
|
|
597
|
+
if (v2 !== void 0 && v2 !== null) out.append(`${k}[${k2}]`, String(v2));
|
|
195
598
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
599
|
+
} else {
|
|
600
|
+
out.append(k, String(v));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return out.toString();
|
|
604
|
+
}
|
|
605
|
+
function webhookMutationId(eventId) {
|
|
606
|
+
return `stripe:${eventId}`;
|
|
607
|
+
}
|
|
608
|
+
function findApplicationRef(obj) {
|
|
609
|
+
const metaOf = (v) => v && typeof v === "object" ? v.metadata ?? {} : {};
|
|
610
|
+
const pick = (m) => typeof m.applicationId === "string" ? m.applicationId : void 0;
|
|
611
|
+
const applicationId = pick(metaOf(obj)) ?? pick(metaOf(obj.subscription_details)) ?? pick(metaOf(obj.parent?.subscription_details));
|
|
612
|
+
const customerId = typeof obj.customer === "string" ? obj.customer : void 0;
|
|
613
|
+
return { ...applicationId ? { applicationId } : {}, ...customerId ? { customerId } : {} };
|
|
614
|
+
}
|
|
615
|
+
function normalizeWebhookEvent(event) {
|
|
616
|
+
const obj = event.data?.object ?? {};
|
|
617
|
+
const ref = findApplicationRef(obj);
|
|
618
|
+
switch (event.type) {
|
|
619
|
+
case "invoice.paid": {
|
|
620
|
+
const lines = obj.lines?.data ?? [];
|
|
621
|
+
const periodEnd = lines[0]?.period?.end;
|
|
622
|
+
const renewalAt = typeof periodEnd === "number" ? periodEnd * 1e3 : void 0;
|
|
623
|
+
const kind = obj.billing_reason === "subscription_create" ? "first_payment" : "renewal";
|
|
624
|
+
return { kind, ...ref, ...renewalAt !== void 0 ? { renewalAt } : {} };
|
|
625
|
+
}
|
|
626
|
+
case "charge.refunded":
|
|
627
|
+
return { kind: "refunded", ...ref };
|
|
628
|
+
case "customer.subscription.deleted":
|
|
629
|
+
return { kind: "canceled", ...ref };
|
|
630
|
+
default:
|
|
631
|
+
return { kind: "ignored", type: event.type };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function firstPaymentPatch(currentStatus, renewalAt) {
|
|
635
|
+
return {
|
|
636
|
+
...currentStatus === "submitted" ? { status: "paid_pending_vetting" } : {},
|
|
637
|
+
...renewalAt !== void 0 ? { renewalAt } : {}
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function renewalPatch(renewalAt) {
|
|
641
|
+
return { renewalAt };
|
|
642
|
+
}
|
|
643
|
+
function refundedPatch() {
|
|
644
|
+
return { status: "refunded" };
|
|
645
|
+
}
|
|
646
|
+
function canceledPatch() {
|
|
647
|
+
return { canceled: true };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// src/payments-stripe.ts
|
|
651
|
+
async function stripeCall(sk, method, path, params, idempotencyKey) {
|
|
652
|
+
const qs = method === "GET" && params ? `?${stripeForm(params)}` : "";
|
|
653
|
+
const headers = { authorization: `Bearer ${sk}` };
|
|
654
|
+
if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
|
|
655
|
+
const init = { method, headers };
|
|
656
|
+
if (method === "POST" && params) {
|
|
657
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
658
|
+
init.body = stripeForm(params);
|
|
659
|
+
}
|
|
660
|
+
const res = await fetch(`https://api.stripe.com${path}${qs}`, init);
|
|
661
|
+
const body = await res.json().catch(() => ({}));
|
|
662
|
+
return { ok: res.ok, status: res.status, body };
|
|
663
|
+
}
|
|
664
|
+
function fail(op, r) {
|
|
665
|
+
const err = new Error(`stripe ${op} failed: ${r.status}`);
|
|
666
|
+
err.code = "stripe_error";
|
|
667
|
+
throw err;
|
|
668
|
+
}
|
|
669
|
+
function clientSecretOf(sub) {
|
|
670
|
+
const inv = sub.latest_invoice;
|
|
671
|
+
const confirmation = inv?.confirmation_secret;
|
|
672
|
+
const intent = inv?.payment_intent;
|
|
673
|
+
const secret = confirmation?.client_secret ?? intent?.client_secret;
|
|
674
|
+
return typeof secret === "string" ? secret : void 0;
|
|
675
|
+
}
|
|
676
|
+
function requireSecret(secretKey) {
|
|
677
|
+
if (!secretKey) {
|
|
678
|
+
const err = new Error("stripe secret key missing");
|
|
679
|
+
err.code = "not_configured";
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
682
|
+
return secretKey;
|
|
683
|
+
}
|
|
684
|
+
function createStripeProvider(config) {
|
|
685
|
+
const { secretKey, webhookSecret } = config;
|
|
686
|
+
return {
|
|
687
|
+
async createSubscription(input) {
|
|
688
|
+
const sk = requireSecret(secretKey);
|
|
689
|
+
const meta = { applicationId: input.applicationId, groupId: input.groupId, email: input.email };
|
|
690
|
+
let customerId = input.existingCustomerId;
|
|
691
|
+
if (!customerId) {
|
|
692
|
+
const cust = await stripeCall(
|
|
693
|
+
sk,
|
|
694
|
+
"POST",
|
|
695
|
+
"/v1/customers",
|
|
696
|
+
{ email: input.email, name: input.name, metadata: meta },
|
|
697
|
+
`cus:${input.applicationId}`
|
|
698
|
+
);
|
|
699
|
+
if (!cust.ok) fail("customer create", cust);
|
|
700
|
+
customerId = String(cust.body.id);
|
|
203
701
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
702
|
+
const sub = await stripeCall(
|
|
703
|
+
sk,
|
|
704
|
+
"POST",
|
|
705
|
+
"/v1/subscriptions",
|
|
706
|
+
{
|
|
707
|
+
customer: customerId,
|
|
708
|
+
"items[0][price]": input.priceId,
|
|
709
|
+
payment_behavior: "default_incomplete",
|
|
710
|
+
"payment_settings[save_default_payment_method]": "on_subscription",
|
|
711
|
+
"payment_settings[payment_method_types][0]": "card",
|
|
712
|
+
"expand[0]": "latest_invoice.confirmation_secret",
|
|
713
|
+
metadata: meta
|
|
714
|
+
},
|
|
715
|
+
// The hardening S&S lacked: one subscription per application, so a client
|
|
716
|
+
// retry between create and the db write can't orphan a second one.
|
|
717
|
+
`sub:${input.applicationId}`
|
|
718
|
+
);
|
|
719
|
+
if (!sub.ok) fail("subscription create", sub);
|
|
720
|
+
const clientSecret = clientSecretOf(sub.body);
|
|
721
|
+
if (!clientSecret) fail("subscription confirmation-secret missing", sub);
|
|
722
|
+
return { customerId, subscriptionId: String(sub.body.id), clientSecret };
|
|
723
|
+
},
|
|
724
|
+
async ingestWebhook(rawBody, sigHeader) {
|
|
725
|
+
if (!webhookSecret || !await verifyStripeSignature(rawBody, sigHeader, webhookSecret)) {
|
|
726
|
+
return { ok: false, reason: "bad_signature" };
|
|
223
727
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
return json({ error: "email and hubRecordId are required" }, 400);
|
|
239
|
-
}
|
|
240
|
-
const { recordId } = await projectSharedRecord(
|
|
241
|
-
{ crm: chapter.crm, db, now: () => Date.now(), newId: () => crypto.randomUUID() },
|
|
242
|
-
person
|
|
243
|
-
);
|
|
244
|
-
return json({ recordId });
|
|
728
|
+
const event = JSON.parse(rawBody);
|
|
729
|
+
return { ok: true, eventId: event.id, event: normalizeWebhookEvent(event) };
|
|
730
|
+
},
|
|
731
|
+
async refund(input) {
|
|
732
|
+
const sk = requireSecret(secretKey);
|
|
733
|
+
const charges = await stripeCall(sk, "GET", "/v1/charges", { customer: input.customerId, limit: 100 });
|
|
734
|
+
if (!charges.ok) fail("charges list", charges);
|
|
735
|
+
const rows = charges.body.data ?? [];
|
|
736
|
+
const paid = rows.filter((c) => c.status === "succeeded" && c.refunded !== true);
|
|
737
|
+
const charge = paid[paid.length - 1];
|
|
738
|
+
if (!charge) {
|
|
739
|
+
const err = new Error("no paid charge to refund");
|
|
740
|
+
err.code = "no_charge";
|
|
741
|
+
throw err;
|
|
245
742
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
743
|
+
const refund = await stripeCall(sk, "POST", "/v1/refunds", { charge: String(charge.id) });
|
|
744
|
+
if (!refund.ok) fail("refund", refund);
|
|
745
|
+
const cancel = await stripeCall(sk, "DELETE", `/v1/subscriptions/${input.subscriptionId}`);
|
|
746
|
+
const amount = refund.body.amount;
|
|
747
|
+
return { refundedCents: typeof amount === "number" ? amount : null, subscriptionCanceled: cancel.ok };
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/worker-routes-payments.ts
|
|
753
|
+
var codeOf = (err) => err && typeof err === "object" && typeof err.code === "string" ? err.code : "unknown";
|
|
754
|
+
async function firstRow2(db, ns, q) {
|
|
755
|
+
const res = await db.query({ [ns]: { $: q } });
|
|
756
|
+
const rows = res[ns];
|
|
757
|
+
return Array.isArray(rows) ? rows[0] : void 0;
|
|
758
|
+
}
|
|
759
|
+
function lineItems(group) {
|
|
760
|
+
const standard = Number(group.standardPriceCents ?? 0);
|
|
761
|
+
const discount = Number(group.foundingDiscountCents ?? 0);
|
|
762
|
+
return { standardCents: standard, discountCents: discount, dueTodayCents: standard - discount };
|
|
763
|
+
}
|
|
764
|
+
async function findApplication(db, event) {
|
|
765
|
+
if ("applicationId" in event && event.applicationId) {
|
|
766
|
+
return firstRow2(db, "applications", { where: { id: event.applicationId }, limit: 1 });
|
|
767
|
+
}
|
|
768
|
+
if ("customerId" in event && event.customerId) {
|
|
769
|
+
return firstRow2(db, "applications", { where: { stripeCustomerId: event.customerId }, order: { createdAt: "desc" }, limit: 1 });
|
|
770
|
+
}
|
|
771
|
+
return void 0;
|
|
772
|
+
}
|
|
773
|
+
function webhookPatch(event, status) {
|
|
774
|
+
switch (event.kind) {
|
|
775
|
+
case "first_payment":
|
|
776
|
+
return firstPaymentPatch(status, event.renewalAt);
|
|
777
|
+
case "renewal":
|
|
778
|
+
return event.renewalAt !== void 0 ? renewalPatch(event.renewalAt) : {};
|
|
779
|
+
case "refunded":
|
|
780
|
+
return refundedPatch();
|
|
781
|
+
case "canceled":
|
|
782
|
+
return canceledPatch();
|
|
783
|
+
default:
|
|
784
|
+
return {};
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
async function startSubscription(req, env, ctx) {
|
|
788
|
+
let body;
|
|
789
|
+
try {
|
|
790
|
+
body = JSON.parse(await req.text());
|
|
791
|
+
} catch {
|
|
792
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
793
|
+
}
|
|
794
|
+
const applicationId = typeof body.applicationId === "string" ? body.applicationId : "";
|
|
795
|
+
if (!applicationId) return json({ error: "applicationId required" }, 400);
|
|
796
|
+
if (body.refundPolicyAck !== true) return json({ error: "refundPolicyAck required" }, 400);
|
|
797
|
+
const db = ctx.makeDb(env);
|
|
798
|
+
const app = await firstRow2(db, "applications", { where: { id: applicationId }, limit: 1 });
|
|
799
|
+
if (!app) return json({ error: "not found" }, 404);
|
|
800
|
+
if (app.status !== "submitted") return json({ error: "already processed" }, 409);
|
|
801
|
+
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
802
|
+
const priceId = group?.stripePriceId;
|
|
803
|
+
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
804
|
+
if (!group || !priceId || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
805
|
+
const provider = createStripeProvider({ secretKey });
|
|
806
|
+
let result;
|
|
807
|
+
try {
|
|
808
|
+
result = await provider.createSubscription({
|
|
809
|
+
applicationId,
|
|
810
|
+
groupId: String(group.id),
|
|
811
|
+
email: String(app.email ?? ""),
|
|
812
|
+
name: `${app.firstName ?? ""} ${app.lastName ?? ""}`.trim(),
|
|
813
|
+
priceId: String(priceId),
|
|
814
|
+
existingCustomerId: typeof app.stripeCustomerId === "string" ? app.stripeCustomerId : void 0
|
|
815
|
+
});
|
|
816
|
+
} catch (err) {
|
|
817
|
+
return json({ error: "payment setup failed", code: codeOf(err) }, 502);
|
|
818
|
+
}
|
|
819
|
+
await db.transact([
|
|
820
|
+
{
|
|
821
|
+
t: "update",
|
|
822
|
+
ns: "applications",
|
|
823
|
+
id: applicationId,
|
|
824
|
+
attrs: { stripeCustomerId: result.customerId, stripeSubscriptionId: result.subscriptionId, refundPolicyAckAt: Date.now() }
|
|
825
|
+
}
|
|
826
|
+
]);
|
|
827
|
+
return json({ clientSecret: result.clientSecret, publishableKey: group.stripePublishableKey ?? null, lineItems: lineItems(group) });
|
|
828
|
+
}
|
|
829
|
+
async function ingestWebhook(req, env, ctx) {
|
|
830
|
+
const db = ctx.makeDb(env);
|
|
831
|
+
const webhookSecret = await getVaultSecret(db, "stripe_webhook_secret");
|
|
832
|
+
if (!webhookSecret) return json({ error: "webhook not configured" }, 503);
|
|
833
|
+
const rawBody = await req.text();
|
|
834
|
+
const ingest = await createStripeProvider({ webhookSecret }).ingestWebhook(rawBody, req.headers.get("stripe-signature") ?? "");
|
|
835
|
+
if (!ingest.ok) return json({ error: "invalid signature" }, 400);
|
|
836
|
+
const { eventId, event } = ingest;
|
|
837
|
+
if (event.kind === "ignored") return json({ ok: true, ignored: event.type });
|
|
838
|
+
const app = await findApplication(db, event);
|
|
839
|
+
if (!app) return json({ ok: true, matched: false });
|
|
840
|
+
const patch = webhookPatch(event, String(app.status ?? ""));
|
|
841
|
+
if (Object.keys(patch).length) {
|
|
842
|
+
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: patch }], { mutationId: webhookMutationId(eventId) });
|
|
843
|
+
}
|
|
844
|
+
return json({ ok: true });
|
|
845
|
+
}
|
|
846
|
+
async function refundApplication(req, url, env, ctx) {
|
|
847
|
+
const rawDb = ctx.makeDb(env);
|
|
848
|
+
const u = await ctx.verifyUser(req, env);
|
|
849
|
+
if (!u || !await ctx.isAdmin(rawDb, u)) return json({ error: "forbidden" }, 403);
|
|
850
|
+
const id = url.pathname.split("/")[4] ?? "";
|
|
851
|
+
const db = rawDb;
|
|
852
|
+
const app = await firstRow2(db, "applications", { where: { id }, limit: 1 });
|
|
853
|
+
if (!app) return json({ error: "not found" }, 404);
|
|
854
|
+
if (app.status === "refunded") return json({ error: "already refunded" }, 409);
|
|
855
|
+
if (app.status === "approved") return json({ error: "approved memberships are non-refundable" }, 409);
|
|
856
|
+
if (!app.stripeSubscriptionId) return json({ error: "no subscription on file" }, 409);
|
|
857
|
+
if (!app.stripeCustomerId) return json({ error: "no customer on file" }, 409);
|
|
858
|
+
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
859
|
+
if (!secretKey) return json({ error: "payments not configured" }, 503);
|
|
860
|
+
try {
|
|
861
|
+
const result = await createStripeProvider({ secretKey }).refund({
|
|
862
|
+
customerId: String(app.stripeCustomerId),
|
|
863
|
+
subscriptionId: String(app.stripeSubscriptionId)
|
|
864
|
+
});
|
|
865
|
+
return json({ ok: true, refundedCents: result.refundedCents, subscriptionCanceled: result.subscriptionCanceled });
|
|
866
|
+
} catch (err) {
|
|
867
|
+
const code = codeOf(err);
|
|
868
|
+
return json({ error: "refund failed", code }, code === "no_charge" ? 409 : 502);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
var REFUND_PATH = /^\/api\/admin\/applications\/[^/]+\/refund$/;
|
|
872
|
+
var handlePayments = async (req, url, env, ctx) => {
|
|
873
|
+
if (ctx.chapter.mode !== "chapter") return null;
|
|
874
|
+
if (req.method === "POST" && url.pathname === "/api/payments/subscription") return startSubscription(req, env, ctx);
|
|
875
|
+
if (req.method === "POST" && url.pathname === "/api/webhooks/stripe") return ingestWebhook(req, env, ctx);
|
|
876
|
+
if (req.method === "POST" && REFUND_PATH.test(url.pathname)) return refundApplication(req, url, env, ctx);
|
|
877
|
+
return null;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
// src/worker.ts
|
|
881
|
+
var ROUTES = [handleConfig, handleMe, handleCrm, handleNetworkShared, handleMember, handleSchedule, handlePayments];
|
|
882
|
+
function chapterWorker(options) {
|
|
883
|
+
const ctx = createWorkerContext(options);
|
|
884
|
+
return {
|
|
885
|
+
async fetch(req, env) {
|
|
886
|
+
const url = new URL(req.url);
|
|
887
|
+
for (const route of ROUTES) {
|
|
888
|
+
const res = await route(req, url, env, ctx);
|
|
889
|
+
if (res) return res;
|
|
276
890
|
}
|
|
277
891
|
return env.ASSETS.fetch(req);
|
|
278
892
|
}
|
|
279
893
|
};
|
|
280
|
-
return handler;
|
|
281
894
|
}
|
|
282
895
|
//# sourceMappingURL=index.cjs.map
|