@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/LICENSE.md +165 -0
- package/README.md +182 -0
- package/package.json +31 -0
- package/src/codes.ts +47 -0
- package/src/config.ts +153 -0
- package/src/definition.tsx +292 -0
- package/src/demo.ts +526 -0
- package/src/entitlement.ts +371 -0
- package/src/handlers-admin.ts +405 -0
- package/src/handlers.ts +397 -0
- package/src/index.ts +18 -0
- package/src/money.ts +42 -0
- package/src/period.ts +76 -0
- package/src/plans.ts +183 -0
- package/src/schema.ts +153 -0
- package/src/store.ts +966 -0
- package/src/stripe/client.ts +303 -0
- package/src/stripe/events.ts +189 -0
- package/src/stripe/webhook.ts +64 -0
- package/src/tasks.ts +156 -0
- package/src/ui/admin.tsx +925 -0
- package/src/ui/pages.tsx +557 -0
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
|
+
}
|