@tribe-nest/forge 3.9.0 → 3.11.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.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The ONE answer to "does this membership grant access right now", and the
3
+ * separate answer to "what should we tell the member".
4
+ *
5
+ * ## Why this file exists
6
+ *
7
+ * The backend has always kept a member's benefits alive through `past_due` and
8
+ * the grace window — that is a locked product decision (retention beats a few
9
+ * days of free access; an abrupt cut-off during a bank hiccup is the top
10
+ * cancellation trigger). Three fan-facing components disagreed with it, each
11
+ * with its own `status === "active"` string compare:
12
+ *
13
+ * - Forge `MembershipGate` → locked past_due members out of paid content
14
+ * - Forge `AccountDashboard` → badge read "Cancelled"
15
+ * - client `MembershipTab` → badge read "Cancelled", cancel button hidden
16
+ *
17
+ * So a member whose card bounced lost access instantly AND was told they had
18
+ * cancelled. Both wrong, from the same overloaded boolean.
19
+ *
20
+ * ## How it stays fixed
21
+ *
22
+ * The server is the source of truth. `describeMembershipAccess` (backend,
23
+ * `services/_core/membershipAccess.ts`) runs the SAME `grantsAccess` predicate
24
+ * the membership guard, community tier resolution and the notification workers
25
+ * use, and ships the result as `membership.access` on every membership a fan
26
+ * surface receives. `getMembershipAccess` below prefers that block. It never
27
+ * re-decides; a UI gate and a server gate cannot disagree.
28
+ *
29
+ * The status fallback exists only for a client running against a server that
30
+ * predates the `access` block. Keep it in lockstep with
31
+ * `ACCESS_GRANTING_STATUSES` on the backend — and prefer adding to the server.
32
+ */
33
+
34
+ export const MEMBERSHIP_BILLING_STATE = {
35
+ active: "active",
36
+ ending: "ending",
37
+ pastDue: "past_due",
38
+ grace: "grace",
39
+ pending: "pending",
40
+ ended: "ended",
41
+ } as const;
42
+
43
+ export type MembershipBillingState = (typeof MEMBERSHIP_BILLING_STATE)[keyof typeof MEMBERSHIP_BILLING_STATE];
44
+
45
+ /** Mirror of the backend's `ACCESS_GRANTING_STATUSES` — fallback only. */
46
+ const ACCESS_GRANTING_STATUSES = ["active", "past_due", "grace"];
47
+ const RECOVERABLE_STATUSES = ["past_due", "grace"];
48
+
49
+ export type MembershipAccessSummary = {
50
+ /** May this member use their benefits right now. */
51
+ hasAccess: boolean;
52
+ billingState: MembershipBillingState;
53
+ /** A charge failed and recovery is still possible — show the fix-my-card path. */
54
+ paymentFailed: boolean;
55
+ /** ISO date access is cut if the card is not fixed. */
56
+ graceUntil: string | null;
57
+ pastDueAt: string | null;
58
+ cancelAtPeriodEnd: boolean;
59
+ };
60
+
61
+ export type MembershipAccessInput =
62
+ | {
63
+ status?: string | null;
64
+ cancelAtPeriodEnd?: boolean | null;
65
+ graceUntil?: string | null;
66
+ pastDueAt?: string | null;
67
+ access?: Partial<MembershipAccessSummary> | null;
68
+ }
69
+ | null
70
+ | undefined;
71
+
72
+ const NO_MEMBERSHIP: MembershipAccessSummary = {
73
+ hasAccess: false,
74
+ billingState: MEMBERSHIP_BILLING_STATE.ended,
75
+ paymentFailed: false,
76
+ graceUntil: null,
77
+ pastDueAt: null,
78
+ cancelAtPeriodEnd: false,
79
+ };
80
+
81
+ const billingStateFromStatus = (status: string | null | undefined, cancelAtPeriodEnd: boolean) => {
82
+ switch (status) {
83
+ case "active":
84
+ return cancelAtPeriodEnd ? MEMBERSHIP_BILLING_STATE.ending : MEMBERSHIP_BILLING_STATE.active;
85
+ case "past_due":
86
+ return MEMBERSHIP_BILLING_STATE.pastDue;
87
+ case "grace":
88
+ return MEMBERSHIP_BILLING_STATE.grace;
89
+ case "pending":
90
+ return MEMBERSHIP_BILLING_STATE.pending;
91
+ default:
92
+ return MEMBERSHIP_BILLING_STATE.ended;
93
+ }
94
+ };
95
+
96
+ export function getMembershipAccess(membership: MembershipAccessInput): MembershipAccessSummary {
97
+ if (!membership) return NO_MEMBERSHIP;
98
+
99
+ const server = membership.access;
100
+ if (server && typeof server.hasAccess === "boolean" && server.billingState) {
101
+ return {
102
+ hasAccess: server.hasAccess,
103
+ billingState: server.billingState,
104
+ paymentFailed: !!server.paymentFailed,
105
+ graceUntil: server.graceUntil ?? null,
106
+ pastDueAt: server.pastDueAt ?? null,
107
+ cancelAtPeriodEnd: !!server.cancelAtPeriodEnd,
108
+ };
109
+ }
110
+
111
+ const status = membership.status ?? null;
112
+ const cancelAtPeriodEnd = !!membership.cancelAtPeriodEnd;
113
+ return {
114
+ hasAccess: !!status && ACCESS_GRANTING_STATUSES.includes(status),
115
+ billingState: billingStateFromStatus(status, cancelAtPeriodEnd),
116
+ paymentFailed: !!status && RECOVERABLE_STATUSES.includes(status),
117
+ graceUntil: membership.graceUntil ?? null,
118
+ pastDueAt: membership.pastDueAt ?? null,
119
+ cancelAtPeriodEnd,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * The label + explanation a member should see. Deliberately separate from
125
+ * access: a `past_due` member keeps their benefits AND must be told their
126
+ * payment failed. Showing "Active" to someone whose card bounced is its own
127
+ * failure — it is why they never fix it.
128
+ */
129
+ export type MembershipStatusMessage = {
130
+ label: string;
131
+ /** Longer explanation, or null when there is nothing to explain. */
132
+ detail: string | null;
133
+ /** Show the "update payment method" affordance. */
134
+ showUpdatePayment: boolean;
135
+ /** Severity for styling — the caller maps this onto its own theme colors. */
136
+ tone: "positive" | "warning" | "neutral";
137
+ };
138
+
139
+ const formatDay = (iso: string | null): string | null => {
140
+ if (!iso) return null;
141
+ const date = new Date(iso);
142
+ return Number.isNaN(date.getTime()) ? null : date.toLocaleDateString();
143
+ };
144
+
145
+ export function getMembershipStatusMessage(access: MembershipAccessSummary): MembershipStatusMessage {
146
+ switch (access.billingState) {
147
+ case MEMBERSHIP_BILLING_STATE.active:
148
+ return { label: "Active", detail: null, showUpdatePayment: false, tone: "positive" };
149
+
150
+ case MEMBERSHIP_BILLING_STATE.ending:
151
+ return {
152
+ label: "Ending",
153
+ detail: "Your membership is cancelled and will not renew. You keep access until the end of this period.",
154
+ showUpdatePayment: false,
155
+ tone: "neutral",
156
+ };
157
+
158
+ case MEMBERSHIP_BILLING_STATE.pastDue:
159
+ case MEMBERSHIP_BILLING_STATE.grace: {
160
+ const until = formatDay(access.graceUntil);
161
+ return {
162
+ label: "Payment failed",
163
+ detail: until
164
+ ? `We couldn't charge your payment method. You still have full access until ${until} — update your card to keep it.`
165
+ : "We couldn't charge your payment method. You still have full access — update your card to keep it.",
166
+ showUpdatePayment: true,
167
+ tone: "warning",
168
+ };
169
+ }
170
+
171
+ case MEMBERSHIP_BILLING_STATE.pending:
172
+ return {
173
+ label: "Pending",
174
+ detail: "Your membership isn't active yet — finish checkout to unlock it.",
175
+ showUpdatePayment: false,
176
+ tone: "neutral",
177
+ };
178
+
179
+ default:
180
+ return { label: "Ended", detail: null, showUpdatePayment: false, tone: "neutral" };
181
+ }
182
+ }