@odla-ai/chapter 0.52.1 → 0.54.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 CHANGED
@@ -1735,12 +1735,39 @@ These bite silently — a smoke test won't catch them:
1735
1735
  Neither helper installs a cron or provisions infrastructure by itself.
1736
1736
 
1737
1737
  The member area exposes the authenticated purchaser's named-seat checkout
1738
- only when configured. Recipient hosts pass `initialNamedSeatId` from the
1738
+ only when configured, and as soon as the membership is paid: `/api/me`
1739
+ reports `namedSeatsEnabled` for a paid application whose interview is still
1740
+ ahead, so a member who skipped the seat at checkout finds it on her members
1741
+ page rather than after approval. The authority still decides eligibility on
1742
+ every read. Recipient hosts pass `initialNamedSeatId` from the
1739
1743
  invitation's `seat` query parameter and an authenticated `namedSeatClaimApi`
1740
1744
  to `JoinIsland`; the recipient completes their own application and acceptance
1741
1745
  without a second subscription checkout. A primary cancellation also governs
1742
1746
  seat renewal; buying a seat does not restore canceled auto-renewal.
1743
1747
 
1748
+ An offering may price the seat at zero (`seatPriceCents: 0`): the membership
1749
+ then **includes** the seat. `GET /api/named-seat` reports `included: true`
1750
+ with `amountCents: 0`, and `POST /api/named-seat/checkout` grants the seat in
1751
+ the same request, with no PaymentIntent, no publishable key and no renewal
1752
+ line: the seat lands in `pending_acceptance` with its entitlement and the
1753
+ invitation effect, exactly where a paid seat lands after payment, and its
1754
+ term follows the primary's renewal. The receipt carries `included: true` and
1755
+ no `clientSecret`, so a host skips the card form (`NamedSeatPurchase` asks
1756
+ for the same consent and then sends the invitation). Refunding an included
1757
+ seat closes it with `refundedCents: 0` and revokes an active recipient; the
1758
+ provider is never asked for anything. `lowerSeatPrice` and
1759
+ `POST /api/admin/membership-seats/reprice` (`{ chapterId, runtime, skuId }`,
1760
+ super admin, network owner) move a SKU's existing memberships down to the
1761
+ current published offering's seat price, so a member who paid before the
1762
+ change gets the included seat too; it only ever lowers, skips a seat already
1763
+ paid for, and names every membership it left alone.
1764
+
1765
+ The offer's `seat` also carries `recipientState` (`payment_pending`,
1766
+ `invited`, `accepted`, `joined`, `declined`, `refunded`, `canceled`) with
1767
+ `purchasedAt`, `acceptedAt` and `joinedAt`, so a members page can say who was
1768
+ named and whether that person joined without folding the seat status machine
1769
+ itself; `NamedSeatRecipientLine` renders it.
1770
+
1744
1771
  Delivery logs distinguish sent, logged-only, in-flight, uncertain and
1745
1772
  retry-approved outcomes. The authenticated operator resolution route
1746
1773
  `/api/admin/email/log/:id/resolve` records evidence against the exact attempt;
@@ -1782,6 +1809,23 @@ These bite silently — a smoke test won't catch them:
1782
1809
  and a payable price without reading the database; nothing on it edits a
1783
1810
  version.
1784
1811
 
1812
+ A promise is frozen at checkout, so publishing a later discount policy version
1813
+ changes nothing for members who already paid. When the later version widens
1814
+ the benefit to `while_active`, the network owner moves the cohort's existing
1815
+ members onto it with `POST /api/admin/membership-discount/reterm`
1816
+ ({ chapterId, policyVersionId, runtime?, appId?, membershipIds?, limit? }; super
1817
+ admin; `runtime` defaults to the deployment's own) or the "Move members to this
1818
+ benefit" action the panel shows on that version. Each member's Stripe
1819
+ subscription receives the version's forever coupon first, then the frozen row
1820
+ takes the version and its lower renewal amount under the row's revision, so a
1821
+ run that stops between the two is completed by running it again. It only ever
1822
+ widens: a version that would raise a member's renewal, a lapsed, refunded,
1823
+ canceled, declined or restarting purchase, another cohort, and a subscription
1824
+ the provider no longer matches are all left alone and named in the receipt
1825
+ with the reason. A chapter's own billing forecast reports a provider discount
1826
+ its frozen checkout terms do not explain as unknown, never as a full-price
1827
+ renewal.
1828
+
1785
1829
  On a chapter, a person record with no linked application — someone invited or
1786
1830
  a candidate — is managed by the chapter's own admins the way the network
1787
1831
  owner manages it: the record's Invitation tab sets or revokes the local
@@ -780,11 +780,11 @@ function SeatPanel(props) {
780
780
  " \xB7 ",
781
781
  seat.recipientEmail,
782
782
  " \xB7 ",
783
- seat.status.replaceAll("_", " ")
783
+ (seat.recipientState ?? seat.status).replaceAll("_", " ")
784
784
  ] }),
