@meith/plugin-dues 0.1.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/src/tasks.ts ADDED
@@ -0,0 +1,156 @@
1
+ import { addDays } from './period'
2
+ import { clampGrantUntil, TOP_UP_WHEN_WITHIN_DAYS } from './plans'
3
+ import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
4
+ import {
5
+ expireDueMemberships,
6
+ extendMembership,
7
+ longLiveMemberships,
8
+ markEventProcessed,
9
+ markEventFailed,
10
+ membershipsPastPeriod,
11
+ pendingOrdersOlderThan,
12
+ setMembershipStatus,
13
+ settleOrder,
14
+ unprocessedEvents,
15
+ } from './store'
16
+ import type { StripeClient } from './stripe/client'
17
+ import { parseEventEnvelope, toInternalEvent } from './stripe/events'
18
+
19
+ const PENDING_AFTER_MINUTES = 15
20
+ const BATCH = 50
21
+
22
+ export interface ReconcileResult {
23
+ readonly eventsReplayed: number
24
+ readonly ordersSettled: number
25
+ readonly ordersClosed: number
26
+ readonly subscriptionsCorrected: number
27
+ }
28
+
29
+ export async function runReconcile(
30
+ deps: EntitlementDeps,
31
+ stripe: StripeClient | null,
32
+ ): Promise<ReconcileResult> {
33
+ let eventsReplayed = 0
34
+ let ordersSettled = 0
35
+ let ordersClosed = 0
36
+ let subscriptionsCorrected = 0
37
+
38
+ for (const event of await unprocessedEvents(deps.data, BATCH)) {
39
+ const envelope = parseEventEnvelope(event.payload)
40
+ if (envelope === null) {
41
+ await markEventProcessed(deps.data, event.id, 'unparseable')
42
+ continue
43
+ }
44
+ try {
45
+ const outcome = await applyInternalEvent(deps, toInternalEvent(envelope))
46
+ await markEventProcessed(deps.data, event.id, outcome)
47
+ eventsReplayed += 1
48
+ } catch (error) {
49
+ await markEventFailed(
50
+ deps.data,
51
+ event.id,
52
+ `failed: ${error instanceof Error ? error.message : String(error)}`,
53
+ )
54
+ }
55
+ }
56
+
57
+ if (stripe === null) {
58
+ return { eventsReplayed, ordersSettled, ordersClosed, subscriptionsCorrected }
59
+ }
60
+
61
+ for (const order of await pendingOrdersOlderThan(deps.data, PENDING_AFTER_MINUTES, BATCH)) {
62
+ if (order.stripeSessionId === null) {
63
+ await settleOrder(deps.data, order.id, { status: 'cancelled' })
64
+ ordersClosed += 1
65
+ continue
66
+ }
67
+
68
+ const session = await stripe.getCheckoutSession(order.stripeSessionId)
69
+ if (session.status === 'complete' && session.paymentStatus !== 'unpaid') {
70
+ await settlePaidOrder(deps, order, {
71
+ amountTotal: session.amountTotal,
72
+ currency: session.currency,
73
+ subscriptionId: session.subscriptionId,
74
+ paymentIntentId: session.paymentIntentId,
75
+ })
76
+ ordersSettled += 1
77
+ } else if (session.status === 'expired') {
78
+ await settleOrder(deps.data, order.id, { status: 'cancelled' })
79
+ ordersClosed += 1
80
+ }
81
+ }
82
+
83
+ for (const membership of await membershipsPastPeriod(deps.data, BATCH)) {
84
+ if (membership.stripeSubscriptionId === null) continue
85
+ const subscription = await stripe.getSubscription(membership.stripeSubscriptionId)
86
+
87
+ if (
88
+ subscription.status === 'active' &&
89
+ subscription.currentPeriodEnd !== null &&
90
+ subscription.currentPeriodEnd > membership.currentPeriodEnd
91
+ ) {
92
+ const graceUntil = addDays(subscription.currentPeriodEnd, deps.config.graceDays)
93
+ await extendMembership(deps.data, membership.id, {
94
+ currentPeriodEnd: subscription.currentPeriodEnd,
95
+ graceUntil,
96
+ })
97
+ try {
98
+ await deps.grants.grant({
99
+ userId: membership.userId,
100
+ groupKey: membership.groupKey,
101
+ until: graceUntil,
102
+ reason: `dues reconcile of subscription ${membership.stripeSubscriptionId}`,
103
+ })
104
+ } catch (error) {
105
+ deps.log('dues: reconcile grant refused', {
106
+ membershipId: membership.id,
107
+ reason: error instanceof Error ? error.message : String(error),
108
+ })
109
+ }
110
+ subscriptionsCorrected += 1
111
+ } else if (subscription.status === 'past_due' || subscription.status === 'unpaid') {
112
+ await setMembershipStatus(deps.data, membership.id, 'grace')
113
+ subscriptionsCorrected += 1
114
+ } else if (subscription.status === 'canceled') {
115
+ await setMembershipStatus(deps.data, membership.id, 'closing')
116
+ subscriptionsCorrected += 1
117
+ }
118
+ }
119
+
120
+ return { eventsReplayed, ordersSettled, ordersClosed, subscriptionsCorrected }
121
+ }
122
+
123
+ export async function runSweep(deps: EntitlementDeps): Promise<number> {
124
+ const expired = await expireDueMemberships(deps.data, 200)
125
+ await topUpLongGrants(deps)
126
+ return expired
127
+ }
128
+
129
+ async function topUpLongGrants(deps: EntitlementDeps): Promise<void> {
130
+ const now = deps.now()
131
+ const horizon = addDays(now, TOP_UP_WHEN_WITHIN_DAYS)
132
+
133
+ for (const membership of await longLiveMemberships(deps.data, horizon, 200)) {
134
+ const target = clampGrantUntil(membership.graceUntil, now)
135
+
136
+ const grants = await deps.grants.list(membership.userId)
137
+ const current = grants.find((grant) => grant.groupKey === membership.groupKey)
138
+ if (current !== undefined && (current.expiresAt >= target || current.expiresAt > horizon)) {
139
+ continue
140
+ }
141
+
142
+ try {
143
+ await deps.grants.grant({
144
+ userId: membership.userId,
145
+ groupKey: membership.groupKey,
146
+ until: target,
147
+ reason: `dues grant window top-up for membership ${membership.id}`,
148
+ })
149
+ } catch (error) {
150
+ deps.log('dues: grant top-up refused', {
151
+ membershipId: membership.id,
152
+ reason: error instanceof Error ? error.message : String(error),
153
+ })
154
+ }
155
+ }
156
+ }