@odla-ai/chapter 0.56.2 → 0.58.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/README.md +68 -0
- package/dist/{chunk-E7CB5K2D.js → chunk-CKC6X5H2.js} +47 -3
- package/dist/chunk-CKC6X5H2.js.map +1 -0
- package/dist/{chunk-4M2PN5K3.js → chunk-FHKZVF5G.js} +3 -1
- package/dist/chunk-FHKZVF5G.js.map +1 -0
- package/dist/{chunk-ZAE24YKY.js → chunk-L2IPS2FQ.js} +51 -4
- package/dist/chunk-L2IPS2FQ.js.map +1 -0
- package/dist/{copy-context-CaUXIxUI.d.ts → copy-context-DE4YMAbz.d.ts} +1 -1
- package/dist/index.cjs +178 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -5
- package/dist/index.d.ts +38 -5
- package/dist/index.js +178 -15
- package/dist/index.js.map +1 -1
- package/dist/ui/admin/index.d.ts +4 -3
- package/dist/ui/admin/index.js +2 -2
- package/dist/ui/index.d.ts +1 -1
- package/dist/ui/index.js +3 -3
- package/dist/ui/member/index.d.ts +10 -3
- package/dist/ui/member/index.js +2 -2
- package/dist/worker/index.cjs +1580 -1129
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +149 -3
- package/dist/worker/index.d.ts +149 -3
- package/dist/worker/index.js +1580 -1129
- package/dist/worker/index.js.map +1 -1
- package/package.json +1 -1
- package/runbooks/greenfield.md +1 -1
- package/dist/chunk-4M2PN5K3.js.map +0 -1
- package/dist/chunk-E7CB5K2D.js.map +0 -1
- package/dist/chunk-ZAE24YKY.js.map +0 -1
package/dist/worker/index.js
CHANGED
|
@@ -4036,912 +4036,887 @@ var handlePaymentRecovery = async (req, url, env, ctx) => {
|
|
|
4036
4036
|
}
|
|
4037
4037
|
};
|
|
4038
4038
|
|
|
4039
|
-
// src/
|
|
4040
|
-
var
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
4052
|
-
const db = raw2, app = await membershipLoad(db, "applications", match[1]);
|
|
4053
|
-
if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME) || app.namedSeatId) return json({ error: "not found" }, 404);
|
|
4054
|
-
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(app.id));
|
|
4055
|
-
if (!intent) return json({ error: "paid membership required" }, 409);
|
|
4039
|
+
// src/scheduling.ts
|
|
4040
|
+
var SCHEDULING_DEFAULTS = {
|
|
4041
|
+
slotMinutes: 45,
|
|
4042
|
+
days: [1, 2, 3, 4, 5],
|
|
4043
|
+
startHour: 9,
|
|
4044
|
+
endHour: 17,
|
|
4045
|
+
timezone: "America/Los_Angeles",
|
|
4046
|
+
minNoticeHours: 24,
|
|
4047
|
+
windowDays: 21,
|
|
4048
|
+
summaryTemplate: "Introduction call with {{firstName}} {{lastName}}"
|
|
4049
|
+
};
|
|
4050
|
+
function isValidTimeZone(tz) {
|
|
4056
4051
|
try {
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
const seat = offer.seat ?? null, refundable = Boolean(seat && REFUNDABLE.has(seat.status));
|
|
4060
|
-
if (!refund) {
|
|
4061
|
-
return json({
|
|
4062
|
-
seat,
|
|
4063
|
-
amountCents: offer.amountCents,
|
|
4064
|
-
currency: offer.currency,
|
|
4065
|
-
termEndsAt: offer.termEndsAt,
|
|
4066
|
-
purchaseEndsAt: offer.purchaseEndsAt,
|
|
4067
|
-
refundable
|
|
4068
|
-
});
|
|
4069
|
-
}
|
|
4070
|
-
if (!seat) return json({ error: "no purchased seat", code: "seat_not_found" }, 409);
|
|
4071
|
-
if (seat.status === "refunded") {
|
|
4072
|
-
await closeRecipientApplication(db, env, ctx, seat.seatId);
|
|
4073
|
-
return json({ ok: true, seatId: seat.seatId, status: "refunded", replayed: true });
|
|
4074
|
-
}
|
|
4075
|
-
if (!refundable) return json({ error: `seat is ${seat.status.replaceAll("_", " ")}`, code: "seat_not_refundable" }, 409);
|
|
4076
|
-
const receipt = await seats.operatorRefund({ ...target, seatId: seat.seatId }, env);
|
|
4077
|
-
await closeRecipientApplication(db, env, ctx, seat.seatId);
|
|
4078
|
-
return json({ ok: true, ...receipt });
|
|
4052
|
+
new Intl.DateTimeFormat(void 0, { timeZone: tz });
|
|
4053
|
+
return true;
|
|
4079
4054
|
} catch {
|
|
4080
|
-
return
|
|
4055
|
+
return false;
|
|
4081
4056
|
}
|
|
4082
|
-
};
|
|
4083
|
-
async function closeRecipientApplication(db, env, ctx, seatId) {
|
|
4084
|
-
const recipient2 = (await db.query({ applications: { $: { where: { namedSeatId: seatId }, limit: 1 } } })).applications?.[0];
|
|
4085
|
-
if (!recipient2 || !chapterBillingApplication(recipient2, ctx.chapter.id, env.ODLA_RUNTIME) || recipient2.status === "refunded") return;
|
|
4086
|
-
const attrs = { status: "refunded", renewalAt: 0, canceled: true };
|
|
4087
|
-
await db.transact(
|
|
4088
|
-
[{ t: "update", ns: "applications", id: String(recipient2.id), attrs }],
|
|
4089
|
-
{ mutationId: `verified-refund:${String(recipient2.id)}:${seatId}` }
|
|
4090
|
-
);
|
|
4091
|
-
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...recipient2, ...attrs }, stage: "refunded" }).catch(() => void 0);
|
|
4092
4057
|
}
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4058
|
+
function resolveScheduling(config) {
|
|
4059
|
+
const result = validateScheduling(config);
|
|
4060
|
+
if (result.ok) return result.value;
|
|
4061
|
+
const detail = Object.entries(result.errors).map(([field, message]) => `${field}: ${message}`).join(" ");
|
|
4062
|
+
throw new Error(`scheduling: ${detail}`);
|
|
4063
|
+
}
|
|
4064
|
+
function validateScheduling(config) {
|
|
4065
|
+
const d = config ?? {};
|
|
4066
|
+
const c = {
|
|
4067
|
+
slotMinutes: d.slotMinutes ?? SCHEDULING_DEFAULTS.slotMinutes,
|
|
4068
|
+
days: d.days ?? SCHEDULING_DEFAULTS.days,
|
|
4069
|
+
startHour: d.startHour ?? SCHEDULING_DEFAULTS.startHour,
|
|
4070
|
+
endHour: d.endHour ?? SCHEDULING_DEFAULTS.endHour,
|
|
4071
|
+
timezone: d.timezone ?? SCHEDULING_DEFAULTS.timezone,
|
|
4072
|
+
minNoticeHours: d.minNoticeHours ?? SCHEDULING_DEFAULTS.minNoticeHours,
|
|
4073
|
+
windowDays: d.windowDays ?? SCHEDULING_DEFAULTS.windowDays,
|
|
4074
|
+
summaryTemplate: d.summaryTemplate ?? SCHEDULING_DEFAULTS.summaryTemplate
|
|
4110
4075
|
};
|
|
4111
|
-
const
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
const invoice = await call("GET", `/v1/invoices/${encodeURIComponent(invoiceRef.invoiceId)}`);
|
|
4115
|
-
if (invoice.id !== invoiceRef.invoiceId || id2(invoice.subscription ?? invoice.parent?.subscription_details?.subscription) !== sub.id || id2(invoice.customer) !== seat.providerCustomerId || invoice.status !== "paid" || invoice.billing_reason !== "subscription_create" || invoice.total !== invoiceRef.totalCents || invoice.amount_paid !== invoiceRef.totalCents || invoice.currency !== seat.currency || invoice.livemode !== (seat.environment === "prod")) fail4();
|
|
4116
|
-
let chargeId = id2(invoice.charge), paymentId = id2(invoice.payment_intent);
|
|
4117
|
-
if (!chargeId && !paymentId) {
|
|
4118
|
-
const payments = await call("GET", "/v1/invoice_payments", { invoice: invoice.id, status: "paid", limit: 100 });
|
|
4119
|
-
if (!Array.isArray(payments.data) || payments.data.length !== 1 || payments.has_more !== false) fail4();
|
|
4120
|
-
const payment = payments.data[0];
|
|
4121
|
-
if (id2(payment.invoice) !== invoice.id || payment.status !== "paid" || payment.payment?.type !== "payment_intent") fail4();
|
|
4122
|
-
paymentId = id2(payment.payment.payment_intent);
|
|
4123
|
-
}
|
|
4124
|
-
if (!chargeId) {
|
|
4125
|
-
if (!/^pi_[A-Za-z0-9_]+$/.test(paymentId)) fail4();
|
|
4126
|
-
const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(paymentId)}`);
|
|
4127
|
-
if (payment.id !== paymentId || id2(payment.customer) !== seat.providerCustomerId || payment.status !== "succeeded" || payment.amount_received !== invoiceRef.totalCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod")) fail4();
|
|
4128
|
-
chargeId = id2(payment.latest_charge);
|
|
4129
|
-
}
|
|
4130
|
-
if (!/^ch_[A-Za-z0-9_]+$/.test(chargeId)) fail4();
|
|
4131
|
-
const charge = await call("GET", `/v1/charges/${encodeURIComponent(chargeId)}`);
|
|
4132
|
-
if (charge.id !== chargeId || id2(charge.customer) !== seat.providerCustomerId || charge.status !== "succeeded" || charge.livemode !== (seat.environment === "prod") || charge.amount !== invoiceRef.totalCents || charge.currency !== seat.currency || charge.invoice != null && id2(charge.invoice) !== invoice.id || paymentId && id2(charge.payment_intent) !== paymentId || !Number.isSafeInteger(charge.amount_refunded) || charge.amount_refunded < 0 || charge.amount_refunded > charge.amount) fail4();
|
|
4133
|
-
const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
|
|
4134
|
-
if (items.length > 1) fail4();
|
|
4135
|
-
if (items.length && sub.status !== "canceled") {
|
|
4136
|
-
const item = items[0];
|
|
4137
|
-
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.quantity !== 1 || item.price?.id !== seat.priceId) fail4();
|
|
4138
|
-
const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
|
|
4139
|
-
if (removed.id !== item.id || removed.deleted !== true) fail4("named_seat_renewal_removal_unconfirmed");
|
|
4076
|
+
const errors = {};
|
|
4077
|
+
if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
|
|
4078
|
+
errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
|
|
4140
4079
|
}
|
|
4141
|
-
if (
|
|
4142
|
-
|
|
4143
|
-
if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail4("named_seat_refund_history_incomplete");
|
|
4144
|
-
let refundedCents = 0;
|
|
4145
|
-
const refundIds = [];
|
|
4146
|
-
for (const refund of list.data) {
|
|
4147
|
-
if (id2(refund.charge) !== chargeId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !/^re_[A-Za-z0-9_]+$/.test(refund.id) || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail4();
|
|
4148
|
-
if (["pending", "requires_action"].includes(refund.status)) fail4("named_seat_refund_pending");
|
|
4149
|
-
if (refund.status === "succeeded") {
|
|
4150
|
-
if (refund.metadata?.seatId !== seat.id || refund.metadata?.membershipId !== seat.primaryMembershipId) fail4("named_seat_refund_allocation_ambiguous");
|
|
4151
|
-
refundedCents += refund.amount;
|
|
4152
|
-
refundIds.push(refund.id);
|
|
4153
|
-
}
|
|
4080
|
+
if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
|
|
4081
|
+
errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
|
|
4154
4082
|
}
|
|
4155
|
-
if (
|
|
4156
|
-
|
|
4157
|
-
const remaining = seat.amountCents - refundedCents;
|
|
4158
|
-
const refund = await call("POST", "/v1/refunds", {
|
|
4159
|
-
charge: chargeId,
|
|
4160
|
-
amount: remaining,
|
|
4161
|
-
reason: "requested_by_customer",
|
|
4162
|
-
metadata: { ...metadata, membershipId: seat.primaryMembershipId, seatId: seat.id }
|
|
4163
|
-
}, `combined-seat-refund:${seat.id}:${remaining}`);
|
|
4164
|
-
if (id2(refund.charge) !== chargeId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail4();
|
|
4165
|
-
if (refund.status !== "succeeded") fail4("named_seat_refund_pending");
|
|
4166
|
-
refundedCents += remaining;
|
|
4167
|
-
refundIds.push(refund.id);
|
|
4083
|
+
if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
|
|
4084
|
+
errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
|
|
4168
4085
|
}
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
// src/membership-seat-refund-stripe.ts
|
|
4173
|
-
var fail5 = (code = "named_seat_refund_mismatch") => {
|
|
4174
|
-
throw Object.assign(new Error(code), { code });
|
|
4175
|
-
};
|
|
4176
|
-
async function refundNamedSeatStripePayment(secretKey, seat) {
|
|
4177
|
-
if (seat.combinedInvoice) return refundCheckoutNamedSeat(secretKey, seat);
|
|
4178
|
-
if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail5();
|
|
4179
|
-
const call = async (method, path, params, idempotencyKey) => {
|
|
4180
|
-
const result = await stripeRequest5({ secretKey }, method, path, params, idempotencyKey ? { idempotencyKey } : {});
|
|
4181
|
-
if (!result.ok || !result.body) return fail5("named_seat_refund_provider_unavailable");
|
|
4182
|
-
return result.body;
|
|
4183
|
-
};
|
|
4184
|
-
const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId, membershipId: seat.primaryMembershipId, seatId: seat.id };
|
|
4185
|
-
const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(seat.providerPaymentIntentId)}`);
|
|
4186
|
-
if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value)) fail5();
|
|
4187
|
-
const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
|
|
4188
|
-
if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data)) fail5();
|
|
4189
|
-
const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
|
|
4190
|
-
if (items.length > 1) fail5();
|
|
4191
|
-
if (items.length && sub.status !== "canceled") {
|
|
4192
|
-
const item = items[0];
|
|
4193
|
-
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail5();
|
|
4194
|
-
const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
|
|
4195
|
-
if (removed.id !== item.id || removed.deleted !== true) fail5("named_seat_renewal_removal_unconfirmed");
|
|
4086
|
+
if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
|
|
4087
|
+
errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
|
|
4196
4088
|
}
|
|
4197
|
-
const
|
|
4198
|
-
if (!
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
for (const refund of list.data) {
|
|
4202
|
-
if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail5();
|
|
4203
|
-
if (refund.status === "succeeded") {
|
|
4204
|
-
refundedCents += refund.amount;
|
|
4205
|
-
refundIds.push(refund.id);
|
|
4206
|
-
} else if (refund.status === "pending" || refund.status === "requires_action") pendingCents += refund.amount;
|
|
4089
|
+
const days = [...c.days];
|
|
4090
|
+
if (!days.length) errors.days = "Pick at least one day.";
|
|
4091
|
+
else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
|
|
4092
|
+
errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
|
|
4207
4093
|
}
|
|
4208
|
-
if (
|
|
4209
|
-
|
|
4210
|
-
if (refundedCents < seat.amountCents) {
|
|
4211
|
-
const remaining = seat.amountCents - refundedCents;
|
|
4212
|
-
const refund = await call("POST", "/v1/refunds", {
|
|
4213
|
-
payment_intent: seat.providerPaymentIntentId,
|
|
4214
|
-
amount: remaining,
|
|
4215
|
-
reason: "requested_by_customer",
|
|
4216
|
-
metadata
|
|
4217
|
-
}, `named-seat-refund:${seat.id}:${remaining}`);
|
|
4218
|
-
if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail5();
|
|
4219
|
-
if (refund.status !== "succeeded") fail5("named_seat_refund_pending");
|
|
4220
|
-
refundedCents += remaining;
|
|
4221
|
-
refundIds.push(refund.id);
|
|
4094
|
+
if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
|
|
4095
|
+
errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
|
|
4222
4096
|
}
|
|
4223
|
-
|
|
4097
|
+
if (typeof c.summaryTemplate !== "string") errors.summaryTemplate = "Calendar summary template must be text.";
|
|
4098
|
+
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
|
|
4224
4099
|
}
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
async function recipientKey(input, email) {
|
|
4228
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([input.appId, input.chapterId, input.environment, input.runtime, email])));
|
|
4229
|
-
return `seat-recipient:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
4100
|
+
function slotWindow(now, windowDays) {
|
|
4101
|
+
return { from: now, to: now + windowDays * 864e5 };
|
|
4230
4102
|
}
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
const
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
};
|
|
4273
|
-
try {
|
|
4274
|
-
await db.transact([
|
|
4275
|
-
membershipUpdate("networkSeat", id7, attrs),
|
|
4276
|
-
membershipUpdate("networkSeatRecipient", claimId, {
|
|
4277
|
-
id: claimId,
|
|
4278
|
-
appId: input.appId,
|
|
4279
|
-
chapterId: input.chapterId,
|
|
4280
|
-
environment: input.environment,
|
|
4281
|
-
runtime: input.runtime,
|
|
4282
|
-
seatId: id7,
|
|
4283
|
-
recipientEmailNormalized: email,
|
|
4284
|
-
createdAt: now
|
|
4285
|
-
}),
|
|
4286
|
-
membershipUpdate("networkMembership", memberId, { namedSeatId: id7, revision: Number(member.revision) + 1, updatedAt: now })
|
|
4287
|
-
], { mutationId: `named-seat-reserve:${id7}`, guards: [
|
|
4288
|
-
{ ns: "networkSeat", id: id7, exists: false },
|
|
4289
|
-
{ ns: "networkSeatRecipient", id: claimId, exists: false },
|
|
4290
|
-
{ ns: "networkMembership", id: memberId, exists: true, equals: { revision: member.revision }, absent: ["namedSeatId"] },
|
|
4291
|
-
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
|
|
4292
|
-
] });
|
|
4293
|
-
} catch (error) {
|
|
4294
|
-
if (error.code !== "transact_guard_failed") throw error;
|
|
4295
|
-
}
|
|
4296
|
-
seat = await membershipLoad(db, "networkSeat", id7);
|
|
4297
|
-
if (!seat && await membershipLoad(db, "networkSeatRecipient", claimId)) membershipFail("named_seat_recipient_unavailable");
|
|
4298
|
-
}
|
|
4299
|
-
requireMembershipScope(seat, input);
|
|
4300
|
-
if (seat.primaryMembershipId !== memberId || seat.recipientName !== name || seat.recipientEmailNormalized !== email || seat.priceId !== input.priceId || seat.purchaserUserId !== input.purchaserUserId) membershipFail("named_seat_purchase_conflict");
|
|
4301
|
-
return seat;
|
|
4103
|
+
function endForSlot(startAt, slotMinutes) {
|
|
4104
|
+
return startAt + slotMinutes * 6e4;
|
|
4105
|
+
}
|
|
4106
|
+
function isSlotAvailable(slots, startAt) {
|
|
4107
|
+
return slots.some((s) => s.startAt === startAt);
|
|
4108
|
+
}
|
|
4109
|
+
function renderSummary(template, app) {
|
|
4110
|
+
return template.replace("{{firstName}}", app.firstName ?? "").replace("{{lastName}}", app.lastName ?? "");
|
|
4111
|
+
}
|
|
4112
|
+
function bookingDecision(existing) {
|
|
4113
|
+
const eventId = existing?.googleEventId ?? null;
|
|
4114
|
+
return { reschedule: Boolean(eventId), eventId };
|
|
4115
|
+
}
|
|
4116
|
+
function introIdempotencyKey(applicationId) {
|
|
4117
|
+
return `application:${applicationId}:intro`;
|
|
4118
|
+
}
|
|
4119
|
+
function meetingCreateRow(i) {
|
|
4120
|
+
return {
|
|
4121
|
+
id: i.meetingId,
|
|
4122
|
+
applicationId: i.applicationId,
|
|
4123
|
+
groupId: i.groupId,
|
|
4124
|
+
startAt: i.startAt,
|
|
4125
|
+
endAt: i.endAt,
|
|
4126
|
+
timezone: i.timezone,
|
|
4127
|
+
status: "scheduled",
|
|
4128
|
+
googleEventId: i.googleEventId,
|
|
4129
|
+
...i.meetUrl ? { meetUrl: i.meetUrl } : {},
|
|
4130
|
+
...i.htmlLink ? { htmlLink: i.htmlLink } : {},
|
|
4131
|
+
drift: "none",
|
|
4132
|
+
createdAt: i.createdAt
|
|
4133
|
+
};
|
|
4134
|
+
}
|
|
4135
|
+
function meetingRescheduleUpdate(startAt, endAt) {
|
|
4136
|
+
return { startAt, endAt, drift: "none" };
|
|
4137
|
+
}
|
|
4138
|
+
function applicationBookingUpdate(currentStatus, startAt, htmlLink) {
|
|
4139
|
+
return {
|
|
4140
|
+
meetingAt: startAt,
|
|
4141
|
+
...htmlLink ? { meetingLink: htmlLink } : {},
|
|
4142
|
+
...currentStatus !== "call_scheduled" ? { status: "call_scheduled" } : {}
|
|
4143
|
+
};
|
|
4302
4144
|
}
|
|
4303
4145
|
|
|
4304
|
-
// src/
|
|
4305
|
-
|
|
4306
|
-
|
|
4307
|
-
|
|
4308
|
-
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
4322
|
-
|
|
4323
|
-
|
|
4146
|
+
// src/session.ts
|
|
4147
|
+
function applicationSummary(app) {
|
|
4148
|
+
return {
|
|
4149
|
+
id: app.id,
|
|
4150
|
+
firstName: app.firstName ?? null,
|
|
4151
|
+
lastName: app.lastName ?? null,
|
|
4152
|
+
email: app.email ?? null,
|
|
4153
|
+
status: app.status,
|
|
4154
|
+
createdAt: app.createdAt ?? null,
|
|
4155
|
+
meetingLink: app.meetingLink ?? null,
|
|
4156
|
+
paid: Boolean(app.stripeSubscriptionId) && app.status !== "refunded" && app.canceled !== true && !["lapsed", "refunded", "refund_pending", "canceled", "restarting"].includes(app.membershipStatus ?? ""),
|
|
4157
|
+
...app.membershipStatus ? { membershipStatus: app.membershipStatus, membershipGraceEndsAt: app.membershipGraceEndsAt, membershipAutoRenew: app.membershipAutoRenew } : {},
|
|
4158
|
+
renewalAt: app.renewalAt ?? null,
|
|
4159
|
+
canceled: app.canceled === true,
|
|
4160
|
+
tierId: app.tierId ?? null
|
|
4161
|
+
};
|
|
4162
|
+
}
|
|
4163
|
+
function memberApplication(app, meeting, defaultTimezone) {
|
|
4164
|
+
const summary = applicationSummary(app);
|
|
4165
|
+
let meetingAt = app.meetingAt ?? null;
|
|
4166
|
+
let meetUrl = null;
|
|
4167
|
+
let timezone = defaultTimezone;
|
|
4168
|
+
if (meeting) {
|
|
4169
|
+
timezone = meeting.timezone ?? timezone;
|
|
4170
|
+
if (meeting.status === "scheduled") {
|
|
4171
|
+
meetingAt = meeting.startAt ?? null;
|
|
4172
|
+
meetUrl = meeting.meetUrl ?? null;
|
|
4173
|
+
} else {
|
|
4174
|
+
meetingAt = null;
|
|
4175
|
+
}
|
|
4324
4176
|
}
|
|
4177
|
+
return { ...summary, meetingAt, meetUrl, timezone };
|
|
4325
4178
|
}
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4179
|
+
|
|
4180
|
+
// src/member-session-application.ts
|
|
4181
|
+
var local = (row2, chapterId, runtime) => row2.groupId === chapterId && (runtime ? row2.stripeRuntime === runtime : row2.stripeRuntime === void 0 || row2.stripeRuntime === "default");
|
|
4182
|
+
async function memberSessionApplication(db, chapterId, runtime, identity2) {
|
|
4183
|
+
const lookup = async (where) => {
|
|
4184
|
+
const rows5 = (await db.query({ applications: { $: { where: { groupId: chapterId, ...where }, limit: 100 } } })).applications;
|
|
4185
|
+
if (!Array.isArray(rows5) || rows5.length >= 100) throw new Error("member identity lookup incomplete");
|
|
4186
|
+
return rows5.filter((row2) => local(row2, chapterId, runtime));
|
|
4187
|
+
};
|
|
4188
|
+
let apps = await lookup({ clerkUserId: identity2.userId });
|
|
4189
|
+
let emailFallback = false;
|
|
4190
|
+
if (!apps.length && identity2.email) {
|
|
4191
|
+
const normalized = normalizeChapterEmail(identity2.email);
|
|
4192
|
+
const normalizedRows = await lookup({ emailNormalized: normalized });
|
|
4193
|
+
const legacyRows = await lookup({ email: identity2.email });
|
|
4194
|
+
apps = [...new Map([...normalizedRows, ...legacyRows].map((row2) => [row2.id, row2])).values()];
|
|
4195
|
+
if (apps.length !== 1 || apps[0]?.clerkUserId !== void 0 || apps[0]?.clerkAccountPending === true || ["declined", "canceled", "refunded"].includes(String(apps[0]?.status))) return null;
|
|
4196
|
+
emailFallback = true;
|
|
4336
4197
|
}
|
|
4337
|
-
|
|
4338
|
-
|
|
4198
|
+
let app = apps.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
4199
|
+
if (!app) return null;
|
|
4200
|
+
if (emailFallback) {
|
|
4201
|
+
const keys3 = ["groupId", "stripeRuntime", "email", "emailNormalized", "status", "clerkAccountPending"];
|
|
4339
4202
|
try {
|
|
4340
|
-
await db.transact([
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
termEndsAt: member.termEndsAt,
|
|
4346
|
-
acceptanceEndsAt: Number(member.termEndsAt),
|
|
4347
|
-
entitlementId,
|
|
4348
|
-
revision: Number(seat.revision) + 1,
|
|
4349
|
-
updatedAt: now
|
|
4350
|
-
}),
|
|
4351
|
-
membershipUpdate("networkEntitlement", entitlementId, {
|
|
4352
|
-
id: entitlementId,
|
|
4353
|
-
...membershipScope(input),
|
|
4354
|
-
membershipId: member.id,
|
|
4355
|
-
seatId: seat.id,
|
|
4356
|
-
kind: "named_seat",
|
|
4357
|
-
status: "pending_acceptance",
|
|
4358
|
-
termStartedAt: now,
|
|
4359
|
-
termEndsAt: member.termEndsAt,
|
|
4360
|
-
offeringSnapshot: member.offeringSnapshot,
|
|
4361
|
-
sourceEventId: source,
|
|
4362
|
-
revision: 1,
|
|
4363
|
-
createdAt: now,
|
|
4364
|
-
updatedAt: now
|
|
4365
|
-
}),
|
|
4366
|
-
membershipUpdate("membershipEffect", inviteId, {
|
|
4367
|
-
id: inviteId,
|
|
4368
|
-
chapterId: input.chapterId,
|
|
4369
|
-
environment: input.environment,
|
|
4370
|
-
runtime: input.runtime,
|
|
4371
|
-
membershipId: member.id,
|
|
4372
|
-
seatId: seat.id,
|
|
4373
|
-
kind: "invite_named_recipient",
|
|
4374
|
-
status: "pending",
|
|
4375
|
-
attempts: 0,
|
|
4376
|
-
createdAt: now,
|
|
4377
|
-
updatedAt: now
|
|
4378
|
-
})
|
|
4379
|
-
], { mutationId: `named-seat-included:${seat.id}`, guards: [
|
|
4380
|
-
{
|
|
4381
|
-
ns: "networkSeat",
|
|
4382
|
-
id: seat.id,
|
|
4203
|
+
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: { clerkUserId: identity2.userId } }], {
|
|
4204
|
+
mutationId: `member-account-link:${app.id}:${identity2.userId}`,
|
|
4205
|
+
guards: [{
|
|
4206
|
+
ns: "applications",
|
|
4207
|
+
id: String(app.id),
|
|
4383
4208
|
exists: true,
|
|
4384
|
-
equals:
|
|
4385
|
-
absent: ["
|
|
4386
|
-
}
|
|
4387
|
-
|
|
4388
|
-
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision, namedSeatId: seat.id } },
|
|
4389
|
-
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
|
|
4390
|
-
] });
|
|
4209
|
+
equals: Object.fromEntries(keys3.filter((key3) => app[key3] !== void 0).map((key3) => [key3, app[key3]])),
|
|
4210
|
+
absent: ["clerkUserId", ...keys3.filter((key3) => app[key3] === void 0)]
|
|
4211
|
+
}]
|
|
4212
|
+
});
|
|
4391
4213
|
} catch (error) {
|
|
4392
4214
|
if (error.code !== "transact_guard_failed") throw error;
|
|
4393
4215
|
}
|
|
4216
|
+
const saved = (await db.query({ applications: { $: { where: { id: app.id }, limit: 1 } } })).applications?.[0];
|
|
4217
|
+
if (!saved || !local(saved, chapterId, runtime) || saved.clerkUserId !== identity2.userId || keys3.some((key3) => saved[key3] !== app[key3])) return null;
|
|
4218
|
+
app = saved;
|
|
4394
4219
|
}
|
|
4395
|
-
const
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4220
|
+
const meeting = (await db.query({ meetings: { $: {
|
|
4221
|
+
where: { applicationId: app.id, status: "scheduled" },
|
|
4222
|
+
order: { createdAt: "desc" },
|
|
4223
|
+
limit: 1
|
|
4224
|
+
} } })).meetings?.[0];
|
|
4225
|
+
const group = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups?.[0];
|
|
4226
|
+
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
4227
|
+
return { source: app, application: memberApplication(
|
|
4228
|
+
app,
|
|
4229
|
+
meeting,
|
|
4230
|
+
timezone
|
|
4231
|
+
) };
|
|
4399
4232
|
}
|
|
4400
4233
|
|
|
4401
|
-
// src/
|
|
4402
|
-
async function
|
|
4403
|
-
|
|
4404
|
-
|
|
4405
|
-
const
|
|
4406
|
-
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
membershipId: member.id,
|
|
4421
|
-
seatId: seat.id,
|
|
4422
|
-
kind: "refund_seat",
|
|
4423
|
-
status: "pending",
|
|
4424
|
-
attempts: 0,
|
|
4425
|
-
createdAt: now,
|
|
4426
|
-
updatedAt: now
|
|
4427
|
-
}),
|
|
4428
|
-
...seat.recipientUserId ? [membershipUpdate("membershipEffect", `${seat.id}:revoke`, {
|
|
4429
|
-
id: `${seat.id}:revoke`,
|
|
4430
|
-
chapterId: input.chapterId,
|
|
4431
|
-
environment: input.environment,
|
|
4432
|
-
runtime: input.runtime,
|
|
4433
|
-
membershipId: member.id,
|
|
4434
|
-
seatId: seat.id,
|
|
4435
|
-
kind: "revoke_named_recipient_access",
|
|
4436
|
-
status: "pending",
|
|
4437
|
-
attempts: 0,
|
|
4438
|
-
createdAt: now,
|
|
4439
|
-
updatedAt: now
|
|
4440
|
-
})] : []
|
|
4441
|
-
], { mutationId: `named-seat-refund-request:${seat.id}`, guards: [
|
|
4442
|
-
{ ns: "networkSeat", id: input.seatId, exists: true, equals: { revision: seat.revision } },
|
|
4443
|
-
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision } },
|
|
4444
|
-
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
|
|
4445
|
-
...entitlement ? [{ ns: "networkEntitlement", id: String(entitlement.id), exists: true, equals: { revision: entitlement.revision } }] : []
|
|
4446
|
-
] });
|
|
4447
|
-
}
|
|
4448
|
-
async function completeNamedSeatRefund(db, seatId, secretKey, now) {
|
|
4449
|
-
membershipClock(now);
|
|
4450
|
-
const seat = await membershipLoad(db, "networkSeat", seatId);
|
|
4451
|
-
if (!seat) return membershipFail("named_seat_not_found");
|
|
4452
|
-
await loadMembershipAuthorityState(db, seat);
|
|
4453
|
-
if (seat.status === "refunded") return;
|
|
4454
|
-
if (seat.status !== "refund_pending") membershipFail("named_seat_refund_not_ready");
|
|
4455
|
-
const result = namedSeatIncluded(seat) ? { refundedCents: 0, refundIds: [] } : await refundNamedSeatStripePayment(secretKey, seat);
|
|
4456
|
-
await db.transact([
|
|
4457
|
-
membershipUpdate("networkSeat", seatId, {
|
|
4458
|
-
status: "refunded",
|
|
4459
|
-
refundedCents: result.refundedCents,
|
|
4460
|
-
providerRefundIds: result.refundIds,
|
|
4461
|
-
revision: Number(seat.revision) + 1,
|
|
4462
|
-
updatedAt: now
|
|
4463
|
-
}),
|
|
4464
|
-
membershipUpdate("membershipEffect", `${seatId}:refund`, { status: "complete", updatedAt: now })
|
|
4465
|
-
], { mutationId: `named-seat-refunded:${seatId}`, guards: [
|
|
4466
|
-
{ ns: "networkSeat", id: seatId, exists: true, equals: { status: "refund_pending", revision: seat.revision } }
|
|
4467
|
-
] });
|
|
4468
|
-
}
|
|
4469
|
-
|
|
4470
|
-
// src/membership-seat-operator-refund.ts
|
|
4471
|
-
async function refundNamedSeatByOperator(db, input, stripeSecretKey, now) {
|
|
4472
|
-
const seat = await membershipLoad(db, "networkSeat", input.seatId);
|
|
4473
|
-
requireMembershipScope(seat, input);
|
|
4474
|
-
await requestNamedSeatRefund(db, { ...input, reason: "operator" }, now);
|
|
4475
|
-
await completeNamedSeatRefund(db, input.seatId, stripeSecretKey, now);
|
|
4476
|
-
const refunded = await membershipLoad(db, "networkSeat", input.seatId);
|
|
4477
|
-
if (refunded?.status !== "refunded" || !Number.isSafeInteger(refunded.refundedCents) || Number(refunded.refundedCents) !== Number(seat.amountCents)) {
|
|
4478
|
-
throw new Error("named seat refund unconfirmed");
|
|
4234
|
+
// src/named-seat-request.ts
|
|
4235
|
+
async function smallRequestBody(req) {
|
|
4236
|
+
const reader = req.body?.getReader();
|
|
4237
|
+
if (!reader) throw new Error("request body required");
|
|
4238
|
+
const chunks = [];
|
|
4239
|
+
let size = 0;
|
|
4240
|
+
try {
|
|
4241
|
+
while (true) {
|
|
4242
|
+
const chunk = await reader.read();
|
|
4243
|
+
if (chunk.done) break;
|
|
4244
|
+
size += chunk.value.byteLength;
|
|
4245
|
+
if (size > 4096) {
|
|
4246
|
+
await reader.cancel();
|
|
4247
|
+
throw new Error("request too large");
|
|
4248
|
+
}
|
|
4249
|
+
chunks.push(chunk.value);
|
|
4250
|
+
}
|
|
4251
|
+
} finally {
|
|
4252
|
+
reader.releaseLock();
|
|
4479
4253
|
}
|
|
4480
|
-
|
|
4254
|
+
const bytes = new Uint8Array(size);
|
|
4255
|
+
let offset = 0;
|
|
4256
|
+
for (const chunk of chunks) {
|
|
4257
|
+
bytes.set(chunk, offset);
|
|
4258
|
+
offset += chunk.byteLength;
|
|
4259
|
+
}
|
|
4260
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
4261
|
+
}
|
|
4262
|
+
async function namedSeatRequest(req) {
|
|
4263
|
+
const body = JSON.parse(await smallRequestBody(req));
|
|
4264
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("object required");
|
|
4265
|
+
return body;
|
|
4481
4266
|
}
|
|
4482
4267
|
|
|
4483
|
-
// src/
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
const
|
|
4488
|
-
const
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
if (
|
|
4492
|
-
|
|
4493
|
-
if (
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4268
|
+
// src/named-seat-release.ts
|
|
4269
|
+
var SEAT_FIELDS = ["namedSeatId", "namedSeatPrimaryApplicationId", "namedSeatClaimPending", "namedSeatAcceptedAt"];
|
|
4270
|
+
async function releaseNamedSeatRecipientApplications(db, env, ctx, input) {
|
|
4271
|
+
const bound = (await db.query({ applications: { $: { where: { namedSeatId: input.seatId }, limit: 5 } } })).applications ?? [];
|
|
4272
|
+
const keep = input.keepRecipientEmail ? normalizeChapterEmail(input.keepRecipientEmail) : null;
|
|
4273
|
+
const released = [];
|
|
4274
|
+
for (const app of bound) {
|
|
4275
|
+
if (!chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) continue;
|
|
4276
|
+
if (input.recipientApplicationId && app.id !== input.recipientApplicationId) continue;
|
|
4277
|
+
if (input.recipientUserId && app.clerkUserId !== input.recipientUserId) continue;
|
|
4278
|
+
if (keep && normalizeChapterEmail(String(app.emailNormalized ?? app.email ?? "")) === keep) continue;
|
|
4279
|
+
await releaseOne(db, env, ctx, app);
|
|
4280
|
+
released.push(String(app.id));
|
|
4281
|
+
}
|
|
4282
|
+
return { released };
|
|
4283
|
+
}
|
|
4284
|
+
async function releaseOne(db, env, ctx, app) {
|
|
4285
|
+
const id7 = String(app.id), closed = app.status === "declined" && app.canceled === true;
|
|
4286
|
+
if (!closed) {
|
|
4287
|
+
await db.transact([
|
|
4288
|
+
{ t: "update", ns: "applications", id: id7, attrs: { status: "declined", canceled: true } },
|
|
4289
|
+
{ t: "retract", ns: "applications", id: id7, attrs: SEAT_FIELDS.filter((key3) => app[key3] !== void 0) }
|
|
4290
|
+
], { mutationId: `named-seat-release:${id7}:${String(app.namedSeatId)}`, guards: [
|
|
4291
|
+
{ ns: "applications", id: id7, exists: true, equals: { namedSeatId: app.namedSeatId, status: app.status } }
|
|
4503
4292
|
] });
|
|
4504
|
-
|
|
4293
|
+
} else if (SEAT_FIELDS.some((key3) => app[key3] !== void 0)) {
|
|
4294
|
+
await db.transact(
|
|
4295
|
+
[{ t: "retract", ns: "applications", id: id7, attrs: SEAT_FIELDS.filter((key3) => app[key3] !== void 0) }],
|
|
4296
|
+
{ guards: [{ ns: "applications", id: id7, exists: true, equals: { namedSeatId: app.namedSeatId } }] }
|
|
4297
|
+
);
|
|
4505
4298
|
}
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
const state = await loadMembershipAuthorityState(db, scope2);
|
|
4514
|
-
const versions = (await db.query({ membershipOfferingVersion: { $: { where: { chapterId: scope2.chapterId }, limit: 200 } } })).membershipOfferingVersion ?? [];
|
|
4515
|
-
if (versions.length >= 200) throw Object.assign(new Error("policy_history_incomplete"), { code: "policy_history_incomplete" });
|
|
4516
|
-
const current = versions.map((row2) => membershipOfferingVersion(row2.snapshot)).filter((offer) => offer.baseSkuId === scope2.skuId && offer.status === "published" && now >= offer.effectiveFrom && (offer.effectiveUntil === void 0 || now < offer.effectiveUntil)).sort((a, b) => b.version - a.version)[0];
|
|
4517
|
-
if (!current) throw Object.assign(new Error("ambiguous_membership_policy"), { code: "ambiguous_membership_policy" });
|
|
4518
|
-
const target = current.seatPriceCents;
|
|
4519
|
-
const rows5 = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
|
|
4520
|
-
const receipt = { seatPriceCents: target, repriced: 0, skipped: [], more: rows5.length > limit };
|
|
4521
|
-
for (const member of rows5.slice(0, limit)) {
|
|
4522
|
-
if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) throw new Error("membership reprice scope mismatch");
|
|
4523
|
-
const id7 = String(member.id), skip = (reason) => {
|
|
4524
|
-
receipt.skipped.push({ membershipId: id7, reason });
|
|
4525
|
-
};
|
|
4526
|
-
const offering = membershipOfferingVersion(member.offeringSnapshot);
|
|
4527
|
-
if (offering.seatPriceCents <= target) {
|
|
4528
|
-
skip("seat price already at or below the current offering");
|
|
4529
|
-
continue;
|
|
4530
|
-
}
|
|
4531
|
-
if (!["paid_pending_approval", "active", "cancel_at_term_end", "payment_grace"].includes(String(member.status))) {
|
|
4532
|
-
skip(`membership is ${String(member.status)}`);
|
|
4533
|
-
continue;
|
|
4534
|
-
}
|
|
4535
|
-
const seat = member.namedSeatId ? await membershipLoad(db, "networkSeat", String(member.namedSeatId)) : void 0;
|
|
4536
|
-
if (seat && (seat.status !== "payment_pending" || seat.providerPaymentIntentId !== void 0)) {
|
|
4537
|
-
skip(`seat is ${String(seat.status)}`);
|
|
4538
|
-
continue;
|
|
4539
|
-
}
|
|
4540
|
-
const ops = [membershipUpdate("networkMembership", id7, { offeringSnapshot: { ...offering, seatPriceCents: target }, revision: Number(member.revision) + 1, updatedAt: now })];
|
|
4541
|
-
const guards = [
|
|
4542
|
-
{ ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision } },
|
|
4543
|
-
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } }
|
|
4544
|
-
];
|
|
4545
|
-
if (seat) {
|
|
4546
|
-
ops.push(membershipUpdate("networkSeat", String(seat.id), { amountCents: target, offeringSnapshot: { ...offering, seatPriceCents: target }, revision: Number(seat.revision) + 1, updatedAt: now }));
|
|
4547
|
-
guards.push({ ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision, status: "payment_pending" } });
|
|
4548
|
-
}
|
|
4549
|
-
await db.transact(ops, { mutationId: `seat-reprice:${id7}:${member.revision}`, guards });
|
|
4550
|
-
receipt.repriced++;
|
|
4299
|
+
const promoteTo = ctx.chapter.operations.onApprove.promoteTo;
|
|
4300
|
+
if (promoteTo !== false && typeof app.clerkUserId === "string" && app.clerkUserId) {
|
|
4301
|
+
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
4302
|
+
if (!secret) throw new Error("named seat release provider unavailable");
|
|
4303
|
+
const user = await clerkGetUser(secret, app.clerkUserId, fetch, clerkRoleMetadata(ctx.chapter.auth));
|
|
4304
|
+
if (!user) throw new Error("named seat release provider unavailable");
|
|
4305
|
+
if (user.role !== ctx.chapter.auth.adminRole && user.role === promoteTo && !await clerkSetRole(secret, user.id, ctx.chapter.auth.ladder[0], fetch, clerkRoleMetadata(ctx.chapter.auth))) throw new Error("named seat release role pending");
|
|
4551
4306
|
}
|
|
4552
|
-
|
|
4307
|
+
const seatless = Object.fromEntries(Object.entries({ ...app, status: "declined", canceled: true }).filter(([key3]) => !SEAT_FIELDS.includes(key3)));
|
|
4308
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: seatless, stage: "declined" }).catch(() => void 0);
|
|
4553
4309
|
}
|
|
4554
4310
|
|
|
4555
|
-
// src/worker-routes-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
var REPRICE = "/api/admin/membership-seats/reprice";
|
|
4559
|
-
var ONE = /^\/api\/admin\/membership-seats\/([A-Za-z0-9][A-Za-z0-9_.:-]{2,159})\/refund$/;
|
|
4560
|
-
var SCOPE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
4561
|
-
var PUBLIC = [
|
|
4562
|
-
"id",
|
|
4563
|
-
"appId",
|
|
4564
|
-
"chapterId",
|
|
4565
|
-
"environment",
|
|
4566
|
-
"runtime",
|
|
4567
|
-
"applicationId",
|
|
4568
|
-
"skuId",
|
|
4569
|
-
"status",
|
|
4570
|
-
"amountCents",
|
|
4571
|
-
"currency",
|
|
4572
|
-
"recipientName",
|
|
4573
|
-
"recipientEmailNormalized",
|
|
4574
|
-
"purchaserUserId",
|
|
4575
|
-
"purchasedAt",
|
|
4576
|
-
"acceptedAt",
|
|
4577
|
-
"journeyApprovedAt",
|
|
4578
|
-
"refundedCents",
|
|
4579
|
-
"refundReason",
|
|
4580
|
-
"termEndsAt",
|
|
4581
|
-
"updatedAt"
|
|
4582
|
-
];
|
|
4583
|
-
var project = (row2) => Object.fromEntries(PUBLIC.filter((key3) => row2[key3] !== void 0).map((key3) => [key3, row2[key3]]));
|
|
4584
|
-
var handleAdminMembershipSeats = async (req, url, env, ctx) => {
|
|
4585
|
-
const one = url.pathname.match(ONE), lift = url.pathname === LIFT, reprice = url.pathname === REPRICE;
|
|
4586
|
-
if (url.pathname !== LIST && !one && !lift && !reprice) return null;
|
|
4587
|
-
const operator = ctx.membershipAuthorityOperator;
|
|
4588
|
-
if (!operator) return json({ error: "not found" }, 404);
|
|
4589
|
-
if (req.method !== (one || lift || reprice ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
|
|
4590
|
-
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
4591
|
-
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4592
|
-
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
4593
|
-
let db;
|
|
4311
|
+
// src/worker-routes-named-seat-regift.ts
|
|
4312
|
+
async function recipientFrom(req, purchaserEmail) {
|
|
4313
|
+
let body;
|
|
4594
4314
|
try {
|
|
4595
|
-
|
|
4315
|
+
body = await namedSeatRequest(req);
|
|
4596
4316
|
} catch {
|
|
4597
|
-
return json({ error: "
|
|
4598
|
-
}
|
|
4599
|
-
if (lift || reprice) {
|
|
4600
|
-
let body;
|
|
4601
|
-
try {
|
|
4602
|
-
body = await req.json();
|
|
4603
|
-
} catch {
|
|
4604
|
-
return json({ error: "chapterId and runtime are required" }, 400);
|
|
4605
|
-
}
|
|
4606
|
-
const appId = body.appId ?? body.chapterId;
|
|
4607
|
-
if (typeof body.chapterId !== "string" || !SCOPE.test(body.chapterId) || typeof appId !== "string" || !SCOPE.test(appId) || typeof body.runtime !== "string" || !/^[a-z0-9]{2,12}$/.test(body.runtime) || reprice && (typeof body.skuId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{2,159}$/.test(body.skuId))) {
|
|
4608
|
-
return json({ error: reprice ? "chapterId, runtime and skuId are required" : "chapterId and runtime are required" }, 400);
|
|
4609
|
-
}
|
|
4610
|
-
const scope2 = { appId, chapterId: body.chapterId, environment: env.ODLA_ENV, runtime: body.runtime };
|
|
4611
|
-
try {
|
|
4612
|
-
if (reprice) return json({ ok: true, ...await lowerSeatPrice(db, { ...scope2, skuId: String(body.skuId) }, Date.now()) });
|
|
4613
|
-
return json({ ok: true, ...await liftSeatPurchaseWindow(db, scope2, Date.now()) });
|
|
4614
|
-
} catch (error) {
|
|
4615
|
-
const code = error.code;
|
|
4616
|
-
return json({ error: `${reprice ? "seat reprice" : "purchase window lift"} is pending; retry to confirm`, ...code ? { code } : {} }, code ? 409 : 503);
|
|
4617
|
-
}
|
|
4317
|
+
return json({ error: "invalid request" }, 400);
|
|
4618
4318
|
}
|
|
4619
|
-
if (!
|
|
4620
|
-
|
|
4621
|
-
return json({ seats: rows5.slice(0, 200).map(project), truncated: rows5.length > 200 });
|
|
4319
|
+
if (Object.keys(body).some((key3) => !["recipientName", "recipientEmail"].includes(key3)) || typeof body.recipientName !== "string" || typeof body.recipientEmail !== "string" || body.recipientName.length > 160 || body.recipientEmail.length > 254) {
|
|
4320
|
+
return json({ error: "a recipient name and email are required" }, 400);
|
|
4622
4321
|
}
|
|
4623
|
-
const
|
|
4624
|
-
if (
|
|
4625
|
-
|
|
4626
|
-
return json({ ok: true, seatId: seat.id, status: "refunded", refundedCents: seat.refundedCents, subscriptionCanceled: false, replayed: true });
|
|
4322
|
+
const recipientName = body.recipientName.trim().replace(/\s+/g, " "), recipientEmail = normalizeChapterEmail(body.recipientEmail);
|
|
4323
|
+
if (recipientName.length < 2 || /[\u0000-\u001f\u007f]/.test(body.recipientName) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail) || purchaserEmail && recipientEmail === normalizeChapterEmail(purchaserEmail)) {
|
|
4324
|
+
return json({ error: "a valid name and email for someone other than the purchaser are required" }, 400);
|
|
4627
4325
|
}
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4326
|
+
return { recipientName, recipientEmail };
|
|
4327
|
+
}
|
|
4328
|
+
async function regiftChapterNamedSeat(db, env, ctx, req, target, purchaserEmail) {
|
|
4329
|
+
const seats = ctx.membershipAuthority?.namedSeats;
|
|
4330
|
+
if (!seats?.regift) return json({ error: "naming a different recipient is not available on this network authority" }, 503);
|
|
4331
|
+
const recipient2 = await recipientFrom(req, purchaserEmail);
|
|
4332
|
+
if (recipient2 instanceof Response) return recipient2;
|
|
4333
|
+
const offer = await seats.offer(target, env), seat = offer.seat;
|
|
4334
|
+
if (!seat) return json({ error: "no purchased seat", code: "seat_not_found" }, 409);
|
|
4335
|
+
if (seat.regiftable !== true) return json({ error: `the seat is ${seat.status.replaceAll("_", " ")} and can no longer be given to someone else`, code: "seat_not_regiftable" }, 409);
|
|
4336
|
+
const receipt = await seats.regift({ ...target, seatId: seat.seatId, ...recipient2 }, env);
|
|
4337
|
+
if (receipt.seatId !== seat.seatId || receipt.recipientEmail !== recipient2.recipientEmail) throw new Error("named seat regift differs from the request");
|
|
4338
|
+
const released = await releaseNamedSeatRecipientApplications(db, env, ctx, { seatId: seat.seatId, keepRecipientEmail: receipt.recipientEmail });
|
|
4339
|
+
const consentId = `${offer.membershipId}:named-seat-consent`, consent = await membershipLoad(db, "namedSeatConsents", consentId);
|
|
4340
|
+
if (consent) await db.transact([{ t: "update", ns: "namedSeatConsents", id: consentId, attrs: {
|
|
4341
|
+
recipientName: receipt.recipientName,
|
|
4342
|
+
recipientEmail: receipt.recipientEmail,
|
|
4343
|
+
acceptedAt: Date.now()
|
|
4344
|
+
} }], { guards: [{ ns: "namedSeatConsents", id: consentId, exists: true }] });
|
|
4345
|
+
return json({ ok: true, seat: receipt, releasedApplicationIds: released.released });
|
|
4346
|
+
}
|
|
4347
|
+
var handleNamedSeatRegift = async (req, url, env, ctx) => {
|
|
4348
|
+
if (url.pathname !== "/api/named-seat/regift") return null;
|
|
4349
|
+
const seats = ctx.membershipAuthority?.namedSeats;
|
|
4350
|
+
if (ctx.chapter.mode !== "chapter" || !seats) return json({ error: "named seats are not configured" }, 404);
|
|
4351
|
+
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
4352
|
+
const user = await ctx.verifyUser(req, env);
|
|
4353
|
+
if (!user?.email) return json({ error: "unauthorized" }, 401);
|
|
4354
|
+
const db = ctx.makeDb(env);
|
|
4631
4355
|
try {
|
|
4632
|
-
const
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
}, secret, Date.now());
|
|
4641
|
-
return json({ ok: true, ...receipt });
|
|
4642
|
-
} catch (error) {
|
|
4643
|
-
const code = error.code;
|
|
4644
|
-
return json({ error: "seat refund is pending; retry to confirm", ...code ? { code } : {} }, code ? 409 : 503);
|
|
4356
|
+
const member = await memberSessionApplication(db, ctx.chapter.id, env.ODLA_RUNTIME, { userId: user.userId, email: user.email });
|
|
4357
|
+
if (!member || member.source.clerkUserId !== user.userId) return json({ error: "membership required" }, 403);
|
|
4358
|
+
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(member.source.id));
|
|
4359
|
+
if (!intent) return json({ error: "paid membership required" }, 409);
|
|
4360
|
+
const target = await membershipTargetForIntent(intent, member.source, env, ctx);
|
|
4361
|
+
return await regiftChapterNamedSeat(db, env, ctx, req, target, user.email);
|
|
4362
|
+
} catch {
|
|
4363
|
+
return json({ error: "named seat service unavailable; refresh to check the seat before trying again" }, 503);
|
|
4645
4364
|
}
|
|
4646
4365
|
};
|
|
4647
4366
|
|
|
4648
|
-
// src/
|
|
4649
|
-
var
|
|
4650
|
-
var
|
|
4651
|
-
var
|
|
4652
|
-
const
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
const
|
|
4657
|
-
|
|
4658
|
-
}
|
|
4659
|
-
var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
|
|
4660
|
-
function stateOf(item, all, now, window, rivals) {
|
|
4661
|
-
if (item.status !== "published") return { state: item.status };
|
|
4662
|
-
const mine = window(item);
|
|
4663
|
-
if (now < mine.from) return { state: "scheduled" };
|
|
4664
|
-
const closed = mine.until !== void 0 && mine.until <= now;
|
|
4665
|
-
const higher = all.filter((other) => other.id !== item.id && other.status === "published" && other.version > item.version && rivals(item, other)).map(window).filter((w) => w.from <= now && (w.until === void 0 || w.until > mine.from));
|
|
4666
|
-
if (!closed && !higher.some((w) => live(w, now))) return { state: "active" };
|
|
4667
|
-
const dates = higher.map((w) => Math.max(w.from, mine.from));
|
|
4668
|
-
if (closed) dates.push(mine.until);
|
|
4669
|
-
return { state: "superseded", supersededAt: Math.min(...dates) };
|
|
4670
|
-
}
|
|
4671
|
-
var mechanism = (policy2) => policy2.percentOffBps === 0 || policy2.discountEligibleSkuIds.length === 0 ? "none" : policy2.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy2.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
|
|
4672
|
-
var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
|
|
4673
|
-
var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
|
|
4674
|
-
function normalize(rows5, collection, parse, invalid2) {
|
|
4675
|
-
const out = [];
|
|
4676
|
-
for (const row2 of rows5) {
|
|
4677
|
-
try {
|
|
4678
|
-
out.push({ value: parse(row2.snapshot), row: row2 });
|
|
4679
|
-
} catch (error) {
|
|
4680
|
-
invalid2.push({ collection, id: String(row2.id ?? ""), error: error instanceof Error ? error.message : String(error) });
|
|
4681
|
-
}
|
|
4682
|
-
}
|
|
4683
|
-
return out;
|
|
4684
|
-
}
|
|
4685
|
-
async function listMembershipPolicyVersions(db, input) {
|
|
4686
|
-
const where = input.chapterId ? { chapterId: input.chapterId } : {};
|
|
4687
|
-
const rows5 = await db.query({
|
|
4688
|
-
membershipOfferingVersion: { $: { where, limit: LIMIT } },
|
|
4689
|
-
discountPolicyVersion: { $: { where, limit: LIMIT } }
|
|
4690
|
-
});
|
|
4691
|
-
const offeringRows = rows5.membershipOfferingVersion ?? [], policyRows = rows5.discountPolicyVersion ?? [];
|
|
4692
|
-
const invalid2 = [];
|
|
4693
|
-
const offerings = normalize(offeringRows, "membershipOfferingVersion", membershipOfferingVersion, invalid2);
|
|
4694
|
-
const policies = normalize(policyRows, "discountPolicyVersion", membershipDiscountPolicyVersion, invalid2);
|
|
4695
|
-
const offeringValues = offerings.map((item) => item.value), policyValues = policies.map((item) => item.value);
|
|
4696
|
-
return {
|
|
4697
|
-
now: input.now,
|
|
4698
|
-
chapterId: input.chapterId ?? null,
|
|
4699
|
-
offerings: offerings.map(({ value, row: row2 }) => ({
|
|
4700
|
-
...value,
|
|
4701
|
-
...provenance(row2),
|
|
4702
|
-
...stateOf(
|
|
4703
|
-
value,
|
|
4704
|
-
offeringValues,
|
|
4705
|
-
input.now,
|
|
4706
|
-
(v) => ({ from: v.effectiveFrom, ...v.effectiveUntil !== void 0 ? { until: v.effectiveUntil } : {} }),
|
|
4707
|
-
(a, b) => a.chapterId === b.chapterId && a.baseSkuId === b.baseSkuId
|
|
4708
|
-
)
|
|
4709
|
-
})).sort((a, b) => byChapter(a, b) || a.baseSkuId.localeCompare(b.baseSkuId) || b.version - a.version),
|
|
4710
|
-
discountPolicies: policies.map(({ value, row: row2 }) => ({
|
|
4711
|
-
...value,
|
|
4712
|
-
...provenance(row2),
|
|
4713
|
-
mechanism: mechanism(value),
|
|
4714
|
-
...stateOf(
|
|
4715
|
-
value,
|
|
4716
|
-
policyValues,
|
|
4717
|
-
input.now,
|
|
4718
|
-
(v) => ({ from: v.qualifyingStartsAt, until: v.qualifyingEndsAt }),
|
|
4719
|
-
(a, b) => a.chapterId === b.chapterId && sharesSku(a, b)
|
|
4720
|
-
)
|
|
4721
|
-
})).sort((a, b) => byChapter(a, b) || b.version - a.version),
|
|
4722
|
-
invalid: invalid2,
|
|
4723
|
-
truncated: offeringRows.length >= LIMIT || policyRows.length >= LIMIT
|
|
4724
|
-
};
|
|
4725
|
-
}
|
|
4726
|
-
|
|
4727
|
-
// src/worker-routes-admin-membership-policy.ts
|
|
4728
|
-
var PATH3 = "/api/admin/membership-policy-versions";
|
|
4729
|
-
var SCOPE2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
4730
|
-
var handleAdminMembershipPolicyVersions = async (req, url, env, ctx) => {
|
|
4731
|
-
if (url.pathname !== PATH3) return null;
|
|
4732
|
-
const operator = ctx.membershipAuthorityOperator;
|
|
4733
|
-
if (!operator) return json({ error: "not found" }, 404);
|
|
4734
|
-
if (req.method !== "GET") return json({ error: "method not allowed" }, 405);
|
|
4367
|
+
// src/worker-routes-admin-named-seat.ts
|
|
4368
|
+
var PATH2 = /^\/api\/admin\/applications\/([0-9a-f-]+)\/named-seat(\/refund|\/regift)?$/;
|
|
4369
|
+
var REFUNDABLE = /* @__PURE__ */ new Set(["renewal_pending", "pending_acceptance", "pending_approval", "pending_primary", "active"]);
|
|
4370
|
+
var handleAdminNamedSeat = async (req, url, env, ctx) => {
|
|
4371
|
+
const match = url.pathname.match(PATH2);
|
|
4372
|
+
if (!match) return null;
|
|
4373
|
+
const seats = ctx.membershipAuthority?.namedSeats;
|
|
4374
|
+
if (ctx.chapter.mode !== "chapter" || !seats) return json({ error: "named seats are not configured" }, 404);
|
|
4375
|
+
const refund = match[2] === "/refund", regift = match[2] === "/regift";
|
|
4376
|
+
if (req.method !== (refund || regift ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
|
|
4735
4377
|
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
4736
4378
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4737
4379
|
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
4738
|
-
const
|
|
4739
|
-
if (
|
|
4740
|
-
|
|
4380
|
+
const db = raw2, app = await membershipLoad(db, "applications", match[1]);
|
|
4381
|
+
if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME) || app.namedSeatId) return json({ error: "not found" }, 404);
|
|
4382
|
+
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(app.id));
|
|
4383
|
+
if (!intent) return json({ error: "paid membership required" }, 409);
|
|
4741
4384
|
try {
|
|
4742
|
-
|
|
4385
|
+
const target = await membershipTargetForIntent(intent, app, env, ctx);
|
|
4386
|
+
if (regift) return await regiftChapterNamedSeat(db, env, ctx, req, target, typeof app.email === "string" ? app.email : void 0);
|
|
4387
|
+
const offer = await seats.offer(target, env);
|
|
4388
|
+
const seat = offer.seat ?? null, refundable = Boolean(seat && REFUNDABLE.has(seat.status));
|
|
4389
|
+
if (!refund) {
|
|
4390
|
+
return json({
|
|
4391
|
+
seat,
|
|
4392
|
+
amountCents: offer.amountCents,
|
|
4393
|
+
currency: offer.currency,
|
|
4394
|
+
termEndsAt: offer.termEndsAt,
|
|
4395
|
+
purchaseEndsAt: offer.purchaseEndsAt,
|
|
4396
|
+
refundable,
|
|
4397
|
+
regiftable: seat?.regiftable === true
|
|
4398
|
+
});
|
|
4399
|
+
}
|
|
4400
|
+
if (!seat) return json({ error: "no purchased seat", code: "seat_not_found" }, 409);
|
|
4401
|
+
if (seat.status === "refunded") {
|
|
4402
|
+
await closeRecipientApplication(db, env, ctx, seat.seatId);
|
|
4403
|
+
return json({ ok: true, seatId: seat.seatId, status: "refunded", replayed: true });
|
|
4404
|
+
}
|
|
4405
|
+
if (!refundable) return json({ error: `seat is ${seat.status.replaceAll("_", " ")}`, code: "seat_not_refundable" }, 409);
|
|
4406
|
+
const receipt = await seats.operatorRefund({ ...target, seatId: seat.seatId }, env);
|
|
4407
|
+
await closeRecipientApplication(db, env, ctx, seat.seatId);
|
|
4408
|
+
return json({ ok: true, ...receipt });
|
|
4743
4409
|
} catch {
|
|
4744
|
-
return json({ error: "
|
|
4410
|
+
return json({ error: "named seat service unavailable; retry to confirm" }, 503);
|
|
4745
4411
|
}
|
|
4746
|
-
return json(await listMembershipPolicyVersions(db, { chapterId, now: Date.now() }));
|
|
4747
4412
|
};
|
|
4748
|
-
|
|
4749
|
-
|
|
4750
|
-
|
|
4751
|
-
|
|
4752
|
-
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
}
|
|
4757
|
-
async function verifySubscriptionPrice(secretKey, input, resume = false) {
|
|
4758
|
-
if (!/^price_[A-Za-z0-9]+$/.test(input.priceId) || !Number.isSafeInteger(input.priceCents) || input.priceCents <= 0 || !/^[a-z]{3}$/.test(input.currency) || !["month", "year"].includes(input.interval)) subscriptionContractMismatch();
|
|
4759
|
-
const result = await stripeRequest6({ secretKey }, "GET", `/v1/prices/${encodeURIComponent(input.priceId)}`);
|
|
4760
|
-
const price = result.body;
|
|
4761
|
-
if (!result.ok || !price || price.id !== input.priceId || !resume && price.active !== true || price.unit_amount !== input.priceCents || price.currency !== input.currency || price.type !== "recurring" || price.billing_scheme !== "per_unit" || price.custom_unit_amount || price.transform_quantity || price.recurring?.interval !== input.interval || price.recurring?.interval_count !== 1 || price.recurring?.usage_type !== "licensed" || price.livemode !== (input.environment === "prod")) subscriptionContractMismatch();
|
|
4762
|
-
return typeof price.product === "string" ? price.product : void 0;
|
|
4763
|
-
}
|
|
4764
|
-
function verifySubscriptionResult(sub, input, customerId, metadata) {
|
|
4765
|
-
const items = sub?.items;
|
|
4766
|
-
const seat = input.namedSeat?.amountCents ? input.namedSeat : void 0;
|
|
4767
|
-
const primary = items?.data?.find((item) => item.price?.id === input.priceId);
|
|
4768
|
-
const seatItem = seat && items?.data?.find((item) => item.price?.id === seat.priceId);
|
|
4769
|
-
if (!sub || !/^sub_[A-Za-z0-9]+$/.test(sub.id) || sub.customer !== customerId || input.existingSubscriptionId && sub.id !== input.existingSubscriptionId || !(input.billingTerms?.initialTermEndsAt ? ["incomplete", "active", "trialing"] : ["incomplete", "active"]).includes(sub.status) || sub.livemode !== (input.environment === "prod") || !Object.entries(metadata).every(([key3, value]) => sub.metadata?.[key3] === value) || !Array.isArray(items?.data) || items.has_more !== false || items.data.length !== (seat ? 2 : 1) || primary?.quantity !== 1 || seat && (seatItem?.quantity !== 1 || seatItem.metadata?.seatId !== seat.seatId || seatItem.metadata?.membershipId !== seat.membershipId)) subscriptionContractMismatch();
|
|
4770
|
-
const invoice = sub.latest_invoice;
|
|
4771
|
-
if (!invoice || typeof invoice !== "object" || invoice.customer !== customerId || invoice.currency !== input.currency || invoice.total !== subscriptionAmountDue(input) + (seat?.amountCents ?? 0) || invoice.billing_reason !== "subscription_create") subscriptionContractMismatch();
|
|
4772
|
-
verifySubscriptionBillingResult(sub, input);
|
|
4413
|
+
async function closeRecipientApplication(db, env, ctx, seatId) {
|
|
4414
|
+
const recipient2 = (await db.query({ applications: { $: { where: { namedSeatId: seatId }, limit: 1 } } })).applications?.[0];
|
|
4415
|
+
if (!recipient2 || !chapterBillingApplication(recipient2, ctx.chapter.id, env.ODLA_RUNTIME) || recipient2.status === "refunded") return;
|
|
4416
|
+
const attrs = { status: "refunded", renewalAt: 0, canceled: true };
|
|
4417
|
+
await db.transact(
|
|
4418
|
+
[{ t: "update", ns: "applications", id: String(recipient2.id), attrs }],
|
|
4419
|
+
{ mutationId: `verified-refund:${String(recipient2.id)}:${seatId}` }
|
|
4420
|
+
);
|
|
4421
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...recipient2, ...attrs }, stage: "refunded" }).catch(() => void 0);
|
|
4773
4422
|
}
|
|
4774
4423
|
|
|
4775
|
-
// src/membership-
|
|
4776
|
-
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4780
|
-
|
|
4781
|
-
|
|
4424
|
+
// src/membership-seat-refund-stripe.ts
|
|
4425
|
+
import { stripeRequest as stripeRequest5 } from "@odla-ai/stripe";
|
|
4426
|
+
|
|
4427
|
+
// src/membership-checkout-seat-refund.ts
|
|
4428
|
+
import { stripeRequest as stripeRequest4 } from "@odla-ai/stripe";
|
|
4429
|
+
var fail4 = (code = "named_seat_refund_mismatch") => {
|
|
4430
|
+
throw Object.assign(new Error(code), { code });
|
|
4782
4431
|
};
|
|
4783
|
-
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
return
|
|
4432
|
+
var id2 = (value) => typeof value === "string" ? value : value?.id ?? "";
|
|
4433
|
+
async function refundCheckoutNamedSeat(secretKey, seat) {
|
|
4434
|
+
const invoiceRef = seat.combinedInvoice;
|
|
4435
|
+
if (!invoiceRef || !/^in_[A-Za-z0-9_]+$/.test(invoiceRef.invoiceId) || !Number.isSafeInteger(invoiceRef.totalCents) || invoiceRef.totalCents <= seat.amountCents || seat.amountCents <= 0 || seat.status !== "refund_pending") fail4();
|
|
4436
|
+
const call = async (method, path, params, key3) => {
|
|
4437
|
+
const result = await stripeRequest4({ secretKey }, method, path, params, key3 ? { idempotencyKey: key3 } : {});
|
|
4438
|
+
if (!result.ok || !result.body) return fail4("named_seat_refund_provider_unavailable");
|
|
4439
|
+
return result.body;
|
|
4440
|
+
};
|
|
4441
|
+
const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId };
|
|
4442
|
+
const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
|
|
4443
|
+
if (sub.id !== seat.providerSubscriptionId || id2(sub.customer) !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries(metadata).every(([key3, value]) => sub.metadata?.[key3] === value) || !Array.isArray(sub.items?.data) || sub.items.has_more !== false) fail4();
|
|
4444
|
+
const invoice = await call("GET", `/v1/invoices/${encodeURIComponent(invoiceRef.invoiceId)}`);
|
|
4445
|
+
if (invoice.id !== invoiceRef.invoiceId || id2(invoice.subscription ?? invoice.parent?.subscription_details?.subscription) !== sub.id || id2(invoice.customer) !== seat.providerCustomerId || invoice.status !== "paid" || invoice.billing_reason !== "subscription_create" || invoice.total !== invoiceRef.totalCents || invoice.amount_paid !== invoiceRef.totalCents || invoice.currency !== seat.currency || invoice.livemode !== (seat.environment === "prod")) fail4();
|
|
4446
|
+
let chargeId = id2(invoice.charge), paymentId = id2(invoice.payment_intent);
|
|
4447
|
+
if (!chargeId && !paymentId) {
|
|
4448
|
+
const payments = await call("GET", "/v1/invoice_payments", { invoice: invoice.id, status: "paid", limit: 100 });
|
|
4449
|
+
if (!Array.isArray(payments.data) || payments.data.length !== 1 || payments.has_more !== false) fail4();
|
|
4450
|
+
const payment = payments.data[0];
|
|
4451
|
+
if (id2(payment.invoice) !== invoice.id || payment.status !== "paid" || payment.payment?.type !== "payment_intent") fail4();
|
|
4452
|
+
paymentId = id2(payment.payment.payment_intent);
|
|
4791
4453
|
}
|
|
4792
|
-
if (
|
|
4793
|
-
|
|
4794
|
-
}
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
const read2 = await stripeRequest7({ secretKey: sk }, "GET", path, { "expand[0]": "discounts" });
|
|
4798
|
-
const sub = read2.body, items = sub?.items?.data;
|
|
4799
|
-
const metadata = { ...stripePartitionMetadata(scope2), applicationId: String(member.applicationId) };
|
|
4800
|
-
if (!read2.ok || sub.id !== subId || sub.customer !== member.providerCustomerId || sub.livemode !== (scope2.environment === "prod") || !PROVIDER_LIVE.has(sub.status) || !Object.entries(metadata).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(items) || items.length < 1 || items.length > 2 || items.filter((item) => item.price?.id === member.providerPriceId && item.quantity === 1 && item.price?.product === productId).length !== 1) {
|
|
4801
|
-
throw Object.assign(new Error("provider subscription does not match the frozen membership"), { code: "provider_mismatch" });
|
|
4454
|
+
if (!chargeId) {
|
|
4455
|
+
if (!/^pi_[A-Za-z0-9_]+$/.test(paymentId)) fail4();
|
|
4456
|
+
const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(paymentId)}`);
|
|
4457
|
+
if (payment.id !== paymentId || id2(payment.customer) !== seat.providerCustomerId || payment.status !== "succeeded" || payment.amount_received !== invoiceRef.totalCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod")) fail4();
|
|
4458
|
+
chargeId = id2(payment.latest_charge);
|
|
4802
4459
|
}
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
|
|
4807
|
-
if (
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
"
|
|
4812
|
-
|
|
4813
|
-
const after = written.body?.discounts;
|
|
4814
|
-
if (!written.ok || written.body?.id !== subId || !Array.isArray(after) || after.length !== 1 || couponOf(after[0]) !== couponId) {
|
|
4815
|
-
membershipFail("membership_reterm_provider_unconfirmed");
|
|
4460
|
+
if (!/^ch_[A-Za-z0-9_]+$/.test(chargeId)) fail4();
|
|
4461
|
+
const charge = await call("GET", `/v1/charges/${encodeURIComponent(chargeId)}`);
|
|
4462
|
+
if (charge.id !== chargeId || id2(charge.customer) !== seat.providerCustomerId || charge.status !== "succeeded" || charge.livemode !== (seat.environment === "prod") || charge.amount !== invoiceRef.totalCents || charge.currency !== seat.currency || charge.invoice != null && id2(charge.invoice) !== invoice.id || paymentId && id2(charge.payment_intent) !== paymentId || !Number.isSafeInteger(charge.amount_refunded) || charge.amount_refunded < 0 || charge.amount_refunded > charge.amount) fail4();
|
|
4463
|
+
const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
|
|
4464
|
+
if (items.length > 1) fail4();
|
|
4465
|
+
if (items.length && sub.status !== "canceled") {
|
|
4466
|
+
const item = items[0];
|
|
4467
|
+
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.quantity !== 1 || item.price?.id !== seat.priceId) fail4();
|
|
4468
|
+
const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
|
|
4469
|
+
if (removed.id !== item.id || removed.deleted !== true) fail4("named_seat_renewal_removal_unconfirmed");
|
|
4816
4470
|
}
|
|
4817
|
-
return
|
|
4818
|
-
}
|
|
4819
|
-
|
|
4471
|
+
if (charge.amount_refunded === charge.amount && charge.refunded === true) return { refundedCents: seat.amountCents, refundIds: [] };
|
|
4472
|
+
const list = await call("GET", "/v1/refunds", { charge: chargeId, limit: 100 });
|
|
4473
|
+
if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail4("named_seat_refund_history_incomplete");
|
|
4474
|
+
let refundedCents = 0;
|
|
4475
|
+
const refundIds = [];
|
|
4476
|
+
for (const refund of list.data) {
|
|
4477
|
+
if (id2(refund.charge) !== chargeId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !/^re_[A-Za-z0-9_]+$/.test(refund.id) || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail4();
|
|
4478
|
+
if (["pending", "requires_action"].includes(refund.status)) fail4("named_seat_refund_pending");
|
|
4479
|
+
if (refund.status === "succeeded") {
|
|
4480
|
+
if (refund.metadata?.seatId !== seat.id || refund.metadata?.membershipId !== seat.primaryMembershipId) fail4("named_seat_refund_allocation_ambiguous");
|
|
4481
|
+
refundedCents += refund.amount;
|
|
4482
|
+
refundIds.push(refund.id);
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
if (refundedCents !== charge.amount_refunded || refundedCents > seat.amountCents) fail4();
|
|
4486
|
+
if (refundedCents < seat.amountCents) {
|
|
4487
|
+
const remaining = seat.amountCents - refundedCents;
|
|
4488
|
+
const refund = await call("POST", "/v1/refunds", {
|
|
4489
|
+
charge: chargeId,
|
|
4490
|
+
amount: remaining,
|
|
4491
|
+
reason: "requested_by_customer",
|
|
4492
|
+
metadata: { ...metadata, membershipId: seat.primaryMembershipId, seatId: seat.id }
|
|
4493
|
+
}, `combined-seat-refund:${seat.id}:${remaining}`);
|
|
4494
|
+
if (id2(refund.charge) !== chargeId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail4();
|
|
4495
|
+
if (refund.status !== "succeeded") fail4("named_seat_refund_pending");
|
|
4496
|
+
refundedCents += remaining;
|
|
4497
|
+
refundIds.push(refund.id);
|
|
4498
|
+
}
|
|
4499
|
+
return { refundedCents, refundIds };
|
|
4500
|
+
}
|
|
4501
|
+
|
|
4502
|
+
// src/membership-seat-refund-stripe.ts
|
|
4503
|
+
var fail5 = (code = "named_seat_refund_mismatch") => {
|
|
4504
|
+
throw Object.assign(new Error(code), { code });
|
|
4505
|
+
};
|
|
4506
|
+
async function refundNamedSeatStripePayment(secretKey, seat) {
|
|
4507
|
+
if (seat.combinedInvoice) return refundCheckoutNamedSeat(secretKey, seat);
|
|
4508
|
+
if (!seat.providerPaymentIntentId || seat.status !== "refund_pending") fail5();
|
|
4509
|
+
const call = async (method, path, params, idempotencyKey) => {
|
|
4510
|
+
const result = await stripeRequest5({ secretKey }, method, path, params, idempotencyKey ? { idempotencyKey } : {});
|
|
4511
|
+
if (!result.ok || !result.body) return fail5("named_seat_refund_provider_unavailable");
|
|
4512
|
+
return result.body;
|
|
4513
|
+
};
|
|
4514
|
+
const metadata = { ...stripePartitionMetadata(seat), applicationId: seat.applicationId, membershipId: seat.primaryMembershipId, seatId: seat.id };
|
|
4515
|
+
const payment = await call("GET", `/v1/payment_intents/${encodeURIComponent(seat.providerPaymentIntentId)}`);
|
|
4516
|
+
if (payment.id !== seat.providerPaymentIntentId || payment.customer !== seat.providerCustomerId || payment.amount !== seat.amountCents || payment.amount_received !== seat.amountCents || payment.currency !== seat.currency || payment.livemode !== (seat.environment === "prod") || payment.status !== "succeeded" || !Object.entries(metadata).every(([key3, value]) => payment.metadata?.[key3] === value)) fail5();
|
|
4517
|
+
const sub = await call("GET", `/v1/subscriptions/${encodeURIComponent(seat.providerSubscriptionId)}`);
|
|
4518
|
+
if (sub.id !== seat.providerSubscriptionId || sub.customer !== seat.providerCustomerId || sub.livemode !== (seat.environment === "prod") || !Object.entries({ ...stripePartitionMetadata(seat), applicationId: seat.applicationId }).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(sub.items?.data)) fail5();
|
|
4519
|
+
const items = sub.items.data.filter((item) => item.metadata?.seatId === seat.id);
|
|
4520
|
+
if (items.length > 1) fail5();
|
|
4521
|
+
if (items.length && sub.status !== "canceled") {
|
|
4522
|
+
const item = items[0];
|
|
4523
|
+
if (!/^si_[A-Za-z0-9_]+$/.test(item.id) || item.price?.id !== seat.priceId || item.quantity !== 1) fail5();
|
|
4524
|
+
const removed = await call("DELETE", `/v1/subscription_items/${encodeURIComponent(item.id)}`, { proration_behavior: "none" });
|
|
4525
|
+
if (removed.id !== item.id || removed.deleted !== true) fail5("named_seat_renewal_removal_unconfirmed");
|
|
4526
|
+
}
|
|
4527
|
+
const list = await call("GET", "/v1/refunds", { payment_intent: seat.providerPaymentIntentId, limit: 100 });
|
|
4528
|
+
if (!Array.isArray(list.data) || list.has_more !== false || list.data.length >= 100) fail5("named_seat_refund_history_incomplete");
|
|
4529
|
+
let refundedCents = 0, pendingCents = 0;
|
|
4530
|
+
const refundIds = [];
|
|
4531
|
+
for (const refund of list.data) {
|
|
4532
|
+
if (!/^re_[A-Za-z0-9_]+$/.test(refund.id) || refund.payment_intent !== seat.providerPaymentIntentId || refund.currency !== seat.currency || !Number.isSafeInteger(refund.amount) || refund.amount <= 0 || !["succeeded", "pending", "requires_action", "failed", "canceled"].includes(refund.status)) fail5();
|
|
4533
|
+
if (refund.status === "succeeded") {
|
|
4534
|
+
refundedCents += refund.amount;
|
|
4535
|
+
refundIds.push(refund.id);
|
|
4536
|
+
} else if (refund.status === "pending" || refund.status === "requires_action") pendingCents += refund.amount;
|
|
4537
|
+
}
|
|
4538
|
+
if (refundedCents + pendingCents > seat.amountCents) fail5();
|
|
4539
|
+
if (pendingCents) fail5("named_seat_refund_pending");
|
|
4540
|
+
if (refundedCents < seat.amountCents) {
|
|
4541
|
+
const remaining = seat.amountCents - refundedCents;
|
|
4542
|
+
const refund = await call("POST", "/v1/refunds", {
|
|
4543
|
+
payment_intent: seat.providerPaymentIntentId,
|
|
4544
|
+
amount: remaining,
|
|
4545
|
+
reason: "requested_by_customer",
|
|
4546
|
+
metadata
|
|
4547
|
+
}, `named-seat-refund:${seat.id}:${remaining}`);
|
|
4548
|
+
if (refund.payment_intent !== seat.providerPaymentIntentId || refund.amount !== remaining || refund.currency !== seat.currency || !/^re_[A-Za-z0-9_]+$/.test(refund.id)) fail5();
|
|
4549
|
+
if (refund.status !== "succeeded") fail5("named_seat_refund_pending");
|
|
4550
|
+
refundedCents += remaining;
|
|
4551
|
+
refundIds.push(refund.id);
|
|
4552
|
+
}
|
|
4553
|
+
return { refundedCents, refundIds };
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
// src/membership-seat-reservation.ts
|
|
4557
|
+
async function namedSeatRecipientClaimId(input, email) {
|
|
4558
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([input.appId, input.chapterId, input.environment, input.runtime, email])));
|
|
4559
|
+
return `seat-recipient:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
4560
|
+
}
|
|
4561
|
+
async function reserveNamedMembershipSeat(db, input, now) {
|
|
4562
|
+
membershipClock(now);
|
|
4563
|
+
const name = typeof input.recipientName === "string" ? input.recipientName.trim().replace(/\s+/g, " ") : "";
|
|
4564
|
+
const email = normalizeChapterEmail(typeof input.recipientEmail === "string" ? input.recipientEmail : "");
|
|
4565
|
+
const purchaser = normalizeChapterEmail(typeof input.purchaserEmail === "string" ? input.purchaserEmail : "");
|
|
4566
|
+
if (name.length < 2 || name.length > 160 || /[\u0000-\u001f\u007f]/.test(input.recipientName) || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || !purchaser || email === purchaser || !/^price_[A-Za-z0-9_]+$/.test(input.priceId) || !/^user_[A-Za-z0-9_]+$/.test(input.purchaserUserId)) membershipFail("invalid_named_seat_recipient");
|
|
4567
|
+
const state = await loadMembershipAuthorityState(db, input);
|
|
4568
|
+
const memberId = await networkMembershipId(input), member = await membershipLoad(db, "networkMembership", memberId);
|
|
4569
|
+
requireMembershipScope(member, input);
|
|
4570
|
+
if (!Number.isSafeInteger(member.revision) || !/^price_[A-Za-z0-9_]+$/.test(String(member.providerPriceId)) || member.providerPriceId === input.priceId) membershipFail("named_seat_purchase_unavailable");
|
|
4571
|
+
const id7 = (await membershipOfferIdForRequest({ ...input, requestId: "named_seat" })).replace(/^offer_/, "seat_");
|
|
4572
|
+
let seat = await membershipLoad(db, "networkSeat", id7);
|
|
4573
|
+
if (!seat) {
|
|
4574
|
+
const offering = membershipOfferingVersion(member.offeringSnapshot), claimId = await namedSeatRecipientClaimId(input, email);
|
|
4575
|
+
const purchaseEndsAt = seatPurchaseDeadline(offering, member);
|
|
4576
|
+
if (!["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) || Number(member.refundedCents) !== 0 || now < Number(member.initialTermStartedAt) || now >= Number(member.termEndsAt) || now > purchaseEndsAt || !Number.isSafeInteger(purchaseEndsAt) || member.namedSeatId !== void 0) {
|
|
4577
|
+
membershipFail("named_seat_purchase_unavailable");
|
|
4578
|
+
}
|
|
4579
|
+
const attrs = {
|
|
4580
|
+
id: id7,
|
|
4581
|
+
...membershipScope(input),
|
|
4582
|
+
primaryMembershipId: memberId,
|
|
4583
|
+
recipientName: name,
|
|
4584
|
+
recipientEmailNormalized: email,
|
|
4585
|
+
purchaserUserId: input.purchaserUserId,
|
|
4586
|
+
recipientClaimId: claimId,
|
|
4587
|
+
priceId: input.priceId,
|
|
4588
|
+
primaryPriceId: member.providerPriceId,
|
|
4589
|
+
amountCents: offering.seatPriceCents,
|
|
4590
|
+
currency: offering.currency,
|
|
4591
|
+
interval: offering.interval,
|
|
4592
|
+
providerCustomerId: member.providerCustomerId,
|
|
4593
|
+
providerSubscriptionId: member.providerSubscriptionId,
|
|
4594
|
+
status: "payment_pending",
|
|
4595
|
+
preparedAt: now,
|
|
4596
|
+
termEndsAt: member.termEndsAt,
|
|
4597
|
+
seatPurchaseEndsAt: purchaseEndsAt,
|
|
4598
|
+
offeringSnapshot: offering,
|
|
4599
|
+
revision: 1,
|
|
4600
|
+
createdAt: now,
|
|
4601
|
+
updatedAt: now
|
|
4602
|
+
};
|
|
4603
|
+
try {
|
|
4604
|
+
await db.transact([
|
|
4605
|
+
membershipUpdate("networkSeat", id7, attrs),
|
|
4606
|
+
membershipUpdate("networkSeatRecipient", claimId, {
|
|
4607
|
+
id: claimId,
|
|
4608
|
+
appId: input.appId,
|
|
4609
|
+
chapterId: input.chapterId,
|
|
4610
|
+
environment: input.environment,
|
|
4611
|
+
runtime: input.runtime,
|
|
4612
|
+
seatId: id7,
|
|
4613
|
+
recipientEmailNormalized: email,
|
|
4614
|
+
createdAt: now
|
|
4615
|
+
}),
|
|
4616
|
+
membershipUpdate("networkMembership", memberId, { namedSeatId: id7, revision: Number(member.revision) + 1, updatedAt: now })
|
|
4617
|
+
], { mutationId: `named-seat-reserve:${id7}`, guards: [
|
|
4618
|
+
{ ns: "networkSeat", id: id7, exists: false },
|
|
4619
|
+
{ ns: "networkSeatRecipient", id: claimId, exists: false },
|
|
4620
|
+
{ ns: "networkMembership", id: memberId, exists: true, equals: { revision: member.revision }, absent: ["namedSeatId"] },
|
|
4621
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
|
|
4622
|
+
] });
|
|
4623
|
+
} catch (error) {
|
|
4624
|
+
if (error.code !== "transact_guard_failed") throw error;
|
|
4625
|
+
}
|
|
4626
|
+
seat = await membershipLoad(db, "networkSeat", id7);
|
|
4627
|
+
if (!seat && await membershipLoad(db, "networkSeatRecipient", claimId)) membershipFail("named_seat_recipient_unavailable");
|
|
4628
|
+
}
|
|
4629
|
+
requireMembershipScope(seat, input);
|
|
4630
|
+
if (seat.primaryMembershipId !== memberId || seat.recipientName !== name || seat.recipientEmailNormalized !== email || seat.priceId !== input.priceId || seat.purchaserUserId !== input.purchaserUserId) membershipFail("named_seat_purchase_conflict");
|
|
4631
|
+
return seat;
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
// src/membership-seat-included.ts
|
|
4635
|
+
var namedSeatIncluded = (seat) => Number(seat.amountCents) === 0;
|
|
4636
|
+
var namedSeatSettled = (seat) => namedSeatIncluded(seat) ? seat.purchasedAt !== void 0 : Boolean((seat.providerPaymentIntentId || seat.combinedInvoice) && seat.providerSubscriptionItemId);
|
|
4637
|
+
function namedSeatRecipientState(seat) {
|
|
4638
|
+
switch (String(seat.status)) {
|
|
4639
|
+
case "renewal_pending":
|
|
4640
|
+
case "pending_acceptance":
|
|
4641
|
+
return "invited";
|
|
4642
|
+
case "pending_approval":
|
|
4643
|
+
case "pending_primary":
|
|
4644
|
+
return "accepted";
|
|
4645
|
+
case "active":
|
|
4646
|
+
return "joined";
|
|
4647
|
+
case "refund_pending":
|
|
4648
|
+
case "refunded":
|
|
4649
|
+
return seat.refundReason === "declined" ? "declined" : "refunded";
|
|
4650
|
+
case "canceled":
|
|
4651
|
+
return "canceled";
|
|
4652
|
+
default:
|
|
4653
|
+
return "payment_pending";
|
|
4654
|
+
}
|
|
4655
|
+
}
|
|
4656
|
+
var OPEN = ["pending_acceptance", "pending_approval", "pending_primary", "active"];
|
|
4657
|
+
async function grantIncludedNamedSeat(db, input, now) {
|
|
4658
|
+
membershipClock(now);
|
|
4659
|
+
const seat = await reserveNamedMembershipSeat(db, input, now);
|
|
4660
|
+
if (!namedSeatIncluded(seat)) membershipFail("named_seat_not_included");
|
|
4661
|
+
const state = await loadMembershipAuthorityState(db, input);
|
|
4662
|
+
const member = await membershipLoad(db, "networkMembership", seat.primaryMembershipId);
|
|
4663
|
+
requireMembershipScope(member, input);
|
|
4664
|
+
if (!["paid_pending_approval", "active", "cancel_at_term_end"].includes(String(member.status)) || Number(member.refundedCents) !== 0 || member.namedSeatId !== seat.id || now >= Number(member.termEndsAt)) {
|
|
4665
|
+
membershipFail("named_seat_purchase_unavailable");
|
|
4666
|
+
}
|
|
4667
|
+
if (seat.status === "payment_pending") {
|
|
4668
|
+
const entitlementId = `${seat.id}:entitlement`, inviteId = `${seat.id}:invite`, source = `included:${seat.id}`;
|
|
4669
|
+
try {
|
|
4670
|
+
await db.transact([
|
|
4671
|
+
membershipUpdate("networkSeat", seat.id, {
|
|
4672
|
+
status: "pending_acceptance",
|
|
4673
|
+
purchasedAt: now,
|
|
4674
|
+
termStartedAt: now,
|
|
4675
|
+
termEndsAt: member.termEndsAt,
|
|
4676
|
+
acceptanceEndsAt: Number(member.termEndsAt),
|
|
4677
|
+
entitlementId,
|
|
4678
|
+
revision: Number(seat.revision) + 1,
|
|
4679
|
+
updatedAt: now
|
|
4680
|
+
}),
|
|
4681
|
+
membershipUpdate("networkEntitlement", entitlementId, {
|
|
4682
|
+
id: entitlementId,
|
|
4683
|
+
...membershipScope(input),
|
|
4684
|
+
membershipId: member.id,
|
|
4685
|
+
seatId: seat.id,
|
|
4686
|
+
kind: "named_seat",
|
|
4687
|
+
status: "pending_acceptance",
|
|
4688
|
+
termStartedAt: now,
|
|
4689
|
+
termEndsAt: member.termEndsAt,
|
|
4690
|
+
offeringSnapshot: member.offeringSnapshot,
|
|
4691
|
+
sourceEventId: source,
|
|
4692
|
+
revision: 1,
|
|
4693
|
+
createdAt: now,
|
|
4694
|
+
updatedAt: now
|
|
4695
|
+
}),
|
|
4696
|
+
membershipUpdate("membershipEffect", inviteId, {
|
|
4697
|
+
id: inviteId,
|
|
4698
|
+
chapterId: input.chapterId,
|
|
4699
|
+
environment: input.environment,
|
|
4700
|
+
runtime: input.runtime,
|
|
4701
|
+
membershipId: member.id,
|
|
4702
|
+
seatId: seat.id,
|
|
4703
|
+
kind: "invite_named_recipient",
|
|
4704
|
+
status: "pending",
|
|
4705
|
+
attempts: 0,
|
|
4706
|
+
createdAt: now,
|
|
4707
|
+
updatedAt: now
|
|
4708
|
+
})
|
|
4709
|
+
], { mutationId: `named-seat-included:${seat.id}`, guards: [
|
|
4710
|
+
{
|
|
4711
|
+
ns: "networkSeat",
|
|
4712
|
+
id: seat.id,
|
|
4713
|
+
exists: true,
|
|
4714
|
+
equals: { status: "payment_pending", revision: seat.revision, amountCents: 0 },
|
|
4715
|
+
absent: ["purchasedAt", "providerPaymentIntentId"]
|
|
4716
|
+
},
|
|
4717
|
+
{ ns: "networkEntitlement", id: entitlementId, exists: false },
|
|
4718
|
+
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision, namedSeatId: seat.id } },
|
|
4719
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
|
|
4720
|
+
] });
|
|
4721
|
+
} catch (error) {
|
|
4722
|
+
if (error.code !== "transact_guard_failed") throw error;
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4725
|
+
const current = await membershipLoad(db, "networkSeat", seat.id);
|
|
4726
|
+
requireMembershipScope(current, input);
|
|
4727
|
+
if (!namedSeatIncluded(current) || current.purchasedAt === void 0 || !OPEN.includes(String(current.status))) membershipFail("named_seat_purchase_unavailable");
|
|
4728
|
+
return { seatId: seat.id, amountCents: 0, currency: seat.currency, termEndsAt: Number(current.termEndsAt), status: String(current.status), included: true };
|
|
4729
|
+
}
|
|
4730
|
+
|
|
4731
|
+
// src/membership-seat-refund.ts
|
|
4732
|
+
async function requestNamedSeatRefund(db, input, now) {
|
|
4733
|
+
membershipClock(now);
|
|
4734
|
+
const state = await loadMembershipAuthorityState(db, input);
|
|
4735
|
+
const seat = await membershipLoad(db, "networkSeat", input.seatId);
|
|
4736
|
+
requireMembershipScope(seat, input);
|
|
4737
|
+
const member = await membershipLoad(db, "networkMembership", String(seat.primaryMembershipId));
|
|
4738
|
+
requireMembershipScope(member, input);
|
|
4739
|
+
if (seat.status === "refunded" || seat.status === "refund_pending") return;
|
|
4740
|
+
if (!seat.purchasedAt || !seat.providerPaymentIntentId && !seat.combinedInvoice && !namedSeatIncluded(seat) || input.reason === "declined" && seat.journeyApprovedAt !== void 0 || input.reason === "primary_closed" && !["refunded", "declined"].includes(String(member.status))) membershipFail("named_seat_refund_not_eligible");
|
|
4741
|
+
const entitlement = seat.entitlementId ? await membershipLoad(db, "networkEntitlement", String(seat.entitlementId)) : void 0;
|
|
4742
|
+
await db.transact([
|
|
4743
|
+
membershipUpdate("networkSeat", input.seatId, { status: "refund_pending", refundReason: input.reason, revision: Number(seat.revision) + 1, updatedAt: now }),
|
|
4744
|
+
...entitlement ? [membershipUpdate("networkEntitlement", String(entitlement.id), { status: "closed", revision: Number(entitlement.revision) + 1, updatedAt: now })] : [],
|
|
4745
|
+
membershipUpdate("membershipEffect", `${seat.id}:refund`, {
|
|
4746
|
+
id: `${seat.id}:refund`,
|
|
4747
|
+
chapterId: input.chapterId,
|
|
4748
|
+
environment: input.environment,
|
|
4749
|
+
runtime: input.runtime,
|
|
4750
|
+
membershipId: member.id,
|
|
4751
|
+
seatId: seat.id,
|
|
4752
|
+
kind: "refund_seat",
|
|
4753
|
+
status: "pending",
|
|
4754
|
+
attempts: 0,
|
|
4755
|
+
createdAt: now,
|
|
4756
|
+
updatedAt: now
|
|
4757
|
+
}),
|
|
4758
|
+
...seat.recipientUserId ? [membershipUpdate("membershipEffect", `${seat.id}:revoke`, {
|
|
4759
|
+
id: `${seat.id}:revoke`,
|
|
4760
|
+
chapterId: input.chapterId,
|
|
4761
|
+
environment: input.environment,
|
|
4762
|
+
runtime: input.runtime,
|
|
4763
|
+
membershipId: member.id,
|
|
4764
|
+
seatId: seat.id,
|
|
4765
|
+
kind: "revoke_named_recipient_access",
|
|
4766
|
+
status: "pending",
|
|
4767
|
+
attempts: 0,
|
|
4768
|
+
createdAt: now,
|
|
4769
|
+
updatedAt: now
|
|
4770
|
+
})] : []
|
|
4771
|
+
], { mutationId: `named-seat-refund-request:${seat.id}`, guards: [
|
|
4772
|
+
{ ns: "networkSeat", id: input.seatId, exists: true, equals: { revision: seat.revision } },
|
|
4773
|
+
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision } },
|
|
4774
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } },
|
|
4775
|
+
...entitlement ? [{ ns: "networkEntitlement", id: String(entitlement.id), exists: true, equals: { revision: entitlement.revision } }] : []
|
|
4776
|
+
] });
|
|
4777
|
+
}
|
|
4778
|
+
async function completeNamedSeatRefund(db, seatId, secretKey, now) {
|
|
4820
4779
|
membershipClock(now);
|
|
4821
|
-
const
|
|
4822
|
-
if (!
|
|
4823
|
-
|
|
4824
|
-
if (
|
|
4780
|
+
const seat = await membershipLoad(db, "networkSeat", seatId);
|
|
4781
|
+
if (!seat) return membershipFail("named_seat_not_found");
|
|
4782
|
+
await loadMembershipAuthorityState(db, seat);
|
|
4783
|
+
if (seat.status === "refunded") return;
|
|
4784
|
+
if (seat.status !== "refund_pending") membershipFail("named_seat_refund_not_ready");
|
|
4785
|
+
const result = namedSeatIncluded(seat) ? { refundedCents: 0, refundIds: [] } : await refundNamedSeatStripePayment(secretKey, seat);
|
|
4786
|
+
await db.transact([
|
|
4787
|
+
membershipUpdate("networkSeat", seatId, {
|
|
4788
|
+
status: "refunded",
|
|
4789
|
+
refundedCents: result.refundedCents,
|
|
4790
|
+
providerRefundIds: result.refundIds,
|
|
4791
|
+
revision: Number(seat.revision) + 1,
|
|
4792
|
+
updatedAt: now
|
|
4793
|
+
}),
|
|
4794
|
+
membershipUpdate("membershipEffect", `${seatId}:refund`, { status: "complete", updatedAt: now })
|
|
4795
|
+
], { mutationId: `named-seat-refunded:${seatId}`, guards: [
|
|
4796
|
+
{ ns: "networkSeat", id: seatId, exists: true, equals: { status: "refund_pending", revision: seat.revision } }
|
|
4797
|
+
] });
|
|
4798
|
+
}
|
|
4799
|
+
|
|
4800
|
+
// src/membership-seat-operator-refund.ts
|
|
4801
|
+
async function refundNamedSeatByOperator(db, input, stripeSecretKey, now) {
|
|
4802
|
+
const seat = await membershipLoad(db, "networkSeat", input.seatId);
|
|
4803
|
+
requireMembershipScope(seat, input);
|
|
4804
|
+
await requestNamedSeatRefund(db, { ...input, reason: "operator" }, now);
|
|
4805
|
+
await completeNamedSeatRefund(db, input.seatId, stripeSecretKey, now);
|
|
4806
|
+
const refunded = await membershipLoad(db, "networkSeat", input.seatId);
|
|
4807
|
+
if (refunded?.status !== "refunded" || !Number.isSafeInteger(refunded.refundedCents) || Number(refunded.refundedCents) !== Number(seat.amountCents)) {
|
|
4808
|
+
throw new Error("named seat refund unconfirmed");
|
|
4809
|
+
}
|
|
4810
|
+
return { seatId: String(seat.id), status: "refunded", refundedCents: Number(refunded.refundedCents), subscriptionCanceled: false };
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4813
|
+
// src/membership-seat-window.ts
|
|
4814
|
+
async function liftSeatPurchaseWindow(db, scope2, now, limit = 100) {
|
|
4815
|
+
membershipClock(now);
|
|
4816
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError("invalid lift limit");
|
|
4825
4817
|
const state = await loadMembershipAuthorityState(db, scope2);
|
|
4826
|
-
const
|
|
4827
|
-
|
|
4828
|
-
const
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
|
|
4833
|
-
|
|
4834
|
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
receipt.more = found.length > limit;
|
|
4844
|
-
rows5 = found.slice(0, limit);
|
|
4818
|
+
const rows5 = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
|
|
4819
|
+
let lifted = 0;
|
|
4820
|
+
for (const member of rows5.slice(0, limit)) {
|
|
4821
|
+
if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) throw new Error("membership lift scope mismatch");
|
|
4822
|
+
const offering = membershipOfferingVersion(member.offeringSnapshot);
|
|
4823
|
+
if (offering.seatPurchaseWindowCalendarMonths === void 0) continue;
|
|
4824
|
+
const { seatPurchaseWindowCalendarMonths: _window, ...windowless } = offering;
|
|
4825
|
+
await db.transact([membershipUpdate("networkMembership", String(member.id), {
|
|
4826
|
+
offeringSnapshot: windowless,
|
|
4827
|
+
seatPurchaseEndsAt: Number(member.termEndsAt),
|
|
4828
|
+
revision: Number(member.revision) + 1,
|
|
4829
|
+
updatedAt: now
|
|
4830
|
+
})], { mutationId: `seat-window-lift:${member.id}:${member.revision}`, guards: [
|
|
4831
|
+
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision } },
|
|
4832
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } }
|
|
4833
|
+
] });
|
|
4834
|
+
lifted++;
|
|
4845
4835
|
}
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
4853
|
-
|
|
4854
|
-
|
|
4855
|
-
|
|
4856
|
-
|
|
4857
|
-
|
|
4858
|
-
|
|
4859
|
-
|
|
4860
|
-
|
|
4861
|
-
|
|
4836
|
+
return { lifted, more: rows5.length > limit };
|
|
4837
|
+
}
|
|
4838
|
+
|
|
4839
|
+
// src/membership-seat-reprice.ts
|
|
4840
|
+
async function lowerSeatPrice(db, scope2, now, limit = 100) {
|
|
4841
|
+
membershipClock(now);
|
|
4842
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError("invalid reprice limit");
|
|
4843
|
+
const state = await loadMembershipAuthorityState(db, scope2);
|
|
4844
|
+
const versions = (await db.query({ membershipOfferingVersion: { $: { where: { chapterId: scope2.chapterId }, limit: 200 } } })).membershipOfferingVersion ?? [];
|
|
4845
|
+
if (versions.length >= 200) throw Object.assign(new Error("policy_history_incomplete"), { code: "policy_history_incomplete" });
|
|
4846
|
+
const current = versions.map((row2) => membershipOfferingVersion(row2.snapshot)).filter((offer) => offer.baseSkuId === scope2.skuId && offer.status === "published" && now >= offer.effectiveFrom && (offer.effectiveUntil === void 0 || now < offer.effectiveUntil)).sort((a, b) => b.version - a.version)[0];
|
|
4847
|
+
if (!current) throw Object.assign(new Error("ambiguous_membership_policy"), { code: "ambiguous_membership_policy" });
|
|
4848
|
+
const target = current.seatPriceCents;
|
|
4849
|
+
const rows5 = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
|
|
4850
|
+
const receipt = { seatPriceCents: target, repriced: 0, skipped: [], more: rows5.length > limit };
|
|
4851
|
+
for (const member of rows5.slice(0, limit)) {
|
|
4852
|
+
if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) throw new Error("membership reprice scope mismatch");
|
|
4853
|
+
const id7 = String(member.id), skip = (reason) => {
|
|
4854
|
+
receipt.skipped.push({ membershipId: id7, reason });
|
|
4855
|
+
};
|
|
4856
|
+
const offering = membershipOfferingVersion(member.offeringSnapshot);
|
|
4857
|
+
if (offering.seatPriceCents <= target) {
|
|
4858
|
+
skip("seat price already at or below the current offering");
|
|
4862
4859
|
continue;
|
|
4863
4860
|
}
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
skip(id7, "sku_not_discounted");
|
|
4861
|
+
if (!["paid_pending_approval", "active", "cancel_at_term_end", "payment_grace"].includes(String(member.status))) {
|
|
4862
|
+
skip(`membership is ${String(member.status)}`);
|
|
4867
4863
|
continue;
|
|
4868
4864
|
}
|
|
4869
|
-
const
|
|
4870
|
-
|
|
4871
|
-
|
|
4872
|
-
skip(id7, "would_not_widen");
|
|
4865
|
+
const seat = member.namedSeatId ? await membershipLoad(db, "networkSeat", String(member.namedSeatId)) : void 0;
|
|
4866
|
+
if (seat && (seat.status !== "payment_pending" || seat.providerPaymentIntentId !== void 0)) {
|
|
4867
|
+
skip(`seat is ${String(seat.status)}`);
|
|
4873
4868
|
continue;
|
|
4874
4869
|
}
|
|
4875
|
-
const
|
|
4876
|
-
|
|
4877
|
-
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
environment: scope2.environment
|
|
4884
|
-
}, true);
|
|
4885
|
-
if (!productId) throw Object.assign(new Error("price has no product"), { code: "provider_mismatch" });
|
|
4886
|
-
await prepareSubscriptionBillingTerms(stripeSecretKey, {
|
|
4887
|
-
...scope2,
|
|
4888
|
-
priceCents: base,
|
|
4889
|
-
currency: String(member.currency),
|
|
4890
|
-
discountProductId: productId,
|
|
4891
|
-
billingTerms: {
|
|
4892
|
-
offeringVersionId: String(member.offeringVersionId),
|
|
4893
|
-
discount: { policyVersionId: policy2.id, couponId, percentOffBps: policy2.percentOffBps, duration: "forever" }
|
|
4894
|
-
}
|
|
4895
|
-
}, false);
|
|
4896
|
-
providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy2, couponId, productId);
|
|
4897
|
-
} catch (error) {
|
|
4898
|
-
const code = error.code;
|
|
4899
|
-
if (code === "provider_mismatch" || code === "stripe_contract_mismatch") {
|
|
4900
|
-
skip(id7, "provider_mismatch");
|
|
4901
|
-
continue;
|
|
4902
|
-
}
|
|
4903
|
-
throw error;
|
|
4904
|
-
}
|
|
4905
|
-
try {
|
|
4906
|
-
await db.transact([membershipUpdate("networkMembership", id7, {
|
|
4907
|
-
discountPolicyVersionId: policy2.id,
|
|
4908
|
-
discountSnapshot: policy2,
|
|
4909
|
-
renewalAmountCents: renewal,
|
|
4910
|
-
revision: Number(member.revision) + 1,
|
|
4911
|
-
updatedAt: now
|
|
4912
|
-
})], {
|
|
4913
|
-
mutationId: `membership-reterm:${id7}:${policy2.id}:${member.revision}`,
|
|
4914
|
-
guards: [
|
|
4915
|
-
{ ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision, status: member.status } },
|
|
4916
|
-
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } },
|
|
4917
|
-
{ ns: "discountPolicyVersion", id: policy2.id, exists: true }
|
|
4918
|
-
]
|
|
4919
|
-
});
|
|
4920
|
-
} catch (error) {
|
|
4921
|
-
if (error.code !== "transact_guard_failed") throw error;
|
|
4922
|
-
return membershipFail("membership_reterm_retry_required");
|
|
4870
|
+
const ops = [membershipUpdate("networkMembership", id7, { offeringSnapshot: { ...offering, seatPriceCents: target }, revision: Number(member.revision) + 1, updatedAt: now })];
|
|
4871
|
+
const guards = [
|
|
4872
|
+
{ ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision } },
|
|
4873
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } }
|
|
4874
|
+
];
|
|
4875
|
+
if (seat) {
|
|
4876
|
+
ops.push(membershipUpdate("networkSeat", String(seat.id), { amountCents: target, offeringSnapshot: { ...offering, seatPriceCents: target }, revision: Number(seat.revision) + 1, updatedAt: now }));
|
|
4877
|
+
guards.push({ ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision, status: "payment_pending" } });
|
|
4923
4878
|
}
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
...typeof member.discountPolicyVersionId === "string" ? { previousPolicyVersionId: member.discountPolicyVersionId } : {},
|
|
4927
|
-
previousRenewalAmountCents: current,
|
|
4928
|
-
renewalAmountCents: renewal,
|
|
4929
|
-
providerUpdated
|
|
4930
|
-
});
|
|
4879
|
+
await db.transact(ops, { mutationId: `seat-reprice:${id7}:${member.revision}`, guards });
|
|
4880
|
+
receipt.repriced++;
|
|
4931
4881
|
}
|
|
4932
4882
|
return receipt;
|
|
4933
4883
|
}
|
|
4934
4884
|
|
|
4935
|
-
// src/worker-routes-admin-membership-
|
|
4936
|
-
var
|
|
4937
|
-
var
|
|
4938
|
-
var
|
|
4939
|
-
var
|
|
4940
|
-
var
|
|
4941
|
-
|
|
4885
|
+
// src/worker-routes-admin-membership-seats.ts
|
|
4886
|
+
var LIST = "/api/admin/membership-seats";
|
|
4887
|
+
var LIFT = "/api/admin/membership-seats/lift-purchase-window";
|
|
4888
|
+
var REPRICE = "/api/admin/membership-seats/reprice";
|
|
4889
|
+
var ONE = /^\/api\/admin\/membership-seats\/([A-Za-z0-9][A-Za-z0-9_.:-]{2,159})\/refund$/;
|
|
4890
|
+
var SCOPE = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
4891
|
+
var PUBLIC = [
|
|
4892
|
+
"id",
|
|
4893
|
+
"appId",
|
|
4894
|
+
"chapterId",
|
|
4895
|
+
"environment",
|
|
4896
|
+
"runtime",
|
|
4897
|
+
"applicationId",
|
|
4898
|
+
"skuId",
|
|
4899
|
+
"status",
|
|
4900
|
+
"amountCents",
|
|
4901
|
+
"currency",
|
|
4902
|
+
"recipientName",
|
|
4903
|
+
"recipientEmailNormalized",
|
|
4904
|
+
"purchaserUserId",
|
|
4905
|
+
"purchasedAt",
|
|
4906
|
+
"acceptedAt",
|
|
4907
|
+
"journeyApprovedAt",
|
|
4908
|
+
"refundedCents",
|
|
4909
|
+
"refundReason",
|
|
4910
|
+
"termEndsAt",
|
|
4911
|
+
"updatedAt"
|
|
4912
|
+
];
|
|
4913
|
+
var project = (row2) => Object.fromEntries(PUBLIC.filter((key3) => row2[key3] !== void 0).map((key3) => [key3, row2[key3]]));
|
|
4914
|
+
var handleAdminMembershipSeats = async (req, url, env, ctx) => {
|
|
4915
|
+
const one = url.pathname.match(ONE), lift = url.pathname === LIFT, reprice = url.pathname === REPRICE;
|
|
4916
|
+
if (url.pathname !== LIST && !one && !lift && !reprice) return null;
|
|
4942
4917
|
const operator = ctx.membershipAuthorityOperator;
|
|
4943
4918
|
if (!operator) return json({ error: "not found" }, 404);
|
|
4944
|
-
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
4919
|
+
if (req.method !== (one || lift || reprice ? "POST" : "GET")) return json({ error: "method not allowed" }, 405);
|
|
4945
4920
|
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
4946
4921
|
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4947
4922
|
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
@@ -4951,267 +4926,427 @@ var handleAdminMembershipReterm = async (req, url, env, ctx) => {
|
|
|
4951
4926
|
} catch {
|
|
4952
4927
|
return json({ error: "the membership authority is not hosted by this deployment" }, 404);
|
|
4953
4928
|
}
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4929
|
+
if (lift || reprice) {
|
|
4930
|
+
let body;
|
|
4931
|
+
try {
|
|
4932
|
+
body = await req.json();
|
|
4933
|
+
} catch {
|
|
4934
|
+
return json({ error: "chapterId and runtime are required" }, 400);
|
|
4935
|
+
}
|
|
4936
|
+
const appId = body.appId ?? body.chapterId;
|
|
4937
|
+
if (typeof body.chapterId !== "string" || !SCOPE.test(body.chapterId) || typeof appId !== "string" || !SCOPE.test(appId) || typeof body.runtime !== "string" || !/^[a-z0-9]{2,12}$/.test(body.runtime) || reprice && (typeof body.skuId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{2,159}$/.test(body.skuId))) {
|
|
4938
|
+
return json({ error: reprice ? "chapterId, runtime and skuId are required" : "chapterId and runtime are required" }, 400);
|
|
4939
|
+
}
|
|
4940
|
+
const scope2 = { appId, chapterId: body.chapterId, environment: env.ODLA_ENV, runtime: body.runtime };
|
|
4941
|
+
try {
|
|
4942
|
+
if (reprice) return json({ ok: true, ...await lowerSeatPrice(db, { ...scope2, skuId: String(body.skuId) }, Date.now()) });
|
|
4943
|
+
return json({ ok: true, ...await liftSeatPurchaseWindow(db, scope2, Date.now()) });
|
|
4944
|
+
} catch (error) {
|
|
4945
|
+
const code = error.code;
|
|
4946
|
+
return json({ error: `${reprice ? "seat reprice" : "purchase window lift"} is pending; retry to confirm`, ...code ? { code } : {} }, code ? 409 : 503);
|
|
4947
|
+
}
|
|
4959
4948
|
}
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
return json({
|
|
4949
|
+
if (!one) {
|
|
4950
|
+
const rows5 = (await db.query({ networkSeat: { $: { where: { environment: env.ODLA_ENV }, order: { updatedAt: "desc" }, limit: 201 } } })).networkSeat ?? [];
|
|
4951
|
+
return json({ seats: rows5.slice(0, 200).map(project), truncated: rows5.length > 200 });
|
|
4952
|
+
}
|
|
4953
|
+
const seat = await membershipLoad(db, "networkSeat", one[1]);
|
|
4954
|
+
if (!seat || seat.environment !== env.ODLA_ENV) return json({ error: "not found" }, 404);
|
|
4955
|
+
if (seat.status === "refunded") {
|
|
4956
|
+
return json({ ok: true, seatId: seat.id, status: "refunded", refundedCents: seat.refundedCents, subscriptionCanceled: false, replayed: true });
|
|
4963
4957
|
}
|
|
4958
|
+
if (!seat.purchasedAt) return json({ error: "seat has no completed payment to refund", code: "seat_not_refundable" }, 409);
|
|
4964
4959
|
const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
|
|
4965
4960
|
if (!secret) return json({ error: "network payment provider unavailable" }, 503);
|
|
4966
4961
|
try {
|
|
4967
|
-
const receipt = await
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
Date.now()
|
|
4977
|
-
);
|
|
4962
|
+
const receipt = await refundNamedSeatByOperator(db, {
|
|
4963
|
+
appId: String(seat.appId),
|
|
4964
|
+
chapterId: String(seat.chapterId),
|
|
4965
|
+
environment: String(seat.environment),
|
|
4966
|
+
runtime: String(seat.runtime),
|
|
4967
|
+
applicationId: String(seat.applicationId),
|
|
4968
|
+
skuId: String(seat.skuId),
|
|
4969
|
+
seatId: String(seat.id)
|
|
4970
|
+
}, secret, Date.now());
|
|
4978
4971
|
return json({ ok: true, ...receipt });
|
|
4979
4972
|
} catch (error) {
|
|
4980
4973
|
const code = error.code;
|
|
4981
|
-
return json({ error: "
|
|
4982
|
-
}
|
|
4983
|
-
};
|
|
4984
|
-
|
|
4985
|
-
// src/worker-routes-membership-cancel.ts
|
|
4986
|
-
var handleMembershipCancellation = async (req, url, env, ctx) => {
|
|
4987
|
-
const match = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/cancel-renewal$/);
|
|
4988
|
-
if (!match) return null;
|
|
4989
|
-
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
4990
|
-
const user = await ctx.verifyUser(req, env), raw2 = ctx.makeDb(env);
|
|
4991
|
-
if (!user) return json({ error: "unauthorized" }, 401);
|
|
4992
|
-
if (!await ctx.isAdmin(raw2, user)) return json({ error: "forbidden" }, 403);
|
|
4993
|
-
if (!ctx.membershipAuthority?.cancelRenewal) return json({ error: "renewal cancellation unavailable" }, 503);
|
|
4994
|
-
const db = raw2, app = await membershipLoad(db, "applications", match[1]);
|
|
4995
|
-
if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
|
|
4996
|
-
if (app.namedSeatId || app.giftClaimId) return json({ error: "this membership has no independently renewing subscription" }, 409);
|
|
4997
|
-
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(app.id));
|
|
4998
|
-
if (!intent) return json({ error: "membership history needs reconciliation" }, 409);
|
|
4999
|
-
try {
|
|
5000
|
-
const target = await membershipTargetForIntent(intent, app, env, ctx);
|
|
5001
|
-
const receipt = await ctx.membershipAuthority.cancelRenewal(target, env);
|
|
5002
|
-
if (receipt.membershipId !== await networkMembershipId(target) || receipt.autoRenew !== false || !Number.isSafeInteger(receipt.termEndsAt) || !["cancel_at_term_end", "lapsed", "paid_pending_approval"].includes(receipt.status)) throw new Error("invalid cancellation receipt");
|
|
5003
|
-
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: {
|
|
5004
|
-
membershipStatus: receipt.status,
|
|
5005
|
-
membershipAutoRenew: false,
|
|
5006
|
-
membershipGraceEndsAt: 0,
|
|
5007
|
-
renewalAt: receipt.termEndsAt
|
|
5008
|
-
} }], { guards: [{
|
|
5009
|
-
ns: "applications",
|
|
5010
|
-
id: String(app.id),
|
|
5011
|
-
exists: true,
|
|
5012
|
-
equals: { status: app.status, stripeSubscriptionId: app.stripeSubscriptionId }
|
|
5013
|
-
}] });
|
|
5014
|
-
return json({ ok: true, accessEndsAt: receipt.termEndsAt, status: receipt.status });
|
|
5015
|
-
} catch {
|
|
5016
|
-
return json({ error: "renewal cancellation is pending; retry to confirm" }, 503);
|
|
4974
|
+
return json({ error: "seat refund is pending; retry to confirm", ...code ? { code } : {} }, code ? 409 : 503);
|
|
5017
4975
|
}
|
|
5018
4976
|
};
|
|
5019
4977
|
|
|
5020
|
-
// src/
|
|
5021
|
-
var
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
timezone: "America/Los_Angeles",
|
|
5027
|
-
minNoticeHours: 24,
|
|
5028
|
-
windowDays: 14,
|
|
5029
|
-
summaryTemplate: "Introduction call with {{firstName}} {{lastName}}"
|
|
4978
|
+
// src/membership-policy-versions.ts
|
|
4979
|
+
var LIMIT = 200;
|
|
4980
|
+
var text2 = (value) => typeof value === "string" && value.length > 0 && value.length <= 200 ? value : void 0;
|
|
4981
|
+
var instant = (value) => {
|
|
4982
|
+
const at2 = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : NaN;
|
|
4983
|
+
return Number.isSafeInteger(at2) && at2 >= 0 ? at2 : void 0;
|
|
5030
4984
|
};
|
|
5031
|
-
function
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
return true;
|
|
5035
|
-
} catch {
|
|
5036
|
-
return false;
|
|
5037
|
-
}
|
|
4985
|
+
function provenance(row2) {
|
|
4986
|
+
const source = text2(row2.source), actor = text2(row2.actor ?? row2.$createdBy), recordedAt = instant(row2.recordedAt ?? row2.createdAt ?? row2.$createdAt);
|
|
4987
|
+
return { ...source ? { source } : {}, ...actor ? { actor } : {}, ...recordedAt !== void 0 ? { recordedAt } : {} };
|
|
5038
4988
|
}
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
if (
|
|
5042
|
-
const
|
|
5043
|
-
|
|
4989
|
+
var live = (w, now) => w.from <= now && (w.until === void 0 || now < w.until);
|
|
4990
|
+
function stateOf(item, all, now, window, rivals) {
|
|
4991
|
+
if (item.status !== "published") return { state: item.status };
|
|
4992
|
+
const mine = window(item);
|
|
4993
|
+
if (now < mine.from) return { state: "scheduled" };
|
|
4994
|
+
const closed = mine.until !== void 0 && mine.until <= now;
|
|
4995
|
+
const higher = all.filter((other) => other.id !== item.id && other.status === "published" && other.version > item.version && rivals(item, other)).map(window).filter((w) => w.from <= now && (w.until === void 0 || w.until > mine.from));
|
|
4996
|
+
if (!closed && !higher.some((w) => live(w, now))) return { state: "active" };
|
|
4997
|
+
const dates = higher.map((w) => Math.max(w.from, mine.from));
|
|
4998
|
+
if (closed) dates.push(mine.until);
|
|
4999
|
+
return { state: "superseded", supersededAt: Math.min(...dates) };
|
|
5044
5000
|
}
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
};
|
|
5057
|
-
const errors = {};
|
|
5058
|
-
if (!(c.slotMinutes >= 15 && c.slotMinutes <= 240)) {
|
|
5059
|
-
errors.slotMinutes = "Slot length must be between 15 and 240 minutes.";
|
|
5060
|
-
}
|
|
5061
|
-
if (!(c.windowDays >= 1 && c.windowDays <= 62)) {
|
|
5062
|
-
errors.windowDays = "Booking window must be between 1 and 62 days (the calendar caps look-ahead at 62).";
|
|
5063
|
-
}
|
|
5064
|
-
if (!(c.minNoticeHours >= 0 && c.minNoticeHours <= 336)) {
|
|
5065
|
-
errors.minNoticeHours = "Minimum notice must be between 0 and 336 hours.";
|
|
5066
|
-
}
|
|
5067
|
-
if (!(c.startHour >= 0 && c.startHour < c.endHour && c.endHour <= 24)) {
|
|
5068
|
-
errors.hours = "Hours must satisfy 0 \u2264 start < end \u2264 24.";
|
|
5069
|
-
}
|
|
5070
|
-
const days = [...c.days];
|
|
5071
|
-
if (!days.length) errors.days = "Pick at least one day.";
|
|
5072
|
-
else if (!days.every((n) => Number.isInteger(n) && n >= 0 && n <= 6)) {
|
|
5073
|
-
errors.days = "Days must be weekday numbers, 0 (Sunday) through 6 (Saturday).";
|
|
5074
|
-
}
|
|
5075
|
-
if (typeof c.timezone !== "string" || !isValidTimeZone(c.timezone)) {
|
|
5076
|
-
errors.timezone = `"${String(c.timezone)}" is not a valid IANA timezone (for example "America/Los_Angeles").`;
|
|
5001
|
+
var mechanism = (policy2) => policy2.percentOffBps === 0 || policy2.discountEligibleSkuIds.length === 0 ? "none" : policy2.benefitDuration.kind === "while_active" ? "stripe_coupon_forever" : policy2.benefitDuration.kind === "first_term" ? "stripe_coupon_once" : "stripe_coupon_term_bound";
|
|
5002
|
+
var sharesSku = (a, b) => a.cohortEligibleSkuIds.some((sku) => b.cohortEligibleSkuIds.includes(sku));
|
|
5003
|
+
var byChapter = (a, b) => a.chapterId.localeCompare(b.chapterId);
|
|
5004
|
+
function normalize(rows5, collection, parse, invalid2) {
|
|
5005
|
+
const out = [];
|
|
5006
|
+
for (const row2 of rows5) {
|
|
5007
|
+
try {
|
|
5008
|
+
out.push({ value: parse(row2.snapshot), row: row2 });
|
|
5009
|
+
} catch (error) {
|
|
5010
|
+
invalid2.push({ collection, id: String(row2.id ?? ""), error: error instanceof Error ? error.message : String(error) });
|
|
5011
|
+
}
|
|
5077
5012
|
}
|
|
5078
|
-
|
|
5079
|
-
return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, value: { ...c, days } };
|
|
5080
|
-
}
|
|
5081
|
-
function slotWindow(now, windowDays) {
|
|
5082
|
-
return { from: now, to: now + windowDays * 864e5 };
|
|
5083
|
-
}
|
|
5084
|
-
function endForSlot(startAt, slotMinutes) {
|
|
5085
|
-
return startAt + slotMinutes * 6e4;
|
|
5086
|
-
}
|
|
5087
|
-
function isSlotAvailable(slots, startAt) {
|
|
5088
|
-
return slots.some((s) => s.startAt === startAt);
|
|
5089
|
-
}
|
|
5090
|
-
function renderSummary(template, app) {
|
|
5091
|
-
return template.replace("{{firstName}}", app.firstName ?? "").replace("{{lastName}}", app.lastName ?? "");
|
|
5092
|
-
}
|
|
5093
|
-
function bookingDecision(existing) {
|
|
5094
|
-
const eventId = existing?.googleEventId ?? null;
|
|
5095
|
-
return { reschedule: Boolean(eventId), eventId };
|
|
5096
|
-
}
|
|
5097
|
-
function introIdempotencyKey(applicationId) {
|
|
5098
|
-
return `application:${applicationId}:intro`;
|
|
5013
|
+
return out;
|
|
5099
5014
|
}
|
|
5100
|
-
function
|
|
5015
|
+
async function listMembershipPolicyVersions(db, input) {
|
|
5016
|
+
const where = input.chapterId ? { chapterId: input.chapterId } : {};
|
|
5017
|
+
const rows5 = await db.query({
|
|
5018
|
+
membershipOfferingVersion: { $: { where, limit: LIMIT } },
|
|
5019
|
+
discountPolicyVersion: { $: { where, limit: LIMIT } }
|
|
5020
|
+
});
|
|
5021
|
+
const offeringRows = rows5.membershipOfferingVersion ?? [], policyRows = rows5.discountPolicyVersion ?? [];
|
|
5022
|
+
const invalid2 = [];
|
|
5023
|
+
const offerings = normalize(offeringRows, "membershipOfferingVersion", membershipOfferingVersion, invalid2);
|
|
5024
|
+
const policies = normalize(policyRows, "discountPolicyVersion", membershipDiscountPolicyVersion, invalid2);
|
|
5025
|
+
const offeringValues = offerings.map((item) => item.value), policyValues = policies.map((item) => item.value);
|
|
5101
5026
|
return {
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5027
|
+
now: input.now,
|
|
5028
|
+
chapterId: input.chapterId ?? null,
|
|
5029
|
+
offerings: offerings.map(({ value, row: row2 }) => ({
|
|
5030
|
+
...value,
|
|
5031
|
+
...provenance(row2),
|
|
5032
|
+
...stateOf(
|
|
5033
|
+
value,
|
|
5034
|
+
offeringValues,
|
|
5035
|
+
input.now,
|
|
5036
|
+
(v) => ({ from: v.effectiveFrom, ...v.effectiveUntil !== void 0 ? { until: v.effectiveUntil } : {} }),
|
|
5037
|
+
(a, b) => a.chapterId === b.chapterId && a.baseSkuId === b.baseSkuId
|
|
5038
|
+
)
|
|
5039
|
+
})).sort((a, b) => byChapter(a, b) || a.baseSkuId.localeCompare(b.baseSkuId) || b.version - a.version),
|
|
5040
|
+
discountPolicies: policies.map(({ value, row: row2 }) => ({
|
|
5041
|
+
...value,
|
|
5042
|
+
...provenance(row2),
|
|
5043
|
+
mechanism: mechanism(value),
|
|
5044
|
+
...stateOf(
|
|
5045
|
+
value,
|
|
5046
|
+
policyValues,
|
|
5047
|
+
input.now,
|
|
5048
|
+
(v) => ({ from: v.qualifyingStartsAt, until: v.qualifyingEndsAt }),
|
|
5049
|
+
(a, b) => a.chapterId === b.chapterId && sharesSku(a, b)
|
|
5050
|
+
)
|
|
5051
|
+
})).sort((a, b) => byChapter(a, b) || b.version - a.version),
|
|
5052
|
+
invalid: invalid2,
|
|
5053
|
+
truncated: offeringRows.length >= LIMIT || policyRows.length >= LIMIT
|
|
5114
5054
|
};
|
|
5115
5055
|
}
|
|
5116
|
-
|
|
5117
|
-
|
|
5056
|
+
|
|
5057
|
+
// src/worker-routes-admin-membership-policy.ts
|
|
5058
|
+
var PATH3 = "/api/admin/membership-policy-versions";
|
|
5059
|
+
var SCOPE2 = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
5060
|
+
var handleAdminMembershipPolicyVersions = async (req, url, env, ctx) => {
|
|
5061
|
+
if (url.pathname !== PATH3) return null;
|
|
5062
|
+
const operator = ctx.membershipAuthorityOperator;
|
|
5063
|
+
if (!operator) return json({ error: "not found" }, 404);
|
|
5064
|
+
if (req.method !== "GET") return json({ error: "method not allowed" }, 405);
|
|
5065
|
+
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
5066
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
5067
|
+
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
5068
|
+
const chapterId = url.searchParams.get("chapterId");
|
|
5069
|
+
if (chapterId !== null && !SCOPE2.test(chapterId)) return json({ error: "chapterId is invalid" }, 400);
|
|
5070
|
+
let db;
|
|
5071
|
+
try {
|
|
5072
|
+
db = operator.makeDb(env);
|
|
5073
|
+
} catch {
|
|
5074
|
+
return json({ error: "the membership authority is not hosted by this deployment" }, 404);
|
|
5075
|
+
}
|
|
5076
|
+
return json(await listMembershipPolicyVersions(db, { chapterId, now: Date.now() }));
|
|
5077
|
+
};
|
|
5078
|
+
|
|
5079
|
+
// src/membership-discount-reterm.ts
|
|
5080
|
+
import { stripeRequest as stripeRequest7 } from "@odla-ai/stripe";
|
|
5081
|
+
|
|
5082
|
+
// src/subscription-provider-contract.ts
|
|
5083
|
+
import { stripeRequest as stripeRequest6 } from "@odla-ai/stripe";
|
|
5084
|
+
function subscriptionContractMismatch() {
|
|
5085
|
+
throw Object.assign(new Error("Stripe subscription does not match the checkout contract"), { code: "stripe_contract_mismatch" });
|
|
5118
5086
|
}
|
|
5119
|
-
function
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5087
|
+
async function verifySubscriptionPrice(secretKey, input, resume = false) {
|
|
5088
|
+
if (!/^price_[A-Za-z0-9]+$/.test(input.priceId) || !Number.isSafeInteger(input.priceCents) || input.priceCents <= 0 || !/^[a-z]{3}$/.test(input.currency) || !["month", "year"].includes(input.interval)) subscriptionContractMismatch();
|
|
5089
|
+
const result = await stripeRequest6({ secretKey }, "GET", `/v1/prices/${encodeURIComponent(input.priceId)}`);
|
|
5090
|
+
const price = result.body;
|
|
5091
|
+
if (!result.ok || !price || price.id !== input.priceId || !resume && price.active !== true || price.unit_amount !== input.priceCents || price.currency !== input.currency || price.type !== "recurring" || price.billing_scheme !== "per_unit" || price.custom_unit_amount || price.transform_quantity || price.recurring?.interval !== input.interval || price.recurring?.interval_count !== 1 || price.recurring?.usage_type !== "licensed" || price.livemode !== (input.environment === "prod")) subscriptionContractMismatch();
|
|
5092
|
+
return typeof price.product === "string" ? price.product : void 0;
|
|
5093
|
+
}
|
|
5094
|
+
function verifySubscriptionResult(sub, input, customerId, metadata) {
|
|
5095
|
+
const items = sub?.items;
|
|
5096
|
+
const seat = input.namedSeat?.amountCents ? input.namedSeat : void 0;
|
|
5097
|
+
const primary = items?.data?.find((item) => item.price?.id === input.priceId);
|
|
5098
|
+
const seatItem = seat && items?.data?.find((item) => item.price?.id === seat.priceId);
|
|
5099
|
+
if (!sub || !/^sub_[A-Za-z0-9]+$/.test(sub.id) || sub.customer !== customerId || input.existingSubscriptionId && sub.id !== input.existingSubscriptionId || !(input.billingTerms?.initialTermEndsAt ? ["incomplete", "active", "trialing"] : ["incomplete", "active"]).includes(sub.status) || sub.livemode !== (input.environment === "prod") || !Object.entries(metadata).every(([key3, value]) => sub.metadata?.[key3] === value) || !Array.isArray(items?.data) || items.has_more !== false || items.data.length !== (seat ? 2 : 1) || primary?.quantity !== 1 || seat && (seatItem?.quantity !== 1 || seatItem.metadata?.seatId !== seat.seatId || seatItem.metadata?.membershipId !== seat.membershipId)) subscriptionContractMismatch();
|
|
5100
|
+
const invoice = sub.latest_invoice;
|
|
5101
|
+
if (!invoice || typeof invoice !== "object" || invoice.customer !== customerId || invoice.currency !== input.currency || invoice.total !== subscriptionAmountDue(input) + (seat?.amountCents ?? 0) || invoice.billing_reason !== "subscription_create") subscriptionContractMismatch();
|
|
5102
|
+
verifySubscriptionBillingResult(sub, input);
|
|
5125
5103
|
}
|
|
5126
5104
|
|
|
5127
|
-
// src/
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5105
|
+
// src/membership-discount-reterm.ts
|
|
5106
|
+
var SAFE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,159}$/;
|
|
5107
|
+
var RETERMABLE = /* @__PURE__ */ new Set(["active", "paid_pending_approval", "cancel_at_term_end", "payment_grace"]);
|
|
5108
|
+
var PROVIDER_LIVE = /* @__PURE__ */ new Set(["active", "trialing", "past_due"]);
|
|
5109
|
+
var couponOf = (discount) => {
|
|
5110
|
+
const ref = discount?.source?.coupon ?? discount?.coupon;
|
|
5111
|
+
return typeof ref === "string" ? ref : typeof ref?.id === "string" ? ref.id : void 0;
|
|
5112
|
+
};
|
|
5113
|
+
async function retermPolicy(db, scope2, id7) {
|
|
5114
|
+
const row2 = await membershipLoad(db, "discountPolicyVersion", id7);
|
|
5115
|
+
if (!row2) membershipFail("membership_reterm_policy_unknown");
|
|
5116
|
+
let policy2;
|
|
5117
|
+
try {
|
|
5118
|
+
policy2 = membershipDiscountPolicyVersion(row2.snapshot);
|
|
5119
|
+
} catch {
|
|
5120
|
+
return membershipFail("membership_reterm_policy_ineligible");
|
|
5121
|
+
}
|
|
5122
|
+
if (policy2.id !== id7 || policy2.chapterId !== scope2.chapterId || policy2.status !== "published" || policy2.benefitDuration.kind !== "while_active" || policy2.percentOffBps <= 0 || policy2.discountEligibleSkuIds.length === 0) membershipFail("membership_reterm_policy_ineligible");
|
|
5123
|
+
return policy2;
|
|
5143
5124
|
}
|
|
5144
|
-
function
|
|
5145
|
-
const
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
if (
|
|
5150
|
-
|
|
5151
|
-
if (meeting.status === "scheduled") {
|
|
5152
|
-
meetingAt = meeting.startAt ?? null;
|
|
5153
|
-
meetUrl = meeting.meetUrl ?? null;
|
|
5154
|
-
} else {
|
|
5155
|
-
meetingAt = null;
|
|
5156
|
-
}
|
|
5125
|
+
async function attachProviderDiscount(sk, scope2, member, policy2, couponId, productId) {
|
|
5126
|
+
const subId = String(member.providerSubscriptionId), path = `/v1/subscriptions/${encodeURIComponent(subId)}`;
|
|
5127
|
+
const read2 = await stripeRequest7({ secretKey: sk }, "GET", path, { "expand[0]": "discounts" });
|
|
5128
|
+
const sub = read2.body, items = sub?.items?.data;
|
|
5129
|
+
const metadata = { ...stripePartitionMetadata(scope2), applicationId: String(member.applicationId) };
|
|
5130
|
+
if (!read2.ok || sub.id !== subId || sub.customer !== member.providerCustomerId || sub.livemode !== (scope2.environment === "prod") || !PROVIDER_LIVE.has(sub.status) || !Object.entries(metadata).every(([key3, value]) => sub.metadata?.[key3] === value) || sub.items?.has_more !== false || !Array.isArray(items) || items.length < 1 || items.length > 2 || items.filter((item) => item.price?.id === member.providerPriceId && item.quantity === 1 && item.price?.product === productId).length !== 1) {
|
|
5131
|
+
throw Object.assign(new Error("provider subscription does not match the frozen membership"), { code: "provider_mismatch" });
|
|
5157
5132
|
}
|
|
5158
|
-
|
|
5133
|
+
const discounts = sub.discounts ?? (sub.discount ? [sub.discount] : []);
|
|
5134
|
+
if (!Array.isArray(discounts)) throw Object.assign(new Error("provider discounts unreadable"), { code: "provider_mismatch" });
|
|
5135
|
+
const present = discounts.map(couponOf);
|
|
5136
|
+
if (present.length === 1 && present[0] === couponId) return false;
|
|
5137
|
+
if (present.length > 0) throw Object.assign(new Error("provider subscription carries an unrelated discount"), { code: "provider_mismatch" });
|
|
5138
|
+
const written = await stripeRequest7({ secretKey: sk }, "POST", path, {
|
|
5139
|
+
"discounts[0][coupon]": couponId,
|
|
5140
|
+
"expand[0]": "discounts",
|
|
5141
|
+
"metadata[discountPolicyVersionId]": policy2.id
|
|
5142
|
+
}, { idempotencyKey: `membership-reterm:${String(member.id)}:${policy2.id}` });
|
|
5143
|
+
const after = written.body?.discounts;
|
|
5144
|
+
if (!written.ok || written.body?.id !== subId || !Array.isArray(after) || after.length !== 1 || couponOf(after[0]) !== couponId) {
|
|
5145
|
+
membershipFail("membership_reterm_provider_unconfirmed");
|
|
5146
|
+
}
|
|
5147
|
+
return true;
|
|
5159
5148
|
}
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5149
|
+
async function retermMembershipDiscount(db, stripeSecretKey, scope2, input, now) {
|
|
5150
|
+
membershipClock(now);
|
|
5151
|
+
const limit = input.limit ?? 100;
|
|
5152
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new TypeError("invalid reterm limit");
|
|
5153
|
+
if (typeof input.policyVersionId !== "string" || !SAFE.test(input.policyVersionId)) throw new TypeError("invalid policy version id");
|
|
5154
|
+
if (input.membershipIds !== void 0 && (!Array.isArray(input.membershipIds) || input.membershipIds.length === 0 || input.membershipIds.length > limit || input.membershipIds.some((id7) => typeof id7 !== "string" || !SAFE.test(id7)))) throw new TypeError("invalid membership ids");
|
|
5155
|
+
const state = await loadMembershipAuthorityState(db, scope2);
|
|
5156
|
+
const policy2 = await retermPolicy(db, scope2, input.policyVersionId);
|
|
5157
|
+
const receipt = { policyVersionId: policy2.id, retermed: [], unchanged: [], skipped: [], more: false };
|
|
5158
|
+
const skip = (membershipId, reason) => {
|
|
5159
|
+
receipt.skipped.push({ membershipId, reason });
|
|
5168
5160
|
};
|
|
5169
|
-
let
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
const
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5161
|
+
let rows5;
|
|
5162
|
+
if (input.membershipIds) {
|
|
5163
|
+
rows5 = [];
|
|
5164
|
+
for (const id7 of input.membershipIds) {
|
|
5165
|
+
const member = await membershipLoad(db, "networkMembership", id7);
|
|
5166
|
+
if (!member) skip(id7, "not_found");
|
|
5167
|
+
else if (Object.entries(scope2).some(([key3, value]) => member[key3] !== value)) skip(id7, "scope_mismatch");
|
|
5168
|
+
else rows5.push(member);
|
|
5169
|
+
}
|
|
5170
|
+
} else {
|
|
5171
|
+
const found = (await db.query({ networkMembership: { $: { where: { ...scope2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkMembership ?? [];
|
|
5172
|
+
if (found.some((member) => Object.entries(scope2).some(([key3, value]) => member[key3] !== value))) throw new Error("membership reterm scope mismatch");
|
|
5173
|
+
receipt.more = found.length > limit;
|
|
5174
|
+
rows5 = found.slice(0, limit);
|
|
5178
5175
|
}
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5176
|
+
for (const member of rows5) {
|
|
5177
|
+
const id7 = String(member.id);
|
|
5178
|
+
if (member.discountPolicyVersionId === policy2.id) {
|
|
5179
|
+
receipt.unchanged.push(id7);
|
|
5180
|
+
continue;
|
|
5181
|
+
}
|
|
5182
|
+
if (!RETERMABLE.has(String(member.status))) {
|
|
5183
|
+
skip(id7, "status");
|
|
5184
|
+
continue;
|
|
5185
|
+
}
|
|
5186
|
+
if (member.founding !== true) {
|
|
5187
|
+
skip(id7, "not_founding");
|
|
5188
|
+
continue;
|
|
5189
|
+
}
|
|
5190
|
+
if (member.cohort !== policy2.cohortLabel) {
|
|
5191
|
+
skip(id7, "cohort_mismatch");
|
|
5192
|
+
continue;
|
|
5193
|
+
}
|
|
5194
|
+
const skuId = String(member.baseSkuId);
|
|
5195
|
+
if (!policy2.discountEligibleSkuIds.includes(skuId)) {
|
|
5196
|
+
skip(id7, "sku_not_discounted");
|
|
5197
|
+
continue;
|
|
5198
|
+
}
|
|
5199
|
+
const base = Number(member.baseAmountCents), current = Number(member.renewalAmountCents);
|
|
5200
|
+
const renewal = Math.round(base * (1e4 - policy2.percentOffBps) / 1e4);
|
|
5201
|
+
if (!Number.isSafeInteger(base) || base <= 0 || !Number.isSafeInteger(current) || renewal > current) {
|
|
5202
|
+
skip(id7, "would_not_widen");
|
|
5203
|
+
continue;
|
|
5204
|
+
}
|
|
5205
|
+
const couponId = await membershipDiscountCouponId({ ...scope2, skuId }, policy2.id);
|
|
5206
|
+
let providerUpdated;
|
|
5183
5207
|
try {
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5208
|
+
const productId = await verifySubscriptionPrice(stripeSecretKey, {
|
|
5209
|
+
priceId: String(member.providerPriceId),
|
|
5210
|
+
priceCents: base,
|
|
5211
|
+
currency: String(member.currency),
|
|
5212
|
+
interval: member.interval === "month" ? "month" : "year",
|
|
5213
|
+
environment: scope2.environment
|
|
5214
|
+
}, true);
|
|
5215
|
+
if (!productId) throw Object.assign(new Error("price has no product"), { code: "provider_mismatch" });
|
|
5216
|
+
await prepareSubscriptionBillingTerms(stripeSecretKey, {
|
|
5217
|
+
...scope2,
|
|
5218
|
+
priceCents: base,
|
|
5219
|
+
currency: String(member.currency),
|
|
5220
|
+
discountProductId: productId,
|
|
5221
|
+
billingTerms: {
|
|
5222
|
+
offeringVersionId: String(member.offeringVersionId),
|
|
5223
|
+
discount: { policyVersionId: policy2.id, couponId, percentOffBps: policy2.percentOffBps, duration: "forever" }
|
|
5224
|
+
}
|
|
5225
|
+
}, false);
|
|
5226
|
+
providerUpdated = await attachProviderDiscount(stripeSecretKey, scope2, member, policy2, couponId, productId);
|
|
5227
|
+
} catch (error) {
|
|
5228
|
+
const code = error.code;
|
|
5229
|
+
if (code === "provider_mismatch" || code === "stripe_contract_mismatch") {
|
|
5230
|
+
skip(id7, "provider_mismatch");
|
|
5231
|
+
continue;
|
|
5232
|
+
}
|
|
5233
|
+
throw error;
|
|
5234
|
+
}
|
|
5235
|
+
try {
|
|
5236
|
+
await db.transact([membershipUpdate("networkMembership", id7, {
|
|
5237
|
+
discountPolicyVersionId: policy2.id,
|
|
5238
|
+
discountSnapshot: policy2,
|
|
5239
|
+
renewalAmountCents: renewal,
|
|
5240
|
+
revision: Number(member.revision) + 1,
|
|
5241
|
+
updatedAt: now
|
|
5242
|
+
})], {
|
|
5243
|
+
mutationId: `membership-reterm:${id7}:${policy2.id}:${member.revision}`,
|
|
5244
|
+
guards: [
|
|
5245
|
+
{ ns: "networkMembership", id: id7, exists: true, equals: { revision: member.revision, status: member.status } },
|
|
5246
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative" } },
|
|
5247
|
+
{ ns: "discountPolicyVersion", id: policy2.id, exists: true }
|
|
5248
|
+
]
|
|
5193
5249
|
});
|
|
5194
5250
|
} catch (error) {
|
|
5195
5251
|
if (error.code !== "transact_guard_failed") throw error;
|
|
5252
|
+
return membershipFail("membership_reterm_retry_required");
|
|
5196
5253
|
}
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5254
|
+
receipt.retermed.push({
|
|
5255
|
+
membershipId: id7,
|
|
5256
|
+
...typeof member.discountPolicyVersionId === "string" ? { previousPolicyVersionId: member.discountPolicyVersionId } : {},
|
|
5257
|
+
previousRenewalAmountCents: current,
|
|
5258
|
+
renewalAmountCents: renewal,
|
|
5259
|
+
providerUpdated
|
|
5260
|
+
});
|
|
5200
5261
|
}
|
|
5201
|
-
|
|
5202
|
-
where: { applicationId: app.id, status: "scheduled" },
|
|
5203
|
-
order: { createdAt: "desc" },
|
|
5204
|
-
limit: 1
|
|
5205
|
-
} } })).meetings?.[0];
|
|
5206
|
-
const group = (await db.query({ groups: { $: { where: { id: chapterId }, limit: 1 } } })).groups?.[0];
|
|
5207
|
-
const timezone = resolveScheduling(group?.schedulingJson).timezone;
|
|
5208
|
-
return { source: app, application: memberApplication(
|
|
5209
|
-
app,
|
|
5210
|
-
meeting,
|
|
5211
|
-
timezone
|
|
5212
|
-
) };
|
|
5262
|
+
return receipt;
|
|
5213
5263
|
}
|
|
5214
5264
|
|
|
5265
|
+
// src/worker-routes-admin-membership-reterm.ts
|
|
5266
|
+
var PATH4 = "/api/admin/membership-discount/reterm";
|
|
5267
|
+
var SCOPE3 = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
5268
|
+
var ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,159}$/;
|
|
5269
|
+
var BAD = "chapterId and policyVersionId are required; runtime, appId, membershipIds and limit are optional";
|
|
5270
|
+
var handleAdminMembershipReterm = async (req, url, env, ctx) => {
|
|
5271
|
+
if (url.pathname !== PATH4) return null;
|
|
5272
|
+
const operator = ctx.membershipAuthorityOperator;
|
|
5273
|
+
if (!operator) return json({ error: "not found" }, 404);
|
|
5274
|
+
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
5275
|
+
const raw2 = ctx.makeDb(env), user = await ctx.verifyUser(req, env);
|
|
5276
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
5277
|
+
if (!await ctx.isSuperAdminEmail(raw2, user.email, user.userId)) return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
5278
|
+
let db;
|
|
5279
|
+
try {
|
|
5280
|
+
db = operator.makeDb(env);
|
|
5281
|
+
} catch {
|
|
5282
|
+
return json({ error: "the membership authority is not hosted by this deployment" }, 404);
|
|
5283
|
+
}
|
|
5284
|
+
let body;
|
|
5285
|
+
try {
|
|
5286
|
+
body = await req.json();
|
|
5287
|
+
} catch {
|
|
5288
|
+
return json({ error: BAD }, 400);
|
|
5289
|
+
}
|
|
5290
|
+
const appId = body.appId ?? body.chapterId, runtime = body.runtime ?? env.ODLA_RUNTIME;
|
|
5291
|
+
if (typeof body.chapterId !== "string" || !SCOPE3.test(body.chapterId) || typeof appId !== "string" || !SCOPE3.test(appId) || typeof runtime !== "string" || !/^[a-z0-9]{2,12}$/.test(runtime) || typeof body.policyVersionId !== "string" || !ID.test(body.policyVersionId) || body.membershipIds !== void 0 && (!Array.isArray(body.membershipIds) || body.membershipIds.length === 0 || body.membershipIds.length > 200 || body.membershipIds.some((id7) => typeof id7 !== "string" || !ID.test(id7))) || body.limit !== void 0 && (!Number.isSafeInteger(body.limit) || Number(body.limit) < 1 || Number(body.limit) > 200)) {
|
|
5292
|
+
return json({ error: BAD }, 400);
|
|
5293
|
+
}
|
|
5294
|
+
const secret = await getVaultSecret(db, operator.stripeSecretName ?? "stripe_secret_key");
|
|
5295
|
+
if (!secret) return json({ error: "network payment provider unavailable" }, 503);
|
|
5296
|
+
try {
|
|
5297
|
+
const receipt = await retermMembershipDiscount(
|
|
5298
|
+
db,
|
|
5299
|
+
secret,
|
|
5300
|
+
{ appId, chapterId: body.chapterId, environment: env.ODLA_ENV, runtime },
|
|
5301
|
+
{
|
|
5302
|
+
policyVersionId: body.policyVersionId,
|
|
5303
|
+
...body.membershipIds ? { membershipIds: body.membershipIds } : {},
|
|
5304
|
+
...body.limit !== void 0 ? { limit: Number(body.limit) } : {}
|
|
5305
|
+
},
|
|
5306
|
+
Date.now()
|
|
5307
|
+
);
|
|
5308
|
+
return json({ ok: true, ...receipt });
|
|
5309
|
+
} catch (error) {
|
|
5310
|
+
const code = error.code;
|
|
5311
|
+
return json({ error: "re-term is pending; retry to confirm", ...code ? { code } : {} }, code ? 409 : 503);
|
|
5312
|
+
}
|
|
5313
|
+
};
|
|
5314
|
+
|
|
5315
|
+
// src/worker-routes-membership-cancel.ts
|
|
5316
|
+
var handleMembershipCancellation = async (req, url, env, ctx) => {
|
|
5317
|
+
const match = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/cancel-renewal$/);
|
|
5318
|
+
if (!match) return null;
|
|
5319
|
+
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
5320
|
+
const user = await ctx.verifyUser(req, env), raw2 = ctx.makeDb(env);
|
|
5321
|
+
if (!user) return json({ error: "unauthorized" }, 401);
|
|
5322
|
+
if (!await ctx.isAdmin(raw2, user)) return json({ error: "forbidden" }, 403);
|
|
5323
|
+
if (!ctx.membershipAuthority?.cancelRenewal) return json({ error: "renewal cancellation unavailable" }, 503);
|
|
5324
|
+
const db = raw2, app = await membershipLoad(db, "applications", match[1]);
|
|
5325
|
+
if (!app || !chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
|
|
5326
|
+
if (app.namedSeatId || app.giftClaimId) return json({ error: "this membership has no independently renewing subscription" }, 409);
|
|
5327
|
+
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(app.id));
|
|
5328
|
+
if (!intent) return json({ error: "membership history needs reconciliation" }, 409);
|
|
5329
|
+
try {
|
|
5330
|
+
const target = await membershipTargetForIntent(intent, app, env, ctx);
|
|
5331
|
+
const receipt = await ctx.membershipAuthority.cancelRenewal(target, env);
|
|
5332
|
+
if (receipt.membershipId !== await networkMembershipId(target) || receipt.autoRenew !== false || !Number.isSafeInteger(receipt.termEndsAt) || !["cancel_at_term_end", "lapsed", "paid_pending_approval"].includes(receipt.status)) throw new Error("invalid cancellation receipt");
|
|
5333
|
+
await db.transact([{ t: "update", ns: "applications", id: String(app.id), attrs: {
|
|
5334
|
+
membershipStatus: receipt.status,
|
|
5335
|
+
membershipAutoRenew: false,
|
|
5336
|
+
membershipGraceEndsAt: 0,
|
|
5337
|
+
renewalAt: receipt.termEndsAt
|
|
5338
|
+
} }], { guards: [{
|
|
5339
|
+
ns: "applications",
|
|
5340
|
+
id: String(app.id),
|
|
5341
|
+
exists: true,
|
|
5342
|
+
equals: { status: app.status, stripeSubscriptionId: app.stripeSubscriptionId }
|
|
5343
|
+
}] });
|
|
5344
|
+
return json({ ok: true, accessEndsAt: receipt.termEndsAt, status: receipt.status });
|
|
5345
|
+
} catch {
|
|
5346
|
+
return json({ error: "renewal cancellation is pending; retry to confirm" }, 503);
|
|
5347
|
+
}
|
|
5348
|
+
};
|
|
5349
|
+
|
|
5215
5350
|
// src/worker-routes-membership-restart.ts
|
|
5216
5351
|
async function memberRestartState(db, env, ctx, app) {
|
|
5217
5352
|
if (!ctx.membershipAuthority?.restart || app.namedSeatId || app.giftClaimId) return null;
|
|
@@ -5439,6 +5574,7 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
5439
5574
|
clerkUserId: opts.namedSeat.userId,
|
|
5440
5575
|
clerkAccountPending: false
|
|
5441
5576
|
});
|
|
5577
|
+
if (opts.predecessor?.clerkUserId && !opts.namedSeat) Object.assign(row2, { clerkUserId: opts.predecessor.clerkUserId, clerkAccountPending: false });
|
|
5442
5578
|
if (typeof fields.email === "string" && isValidEmail(fields.email.trim())) {
|
|
5443
5579
|
row2.emailNormalized = normalizeChapterEmail(fields.email);
|
|
5444
5580
|
row2.signupEmailKey = row2.emailNormalized;
|
|
@@ -5457,6 +5593,8 @@ async function submitApplication(db, chapter, fields, opts) {
|
|
|
5457
5593
|
}
|
|
5458
5594
|
if (acked) row2.disclaimerAckAt = opts.now;
|
|
5459
5595
|
const ops = [
|
|
5596
|
+
// The unique signup key leaves the closed predecessor before the new attempt takes it.
|
|
5597
|
+
...opts.predecessor && opts.predecessor.id !== id7 ? [{ t: "retract", ns: "applications", id: opts.predecessor.id, attrs: ["signupEmailKey"] }] : [],
|
|
5460
5598
|
{ t: "update", ns: "applications", id: id7, attrs: row2 }
|
|
5461
5599
|
];
|
|
5462
5600
|
if (opts.admissionGrant?.decision === "waive_interview") {
|
|
@@ -5504,27 +5642,35 @@ function joinConfig(group, paymentsReady, tiers = [], conditions = {}) {
|
|
|
5504
5642
|
|
|
5505
5643
|
// src/signup-eligibility.ts
|
|
5506
5644
|
var signupConflict = { code: "account_already_exists", message: "An account or application already exists for this email. Please sign in to continue." };
|
|
5507
|
-
|
|
5508
|
-
|
|
5645
|
+
var closedApplication = (row2) => row2.canceled === true || ["declined", "refunded", "canceled"].includes(String(row2.status));
|
|
5646
|
+
async function resolveSignupEligibility(db, chapter, fields, submissionId) {
|
|
5647
|
+
if (typeof fields.email !== "string" || !isValidEmail(fields.email.trim())) return { conflict: null, predecessor: null };
|
|
5509
5648
|
const email = normalizeChapterEmail(fields.email);
|
|
5510
5649
|
const applicationId = submissionId ? await applicationIdForSubmission(submissionId) : void 0;
|
|
5511
|
-
const conflict = () => Response.json({ error: signupConflict }, { status: 409 });
|
|
5650
|
+
const conflict = () => ({ conflict: Response.json({ error: signupConflict }, { status: 409 }), predecessor: null });
|
|
5512
5651
|
try {
|
|
5513
5652
|
const data = await db.query({
|
|
5514
|
-
applications: { $: { where: { emailNormalized: email }, limit:
|
|
5653
|
+
applications: { $: { where: { emailNormalized: email }, limit: 10 } },
|
|
5515
5654
|
$users: { $: { where: { email }, limit: 2 } }
|
|
5516
5655
|
});
|
|
5517
|
-
|
|
5518
|
-
const
|
|
5519
|
-
if (
|
|
5520
|
-
|
|
5656
|
+
const rows5 = data.applications ?? [], others = rows5.filter((row2) => row2.id !== applicationId);
|
|
5657
|
+
const closed = others.filter((row2) => row2.groupId === chapter.id && closedApplication(row2)).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0));
|
|
5658
|
+
if (others.length !== closed.length) return conflict();
|
|
5659
|
+
const original = rows5.find((row2) => row2.id === applicationId && row2.groupId === chapter.id);
|
|
5660
|
+
const owned = (userId) => original?.clerkUserId === userId || closed.some((row2) => row2.clerkUserId === userId);
|
|
5661
|
+
if ((data.$users ?? []).some((row2) => row2.deleted !== true && !owned(row2.id))) return conflict();
|
|
5662
|
+
const predecessor = (clerkUserId) => closed.length ? { id: String(closed[0].id), ...clerkUserId ? { clerkUserId } : {} } : null;
|
|
5663
|
+
if (chapter.account === "none") return { conflict: null, predecessor: predecessor(typeof closed[0]?.clerkUserId === "string" ? closed[0].clerkUserId : void 0) };
|
|
5521
5664
|
const secret = await getVaultSecret(db, "clerk_secret_key");
|
|
5522
|
-
if (!secret) return Response.json({ error: { code: "clerk_secret_unavailable", message: "account provisioning credential is unavailable" } }, { status: 503 });
|
|
5665
|
+
if (!secret) return { conflict: Response.json({ error: { code: "clerk_secret_unavailable", message: "account provisioning credential is unavailable" } }, { status: 503 }), predecessor: null };
|
|
5523
5666
|
const account = await findSignupAccount(secret, email);
|
|
5524
|
-
if (account
|
|
5525
|
-
return null;
|
|
5667
|
+
if (!account) return { conflict: null, predecessor: predecessor() };
|
|
5668
|
+
if (original && (original.clerkUserId === account.id || belongsToSignup(account, original.id))) return { conflict: null, predecessor: null };
|
|
5669
|
+
const owner = closed.find((row2) => row2.clerkUserId === account.id || belongsToSignup(account, row2.id));
|
|
5670
|
+
if (!owner) return conflict();
|
|
5671
|
+
return { conflict: null, predecessor: { id: String(owner.id), clerkUserId: account.id } };
|
|
5526
5672
|
} catch {
|
|
5527
|
-
return Response.json({ error: { code: "signup_lookup_unavailable", message: "Signup is temporarily unavailable. Please try again." } }, { status: 503 });
|
|
5673
|
+
return { conflict: Response.json({ error: { code: "signup_lookup_unavailable", message: "Signup is temporarily unavailable. Please try again." } }, { status: 503 }), predecessor: null };
|
|
5528
5674
|
}
|
|
5529
5675
|
}
|
|
5530
5676
|
async function rejectUnprovisionedSignup(db, applicationId, status2) {
|
|
@@ -6690,8 +6836,12 @@ async function namedSeatApplicationContext(req, seatId, env, ctx) {
|
|
|
6690
6836
|
}
|
|
6691
6837
|
async function existingNamedSeatApplication(context, env, ctx) {
|
|
6692
6838
|
const { db, userId, email, invitation } = context;
|
|
6693
|
-
const
|
|
6694
|
-
|
|
6839
|
+
const rows5 = (await db.query({ applications: { $: { where: { emailNormalized: normalizeChapterEmail(email) }, limit: 10 } } })).applications ?? [];
|
|
6840
|
+
const apps = rows5.filter((row2) => !closedApplication(row2));
|
|
6841
|
+
if (!apps.length) {
|
|
6842
|
+
const closed = rows5.filter((row2) => row2.groupId === ctx.chapter.id).sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0))[0];
|
|
6843
|
+
return { predecessor: closed ? { id: String(closed.id) } : null };
|
|
6844
|
+
}
|
|
6695
6845
|
const app = apps[0];
|
|
6696
6846
|
if (apps.length !== 1 || app.clerkUserId !== userId || app.groupId !== ctx.chapter.id || stripeRuntimeIdentity(app.stripeRuntime) !== stripeRuntimeIdentity(env.ODLA_RUNTIME) || app.canceled === true || app.stripeSubscriptionId || app.giftClaimId || app.giftOfferId || app.giftIntentId || app.membershipRestartId || (app.namedSeatId ? app.namedSeatId !== invitation.seatId : app.status !== ctx.chapter.pipeline.initial)) {
|
|
6697
6847
|
return json({ error: "Your existing application cannot accept this invitation. Contact the chapter for help.", code: "application_conflict" }, 409);
|
|
@@ -7207,8 +7357,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
7207
7357
|
parsed.tierId = named.invitation.skuId;
|
|
7208
7358
|
submissionId = `named-seat:${named.invitation.seatId}:${named.userId}`;
|
|
7209
7359
|
}
|
|
7210
|
-
const
|
|
7211
|
-
if (
|
|
7360
|
+
const eligibility = named ? null : await resolveSignupEligibility(db, chapter, parsed, submissionId);
|
|
7361
|
+
if (eligibility?.conflict) return eligibility.conflict;
|
|
7212
7362
|
const activeControl = chapter.signupControl ? await loadActiveSignupControl(db, chapter.id, env.ODLA_ENV) : null;
|
|
7213
7363
|
const effectiveChapter = activeControl ? { ...chapter, application: controlledApplication(chapter.application, activeControl.document) } : chapter;
|
|
7214
7364
|
const offeredTierIds = typeof parsed.tierId === "string" && parsed.tierId ? (await loadTiers(db, { id: chapter.id })).map((tier) => tier.id) : void 0;
|
|
@@ -7219,7 +7369,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
7219
7369
|
const effectiveGrant = !named && !giftOffer && typeof parsed.email === "string" ? await findAdmissionGrant(db, chapter.id, parsed.email, now) : null;
|
|
7220
7370
|
const existing = named ? await existingNamedSeatApplication(named, env, ctx) : null;
|
|
7221
7371
|
if (existing instanceof Response) return existing;
|
|
7222
|
-
const
|
|
7372
|
+
const predecessor = existing && "predecessor" in existing ? existing.predecessor : eligibility?.predecessor ?? null;
|
|
7373
|
+
const result = existing && "ok" in existing ? existing : await submitApplication(db, effectiveChapter, parsed, {
|
|
7223
7374
|
submissionId,
|
|
7224
7375
|
groupId: chapter.id,
|
|
7225
7376
|
runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
|
|
@@ -7228,7 +7379,8 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
7228
7379
|
...named ? { namedSeat: { seatId: named.invitation.seatId, primaryApplicationId: named.invitation.primaryApplicationId, userId: named.userId } } : {},
|
|
7229
7380
|
...named ? { tierIds: [named.invitation.skuId] } : offeredTierIds ? { tierIds: offeredTierIds } : {},
|
|
7230
7381
|
...effectiveGrant?.decision === "waive_interview" ? { admissionGrant: effectiveGrant } : {},
|
|
7231
|
-
...giftOffer ? { giftOfferId: giftOffer.id } : {}
|
|
7382
|
+
...giftOffer ? { giftOfferId: giftOffer.id } : {},
|
|
7383
|
+
...predecessor ? { predecessor } : {}
|
|
7232
7384
|
});
|
|
7233
7385
|
if (!result.ok) return json({ error: result.error, ...result.code ? { code: result.code } : {} }, result.status ?? 400);
|
|
7234
7386
|
const apps = (await db.query({ applications: { $: { where: { id: result.id }, limit: 1 } } })).applications;
|
|
@@ -7248,6 +7400,7 @@ var handleMember = async (req, url, env, ctx) => {
|
|
|
7248
7400
|
if (provisioningFailure.status === 409) await rejectUnprovisionedSignup(db, result.id, canonical3.status);
|
|
7249
7401
|
return json(provisioningFailure.body, provisioningFailure.status);
|
|
7250
7402
|
}
|
|
7403
|
+
if (predecessor) await syncApplicationToCrm(crmDeps(db, ctx), { app: canonical3, stage: String(canonical3.status) }).catch(() => void 0);
|
|
7251
7404
|
const groups = (await db.query({ groups: { $: { where: { id: chapter.id }, limit: 1 } } })).groups;
|
|
7252
7405
|
const group = Array.isArray(groups) ? groups[0] : void 0;
|
|
7253
7406
|
const notice = group ? { group, application: canonical3, origin: url.origin } : null;
|
|
@@ -10198,6 +10351,128 @@ var handleGifts = async (req, url, env, ctx) => {
|
|
|
10198
10351
|
return null;
|
|
10199
10352
|
};
|
|
10200
10353
|
|
|
10354
|
+
// src/membership-effect-kinds.ts
|
|
10355
|
+
var MEMBERSHIP_CHAPTER_EFFECT_KINDS = [
|
|
10356
|
+
"invite_named_recipient",
|
|
10357
|
+
"grant_named_recipient_access",
|
|
10358
|
+
"revoke_named_recipient_access",
|
|
10359
|
+
"release_named_recipient"
|
|
10360
|
+
];
|
|
10361
|
+
var namedSeatInvitationOpen = (member) => ["active", "cancel_at_term_end"].includes(String(member.status)) && Number.isSafeInteger(member.journeyApprovedAt);
|
|
10362
|
+
|
|
10363
|
+
// src/membership-seat-regift.ts
|
|
10364
|
+
var REGIFTABLE_SEAT_STATUSES = ["pending_acceptance", "pending_approval", "pending_primary"];
|
|
10365
|
+
var namedSeatRegiftable = (seat) => REGIFTABLE_SEAT_STATUSES.includes(String(seat.status));
|
|
10366
|
+
var receiptFor2 = (seat, released) => ({
|
|
10367
|
+
seatId: String(seat.id),
|
|
10368
|
+
status: "pending_acceptance",
|
|
10369
|
+
recipientName: String(seat.recipientName),
|
|
10370
|
+
recipientEmail: String(seat.recipientEmailNormalized),
|
|
10371
|
+
regiftedAt: Number(seat.regiftedAt),
|
|
10372
|
+
releasedRecipientApplicationId: released
|
|
10373
|
+
});
|
|
10374
|
+
async function regiftNamedSeat(db, input, now) {
|
|
10375
|
+
membershipClock(now);
|
|
10376
|
+
const name = typeof input.recipientName === "string" ? input.recipientName.trim().replace(/\s+/g, " ") : "";
|
|
10377
|
+
const email = normalizeChapterEmail(typeof input.recipientEmail === "string" ? input.recipientEmail : "");
|
|
10378
|
+
if (name.length < 2 || name.length > 160 || /[\u0000-\u001f\u007f]/.test(input.recipientName) || email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) membershipFail("invalid_named_seat_recipient");
|
|
10379
|
+
const state = await loadMembershipAuthorityState(db, input);
|
|
10380
|
+
const seat = await membershipLoad(db, "networkSeat", input.seatId);
|
|
10381
|
+
requireMembershipScope(seat, input);
|
|
10382
|
+
const member = await membershipLoad(db, "networkMembership", String(seat.primaryMembershipId));
|
|
10383
|
+
requireMembershipScope(member, input);
|
|
10384
|
+
if (member.namedSeatId !== seat.id) membershipFail("named_seat_primary_conflict");
|
|
10385
|
+
if (typeof seat.purchaserEmailNormalized === "string" && seat.purchaserEmailNormalized === email) membershipFail("invalid_named_seat_recipient");
|
|
10386
|
+
const history = Array.isArray(seat.formerRecipients) ? seat.formerRecipients : [];
|
|
10387
|
+
if (seat.recipientEmailNormalized === email && seat.recipientName === name && seat.regiftedAt !== void 0 && seat.status === "pending_acceptance") {
|
|
10388
|
+
return receiptFor2(seat, history.at(-1)?.recipientApplicationId ?? null);
|
|
10389
|
+
}
|
|
10390
|
+
if (!namedSeatRegiftable(seat)) membershipFail("named_seat_not_regiftable");
|
|
10391
|
+
if (seat.recipientEmailNormalized === email) membershipFail("named_seat_recipient_unchanged");
|
|
10392
|
+
const entitlement = await membershipLoad(db, "networkEntitlement", String(seat.entitlementId));
|
|
10393
|
+
requireMembershipScope(entitlement, input);
|
|
10394
|
+
const claimId = await namedSeatRecipientClaimId(input, email);
|
|
10395
|
+
const former = {
|
|
10396
|
+
recipientName: String(seat.recipientName),
|
|
10397
|
+
recipientEmail: String(seat.recipientEmailNormalized),
|
|
10398
|
+
recipientClaimId: String(seat.recipientClaimId),
|
|
10399
|
+
releasedAt: now,
|
|
10400
|
+
...typeof seat.recipientUserId === "string" ? { recipientUserId: seat.recipientUserId } : {},
|
|
10401
|
+
...typeof seat.recipientApplicationId === "string" ? { recipientApplicationId: seat.recipientApplicationId } : {},
|
|
10402
|
+
...Number.isSafeInteger(seat.acceptedAt) ? { acceptedAt: Number(seat.acceptedAt) } : {},
|
|
10403
|
+
...Number.isSafeInteger(seat.journeyApprovedAt) ? { journeyApprovedAt: Number(seat.journeyApprovedAt) } : {}
|
|
10404
|
+
};
|
|
10405
|
+
const generation = history.length + 1, inviteId = `${seat.id}:invite:${generation}`, releaseId = `${seat.id}:release:${generation}`;
|
|
10406
|
+
const bound = Boolean(former.recipientUserId && former.recipientApplicationId);
|
|
10407
|
+
const stale = ((await db.query({ membershipEffect: { $: { where: { seatId: seat.id, kind: "invite_named_recipient", status: "pending" }, limit: 10 } } })).membershipEffect ?? []).filter((row2) => row2.chapterId === input.chapterId && row2.environment === input.environment && row2.runtime === input.runtime);
|
|
10408
|
+
const cleared = ["recipientUserId", "recipientApplicationId", "acceptedAt", "journeyApprovedAt"];
|
|
10409
|
+
const effect = (id7, kind, extra = {}) => membershipUpdate("membershipEffect", id7, {
|
|
10410
|
+
id: id7,
|
|
10411
|
+
chapterId: input.chapterId,
|
|
10412
|
+
environment: input.environment,
|
|
10413
|
+
runtime: input.runtime,
|
|
10414
|
+
membershipId: member.id,
|
|
10415
|
+
seatId: seat.id,
|
|
10416
|
+
kind,
|
|
10417
|
+
status: "pending",
|
|
10418
|
+
attempts: 0,
|
|
10419
|
+
createdAt: now,
|
|
10420
|
+
updatedAt: now,
|
|
10421
|
+
...extra
|
|
10422
|
+
});
|
|
10423
|
+
const ops = [
|
|
10424
|
+
membershipUpdate("networkSeat", String(seat.id), {
|
|
10425
|
+
recipientName: name,
|
|
10426
|
+
recipientEmailNormalized: email,
|
|
10427
|
+
recipientClaimId: claimId,
|
|
10428
|
+
status: "pending_acceptance",
|
|
10429
|
+
regiftedAt: now,
|
|
10430
|
+
formerRecipients: [...history, former],
|
|
10431
|
+
revision: Number(seat.revision) + 1,
|
|
10432
|
+
updatedAt: now
|
|
10433
|
+
}),
|
|
10434
|
+
{ t: "retract", ns: "networkSeat", id: String(seat.id), attrs: cleared },
|
|
10435
|
+
membershipUpdate("networkEntitlement", String(entitlement.id), { status: "pending_acceptance", revision: Number(entitlement.revision) + 1, updatedAt: now }),
|
|
10436
|
+
{ t: "retract", ns: "networkEntitlement", id: String(entitlement.id), attrs: cleared },
|
|
10437
|
+
{ t: "delete", ns: "networkSeatRecipient", id: String(seat.recipientClaimId) },
|
|
10438
|
+
membershipUpdate("networkSeatRecipient", claimId, {
|
|
10439
|
+
id: claimId,
|
|
10440
|
+
appId: input.appId,
|
|
10441
|
+
chapterId: input.chapterId,
|
|
10442
|
+
environment: input.environment,
|
|
10443
|
+
runtime: input.runtime,
|
|
10444
|
+
seatId: seat.id,
|
|
10445
|
+
recipientEmailNormalized: email,
|
|
10446
|
+
createdAt: now
|
|
10447
|
+
}),
|
|
10448
|
+
...stale.map((row2) => membershipUpdate("membershipEffect", String(row2.id), { status: "complete", updatedAt: now })),
|
|
10449
|
+
effect(inviteId, "invite_named_recipient"),
|
|
10450
|
+
...bound ? [effect(releaseId, "release_named_recipient", { recipientUserId: former.recipientUserId, recipientApplicationId: former.recipientApplicationId })] : []
|
|
10451
|
+
];
|
|
10452
|
+
try {
|
|
10453
|
+
await db.transact(ops, { mutationId: `named-seat-regift:${seat.id}:${generation}`, guards: [
|
|
10454
|
+
{ ns: "networkSeat", id: String(seat.id), exists: true, equals: { revision: seat.revision, status: seat.status } },
|
|
10455
|
+
{ ns: "networkEntitlement", id: String(entitlement.id), exists: true, equals: { revision: entitlement.revision } },
|
|
10456
|
+
{ ns: "networkMembership", id: String(member.id), exists: true, equals: { revision: member.revision, namedSeatId: seat.id } },
|
|
10457
|
+
{ ns: "networkSeatRecipient", id: claimId, exists: false },
|
|
10458
|
+
...stale.map((row2) => ({ ns: "membershipEffect", id: String(row2.id), exists: true, equals: { status: "pending" } })),
|
|
10459
|
+
{ ns: "membershipAuthorityState", id: String(state.id), exists: true, equals: { mode: "authoritative", revision: state.revision } }
|
|
10460
|
+
] });
|
|
10461
|
+
} catch (error) {
|
|
10462
|
+
if (error.code !== "transact_guard_failed") throw error;
|
|
10463
|
+
const current = await membershipLoad(db, "networkSeat", input.seatId);
|
|
10464
|
+
requireMembershipScope(current, input);
|
|
10465
|
+
if (current.recipientEmailNormalized === email && current.status === "pending_acceptance") return receiptFor2(current, former.recipientApplicationId ?? null);
|
|
10466
|
+
const claim = await membershipLoad(db, "networkSeatRecipient", claimId);
|
|
10467
|
+
if (claim && claim.seatId !== seat.id) membershipFail("named_seat_recipient_unavailable");
|
|
10468
|
+
membershipFail("named_seat_regift_retry_required");
|
|
10469
|
+
}
|
|
10470
|
+
const saved = await membershipLoad(db, "networkSeat", input.seatId);
|
|
10471
|
+
requireMembershipScope(saved, input);
|
|
10472
|
+
if (saved.recipientEmailNormalized !== email || saved.status !== "pending_acceptance") membershipFail("named_seat_regift_retry_required");
|
|
10473
|
+
return receiptFor2(saved, former.recipientApplicationId ?? null);
|
|
10474
|
+
}
|
|
10475
|
+
|
|
10201
10476
|
// src/membership-seat-offer.ts
|
|
10202
10477
|
async function namedSeatOfferDigest(offer) {
|
|
10203
10478
|
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([
|
|
@@ -10241,7 +10516,10 @@ async function readNamedSeatOffer(db, input, now) {
|
|
|
10241
10516
|
recipientState: namedSeatRecipientState(seat),
|
|
10242
10517
|
...at(seat.purchasedAt) !== void 0 ? { purchasedAt: at(seat.purchasedAt) } : {},
|
|
10243
10518
|
...at(seat.acceptedAt) !== void 0 ? { acceptedAt: at(seat.acceptedAt) } : {},
|
|
10244
|
-
...seat.status === "active" && at(seat.journeyApprovedAt) !== void 0 ? { joinedAt: at(seat.journeyApprovedAt) } : {}
|
|
10519
|
+
...seat.status === "active" && at(seat.journeyApprovedAt) !== void 0 ? { joinedAt: at(seat.journeyApprovedAt) } : {},
|
|
10520
|
+
...seat.status === "pending_acceptance" && seat.acceptedAt === void 0 && !namedSeatInvitationOpen(member) ? { invitationHeld: true } : {},
|
|
10521
|
+
...namedSeatRegiftable(seat) ? { regiftable: true } : {},
|
|
10522
|
+
...at(seat.regiftedAt) !== void 0 ? { regiftedAt: at(seat.regiftedAt) } : {}
|
|
10245
10523
|
} } : {}
|
|
10246
10524
|
};
|
|
10247
10525
|
return {
|
|
@@ -10252,40 +10530,6 @@ async function readNamedSeatOffer(db, input, now) {
|
|
|
10252
10530
|
};
|
|
10253
10531
|
}
|
|
10254
10532
|
|
|
10255
|
-
// src/named-seat-request.ts
|
|
10256
|
-
async function smallRequestBody(req) {
|
|
10257
|
-
const reader = req.body?.getReader();
|
|
10258
|
-
if (!reader) throw new Error("request body required");
|
|
10259
|
-
const chunks = [];
|
|
10260
|
-
let size = 0;
|
|
10261
|
-
try {
|
|
10262
|
-
while (true) {
|
|
10263
|
-
const chunk = await reader.read();
|
|
10264
|
-
if (chunk.done) break;
|
|
10265
|
-
size += chunk.value.byteLength;
|
|
10266
|
-
if (size > 4096) {
|
|
10267
|
-
await reader.cancel();
|
|
10268
|
-
throw new Error("request too large");
|
|
10269
|
-
}
|
|
10270
|
-
chunks.push(chunk.value);
|
|
10271
|
-
}
|
|
10272
|
-
} finally {
|
|
10273
|
-
reader.releaseLock();
|
|
10274
|
-
}
|
|
10275
|
-
const bytes = new Uint8Array(size);
|
|
10276
|
-
let offset = 0;
|
|
10277
|
-
for (const chunk of chunks) {
|
|
10278
|
-
bytes.set(chunk, offset);
|
|
10279
|
-
offset += chunk.byteLength;
|
|
10280
|
-
}
|
|
10281
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
10282
|
-
}
|
|
10283
|
-
async function namedSeatRequest(req) {
|
|
10284
|
-
const body = JSON.parse(await smallRequestBody(req));
|
|
10285
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) throw new Error("object required");
|
|
10286
|
-
return body;
|
|
10287
|
-
}
|
|
10288
|
-
|
|
10289
10533
|
// src/worker-routes-named-seat.ts
|
|
10290
10534
|
var handleNamedSeat = async (req, url, env, ctx) => {
|
|
10291
10535
|
if (!["/api/named-seat", "/api/named-seat/checkout"].includes(url.pathname)) return null;
|
|
@@ -10691,7 +10935,8 @@ function applicationActionStages(app, env, ctx) {
|
|
|
10691
10935
|
if (to === from) return false;
|
|
10692
10936
|
if (to === "approved") return canApprove(from, ctx.chapter.pipeline) && from !== "declined";
|
|
10693
10937
|
if (to === "refunded") return Boolean(app.namedSeatId || app.stripeCustomerId && app.stripeSubscriptionId) && (!ctx.chapter.operations.refund.allowedFrom || ctx.chapter.operations.refund.allowedFrom.includes(from));
|
|
10694
|
-
if (ctx.membershipAuthority &&
|
|
10938
|
+
if (to === "declined" && ctx.membershipAuthority) return !app.namedSeatId && !app.stripeSubscriptionId && canTransition(from, to, ctx.chapter.pipeline);
|
|
10939
|
+
if (ctx.membershipAuthority && ["submitted", "paid_pending_vetting", "canceled"].includes(to)) return false;
|
|
10695
10940
|
return canTransition(from, to, ctx.chapter.pipeline);
|
|
10696
10941
|
});
|
|
10697
10942
|
}
|
|
@@ -10824,18 +11069,36 @@ var handleAdminScheduling = async (req, url, env, ctx) => {
|
|
|
10824
11069
|
const got = await adminGroup(req, env, ctx, url);
|
|
10825
11070
|
if (got instanceof Response) return got;
|
|
10826
11071
|
const { db, group } = got;
|
|
11072
|
+
const stored = group.schedulingJson && typeof group.schedulingJson === "object" && !Array.isArray(group.schedulingJson) ? group.schedulingJson : {};
|
|
10827
11073
|
if (req.method === "GET") {
|
|
10828
|
-
|
|
11074
|
+
const current = validateScheduling(stored);
|
|
11075
|
+
if (!current.ok) return json({
|
|
11076
|
+
error: "stored scheduling config is invalid; save the form to repair it",
|
|
11077
|
+
errors: current.errors,
|
|
11078
|
+
scheduling: { ...resolveScheduling(void 0), ...stored }
|
|
11079
|
+
}, 409);
|
|
11080
|
+
return json({ scheduling: current.value });
|
|
10829
11081
|
}
|
|
10830
11082
|
let body;
|
|
10831
11083
|
try {
|
|
10832
11084
|
body = JSON.parse(await req.text());
|
|
11085
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new TypeError("object required");
|
|
10833
11086
|
} catch {
|
|
10834
11087
|
return json({ error: "invalid JSON body" }, 400);
|
|
10835
11088
|
}
|
|
10836
|
-
const checked = validateScheduling(body);
|
|
11089
|
+
const checked = validateScheduling({ ...stored, ...body });
|
|
10837
11090
|
if (!checked.ok) return json({ error: "invalid scheduling config", errors: checked.errors }, 400);
|
|
10838
|
-
|
|
11091
|
+
try {
|
|
11092
|
+
await db.transact([{ t: "update", ns: "groups", id: String(group.id), attrs: { schedulingJson: checked.value } }]);
|
|
11093
|
+
} catch (error) {
|
|
11094
|
+
const code = error.code;
|
|
11095
|
+
console.error("admin_scheduling_write_failed", { code: typeof code === "string" ? code : "unknown" });
|
|
11096
|
+
return json({
|
|
11097
|
+
error: "availability could not be saved; retry",
|
|
11098
|
+
code: "scheduling_write_failed",
|
|
11099
|
+
...typeof code === "string" ? { cause: code } : {}
|
|
11100
|
+
}, 503);
|
|
11101
|
+
}
|
|
10839
11102
|
return json({ scheduling: checked.value });
|
|
10840
11103
|
};
|
|
10841
11104
|
async function upcomingEvents(env) {
|
|
@@ -11461,6 +11724,116 @@ async function refundApplication(db, env, ctx, app) {
|
|
|
11461
11724
|
}
|
|
11462
11725
|
}
|
|
11463
11726
|
|
|
11727
|
+
// src/decline-application.ts
|
|
11728
|
+
var DECLINABLE_FROM = (status2) => !["approved", "declined", "refunded", "canceled"].includes(status2);
|
|
11729
|
+
async function paymentHeld(db, env, ctx, app) {
|
|
11730
|
+
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", String(app.id));
|
|
11731
|
+
if (ctx.membershipAuthority && intent) {
|
|
11732
|
+
try {
|
|
11733
|
+
const target = await membershipTargetForIntent(intent, app, env, ctx);
|
|
11734
|
+
const receipt = await verifyMembershipAccessReceipt(await ctx.membershipAuthority.readAccess(target, env), target, Date.now());
|
|
11735
|
+
return receipt.status !== "unpaid";
|
|
11736
|
+
} catch {
|
|
11737
|
+
return json({ error: "membership authority unavailable; retry before declining", code: "authority_unavailable" }, 503);
|
|
11738
|
+
}
|
|
11739
|
+
}
|
|
11740
|
+
return applicationSummary(app).paid || Boolean(intent?.paymentReceipt);
|
|
11741
|
+
}
|
|
11742
|
+
async function declineApplication(db, env, ctx, app) {
|
|
11743
|
+
if (!chapterBillingApplication(app, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
|
|
11744
|
+
const id7 = String(app.id), status2 = String(app.status ?? "");
|
|
11745
|
+
if (status2 === "declined") return json({ ok: true, status: "declined", replayed: true });
|
|
11746
|
+
if (app.canceled === true || !DECLINABLE_FROM(status2)) return json({ error: `cannot decline from status "${status2}"`, code: "not_declinable" }, 409);
|
|
11747
|
+
if (app.namedSeatId) return json({ error: "a gifted seat is released by naming someone else, or refunded", code: "named_seat_recipient" }, 409);
|
|
11748
|
+
if (!ctx.chapter.pipeline.stages.includes("declined")) return json({ error: "this chapter's pipeline has no declined stage" }, 409);
|
|
11749
|
+
const held = await paymentHeld(db, env, ctx, app);
|
|
11750
|
+
if (held instanceof Response) return held;
|
|
11751
|
+
if (held) return json({ error: "a payment is held for this application; refund it instead", code: "payment_held" }, 409);
|
|
11752
|
+
try {
|
|
11753
|
+
await db.transact(
|
|
11754
|
+
[{ t: "update", ns: "applications", id: id7, attrs: { status: "declined", canceled: true } }],
|
|
11755
|
+
{ mutationId: `decline:${id7}:${status2}`, guards: [{ ns: "applications", id: id7, exists: true, equals: { status: status2 } }] }
|
|
11756
|
+
);
|
|
11757
|
+
} catch (error) {
|
|
11758
|
+
if (error.code !== "transact_guard_failed") throw error;
|
|
11759
|
+
return json({ error: "application changed; reload before declining" }, 409);
|
|
11760
|
+
}
|
|
11761
|
+
await syncApplicationToCrm(crmDeps(db, ctx), { app: { ...app, status: "declined", canceled: true }, stage: "declined" }).catch(() => void 0);
|
|
11762
|
+
return json({ ok: true, status: "declined" });
|
|
11763
|
+
}
|
|
11764
|
+
|
|
11765
|
+
// src/refund-continue.ts
|
|
11766
|
+
var BILLING_FIELDS = [
|
|
11767
|
+
"stripeSubscriptionId",
|
|
11768
|
+
"stripeCustomerId",
|
|
11769
|
+
"canceled",
|
|
11770
|
+
"renewalAt",
|
|
11771
|
+
"refundPolicyAckAt",
|
|
11772
|
+
"membershipStatus",
|
|
11773
|
+
"membershipGraceEndsAt",
|
|
11774
|
+
"membershipAutoRenew",
|
|
11775
|
+
"approvalEffectsPending",
|
|
11776
|
+
"approvalEffectsOrigin",
|
|
11777
|
+
"approvalEffectsLastAttemptAt",
|
|
11778
|
+
"membershipRestartId"
|
|
11779
|
+
];
|
|
11780
|
+
async function resolveFreeTier(db, ctx, app, tierId) {
|
|
11781
|
+
const group = await membershipLoad(db, "groups", String(app.groupId ?? ctx.chapter.id));
|
|
11782
|
+
const tier = group ? findTier(await loadTiers(db, group), tierId) : null;
|
|
11783
|
+
if (!tier || !tierIsFree(tier) || tier.active === false) return json({ error: "continueAs must name an active free tier of this chapter", code: "free_tier_required" }, 400);
|
|
11784
|
+
if (!ctx.chapter.pipeline.approvableFrom.length) return json({ error: "this chapter's pipeline has no approvable stage" }, 409);
|
|
11785
|
+
if (app.namedSeatId) return json({ error: "a gifted seat cannot continue as a free member", code: "named_seat_recipient" }, 409);
|
|
11786
|
+
return tier;
|
|
11787
|
+
}
|
|
11788
|
+
async function continueApplicationOnFreeTier(db, env, ctx, app, tier, origin) {
|
|
11789
|
+
const id7 = String(app.id), now = Date.now();
|
|
11790
|
+
const approvable = ctx.chapter.pipeline.approvableFrom.at(-1);
|
|
11791
|
+
if (!approvable) return json({ error: "this chapter's pipeline has no approvable stage" }, 409);
|
|
11792
|
+
const current = await membershipLoad(db, "applications", id7);
|
|
11793
|
+
if (!current || !chapterBillingApplication(current, ctx.chapter.id, env.ODLA_RUNTIME)) return json({ error: "not found" }, 404);
|
|
11794
|
+
if (current.status === "refunded") {
|
|
11795
|
+
const intent = await membershipLoad(db, "subscriptionCheckoutIntents", id7), quote = await membershipLoad(db, "membershipQuoteProjections", id7);
|
|
11796
|
+
const subscription = typeof intent?.providerSubscriptionId === "string" ? intent.providerSubscriptionId : null;
|
|
11797
|
+
const historyId = `${id7}:continue:${subscription ?? "none"}`;
|
|
11798
|
+
const ops = [
|
|
11799
|
+
...intent && subscription ? [{ t: "update", ns: "membershipCheckoutHistory", id: historyId, attrs: {
|
|
11800
|
+
id: historyId,
|
|
11801
|
+
applicationId: id7,
|
|
11802
|
+
appId: env.ODLA_APP_ID,
|
|
11803
|
+
chapterId: ctx.chapter.id,
|
|
11804
|
+
environment: env.ODLA_ENV,
|
|
11805
|
+
runtime: stripeRuntimeIdentity(env.ODLA_RUNTIME),
|
|
11806
|
+
skuId: String(current.tierId ?? intent.tierId ?? ""),
|
|
11807
|
+
providerSubscriptionId: subscription,
|
|
11808
|
+
applicationSnapshot: current,
|
|
11809
|
+
intentSnapshot: intent,
|
|
11810
|
+
quoteSnapshot: quote ?? {},
|
|
11811
|
+
archivedAt: now
|
|
11812
|
+
} }] : [],
|
|
11813
|
+
...intent ? [{ t: "delete", ns: "subscriptionCheckoutIntents", id: id7 }] : [],
|
|
11814
|
+
...quote ? [{ t: "update", ns: "membershipQuoteProjections", id: id7, attrs: { status: "continued", updatedAt: now } }] : [],
|
|
11815
|
+
{ t: "retract", ns: "applications", id: id7, attrs: BILLING_FIELDS.filter((key3) => current[key3] !== void 0) },
|
|
11816
|
+
{ t: "update", ns: "applications", id: id7, attrs: { tierId: tier.id, status: approvable } }
|
|
11817
|
+
];
|
|
11818
|
+
try {
|
|
11819
|
+
await db.transact(ops, { mutationId: `continue-free:${id7}:${subscription ?? "none"}`, guards: [
|
|
11820
|
+
{ ns: "applications", id: id7, exists: true, equals: { status: "refunded", ...current.tierId !== void 0 ? { tierId: current.tierId } : {} } },
|
|
11821
|
+
...intent && subscription ? [{ ns: "membershipCheckoutHistory", id: historyId, exists: false }] : []
|
|
11822
|
+
] });
|
|
11823
|
+
} catch (error) {
|
|
11824
|
+
if (error.code !== "transact_guard_failed") throw error;
|
|
11825
|
+
return json({ error: "application changed; reload before continuing", code: "continue_conflict" }, 409);
|
|
11826
|
+
}
|
|
11827
|
+
} else if (current.tierId !== tier.id || ![approvable, "approved"].includes(String(current.status))) {
|
|
11828
|
+
return json({ error: `cannot continue as ${tier.id} from status "${String(current.status)}"; refund first`, code: "continue_conflict" }, 409);
|
|
11829
|
+
}
|
|
11830
|
+
const converted = await membershipLoad(db, "applications", id7);
|
|
11831
|
+
if (!converted || converted.tierId !== tier.id) return json({ error: "conversion pending; retry", code: "continue_pending" }, 503);
|
|
11832
|
+
const approved = await approveApplication({ db, env, ctx, app: converted, origin, reason: "manual" });
|
|
11833
|
+
if (!approved.ok) return json({ error: approved.error, code: "continue_pending" }, approved.status);
|
|
11834
|
+
return json({ ok: true, continued: { tierId: tier.id, status: "approved", rolePromoted: approved.rolePromoted, emailLogged: approved.emailLogged } });
|
|
11835
|
+
}
|
|
11836
|
+
|
|
11464
11837
|
// src/patch-application.ts
|
|
11465
11838
|
async function patchApplication(db, env, ctx, app, body) {
|
|
11466
11839
|
const attrs = {};
|
|
@@ -11600,6 +11973,15 @@ var handleAdminApprove = async (req, url, env, ctx) => {
|
|
|
11600
11973
|
if (!result.ok) return json({ error: result.error }, result.status);
|
|
11601
11974
|
return json({ ok: true, status: "approved", rolePromoted: result.rolePromoted, emailLogged: result.emailLogged });
|
|
11602
11975
|
};
|
|
11976
|
+
var handleAdminDecline = async (req, url, env, ctx) => {
|
|
11977
|
+
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/decline$/);
|
|
11978
|
+
if (req.method !== "POST" || !m) return null;
|
|
11979
|
+
const got = await gate4(req, env, ctx);
|
|
11980
|
+
if (got instanceof Response) return got;
|
|
11981
|
+
const app = await loadApp(got, m[1]);
|
|
11982
|
+
if (!app) return json({ error: "not found" }, 404);
|
|
11983
|
+
return declineApplication(got, env, ctx, app);
|
|
11984
|
+
};
|
|
11603
11985
|
var handleAdminRefund = async (req, url, env, ctx) => {
|
|
11604
11986
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)\/refund$/);
|
|
11605
11987
|
if (req.method !== "POST" || !m) return null;
|
|
@@ -11615,7 +11997,21 @@ var handleAdminRefund = async (req, url, env, ctx) => {
|
|
|
11615
11997
|
return json({ error: "super-admin required", code: "super_admin_required" }, 403);
|
|
11616
11998
|
}
|
|
11617
11999
|
}
|
|
11618
|
-
|
|
12000
|
+
const body = req.headers.get("content-length") === "0" || !req.body ? {} : await readJson(req);
|
|
12001
|
+
if (body === null) return json({ error: "invalid JSON body" }, 400);
|
|
12002
|
+
const continueAs = typeof body.continueAs === "string" ? body.continueAs : void 0;
|
|
12003
|
+
if (continueAs === void 0) return refundApplication(db, env, ctx, app);
|
|
12004
|
+
const tier = await resolveFreeTier(db, ctx, app, continueAs);
|
|
12005
|
+
if (tier instanceof Response) return tier;
|
|
12006
|
+
let receipt = {};
|
|
12007
|
+
if (app.stripeSubscriptionId || app.namedSeatId) {
|
|
12008
|
+
const refunded = await refundApplication(db, env, ctx, app);
|
|
12009
|
+
if (!refunded.ok) return refunded;
|
|
12010
|
+
receipt = await refunded.json();
|
|
12011
|
+
}
|
|
12012
|
+
const continued = await continueApplicationOnFreeTier(db, env, ctx, app, tier, url.origin);
|
|
12013
|
+
if (!continued.ok) return continued;
|
|
12014
|
+
return json({ ...receipt, ...await continued.json() });
|
|
11619
12015
|
};
|
|
11620
12016
|
var handleAdminApplicationPatch = async (req, url, env, ctx) => {
|
|
11621
12017
|
const m = url.pathname.match(/^\/api\/admin\/applications\/([0-9a-f-]+)$/);
|
|
@@ -12275,6 +12671,7 @@ async function networkApplicationAction(opts) {
|
|
|
12275
12671
|
const approval = await approveApplication({ db, env, ctx, app, origin: opts.origin, reason: "manual" });
|
|
12276
12672
|
result = json(approval, approval.status);
|
|
12277
12673
|
} else if (input.stage === "refunded") result = await refundApplication(db, env, ctx, app);
|
|
12674
|
+
else if (input.stage === "declined" && ctx.membershipAuthority) result = await declineApplication(db, env, ctx, app);
|
|
12278
12675
|
else result = app.status === input.stage ? json({ ok: true }) : await patchApplication(db, env, ctx, app, { status: input.stage });
|
|
12279
12676
|
if (!result.ok) {
|
|
12280
12677
|
const latest = (await db.query({ applications: { $: { where: { id: appId }, limit: 1 } } })).applications?.[0];
|
|
@@ -15990,10 +16387,14 @@ async function readMembershipChapterEffect(db, input, now) {
|
|
|
15990
16387
|
requireMembershipScope(seat, input);
|
|
15991
16388
|
const member = await membershipLoad(db, "networkMembership", String(seat.primaryMembershipId));
|
|
15992
16389
|
requireMembershipScope(member, input);
|
|
15993
|
-
if (!effect || effect.kind !== input.kind || effect.seatId !== seat.id || effect.membershipId !== member.id || ["chapterId", "environment", "runtime"].some((key3) => effect[key3] !== seat[key3]) || !
|
|
16390
|
+
if (!effect || effect.kind !== input.kind || effect.seatId !== seat.id || effect.membershipId !== member.id || ["chapterId", "environment", "runtime"].some((key3) => effect[key3] !== seat[key3]) || !MEMBERSHIP_CHAPTER_EFFECT_KINDS.includes(input.kind)) throw new Error("invalid chapter effect");
|
|
16391
|
+
const released = input.kind === "release_named_recipient";
|
|
16392
|
+
const recipientUserId = released ? effect.recipientUserId : seat.recipientUserId;
|
|
16393
|
+
const recipientApplicationId = released ? effect.recipientApplicationId : seat.recipientApplicationId;
|
|
15994
16394
|
let applicable;
|
|
15995
|
-
if (input.kind === "invite_named_recipient") applicable = seat.status === "pending_acceptance" && seat.acceptedAt === void 0 && now < Number(member.termEndsAt) &&
|
|
16395
|
+
if (input.kind === "invite_named_recipient") applicable = seat.status === "pending_acceptance" && seat.acceptedAt === void 0 && now < Number(member.termEndsAt) && namedSeatInvitationOpen(member);
|
|
15996
16396
|
else if (input.kind === "revoke_named_recipient_access") applicable = ["refund_pending", "refunded"].includes(String(seat.status));
|
|
16397
|
+
else if (released) applicable = typeof recipientApplicationId === "string" && seat.recipientApplicationId !== recipientApplicationId;
|
|
15997
16398
|
else applicable = (await readNamedSeatAccess(db, {
|
|
15998
16399
|
...input,
|
|
15999
16400
|
recipientUserId: String(seat.recipientUserId),
|
|
@@ -16003,8 +16404,8 @@ async function readMembershipChapterEffect(db, input, now) {
|
|
|
16003
16404
|
...input,
|
|
16004
16405
|
recipientName: String(seat.recipientName),
|
|
16005
16406
|
recipientEmail: String(seat.recipientEmailNormalized),
|
|
16006
|
-
recipientUserId: typeof
|
|
16007
|
-
recipientApplicationId: typeof
|
|
16407
|
+
recipientUserId: typeof recipientUserId === "string" ? recipientUserId : null,
|
|
16408
|
+
recipientApplicationId: typeof recipientApplicationId === "string" ? recipientApplicationId : null,
|
|
16008
16409
|
acceptanceEndsAt: Number(seat.acceptanceEndsAt),
|
|
16009
16410
|
termEndsAt: Number(seat.termEndsAt),
|
|
16010
16411
|
applicable
|
|
@@ -16025,7 +16426,8 @@ var MEMBERSHIP_SEAT_ACTION_FIELDS = {
|
|
|
16025
16426
|
"seat-accept": recipient,
|
|
16026
16427
|
"seat-approve": recipient,
|
|
16027
16428
|
"seat-decline": recipient,
|
|
16028
|
-
"seat-effect": ["seatId", "effectId", "kind"]
|
|
16429
|
+
"seat-effect": ["seatId", "effectId", "kind"],
|
|
16430
|
+
"seat-regift": ["seatId", "recipientName", "recipientEmail"]
|
|
16029
16431
|
};
|
|
16030
16432
|
async function handleMembershipSeatAction(action, db, peer, input, now, stripeSecretName) {
|
|
16031
16433
|
if (!peer.seatPriceId) throw new Error("named seats are not configured");
|
|
@@ -16050,6 +16452,12 @@ async function handleMembershipSeatAction(action, db, peer, input, now, stripeSe
|
|
|
16050
16452
|
kind: String(input.kind)
|
|
16051
16453
|
}, now);
|
|
16052
16454
|
if (action === "seat-offer") return readNamedSeatOffer(db, target, now);
|
|
16455
|
+
if (action === "seat-regift") return regiftNamedSeat(db, {
|
|
16456
|
+
...target,
|
|
16457
|
+
seatId: String(input.seatId),
|
|
16458
|
+
recipientName: String(input.recipientName),
|
|
16459
|
+
recipientEmail: String(input.recipientEmail)
|
|
16460
|
+
}, now);
|
|
16053
16461
|
if (action === "seat-reservations") {
|
|
16054
16462
|
const seat = await reserveNamedMembershipSeat(db, {
|
|
16055
16463
|
...target,
|
|
@@ -16431,7 +16839,9 @@ var membershipAuthorityEntities = {
|
|
|
16431
16839
|
...times(),
|
|
16432
16840
|
refundReason: attr("string", true),
|
|
16433
16841
|
refundedCents: attr("number", true),
|
|
16434
|
-
providerRefundIds: attr("json", true)
|
|
16842
|
+
providerRefundIds: attr("json", true),
|
|
16843
|
+
regiftedAt: attr("number", true),
|
|
16844
|
+
formerRecipients: attr("json", true)
|
|
16435
16845
|
} },
|
|
16436
16846
|
networkSeatRecipient: { attrs: { id: id5(), appId: key(), chapterId: key(), environment: key(), runtime: key(), seatId: key(), recipientEmailNormalized: key(), createdAt: attr("number") } },
|
|
16437
16847
|
membershipEffect: { attrs: {
|
|
@@ -16446,6 +16856,8 @@ var membershipAuthorityEntities = {
|
|
|
16446
16856
|
attempts: attr("number"),
|
|
16447
16857
|
providerObjectId: key(true),
|
|
16448
16858
|
error: attr("string", true),
|
|
16859
|
+
recipientUserId: key(true),
|
|
16860
|
+
recipientApplicationId: key(true),
|
|
16449
16861
|
createdAt: attr("number"),
|
|
16450
16862
|
updatedAt: attr("number", false, false, true)
|
|
16451
16863
|
} }
|
|
@@ -16622,7 +17034,7 @@ async function drainMembershipEffects(db, options, now) {
|
|
|
16622
17034
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !options.stripeSecretKey) throw new TypeError("invalid membership recovery configuration");
|
|
16623
17035
|
await loadMembershipAuthorityState(db, scope2);
|
|
16624
17036
|
const matches2 = (row2) => Object.entries(scope2).every(([key3, value]) => row2[key3] === value);
|
|
16625
|
-
let pending = 0, complete = 0, more = false;
|
|
17037
|
+
let pending = 0, complete = 0, held = 0, more = false;
|
|
16626
17038
|
for (const status2 of ["payment_pending", "payment_processing", "payment_canceling"]) {
|
|
16627
17039
|
const seats = (await db.query({ networkSeat: { $: { where: { ...scope2, status: status2 }, order: { updatedAt: "asc" }, limit: limit + 1 } } })).networkSeat ?? [];
|
|
16628
17040
|
more ||= seats.length > limit;
|
|
@@ -16658,6 +17070,10 @@ async function drainMembershipEffects(db, options, now) {
|
|
|
16658
17070
|
requireMembershipScope(seat, target);
|
|
16659
17071
|
if (seat.primaryMembershipId !== member.id) throw new Error("membership seat scope mismatch");
|
|
16660
17072
|
}
|
|
17073
|
+
if (effect.kind === "invite_named_recipient" && seat?.acceptedAt === void 0 && !namedSeatInvitationOpen(member) && !["refund_pending", "refunded"].includes(String(seat?.status))) {
|
|
17074
|
+
held++;
|
|
17075
|
+
continue;
|
|
17076
|
+
}
|
|
16661
17077
|
try {
|
|
16662
17078
|
const attempts = Number(effect.attempts);
|
|
16663
17079
|
if (!Number.isSafeInteger(attempts) || attempts < 0) throw new Error("invalid membership effect attempt");
|
|
@@ -16681,9 +17097,10 @@ async function drainMembershipEffects(db, options, now) {
|
|
|
16681
17097
|
break;
|
|
16682
17098
|
case "invite_named_recipient":
|
|
16683
17099
|
case "grant_named_recipient_access":
|
|
16684
|
-
case "revoke_named_recipient_access":
|
|
17100
|
+
case "revoke_named_recipient_access":
|
|
17101
|
+
case "release_named_recipient": {
|
|
16685
17102
|
if (!seat) throw new Error("named seat effect missing seat");
|
|
16686
|
-
if (effect.kind
|
|
17103
|
+
if (["invite_named_recipient", "grant_named_recipient_access"].includes(String(effect.kind)) && ["refund_pending", "refunded"].includes(String(seat.status))) break;
|
|
16687
17104
|
if (!options.deliverChapterEffect) throw new Error("chapter effect receiver unavailable");
|
|
16688
17105
|
const receipt = await options.deliverChapterEffect({ ...target, effectId: String(effect.id), seatId: String(effect.seatId), kind: effect.kind });
|
|
16689
17106
|
if (receipt.effectId !== effect.id || receipt.complete !== true) throw new Error("chapter effect receipt unconfirmed");
|
|
@@ -16701,7 +17118,7 @@ async function drainMembershipEffects(db, options, now) {
|
|
|
16701
17118
|
pending++;
|
|
16702
17119
|
}
|
|
16703
17120
|
}
|
|
16704
|
-
return { complete, pending, more };
|
|
17121
|
+
return { complete, pending, held, more };
|
|
16705
17122
|
}
|
|
16706
17123
|
|
|
16707
17124
|
// src/approval-effects-retry.ts
|
|
@@ -16881,6 +17298,14 @@ function createMembershipChapterEffectRoute(options) {
|
|
|
16881
17298
|
tierId: effect.skuId
|
|
16882
17299
|
});
|
|
16883
17300
|
if (!result.sent) throw new Error("invitation delivery unconfirmed");
|
|
17301
|
+
} else if (effect.kind === "release_named_recipient") {
|
|
17302
|
+
stage = "release_recipient";
|
|
17303
|
+
if (!effect.recipientApplicationId || !effect.recipientUserId) throw new Error("recipient not bound");
|
|
17304
|
+
await releaseNamedSeatRecipientApplications(db, env, ctx, {
|
|
17305
|
+
seatId: effect.seatId,
|
|
17306
|
+
recipientApplicationId: effect.recipientApplicationId,
|
|
17307
|
+
recipientUserId: effect.recipientUserId
|
|
17308
|
+
});
|
|
16884
17309
|
} else {
|
|
16885
17310
|
stage = "recipient_load";
|
|
16886
17311
|
if (!effect.recipientApplicationId || !effect.recipientUserId) throw new Error("recipient not bound");
|
|
@@ -16976,6 +17401,18 @@ async function requestNamedSeatOperatorRefund(options) {
|
|
|
16976
17401
|
if (!seat || seat.seatId !== options.seatId || seat.status !== "refunded" || seat.subscriptionCanceled !== false || !Number.isSafeInteger(seat.refundedCents) || seat.refundedCents < 0) throw new Error("invalid named seat refund receipt");
|
|
16977
17402
|
return seat;
|
|
16978
17403
|
}
|
|
17404
|
+
async function requestNamedSeatRegift(options) {
|
|
17405
|
+
const result = await requestAuthority(options, "seat-regift", {
|
|
17406
|
+
applicationId: options.applicationId,
|
|
17407
|
+
skuId: options.skuId,
|
|
17408
|
+
seatId: options.seatId,
|
|
17409
|
+
recipientName: options.recipientName,
|
|
17410
|
+
recipientEmail: options.recipientEmail
|
|
17411
|
+
});
|
|
17412
|
+
const seat = result.seat;
|
|
17413
|
+
if (!seat || seat.seatId !== options.seatId || seat.status !== "pending_acceptance" || typeof seat.recipientName !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(seat.recipientEmail)) || !Number.isSafeInteger(seat.regiftedAt) || seat.releasedRecipientApplicationId !== null && typeof seat.releasedRecipientApplicationId !== "string") throw new Error("invalid named seat regift receipt");
|
|
17414
|
+
return seat;
|
|
17415
|
+
}
|
|
16979
17416
|
async function requestNamedSeatReservation(options) {
|
|
16980
17417
|
const result = await requestAuthority(options, "seat-reservations", {
|
|
16981
17418
|
applicationId: options.applicationId,
|
|
@@ -17182,7 +17619,8 @@ function createMembershipWorkerAdapter(options) {
|
|
|
17182
17619
|
accept: async (input, env) => requestNamedSeatAcceptance({ ...input, ...await transport(input, env) }),
|
|
17183
17620
|
approve: async (input, env) => requestNamedSeatApproval({ ...input, ...await transport(input, env) }),
|
|
17184
17621
|
decline: async (input, env) => requestNamedSeatDecline({ ...input, ...await transport(input, env) }),
|
|
17185
|
-
operatorRefund: async (input, env) => requestNamedSeatOperatorRefund({ ...input, ...await transport(input, env) })
|
|
17622
|
+
operatorRefund: async (input, env) => requestNamedSeatOperatorRefund({ ...input, ...await transport(input, env) }),
|
|
17623
|
+
regift: async (input, env) => requestNamedSeatRegift({ ...input, ...await transport(input, env) })
|
|
17186
17624
|
} } : {}
|
|
17187
17625
|
},
|
|
17188
17626
|
readEffect: async (input, env) => requestMembershipChapterEffect({ ...input, ...await transport(input, env) })
|
|
@@ -17430,6 +17868,7 @@ var BUILTIN_ROUTES = [
|
|
|
17430
17868
|
handleMemberContent,
|
|
17431
17869
|
handleGifts,
|
|
17432
17870
|
handleNamedSeat,
|
|
17871
|
+
handleNamedSeatRegift,
|
|
17433
17872
|
handleNamedSeatRecipient,
|
|
17434
17873
|
handleEmailDeliveryResolution,
|
|
17435
17874
|
handleSchedule,
|
|
@@ -17460,6 +17899,7 @@ var BUILTIN_ROUTES = [
|
|
|
17460
17899
|
handleAdminMeetingReschedule,
|
|
17461
17900
|
handleAdminMeetingCancel,
|
|
17462
17901
|
handleAdminApprove,
|
|
17902
|
+
handleAdminDecline,
|
|
17463
17903
|
handleAdminRefund,
|
|
17464
17904
|
handleAdminApplicationPatch,
|
|
17465
17905
|
handleAdminApplicationActions,
|
|
@@ -17506,15 +17946,21 @@ function chapterWorker(options) {
|
|
|
17506
17946
|
}
|
|
17507
17947
|
export {
|
|
17508
17948
|
AdmissionGrantDeliveryError,
|
|
17949
|
+
DECLINABLE_FROM,
|
|
17509
17950
|
MEMBERSHIP_AUTHORITY_PATH,
|
|
17951
|
+
MEMBERSHIP_CHAPTER_EFFECT_KINDS,
|
|
17510
17952
|
NetworkTargetTransportError,
|
|
17953
|
+
REGIFTABLE_SEAT_STATUSES,
|
|
17511
17954
|
chapterMembershipAccess,
|
|
17512
17955
|
chapterWorker,
|
|
17956
|
+
closedApplication,
|
|
17957
|
+
continueApplicationOnFreeTier,
|
|
17513
17958
|
createMembershipAuthorityHandler,
|
|
17514
17959
|
createMembershipAuthorityStorage,
|
|
17515
17960
|
createMembershipChapterEffectRoute,
|
|
17516
17961
|
createMembershipWorkerAdapter,
|
|
17517
17962
|
createWorkerContext,
|
|
17963
|
+
declineApplication,
|
|
17518
17964
|
deliverAdmissionGrants,
|
|
17519
17965
|
deliverBuilderLibrary,
|
|
17520
17966
|
deliverMemberContent,
|
|
@@ -17525,11 +17971,16 @@ export {
|
|
|
17525
17971
|
liftSeatPurchaseWindow,
|
|
17526
17972
|
lowerSeatPrice,
|
|
17527
17973
|
namedSeatIncluded,
|
|
17974
|
+
namedSeatInvitationOpen,
|
|
17528
17975
|
namedSeatRecipientState,
|
|
17976
|
+
namedSeatRegiftable,
|
|
17529
17977
|
readNamedSeatOffer,
|
|
17530
17978
|
refundNamedSeatByOperator,
|
|
17979
|
+
regiftNamedSeat,
|
|
17980
|
+
releaseNamedSeatRecipientApplications,
|
|
17531
17981
|
renderActiveSignupControlHtml,
|
|
17532
17982
|
resolveNetworkTargetTransport,
|
|
17983
|
+
resolveSignupEligibility,
|
|
17533
17984
|
retermMembershipDiscount,
|
|
17534
17985
|
retryChapterApprovalEffects,
|
|
17535
17986
|
retryLeaderAdmissionDeliveries,
|