785
785
  /* @__PURE__ */ jsxs7("p", { className: "muted", style: { margin: 0 }, children: [
786
- money5,
787
- " paid in full. The seat waits for its recipient and is never refunded automatically."
786
+ data.amountCents === 0 ? "Included with the membership; nothing was charged." : `${money5} paid in full.`,
787
+ " The seat waits for its recipient and is never refunded automatically."
788
788
  ] }),
789
789
  data.refundable ? /* @__PURE__ */ jsx7("div", { children: /* @__PURE__ */ jsx7(Button3, { variant: "danger", mini: true, disabled: busy, onClick: () => void refund(), children: busy ? "Refunding\u2026" : "Refund seat" }) }) : null
790
790
  ] }),
@@ -3506,9 +3506,11 @@ function NetworkSeatBilling(props) {
3506
3506
  }
3507
3507
 
3508
3508
  // src/ui/membership-policy-versions.tsx
3509
- import { DataTable as DataTable5 } from "@odla-ai/ui/components";
3509
+ import { Button as Button20, DataTable as DataTable5 } from "@odla-ai/ui/components";
3510
+ import { useState as useState31 } from "preact/hooks";
3510
3511
  import { Fragment as Fragment14, jsx as jsx38, jsxs as jsxs34 } from "preact/jsx-runtime";
3511
3512
  var PATH = "/api/admin/membership-policy-versions";
3513
+ var RETERM = "/api/admin/membership-discount/reterm";
3512
3514
  var day = (ms) => new Date(ms).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
3513
3515
  var money4 = (cents, currency) => new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
3514
3516
  var STATE = { active: "Active", scheduled: "Scheduled", superseded: "Superseded", draft: "Draft", retired: "Retired" };
@@ -3543,13 +3545,13 @@ var offeringColumns = [
3543
3545
  { key: "sku", header: "SKU", cell: (row) => /* @__PURE__ */ jsx38("code", { children: row.baseSkuId }) },
3544
3546
  { key: "list", header: "List price", cell: (row) => `${money4(row.basePriceCents, row.currency)} / ${row.interval}` },
3545
3547
  { key: "term", header: "Term", cell: (row) => row.term.kind === "fixed_end" ? `Ends ${day(row.term.endsAt)}` : `Rolling ${row.term.months} months` },
3546
- { key: "seat", header: "Seat", cell: (row) => `${money4(row.seatPriceCents, row.currency)} \xB7 ${row.seatPurchaseWindowCalendarMonths === void 0 ? "no window" : `${row.seatPurchaseWindowCalendarMonths}-month window`}` },
3548
+ { key: "seat", header: "Seat", cell: (row) => `${row.seatPriceCents === 0 ? "Included" : money4(row.seatPriceCents, row.currency)} \xB7 ${row.seatPurchaseWindowCalendarMonths === void 0 ? "no window" : `${row.seatPurchaseWindowCalendarMonths}-month window`}` },
3547
3549
  { key: "effectiveFrom", header: "Effective from", cell: (row) => day(row.effectiveFrom), sortAs: "number", sortValue: (row) => row.effectiveFrom },
3548
3550
  { key: "effectiveUntil", header: "Effective until", cell: (row) => row.effectiveUntil === void 0 ? "Open" : day(row.effectiveUntil) },
3549
3551
  { key: "supersededAt", header: "Superseded", cell: superseded },
3550
3552
  { key: "source", header: "Source", cell: (row) => /* @__PURE__ */ jsx38(Source, { row }) }
3551
3553
  ];
