@meith/plugin-dues 0.36.1 → 0.37.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 +3 -8
- package/package.json +4 -4
- package/src/definition.tsx +1 -1
- package/src/index.ts +0 -12
- package/src/demo.ts +0 -526
package/README.md
CHANGED
|
@@ -30,11 +30,6 @@ membership — but their standing does not move, and they are still shown as
|
|
|
30
30
|
staff. Selling a badge is fine; selling somebody out of the one that says who
|
|
31
31
|
answers for the board is not.
|
|
32
32
|
|
|
33
|
-
**Try it before you read any of this**: [demo.meith.dev](https://demo.meith.dev)
|
|
34
|
-
runs this plugin against a Stripe that is not Stripe — a shop with a year of
|
|
35
|
-
history behind it, and a checkout a visitor can actually go through. See
|
|
36
|
-
[demo mode](../../docs/guides/operations/demo-mode.md#the-shop-and-a-stripe-that-is-not-stripe).
|
|
37
|
-
|
|
38
33
|
## Setting up a board
|
|
39
34
|
|
|
40
35
|
1. **Register the plugin** where plugins are registered:
|
|
@@ -52,10 +47,10 @@ history behind it, and a checkout a visitor can actually go through. See
|
|
|
52
47
|
|
|
53
48
|
A board that registers plugins in code and genuinely needs a code-only
|
|
54
49
|
escape hatch — an extra redirect host for a proxy or a loopback address,
|
|
55
|
-
or code-declared seed plans for a
|
|
50
|
+
or code-declared seed plans for a test board — calls `createDues`
|
|
56
51
|
instead: `createDues({ extraRedirectHosts: ['proxy.example'] })`. This
|
|
57
|
-
repository's
|
|
58
|
-
`meith.
|
|
52
|
+
repository's test boards do exactly that, in
|
|
53
|
+
`meith.test.plugins.ts`, behind `DUES_TEST_BOARD`. A
|
|
59
54
|
seed plan populates the plan table on the board's first run and is
|
|
60
55
|
ignored once it has rows — after that, the panel owns the plans, exactly
|
|
61
56
|
as for a board that never declared any.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-dues",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.0",
|
|
4
4
|
"description": "Membership dues for a Meith board: Stripe-backed subscriptions as a contained plugin.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/plugin-kit": "^0.
|
|
24
|
-
"@meith/theme-kit": "^0.
|
|
25
|
-
"@meith/ui": "^0.
|
|
23
|
+
"@meith/plugin-kit": "^0.37.0",
|
|
24
|
+
"@meith/theme-kit": "^0.37.0",
|
|
25
|
+
"@meith/ui": "^0.37.0"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"react": "^19.2.0"
|
package/src/definition.tsx
CHANGED
|
@@ -57,7 +57,7 @@ export function createDues(input: DuesConfigInput = {}): PluginDefinition {
|
|
|
57
57
|
return definePlugin({
|
|
58
58
|
key: 'dues',
|
|
59
59
|
name: 'Dues',
|
|
60
|
-
version: '0.
|
|
60
|
+
version: '0.37.0',
|
|
61
61
|
description: en['dues.definition.description'].replace(
|
|
62
62
|
'{label}',
|
|
63
63
|
staticConfig.label.toLowerCase(),
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
export type { DuesConfigInput, DuesPlanInput } from './config'
|
|
2
2
|
export { createDues, dues, dues as plugin } from './definition'
|
|
3
|
-
export {
|
|
4
|
-
DUES_DEMO_CODES,
|
|
5
|
-
DUES_DEMO_CURRENCY,
|
|
6
|
-
DUES_DEMO_GRACE_DAYS,
|
|
7
|
-
DUES_DEMO_GROUP,
|
|
8
|
-
DUES_DEMO_PLANS,
|
|
9
|
-
DUES_DEMO_PRICES,
|
|
10
|
-
type DuesDemoCast,
|
|
11
|
-
type DuesDemoDeps,
|
|
12
|
-
type DuesDemoSummary,
|
|
13
|
-
seedDuesDemo,
|
|
14
|
-
} from './demo'
|
|
15
3
|
export { duesMessages, duesMessages as messages } from './messages'
|
|
16
4
|
export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
|
|
17
5
|
export { signStripePayload } from './stripe/webhook'
|
package/src/demo.ts
DELETED
|
@@ -1,526 +0,0 @@
|
|
|
1
|
-
import type { PluginData, PluginGrants, PluginNotify } from '@meith/plugin-kit'
|
|
2
|
-
|
|
3
|
-
import { discountedPrice } from './codes'
|
|
4
|
-
import { type DuesConfig, parseDuesConfig, resolveDuesConfig } from './config'
|
|
5
|
-
import { applyInternalEvent, type EntitlementDeps, settlePaidOrder } from './entitlement'
|
|
6
|
-
import { addDays } from './period'
|
|
7
|
-
import {
|
|
8
|
-
attachCheckoutSession,
|
|
9
|
-
type CodeRow,
|
|
10
|
-
insertCode,
|
|
11
|
-
insertOrder,
|
|
12
|
-
insertPlan,
|
|
13
|
-
markEventProcessed,
|
|
14
|
-
type NewPlan,
|
|
15
|
-
type OrderRow,
|
|
16
|
-
type PlanRow,
|
|
17
|
-
planRowByKey,
|
|
18
|
-
recordEvent,
|
|
19
|
-
setCodeDisabled,
|
|
20
|
-
setPlanArchived,
|
|
21
|
-
settleOrder,
|
|
22
|
-
} from './store'
|
|
23
|
-
import { type StripeEventEnvelope, toInternalEvent } from './stripe/events'
|
|
24
|
-
|
|
25
|
-
export const DUES_DEMO_GROUP = 'supporters'
|
|
26
|
-
|
|
27
|
-
export const DUES_DEMO_CURRENCY = 'eur'
|
|
28
|
-
export const DUES_DEMO_GRACE_DAYS = 7
|
|
29
|
-
|
|
30
|
-
export const DUES_DEMO_PLANS = {
|
|
31
|
-
month: 'supporter-month',
|
|
32
|
-
pass: 'pass-90',
|
|
33
|
-
lifetime: 'lifetime',
|
|
34
|
-
founder: 'founder',
|
|
35
|
-
} as const
|
|
36
|
-
|
|
37
|
-
export const DUES_DEMO_CODES = {
|
|
38
|
-
comp: 'COMP-LIFETIME',
|
|
39
|
-
thanks: 'THANKS-2026',
|
|
40
|
-
early: 'EARLY-BIRD',
|
|
41
|
-
} as const
|
|
42
|
-
|
|
43
|
-
export const DUES_DEMO_PRICES: readonly {
|
|
44
|
-
readonly id: string
|
|
45
|
-
readonly name: string
|
|
46
|
-
readonly unitAmount: number
|
|
47
|
-
readonly currency: string
|
|
48
|
-
readonly interval: 'month' | 'year'
|
|
49
|
-
}[] = [
|
|
50
|
-
{
|
|
51
|
-
id: 'price_demo_supporter_month',
|
|
52
|
-
name: 'Supporter',
|
|
53
|
-
unitAmount: 500,
|
|
54
|
-
currency: DUES_DEMO_CURRENCY,
|
|
55
|
-
interval: 'month',
|
|
56
|
-
},
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
export interface DuesDemoCast {
|
|
60
|
-
readonly comped: number
|
|
61
|
-
readonly founder: number
|
|
62
|
-
readonly subscriber: number
|
|
63
|
-
readonly passHolder: number
|
|
64
|
-
readonly lapsing: number
|
|
65
|
-
readonly leaving: number
|
|
66
|
-
readonly refunded: number
|
|
67
|
-
readonly gifter: number
|
|
68
|
-
readonly gifted: number
|
|
69
|
-
readonly staff: number
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface DuesDemoDeps {
|
|
73
|
-
readonly data: PluginData
|
|
74
|
-
readonly grants: (now: () => Date) => PluginGrants
|
|
75
|
-
readonly notify?: PluginNotify | undefined
|
|
76
|
-
readonly cast: DuesDemoCast
|
|
77
|
-
readonly now: Date
|
|
78
|
-
readonly log?: ((message: string, detail?: Record<string, unknown>) => void) | undefined
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export interface DuesDemoSummary {
|
|
82
|
-
readonly plans: number
|
|
83
|
-
readonly codes: number
|
|
84
|
-
readonly orders: number
|
|
85
|
-
readonly memberships: number
|
|
86
|
-
readonly events: number
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function seedDuesDemo(deps: DuesDemoDeps): Promise<DuesDemoSummary> {
|
|
90
|
-
return new DemoSeed(deps).run()
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const NO_OP_NOTIFY: PluginNotify = { send: async () => {} }
|
|
94
|
-
|
|
95
|
-
const PASS_PRICE = 1200
|
|
96
|
-
const MONTH_PRICE = 500
|
|
97
|
-
|
|
98
|
-
class DemoSeed {
|
|
99
|
-
private readonly config: DuesConfig
|
|
100
|
-
private readonly grants: PluginGrants
|
|
101
|
-
private clock: Date
|
|
102
|
-
private eventCounter = 0
|
|
103
|
-
private counts = { plans: 0, codes: 0, orders: 0, memberships: 0, events: 0 }
|
|
104
|
-
|
|
105
|
-
constructor(private readonly deps: DuesDemoDeps) {
|
|
106
|
-
this.config = resolveDuesConfig(parseDuesConfig({}), {
|
|
107
|
-
currency: DUES_DEMO_CURRENCY,
|
|
108
|
-
grace_days: DUES_DEMO_GRACE_DAYS,
|
|
109
|
-
})
|
|
110
|
-
this.clock = deps.now
|
|
111
|
-
this.grants = deps.grants(() => this.clock)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
async run(): Promise<DuesDemoSummary> {
|
|
115
|
-
await this.plans()
|
|
116
|
-
const comp = await this.codes()
|
|
117
|
-
|
|
118
|
-
await this.at(300, () => this.buyFounderPass())
|
|
119
|
-
await this.at(180, () => this.compLifetime(comp))
|
|
120
|
-
|
|
121
|
-
await this.subscription({
|
|
122
|
-
member: this.deps.cast.subscriber,
|
|
123
|
-
subscriptionId: 'sub_demo_supporter',
|
|
124
|
-
boughtDaysAgo: 158,
|
|
125
|
-
renewedDaysAgo: [128, 97, 66, 35, 4],
|
|
126
|
-
})
|
|
127
|
-
await this.subscription({
|
|
128
|
-
member: this.deps.cast.lapsing,
|
|
129
|
-
subscriptionId: 'sub_demo_lapsed',
|
|
130
|
-
boughtDaysAgo: 126,
|
|
131
|
-
renewedDaysAgo: [96, 65, 34],
|
|
132
|
-
failedDaysAgo: 1,
|
|
133
|
-
})
|
|
134
|
-
await this.subscription({
|
|
135
|
-
member: this.deps.cast.leaving,
|
|
136
|
-
subscriptionId: 'sub_demo_leaving',
|
|
137
|
-
boughtDaysAgo: 95,
|
|
138
|
-
renewedDaysAgo: [65, 34, 3],
|
|
139
|
-
cancelledDaysAgo: 2,
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
await this.at(40, () => this.buyPass(this.deps.cast.passHolder))
|
|
143
|
-
await this.at(26, () => this.buyPass(this.deps.cast.refunded))
|
|
144
|
-
await this.at(19, () => this.refund(this.deps.cast.refunded))
|
|
145
|
-
await this.at(12, () => this.gift())
|
|
146
|
-
await this.at(8, () => this.abandonedCheckout())
|
|
147
|
-
|
|
148
|
-
return { ...this.counts }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
private async plans(): Promise<void> {
|
|
152
|
-
await this.plan({
|
|
153
|
-
planKey: DUES_DEMO_PLANS.month,
|
|
154
|
-
name: 'Supporter',
|
|
155
|
-
description: 'The board’s bills, split honestly. Renews monthly; cancel whenever you like.',
|
|
156
|
-
groupKey: DUES_DEMO_GROUP,
|
|
157
|
-
priceMinor: MONTH_PRICE,
|
|
158
|
-
currency: DUES_DEMO_CURRENCY,
|
|
159
|
-
mode: 'auto',
|
|
160
|
-
periodSpec: null,
|
|
161
|
-
billingInterval: 'month',
|
|
162
|
-
stripePriceId: DUES_DEMO_PRICES[0]!.id,
|
|
163
|
-
giftable: false,
|
|
164
|
-
hidden: false,
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
await this.plan({
|
|
168
|
-
planKey: DUES_DEMO_PLANS.pass,
|
|
169
|
-
name: '90-day pass',
|
|
170
|
-
description:
|
|
171
|
-
'Three months among the supporters, paid once. Buy it for yourself or for somebody else.',
|
|
172
|
-
groupKey: DUES_DEMO_GROUP,
|
|
173
|
-
priceMinor: PASS_PRICE,
|
|
174
|
-
currency: DUES_DEMO_CURRENCY,
|
|
175
|
-
mode: 'fixed',
|
|
176
|
-
periodSpec: 'P90D',
|
|
177
|
-
billingInterval: null,
|
|
178
|
-
giftable: true,
|
|
179
|
-
hidden: false,
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
await this.plan({
|
|
183
|
-
planKey: DUES_DEMO_PLANS.lifetime,
|
|
184
|
-
name: 'Lifetime membership',
|
|
185
|
-
description: 'Once, and never again. No renewal, no end date.',
|
|
186
|
-
groupKey: DUES_DEMO_GROUP,
|
|
187
|
-
priceMinor: 9900,
|
|
188
|
-
currency: DUES_DEMO_CURRENCY,
|
|
189
|
-
mode: 'lifetime',
|
|
190
|
-
periodSpec: null,
|
|
191
|
-
billingInterval: null,
|
|
192
|
-
giftable: true,
|
|
193
|
-
hidden: false,
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
const founder = await this.plan({
|
|
197
|
-
planKey: DUES_DEMO_PLANS.founder,
|
|
198
|
-
name: 'Founding supporter',
|
|
199
|
-
description: 'A year at the old price, sold before the plans settled. Not on sale now.',
|
|
200
|
-
groupKey: DUES_DEMO_GROUP,
|
|
201
|
-
priceMinor: 2500,
|
|
202
|
-
currency: DUES_DEMO_CURRENCY,
|
|
203
|
-
mode: 'fixed',
|
|
204
|
-
periodSpec: 'P1Y',
|
|
205
|
-
billingInterval: null,
|
|
206
|
-
giftable: false,
|
|
207
|
-
hidden: false,
|
|
208
|
-
})
|
|
209
|
-
if (founder !== null) await setPlanArchived(this.deps.data, founder.id, true)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
private async plan(input: NewPlan): Promise<PlanRow | null> {
|
|
213
|
-
const row = await insertPlan(this.deps.data, input)
|
|
214
|
-
if (row !== null) this.counts.plans += 1
|
|
215
|
-
return row
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
private async codes(): Promise<CodeRow> {
|
|
219
|
-
const comp = await this.code({
|
|
220
|
-
code: DUES_DEMO_CODES.comp,
|
|
221
|
-
percentOff: 100,
|
|
222
|
-
planKey: DUES_DEMO_PLANS.lifetime,
|
|
223
|
-
maxRedemptions: 5,
|
|
224
|
-
expiresAt: null,
|
|
225
|
-
createdByUserId: this.deps.cast.staff,
|
|
226
|
-
})
|
|
227
|
-
if (comp === null) {
|
|
228
|
-
throw new Error('the demo comp code could not be minted into an empty code table')
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
await this.code({
|
|
232
|
-
code: DUES_DEMO_CODES.thanks,
|
|
233
|
-
percentOff: 25,
|
|
234
|
-
planKey: null,
|
|
235
|
-
maxRedemptions: null,
|
|
236
|
-
expiresAt: addDays(this.deps.now, 60),
|
|
237
|
-
createdByUserId: this.deps.cast.staff,
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
const early = await this.code({
|
|
241
|
-
code: DUES_DEMO_CODES.early,
|
|
242
|
-
percentOff: 50,
|
|
243
|
-
planKey: DUES_DEMO_PLANS.pass,
|
|
244
|
-
maxRedemptions: 25,
|
|
245
|
-
expiresAt: addDays(this.deps.now, -30),
|
|
246
|
-
createdByUserId: this.deps.cast.staff,
|
|
247
|
-
})
|
|
248
|
-
if (early !== null) await setCodeDisabled(this.deps.data, early.id, true)
|
|
249
|
-
|
|
250
|
-
return comp
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
private async code(input: Parameters<typeof insertCode>[1]): Promise<CodeRow | null> {
|
|
254
|
-
const row = await insertCode(this.deps.data, input)
|
|
255
|
-
if (row !== null) this.counts.codes += 1
|
|
256
|
-
return row
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
private async buyFounderPass(): Promise<void> {
|
|
260
|
-
const order = await this.order({
|
|
261
|
-
buyer: this.deps.cast.founder,
|
|
262
|
-
planKey: DUES_DEMO_PLANS.founder,
|
|
263
|
-
sessionId: 'cs_demo_founder',
|
|
264
|
-
})
|
|
265
|
-
await this.settle(order, 'cs_demo_founder', { paymentIntentId: 'pi_demo_founder' })
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
private async compLifetime(code: CodeRow): Promise<void> {
|
|
269
|
-
const order = await this.order({
|
|
270
|
-
buyer: this.deps.cast.comped,
|
|
271
|
-
planKey: DUES_DEMO_PLANS.lifetime,
|
|
272
|
-
sessionId: null,
|
|
273
|
-
code,
|
|
274
|
-
})
|
|
275
|
-
|
|
276
|
-
await settlePaidOrder(this.entitlement(), order, {
|
|
277
|
-
amountTotal: 0,
|
|
278
|
-
currency: DUES_DEMO_CURRENCY,
|
|
279
|
-
subscriptionId: null,
|
|
280
|
-
paymentIntentId: null,
|
|
281
|
-
})
|
|
282
|
-
this.counts.memberships += 1
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
private async buyPass(buyer: number): Promise<void> {
|
|
286
|
-
const sessionId = `cs_demo_pass_${buyer}`
|
|
287
|
-
const order = await this.order({ buyer, planKey: DUES_DEMO_PLANS.pass, sessionId })
|
|
288
|
-
await this.settle(order, sessionId, { paymentIntentId: paymentIntentFor(buyer) })
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
private async gift(): Promise<void> {
|
|
292
|
-
const order = await this.order({
|
|
293
|
-
buyer: this.deps.cast.gifter,
|
|
294
|
-
recipient: this.deps.cast.gifted,
|
|
295
|
-
planKey: DUES_DEMO_PLANS.pass,
|
|
296
|
-
sessionId: 'cs_demo_gift',
|
|
297
|
-
})
|
|
298
|
-
await this.settle(order, 'cs_demo_gift', { paymentIntentId: 'pi_demo_gift' })
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private async refund(buyer: number): Promise<void> {
|
|
302
|
-
await this.event('charge.refunded', {
|
|
303
|
-
id: `ch_demo_refund_${buyer}`,
|
|
304
|
-
payment_intent: paymentIntentFor(buyer),
|
|
305
|
-
amount_refunded: PASS_PRICE,
|
|
306
|
-
currency: DUES_DEMO_CURRENCY,
|
|
307
|
-
})
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
private async abandonedCheckout(): Promise<void> {
|
|
311
|
-
const order = await this.order({
|
|
312
|
-
buyer: this.deps.cast.gifter,
|
|
313
|
-
planKey: DUES_DEMO_PLANS.lifetime,
|
|
314
|
-
sessionId: 'cs_demo_abandoned',
|
|
315
|
-
})
|
|
316
|
-
await settleOrder(this.deps.data, order.id, { status: 'cancelled' })
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
private async subscription(input: {
|
|
320
|
-
readonly member: number
|
|
321
|
-
readonly subscriptionId: string
|
|
322
|
-
readonly boughtDaysAgo: number
|
|
323
|
-
readonly renewedDaysAgo: readonly number[]
|
|
324
|
-
readonly failedDaysAgo?: number
|
|
325
|
-
readonly cancelledDaysAgo?: number
|
|
326
|
-
}): Promise<void> {
|
|
327
|
-
const sessionId = `cs_${input.subscriptionId}`
|
|
328
|
-
|
|
329
|
-
await this.at(input.boughtDaysAgo, async () => {
|
|
330
|
-
const order = await this.order({
|
|
331
|
-
buyer: input.member,
|
|
332
|
-
planKey: DUES_DEMO_PLANS.month,
|
|
333
|
-
sessionId,
|
|
334
|
-
})
|
|
335
|
-
await this.settle(order, sessionId, { subscriptionId: input.subscriptionId })
|
|
336
|
-
})
|
|
337
|
-
|
|
338
|
-
for (const daysAgo of input.renewedDaysAgo) {
|
|
339
|
-
await this.at(daysAgo, () =>
|
|
340
|
-
this.event('invoice.paid', {
|
|
341
|
-
id: `in_${input.subscriptionId}_${daysAgo}`,
|
|
342
|
-
subscription: input.subscriptionId,
|
|
343
|
-
amount_paid: MONTH_PRICE,
|
|
344
|
-
currency: DUES_DEMO_CURRENCY,
|
|
345
|
-
billing_reason: 'subscription_cycle',
|
|
346
|
-
period_end: unix(addDays(this.clock, 31)),
|
|
347
|
-
}),
|
|
348
|
-
)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (input.failedDaysAgo !== undefined) {
|
|
352
|
-
await this.at(input.failedDaysAgo, () =>
|
|
353
|
-
this.event('invoice.payment_failed', {
|
|
354
|
-
id: `in_${input.subscriptionId}_failed`,
|
|
355
|
-
subscription: input.subscriptionId,
|
|
356
|
-
}),
|
|
357
|
-
)
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (input.cancelledDaysAgo !== undefined) {
|
|
361
|
-
await this.at(input.cancelledDaysAgo, () =>
|
|
362
|
-
this.event('customer.subscription.updated', {
|
|
363
|
-
id: input.subscriptionId,
|
|
364
|
-
status: 'active',
|
|
365
|
-
cancel_at_period_end: true,
|
|
366
|
-
}),
|
|
367
|
-
)
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
private async order(input: {
|
|
372
|
-
readonly buyer: number
|
|
373
|
-
readonly recipient?: number
|
|
374
|
-
readonly planKey: string
|
|
375
|
-
readonly sessionId: string | null
|
|
376
|
-
readonly code?: CodeRow
|
|
377
|
-
}): Promise<OrderRow> {
|
|
378
|
-
const plan = await planRowByKey(this.deps.data, input.planKey)
|
|
379
|
-
if (plan === null) {
|
|
380
|
-
throw new Error(`the demo names a dues plan "${input.planKey}" that it never made`)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const charge =
|
|
384
|
-
input.code === undefined
|
|
385
|
-
? plan.priceMinor
|
|
386
|
-
: discountedPrice(plan.priceMinor, input.code.percentOff)
|
|
387
|
-
|
|
388
|
-
const { order } = await insertOrder(this.deps.data, {
|
|
389
|
-
buyerUserId: input.buyer,
|
|
390
|
-
recipientUserId: input.recipient ?? input.buyer,
|
|
391
|
-
planKey: plan.key,
|
|
392
|
-
planName: plan.name,
|
|
393
|
-
groupKey: plan.groupKey,
|
|
394
|
-
amountMinor: charge,
|
|
395
|
-
currency: plan.currency,
|
|
396
|
-
billingMode: plan.mode,
|
|
397
|
-
periodSpec: plan.mode === 'fixed' ? plan.periodSpec : null,
|
|
398
|
-
idempotencyKey: `demo:${plan.key}:${input.buyer}:${this.clock.toISOString()}`,
|
|
399
|
-
codeId: input.code?.id ?? null,
|
|
400
|
-
discountMinor: plan.priceMinor - charge,
|
|
401
|
-
})
|
|
402
|
-
this.counts.orders += 1
|
|
403
|
-
|
|
404
|
-
if (input.sessionId !== null) {
|
|
405
|
-
await attachCheckoutSession(this.deps.data, order.id, { id: input.sessionId, url: null })
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
return order
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
private async settle(
|
|
412
|
-
order: OrderRow,
|
|
413
|
-
sessionId: string,
|
|
414
|
-
stripe: { readonly subscriptionId?: string; readonly paymentIntentId?: string },
|
|
415
|
-
): Promise<void> {
|
|
416
|
-
await this.event('checkout.session.completed', {
|
|
417
|
-
id: sessionId,
|
|
418
|
-
object: 'checkout.session',
|
|
419
|
-
payment_status: 'paid',
|
|
420
|
-
amount_total: order.amountMinor,
|
|
421
|
-
currency: order.currency,
|
|
422
|
-
subscription: stripe.subscriptionId ?? null,
|
|
423
|
-
payment_intent: stripe.paymentIntentId ?? null,
|
|
424
|
-
customer: `cus_demo_${order.buyerUserId}`,
|
|
425
|
-
})
|
|
426
|
-
this.counts.memberships += 1
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
private async event(type: string, object: Record<string, unknown>): Promise<void> {
|
|
430
|
-
this.eventCounter += 1
|
|
431
|
-
const envelope: StripeEventEnvelope = { id: `evt_demo_${this.eventCounter}`, type, object }
|
|
432
|
-
|
|
433
|
-
const recorded = await recordEvent(this.deps.data, {
|
|
434
|
-
stripeEventId: envelope.id,
|
|
435
|
-
type: envelope.type,
|
|
436
|
-
payload: { id: envelope.id, type: envelope.type, data: { object } },
|
|
437
|
-
})
|
|
438
|
-
this.counts.events += 1
|
|
439
|
-
|
|
440
|
-
const outcome = await applyInternalEvent(this.entitlement(), toInternalEvent(envelope))
|
|
441
|
-
await markEventProcessed(this.deps.data, recorded.id, outcome)
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
private entitlement(): EntitlementDeps {
|
|
445
|
-
return {
|
|
446
|
-
config: this.config,
|
|
447
|
-
data: this.deps.data,
|
|
448
|
-
grants: this.grants,
|
|
449
|
-
notify: this.deps.notify ?? NO_OP_NOTIFY,
|
|
450
|
-
log: this.deps.log ?? (() => {}),
|
|
451
|
-
now: () => this.clock,
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
private async at(daysAgo: number, work: () => Promise<void>): Promise<void> {
|
|
456
|
-
const marks = await this.highWaterMarks()
|
|
457
|
-
this.clock = addDays(this.deps.now, -daysAgo)
|
|
458
|
-
await work()
|
|
459
|
-
await this.backdate(this.clock, marks)
|
|
460
|
-
this.clock = this.deps.now
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
private async highWaterMarks(): Promise<Marks> {
|
|
464
|
-
const row = await this.deps.data.one(`
|
|
465
|
-
select
|
|
466
|
-
(select coalesce(max(id), 0) from plugin_dues_order)::int as orders,
|
|
467
|
-
(select coalesce(max(id), 0) from plugin_dues_membership)::int as memberships,
|
|
468
|
-
(select coalesce(max(id), 0) from plugin_dues_ledger)::int as ledger,
|
|
469
|
-
(select coalesce(max(id), 0) from plugin_dues_event)::int as events
|
|
470
|
-
`)
|
|
471
|
-
return {
|
|
472
|
-
orders: Number(row?.orders ?? 0),
|
|
473
|
-
memberships: Number(row?.memberships ?? 0),
|
|
474
|
-
ledger: Number(row?.ledger ?? 0),
|
|
475
|
-
events: Number(row?.events ?? 0),
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
private async backdate(at: Date, marks: Marks): Promise<void> {
|
|
480
|
-
await this.deps.data.query(
|
|
481
|
-
`update plugin_dues_order
|
|
482
|
-
set created_at = $1::timestamptz,
|
|
483
|
-
settled_at = case when settled_at is null then null else $1::timestamptz end
|
|
484
|
-
where id > $2`,
|
|
485
|
-
[at, marks.orders],
|
|
486
|
-
)
|
|
487
|
-
await this.deps.data.query(
|
|
488
|
-
`update plugin_dues_membership
|
|
489
|
-
set created_at = $1::timestamptz, updated_at = $1::timestamptz
|
|
490
|
-
where id > $2`,
|
|
491
|
-
[at, marks.memberships],
|
|
492
|
-
)
|
|
493
|
-
await this.deps.data.query(
|
|
494
|
-
`update plugin_dues_membership
|
|
495
|
-
set updated_at = $1::timestamptz
|
|
496
|
-
where id <= $2 and updated_at > $1::timestamptz`,
|
|
497
|
-
[at, marks.memberships],
|
|
498
|
-
)
|
|
499
|
-
await this.deps.data.query(
|
|
500
|
-
`update plugin_dues_ledger set occurred_at = $1::timestamptz where id > $2`,
|
|
501
|
-
[at, marks.ledger],
|
|
502
|
-
)
|
|
503
|
-
await this.deps.data.query(
|
|
504
|
-
`update plugin_dues_event
|
|
505
|
-
set received_at = $1::timestamptz,
|
|
506
|
-
processed_at = case when processed_at is null then null else $1::timestamptz end
|
|
507
|
-
where id > $2`,
|
|
508
|
-
[at, marks.events],
|
|
509
|
-
)
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
interface Marks {
|
|
514
|
-
readonly orders: number
|
|
515
|
-
readonly memberships: number
|
|
516
|
-
readonly ledger: number
|
|
517
|
-
readonly events: number
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function paymentIntentFor(buyer: number): string {
|
|
521
|
-
return `pi_demo_pass_${buyer}`
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function unix(at: Date): number {
|
|
525
|
-
return Math.floor(at.getTime() / 1000)
|
|
526
|
-
}
|