@odla-ai/chapter 0.30.0 → 0.31.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/chunk-IVXVECKR.js +15 -0
- package/dist/chunk-IVXVECKR.js.map +1 -0
- package/dist/{chunk-IOHVFBXH.js → chunk-QWMP6TSM.js} +75 -34
- package/dist/chunk-QWMP6TSM.js.map +1 -0
- package/dist/{copy-context-efEMF_aD.d.ts → copy-context-HAhNTZdl.d.ts} +54 -19
- package/dist/index.cjs +168 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +160 -21
- package/dist/index.d.ts +160 -21
- package/dist/index.js +150 -2
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +2 -2
- package/dist/ui/conditions/index.d.ts +29 -0
- package/dist/ui/conditions/index.js +32 -0
- package/dist/ui/conditions/index.js.map +1 -0
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +10 -3
- package/dist/ui/member/index.d.ts +62 -3
- package/dist/ui/member/index.js +10 -3
- package/dist/worker/index.cjs +146 -39
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +54 -19
- package/dist/worker/index.d.ts +54 -19
- package/dist/worker/index.js +115 -8
- package/dist/worker/index.js.map +1 -1
- package/package.json +6 -2
- package/dist/chunk-IOHVFBXH.js.map +0 -1
package/dist/worker/index.d.cts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
|
-
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
1
|
+
import { CrmConfig, Crm, FieldConditions } from '@odla-ai/crm';
|
|
2
2
|
import { initAdmin } from '@odla-ai/db';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A tier as declared in `defineChapter`, before it becomes a row.
|
|
6
|
+
*
|
|
7
|
+
* `groupId` is absent here because the chapter owns exactly one group at seed
|
|
8
|
+
* time; the seeder fills it in.
|
|
9
|
+
*/
|
|
10
|
+
interface ChapterTierConfig {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
priceCents: number;
|
|
14
|
+
stripePriceId?: string;
|
|
15
|
+
blurb?: string;
|
|
16
|
+
/** Defaults to declaration order when omitted. */
|
|
17
|
+
sortOrder?: number;
|
|
18
|
+
/** Defaults to true; set false to retire a tier without deleting it. */
|
|
19
|
+
active?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
type TextFields$1<Key extends string> = {
|
|
5
23
|
[Field in Key]: string;
|
|
6
24
|
};
|
|
@@ -147,6 +165,30 @@ interface ResolvedNetwork {
|
|
|
147
165
|
readers: readonly ResolvedNetworkReader[];
|
|
148
166
|
}
|
|
149
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Membership pricing for the group row (chapter mode).
|
|
170
|
+
*
|
|
171
|
+
* This is the single-price model that predates tiers. It still seeds the group
|
|
172
|
+
* row and remains the fallback a chapter reads when it declares no tiers, so
|
|
173
|
+
* existing chapters keep working; new work should prefer `tiers`.
|
|
174
|
+
*/
|
|
175
|
+
interface ChapterPrices {
|
|
176
|
+
standardCents: number;
|
|
177
|
+
foundingDiscountCents?: number;
|
|
178
|
+
/** ISO currency, default "usd". */
|
|
179
|
+
currency?: string;
|
|
180
|
+
/** Billing interval, default "year". */
|
|
181
|
+
interval?: "year" | "month";
|
|
182
|
+
}
|
|
183
|
+
/** Membership policy + compliance copy stored on the group row. */
|
|
184
|
+
interface ChapterPolicy {
|
|
185
|
+
disclaimerText?: string;
|
|
186
|
+
refundPolicyText?: string;
|
|
187
|
+
trustCopy?: string;
|
|
188
|
+
commitmentText?: string;
|
|
189
|
+
normsText?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
150
192
|
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
151
193
|
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
152
194
|
* admin-only and CRM-focused (a directory/registry over the same CRM). */
|
|
@@ -252,23 +294,7 @@ interface ChapterBrand {
|
|
|
252
294
|
}>>;
|
|
253
295
|
logos?: string;
|
|
254
296
|
}
|
|
255
|
-
|
|
256
|
-
interface ChapterPrices {
|
|
257
|
-
standardCents: number;
|
|
258
|
-
foundingDiscountCents?: number;
|
|
259
|
-
/** ISO currency, default "usd". */
|
|
260
|
-
currency?: string;
|
|
261
|
-
/** Billing interval, default "year". */
|
|
262
|
-
interval?: "year" | "month";
|
|
263
|
-
}
|
|
264
|
-
/** Membership policy + compliance copy stored on the group row. */
|
|
265
|
-
interface ChapterPolicy {
|
|
266
|
-
disclaimerText?: string;
|
|
267
|
-
refundPolicyText?: string;
|
|
268
|
-
trustCopy?: string;
|
|
269
|
-
commitmentText?: string;
|
|
270
|
-
normsText?: string;
|
|
271
|
-
}
|
|
297
|
+
|
|
272
298
|
/** One owner-editable transactional email template. */
|
|
273
299
|
interface EmailTemplate {
|
|
274
300
|
subject: string;
|
|
@@ -350,6 +376,11 @@ interface ResolvedPipeline {
|
|
|
350
376
|
interface ChapterApplication {
|
|
351
377
|
required?: readonly string[];
|
|
352
378
|
optional?: readonly string[];
|
|
379
|
+
/** Per-field CEL conditions — show a field, or demand it, based on the
|
|
380
|
+
* answers so far. Enforced server-side as well as rendered, so a hidden
|
|
381
|
+
* field is never demanded and a conditionally-required one cannot be
|
|
382
|
+
* skipped by posting directly. Validated when the chapter is defined. */
|
|
383
|
+
conditions?: Record<string, FieldConditions>;
|
|
353
384
|
/** Per-field character cap. Fields not listed use `defaultMaxLen`. */
|
|
354
385
|
maxLen?: Record<string, number>;
|
|
355
386
|
defaultMaxLen?: number;
|
|
@@ -390,6 +421,8 @@ interface ResolvedApplication {
|
|
|
390
421
|
crmFields: readonly string[];
|
|
391
422
|
maxArrayLen: number;
|
|
392
423
|
validateEmail: boolean;
|
|
424
|
+
/** Resolved per-field conditions. Empty means every field is unconditional. */
|
|
425
|
+
conditions: Record<string, FieldConditions>;
|
|
393
426
|
}
|
|
394
427
|
/** The `defineChapter()` config a site fills in. */
|
|
395
428
|
interface ChapterConfig {
|
|
@@ -411,8 +444,10 @@ interface ChapterConfig {
|
|
|
411
444
|
/** Enable a public, host-rendered formation application workflow. */
|
|
412
445
|
formation?: LeaderFormationConfig;
|
|
413
446
|
thesis?: unknown;
|
|
414
|
-
/** Required in `chapter` mode. */
|
|
447
|
+
/** Required in `chapter` mode, and the fallback when `tiers` is empty. */
|
|
415
448
|
prices?: ChapterPrices;
|
|
449
|
+
/** Membership tiers. A tier priced at 0 is free and never touches Stripe. */
|
|
450
|
+
tiers?: ChapterTierConfig[];
|
|
416
451
|
policy?: ChapterPolicy;
|
|
417
452
|
/** `notificationEmail` required in `chapter` mode. */
|
|
418
453
|
emails?: ChapterEmails;
|
package/dist/worker/index.d.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
|
-
import { CrmConfig, Crm } from '@odla-ai/crm';
|
|
1
|
+
import { CrmConfig, Crm, FieldConditions } from '@odla-ai/crm';
|
|
2
2
|
import { initAdmin } from '@odla-ai/db';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* A tier as declared in `defineChapter`, before it becomes a row.
|
|
6
|
+
*
|
|
7
|
+
* `groupId` is absent here because the chapter owns exactly one group at seed
|
|
8
|
+
* time; the seeder fills it in.
|
|
9
|
+
*/
|
|
10
|
+
interface ChapterTierConfig {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
priceCents: number;
|
|
14
|
+
stripePriceId?: string;
|
|
15
|
+
blurb?: string;
|
|
16
|
+
/** Defaults to declaration order when omitted. */
|
|
17
|
+
sortOrder?: number;
|
|
18
|
+
/** Defaults to true; set false to retire a tier without deleting it. */
|
|
19
|
+
active?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
type TextFields$1<Key extends string> = {
|
|
5
23
|
[Field in Key]: string;
|
|
6
24
|
};
|
|
@@ -147,6 +165,30 @@ interface ResolvedNetwork {
|
|
|
147
165
|
readers: readonly ResolvedNetworkReader[];
|
|
148
166
|
}
|
|
149
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Membership pricing for the group row (chapter mode).
|
|
170
|
+
*
|
|
171
|
+
* This is the single-price model that predates tiers. It still seeds the group
|
|
172
|
+
* row and remains the fallback a chapter reads when it declares no tiers, so
|
|
173
|
+
* existing chapters keep working; new work should prefer `tiers`.
|
|
174
|
+
*/
|
|
175
|
+
interface ChapterPrices {
|
|
176
|
+
standardCents: number;
|
|
177
|
+
foundingDiscountCents?: number;
|
|
178
|
+
/** ISO currency, default "usd". */
|
|
179
|
+
currency?: string;
|
|
180
|
+
/** Billing interval, default "year". */
|
|
181
|
+
interval?: "year" | "month";
|
|
182
|
+
}
|
|
183
|
+
/** Membership policy + compliance copy stored on the group row. */
|
|
184
|
+
interface ChapterPolicy {
|
|
185
|
+
disclaimerText?: string;
|
|
186
|
+
refundPolicyText?: string;
|
|
187
|
+
trustCopy?: string;
|
|
188
|
+
commitmentText?: string;
|
|
189
|
+
normsText?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
150
192
|
/** Which feature profile a site runs. `chapter` is the full public member site
|
|
151
193
|
* (join, Stripe membership, booking, member area, admin, CRM); `hub` is
|
|
152
194
|
* admin-only and CRM-focused (a directory/registry over the same CRM). */
|
|
@@ -252,23 +294,7 @@ interface ChapterBrand {
|
|
|
252
294
|
}>>;
|
|
253
295
|
logos?: string;
|
|
254
296
|
}
|
|
255
|
-
|
|
256
|
-
interface ChapterPrices {
|
|
257
|
-
standardCents: number;
|
|
258
|
-
foundingDiscountCents?: number;
|
|
259
|
-
/** ISO currency, default "usd". */
|
|
260
|
-
currency?: string;
|
|
261
|
-
/** Billing interval, default "year". */
|
|
262
|
-
interval?: "year" | "month";
|
|
263
|
-
}
|
|
264
|
-
/** Membership policy + compliance copy stored on the group row. */
|
|
265
|
-
interface ChapterPolicy {
|
|
266
|
-
disclaimerText?: string;
|
|
267
|
-
refundPolicyText?: string;
|
|
268
|
-
trustCopy?: string;
|
|
269
|
-
commitmentText?: string;
|
|
270
|
-
normsText?: string;
|
|
271
|
-
}
|
|
297
|
+
|
|
272
298
|
/** One owner-editable transactional email template. */
|
|
273
299
|
interface EmailTemplate {
|
|
274
300
|
subject: string;
|
|
@@ -350,6 +376,11 @@ interface ResolvedPipeline {
|
|
|
350
376
|
interface ChapterApplication {
|
|
351
377
|
required?: readonly string[];
|
|
352
378
|
optional?: readonly string[];
|
|
379
|
+
/** Per-field CEL conditions — show a field, or demand it, based on the
|
|
380
|
+
* answers so far. Enforced server-side as well as rendered, so a hidden
|
|
381
|
+
* field is never demanded and a conditionally-required one cannot be
|
|
382
|
+
* skipped by posting directly. Validated when the chapter is defined. */
|
|
383
|
+
conditions?: Record<string, FieldConditions>;
|
|
353
384
|
/** Per-field character cap. Fields not listed use `defaultMaxLen`. */
|
|
354
385
|
maxLen?: Record<string, number>;
|
|
355
386
|
defaultMaxLen?: number;
|
|
@@ -390,6 +421,8 @@ interface ResolvedApplication {
|
|
|
390
421
|
crmFields: readonly string[];
|
|
391
422
|
maxArrayLen: number;
|
|
392
423
|
validateEmail: boolean;
|
|
424
|
+
/** Resolved per-field conditions. Empty means every field is unconditional. */
|
|
425
|
+
conditions: Record<string, FieldConditions>;
|
|
393
426
|
}
|
|
394
427
|
/** The `defineChapter()` config a site fills in. */
|
|
395
428
|
interface ChapterConfig {
|
|
@@ -411,8 +444,10 @@ interface ChapterConfig {
|
|
|
411
444
|
/** Enable a public, host-rendered formation application workflow. */
|
|
412
445
|
formation?: LeaderFormationConfig;
|
|
413
446
|
thesis?: unknown;
|
|
414
|
-
/** Required in `chapter` mode. */
|
|
447
|
+
/** Required in `chapter` mode, and the fallback when `tiers` is empty. */
|
|
415
448
|
prices?: ChapterPrices;
|
|
449
|
+
/** Membership tiers. A tier priced at 0 is free and never touches Stripe. */
|
|
450
|
+
tiers?: ChapterTierConfig[];
|
|
416
451
|
policy?: ChapterPolicy;
|
|
417
452
|
/** `notificationEmail` required in `chapter` mode. */
|
|
418
453
|
emails?: ChapterEmails;
|
package/dist/worker/index.js
CHANGED
|
@@ -153,6 +153,9 @@ async function authorizeCrmDiscussionReference(req, env, fetcher = fetch) {
|
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
// src/member.ts
|
|
157
|
+
import { assertFieldCondition, resolveFieldStates } from "@odla-ai/crm";
|
|
158
|
+
|
|
156
159
|
// src/application-id.ts
|
|
157
160
|
var APPLICATION_ID_DOMAIN = "odla-ai/chapter/application/v1:";
|
|
158
161
|
async function applicationIdForSubmission(submissionId) {
|
|
@@ -191,7 +194,9 @@ function applicantProfile(chapter, fields) {
|
|
|
191
194
|
}
|
|
192
195
|
async function submitApplication(db, chapter, fields, opts) {
|
|
193
196
|
const app = chapter.application;
|
|
194
|
-
|
|
197
|
+
const fieldStates = resolveFieldStates(app.conditions ?? {}, fields, app.required);
|
|
198
|
+
for (const [f, state] of Object.entries(fieldStates)) {
|
|
199
|
+
if (!state.required) continue;
|
|
195
200
|
const v = fields[f];
|
|
196
201
|
if (typeof v !== "string" || v.trim() === "") return { ok: false, error: `${f} is required` };
|
|
197
202
|
}
|
|
@@ -210,10 +215,17 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
210
215
|
const id = opts.submissionId ? await applicationIdForSubmission(opts.submissionId) : opts.newId();
|
|
211
216
|
const row = { id, status: chapter.pipeline.initial, createdAt: opts.now };
|
|
212
217
|
for (const f of [...app.required, ...app.optional]) {
|
|
218
|
+
if (fieldStates[f]?.visible === false) continue;
|
|
213
219
|
if (typeof fields[f] === "string") row[f] = fields[f].trim();
|
|
214
220
|
}
|
|
215
221
|
if (fields.focus !== void 0) row.focus = clampArray(fields.focus, app.maxArrayLen);
|
|
216
222
|
if (opts.groupId) row.groupId = opts.groupId;
|
|
223
|
+
if (typeof fields.tierId === "string" && fields.tierId) {
|
|
224
|
+
if (opts.tierIds && !opts.tierIds.includes(fields.tierId)) {
|
|
225
|
+
return { ok: false, error: "tierId is not an offered tier" };
|
|
226
|
+
}
|
|
227
|
+
row.tierId = fields.tierId;
|
|
228
|
+
}
|
|
217
229
|
if (acked) row.disclaimerAckAt = opts.now;
|
|
218
230
|
const { duplicate } = await db.transact(
|
|
219
231
|
[{ t: "update", ns: "applications", id, attrs: row }],
|
|
@@ -221,12 +233,15 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
221
233
|
);
|
|
222
234
|
return { ok: true, id, duplicate, status: chapter.pipeline.initial, disclaimerAckAt: acked ? opts.now : null };
|
|
223
235
|
}
|
|
224
|
-
function joinConfig(group, paymentsReady) {
|
|
236
|
+
function joinConfig(group, paymentsReady, tiers = [], conditions = {}) {
|
|
225
237
|
return {
|
|
226
238
|
id: group.id,
|
|
227
239
|
name: group.name,
|
|
228
240
|
standardPriceCents: group.standardPriceCents ?? 0,
|
|
229
241
|
foundingDiscountCents: group.foundingDiscountCents ?? 0,
|
|
242
|
+
tiers: [...tiers],
|
|
243
|
+
// The browser evaluates the same conditions the server enforces.
|
|
244
|
+
conditions,
|
|
230
245
|
disclaimerText: group.disclaimerText ?? "",
|
|
231
246
|
refundPolicyText: group.refundPolicyText ?? "",
|
|
232
247
|
trustCopy: group.trustCopy ?? "",
|
|
@@ -236,6 +251,79 @@ function joinConfig(group, paymentsReady) {
|
|
|
236
251
|
};
|
|
237
252
|
}
|
|
238
253
|
|
|
254
|
+
// src/tiers.ts
|
|
255
|
+
function tierIsFree(tier) {
|
|
256
|
+
return !(tier.priceCents > 0) && !tier.stripePriceId;
|
|
257
|
+
}
|
|
258
|
+
function tierPayable(tier, group, hasSecretKey) {
|
|
259
|
+
if (tierIsFree(tier)) return true;
|
|
260
|
+
return Boolean(group.stripePublishableKey && tier.stripePriceId && hasSecretKey);
|
|
261
|
+
}
|
|
262
|
+
function byOrder(a, b) {
|
|
263
|
+
return a.sortOrder - b.sortOrder || a.id.localeCompare(b.id);
|
|
264
|
+
}
|
|
265
|
+
function resolveTiers(group, rows2 = []) {
|
|
266
|
+
const stored = rows2.filter((tier) => tier.active);
|
|
267
|
+
if (stored.length) return [...stored].sort(byOrder);
|
|
268
|
+
return [{
|
|
269
|
+
id: "standard",
|
|
270
|
+
groupId: String(group.id ?? ""),
|
|
271
|
+
name: "Membership",
|
|
272
|
+
priceCents: group.standardPriceCents ?? 0,
|
|
273
|
+
...group.stripePriceId ? { stripePriceId: group.stripePriceId } : {},
|
|
274
|
+
sortOrder: 0,
|
|
275
|
+
active: true
|
|
276
|
+
}];
|
|
277
|
+
}
|
|
278
|
+
function offerableTiers(tiers, group, hasSecretKey) {
|
|
279
|
+
return tiers.filter((tier) => tier.active && tierPayable(tier, group, hasSecretKey));
|
|
280
|
+
}
|
|
281
|
+
function findTier(tiers, tierId) {
|
|
282
|
+
if (!tierId) return tiers.length === 1 ? tiers[0] : null;
|
|
283
|
+
return tiers.find((tier) => tier.id === tierId) ?? null;
|
|
284
|
+
}
|
|
285
|
+
function joinConfigTiers(tiers) {
|
|
286
|
+
return tiers.map((tier) => ({
|
|
287
|
+
id: tier.id,
|
|
288
|
+
name: tier.name,
|
|
289
|
+
priceCents: tier.priceCents,
|
|
290
|
+
blurb: tier.blurb ?? "",
|
|
291
|
+
free: tierIsFree(tier)
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/tiers-store.ts
|
|
296
|
+
function rowToTier(row, groupId) {
|
|
297
|
+
return {
|
|
298
|
+
id: String(row.id ?? ""),
|
|
299
|
+
groupId: String(row.groupId ?? groupId),
|
|
300
|
+
name: String(row.name ?? ""),
|
|
301
|
+
priceCents: typeof row.priceCents === "number" ? row.priceCents : 0,
|
|
302
|
+
...typeof row.stripePriceId === "string" && row.stripePriceId ? { stripePriceId: row.stripePriceId } : {},
|
|
303
|
+
...typeof row.blurb === "string" ? { blurb: row.blurb } : {},
|
|
304
|
+
sortOrder: typeof row.sortOrder === "number" ? row.sortOrder : 0,
|
|
305
|
+
// A row that never set `active` is offered; only an explicit false retires
|
|
306
|
+
// a tier, so a half-seeded row cannot silently hide a membership.
|
|
307
|
+
active: row.active !== false
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async function loadTiers(db, group) {
|
|
311
|
+
const groupId = String(group.id ?? "");
|
|
312
|
+
let rows2;
|
|
313
|
+
try {
|
|
314
|
+
rows2 = (await db.query({
|
|
315
|
+
tiers: { $: { where: { groupId }, order: { sortOrder: "asc" }, limit: 100 } }
|
|
316
|
+
})).tiers;
|
|
317
|
+
} catch {
|
|
318
|
+
rows2 = void 0;
|
|
319
|
+
}
|
|
320
|
+
const list = Array.isArray(rows2) ? rows2.map((row) => rowToTier(row, groupId)) : [];
|
|
321
|
+
return resolveTiers(group, list);
|
|
322
|
+
}
|
|
323
|
+
async function loadOfferableTiers(db, group, hasSecretKey) {
|
|
324
|
+
return offerableTiers(await loadTiers(db, group), group, hasSecretKey);
|
|
325
|
+
}
|
|
326
|
+
|
|
239
327
|
// src/scheduling.ts
|
|
240
328
|
var SCHEDULING_DEFAULTS = {
|
|
241
329
|
slotMinutes: 45,
|
|
@@ -990,7 +1078,15 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
990
1078
|
if (!group) return json({ error: "not found" }, 404);
|
|
991
1079
|
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
992
1080
|
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
993
|
-
|
|
1081
|
+
const offered = await loadOfferableTiers(
|
|
1082
|
+
db,
|
|
1083
|
+
group,
|
|
1084
|
+
Boolean(stripeKey)
|
|
1085
|
+
);
|
|
1086
|
+
return json({
|
|
1087
|
+
...joinConfig(group, paymentsReady, joinConfigTiers(offered), chapter.application.conditions),
|
|
1088
|
+
copy: chapter.copy.join
|
|
1089
|
+
});
|
|
994
1090
|
}
|
|
995
1091
|
if (req.method === "GET" && url.pathname === "/api/join/resume") {
|
|
996
1092
|
const applicationId = url.searchParams.get("application") ?? "";
|
|
@@ -1029,9 +1125,11 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1029
1125
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
1030
1126
|
if (!group) return json({ error: "not found" }, 404);
|
|
1031
1127
|
const stripeKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1032
|
-
const paymentsReady = Boolean(group.stripePublishableKey && group.stripePriceId && stripeKey);
|
|
1033
1128
|
const status = String(app.status ?? "");
|
|
1034
|
-
|
|
1129
|
+
const offered = await loadOfferableTiers(db, group, Boolean(stripeKey));
|
|
1130
|
+
const tier = findTier(offered, typeof app.tierId === "string" ? app.tierId : null);
|
|
1131
|
+
const payable = Boolean(tier) && !tierIsFree(tier) && tierPayable(tier, group, Boolean(stripeKey));
|
|
1132
|
+
if (!payable) return json({ state: "booking", applicationId });
|
|
1035
1133
|
if (status === chapter.pipeline.initial && app.stripeSubscriptionId) {
|
|
1036
1134
|
return json({ state: "paymentPending", applicationId });
|
|
1037
1135
|
}
|
|
@@ -1050,11 +1148,13 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
1050
1148
|
}
|
|
1051
1149
|
const submissionId = typeof parsed.submissionId === "string" ? parsed.submissionId : void 0;
|
|
1052
1150
|
const db = ctx.makeDb(env);
|
|
1151
|
+
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
1053
1152
|
const result = await submitApplication(db, chapter, parsed, {
|
|
1054
1153
|
submissionId,
|
|
1055
1154
|
groupId: chapter.id,
|
|
1056
1155
|
now: Date.now(),
|
|
1057
|
-
newId: () => crypto.randomUUID()
|
|
1156
|
+
newId: () => crypto.randomUUID(),
|
|
1157
|
+
...offeredTierIds ? { tierIds: offeredTierIds } : {}
|
|
1058
1158
|
});
|
|
1059
1159
|
if (!result.ok) return json({ error: result.error }, 400);
|
|
1060
1160
|
if (!result.duplicate) {
|
|
@@ -1638,9 +1738,16 @@ async function startSubscription(req, env, ctx) {
|
|
|
1638
1738
|
if (!app) return json({ error: "not found" }, 404);
|
|
1639
1739
|
if (app.status !== "submitted") return json({ error: "already processed" }, 409);
|
|
1640
1740
|
const group = await firstRow2(db, "groups", { where: { id: String(app.groupId ?? ctx.chapter.id) }, limit: 1 });
|
|
1641
|
-
const priceId = group?.stripePriceId;
|
|
1642
1741
|
const secretKey = await getVaultSecret(db, "stripe_secret_key");
|
|
1643
|
-
if (!group || !
|
|
1742
|
+
if (!group || !secretKey) return json({ error: "payments not configured" }, 503);
|
|
1743
|
+
const tier = findTier(
|
|
1744
|
+
await loadTiers(db, group),
|
|
1745
|
+
typeof app.tierId === "string" ? app.tierId : null
|
|
1746
|
+
);
|
|
1747
|
+
if (!tier) return json({ error: "tier not found", code: "tier_unresolved" }, 400);
|
|
1748
|
+
if (tierIsFree(tier)) return json({ error: "tier is free", code: "tier_not_payable" }, 409);
|
|
1749
|
+
const priceId = tier.stripePriceId;
|
|
1750
|
+
if (!priceId) return json({ error: "payments not configured" }, 503);
|
|
1644
1751
|
const provider = createStripeProvider({ secretKey });
|
|
1645
1752
|
let result;
|
|
1646
1753
|
try {
|