3552
- var policyColumns = [
3554
+ var policyColumns = (getToken) => [
3553
3555
  { key: "version", header: "Version", cell: (row) => /* @__PURE__ */ jsxs34(Fragment14, { children: [
3554
3556
  /* @__PURE__ */ jsxs34("code", { children: [
3555
3557
  "v",
@@ -3572,8 +3574,40 @@ var policyColumns = [
3572
3574
  { key: "benefit", header: "Benefit duration", cell: (row) => row.benefitDuration.kind === "fixed_end" ? `Until ${day(row.benefitDuration.endsAt)}` : row.benefitDuration.kind === "first_term" ? "First term" : "While active" },
3573
3575
  { key: "mechanism", header: "Provider mechanism", cell: (row) => MECHANISM[row.mechanism] },
3574
3576
  { key: "supersededAt", header: "Superseded", cell: superseded },
3575
- { key: "source", header: "Source", cell: (row) => /* @__PURE__ */ jsx38(Source, { row }) }
3577
+ { key: "source", header: "Source", cell: (row) => /* @__PURE__ */ jsx38(Source, { row }) },
3578
+ { key: "members", header: "Existing members", cell: (row) => /* @__PURE__ */ jsx38(RetermAction, { row, getToken }) }
3576
3579
  ];
3580
+ var summary = (receipt) => {
3581
+ const skipped = receipt.skipped.reduce((acc, item) => ({ ...acc, [item.reason]: (acc[item.reason] ?? 0) + 1 }), {});
3582
+ const parts = [
3583
+ `moved ${receipt.retermed.length}`,
3584
+ `already on it ${receipt.unchanged.length}`,
3585
+ ...Object.entries(skipped).map(([reason, count]) => `skipped ${count} (${reason.replaceAll("_", " ")})`)
3586
+ ];
3587
+ return `${parts.join(" \xB7 ")}${receipt.more ? " \xB7 more remain, run again" : ""}`;
3588
+ };
3589
+ function RetermAction({ row, getToken }) {
3590
+ const [busy, setBusy] = useState31(false);
3591
+ const [result, setResult] = useState31(null);
3592
+ if (row.state !== "active" || row.mechanism !== "stripe_coupon_forever") return /* @__PURE__ */ jsx38("span", { className: "muted", children: row.state !== "active" ? "Not the active version" : "Benefit is not while-active" });
3593
+ const run = async () => {
3594
+ if (!globalThis.confirm?.(`Move every paid ${row.cohortLabel} member of ${row.chapterId} onto v${row.version}? Their next renewal invoice carries ${row.percentOffBps / 100}% off while they stay active. A member whose renewal this would raise is left alone.`)) return;
3595
+ setBusy(true);
3596
+ setResult(null);
3597
+ try {
3598
+ const receipt = await adminFetch(getToken, RETERM, { method: "POST", body: JSON.stringify({ chapterId: row.chapterId, policyVersionId: row.id }) });
3599
+ setResult({ tone: "good", text: summary(receipt) });
3600
+ } catch (error) {
3601
+ setResult({ tone: "danger", text: error instanceof Error ? error.message : String(error) });
3602
+ } finally {
3603
+ setBusy(false);
3604
+ }
3605
+ };
3606
+ return /* @__PURE__ */ jsxs34("span", { style: { display: "inline-flex", flexDirection: "column", gap: 4 }, children: [
3607
+ /* @__PURE__ */ jsx38(Button20, { mini: true, disabled: busy, onClick: run, children: busy ? "Moving\u2026" : "Move members to this benefit" }),
3608
+ result ? /* @__PURE__ */ jsx38("span", { role: result.tone === "good" ? "status" : "alert", className: result.tone === "danger" ? "danger" : "muted", children: result.text }) : null
3609
+ ] });
3610
+ }
3577
3611
  function MembershipPolicyVersions(props) {
3578
3612
  const path = props.chapterId ? `${PATH}?chapterId=${encodeURIComponent(props.chapterId)}` : PATH;
3579
3613
  const { data, loading, error } = useAdminResource(props.getToken, path);
@@ -3606,7 +3640,7 @@ function MembershipPolicyVersions(props) {
3606
3640
  DataTable5,
3607
3641
  {
3608
3642
  rows: data.discountPolicies,
3609
- columns: policyColumns,
3643
+ columns: policyColumns(props.getToken),
3610
3644
  rowKey: (row) => row.id,
3611
3645
  ariaLabel: "Discount policy versions",
3612
3646
  emptyState: `No discount policy versions are recorded for ${scope}.`
@@ -3670,4 +3704,4 @@ export {
3670
3704
  NetworkSeatBilling,
3671
3705
  MembershipPolicyVersions
3672
3706
  };
3673
- //# sourceMappingURL=chunk-WWF2TO3H.js.map
3707
+ //# sourceMappingURL=chunk-NDZCR3OW.js.map