@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/plans.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { PluginData } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
import type { DuesConfig } from './config'
|
|
4
|
+
import { isCurrencyCode, isValidMinorAmount } from './money'
|
|
5
|
+
import { addDays, parsePeriod, periodCeilingDays, type Period } from './period'
|
|
6
|
+
import {
|
|
7
|
+
countPlans,
|
|
8
|
+
insertPlan,
|
|
9
|
+
listPlans,
|
|
10
|
+
type NewPlan,
|
|
11
|
+
type PlanMode,
|
|
12
|
+
type PlanRow,
|
|
13
|
+
} from './store'
|
|
14
|
+
|
|
15
|
+
export const GRANT_WINDOW_DAYS = 700
|
|
16
|
+
export const TOP_UP_WHEN_WITHIN_DAYS = 90
|
|
17
|
+
export const LIFETIME_END = new Date('9999-12-31T00:00:00Z')
|
|
18
|
+
|
|
19
|
+
export const MAX_PLAN_DAYS = 2 * 366
|
|
20
|
+
|
|
21
|
+
const PLAN_KEY = /^[a-z][a-z0-9-]{0,39}$/
|
|
22
|
+
|
|
23
|
+
export function clampGrantUntil(graceUntil: Date, now: Date): Date {
|
|
24
|
+
const ceiling = addDays(now, GRANT_WINDOW_DAYS)
|
|
25
|
+
return graceUntil > ceiling ? ceiling : graceUntil
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isLifetime(periodEnd: Date): boolean {
|
|
29
|
+
return periodEnd >= LIFETIME_END
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadPlans(
|
|
33
|
+
data: PluginData,
|
|
34
|
+
config: DuesConfig,
|
|
35
|
+
): Promise<readonly PlanRow[]> {
|
|
36
|
+
if ((await countPlans(data)) === 0 && config.seedPlans.length > 0) {
|
|
37
|
+
for (const seed of config.seedPlans) {
|
|
38
|
+
await insertPlan(data, {
|
|
39
|
+
planKey: seed.key,
|
|
40
|
+
name: seed.name,
|
|
41
|
+
description: seed.description,
|
|
42
|
+
groupKey: seed.group,
|
|
43
|
+
priceMinor: seed.price,
|
|
44
|
+
currency: config.currency,
|
|
45
|
+
mode: seed.billing.mode,
|
|
46
|
+
periodSpec: seed.billing.mode === 'fixed' ? seed.billing.period : null,
|
|
47
|
+
billingInterval: seed.billing.mode === 'auto' ? seed.billing.interval : null,
|
|
48
|
+
stripePriceId: seed.billing.mode === 'auto' ? seed.billing.stripePriceId : null,
|
|
49
|
+
stripeProductId: null,
|
|
50
|
+
giftable: seed.giftable,
|
|
51
|
+
hidden: seed.hidden,
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return listPlans(data)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function shopPlans(
|
|
59
|
+
data: PluginData,
|
|
60
|
+
config: DuesConfig,
|
|
61
|
+
): Promise<readonly PlanRow[]> {
|
|
62
|
+
return (await loadPlans(data, config)).filter((plan) => !plan.hidden && !plan.archived)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function anyPlanByKey(
|
|
66
|
+
data: PluginData,
|
|
67
|
+
config: DuesConfig,
|
|
68
|
+
key: string,
|
|
69
|
+
): Promise<PlanRow | null> {
|
|
70
|
+
return (await loadPlans(data, config)).find((plan) => plan.key === key) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function sellablePlanByKey(
|
|
74
|
+
data: PluginData,
|
|
75
|
+
config: DuesConfig,
|
|
76
|
+
key: string,
|
|
77
|
+
): Promise<PlanRow | null> {
|
|
78
|
+
const plan = await anyPlanByKey(data, config, key)
|
|
79
|
+
return plan === null || plan.archived ? null : plan
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function planPeriod(plan: PlanRow): Period | null {
|
|
83
|
+
return plan.periodSpec === null ? null : parsePeriod(plan.periodSpec)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function describeBilling(plan: PlanRow): string {
|
|
87
|
+
if (plan.mode === 'auto') return `every ${plan.billingInterval ?? 'month'}`
|
|
88
|
+
if (plan.mode === 'lifetime') return 'once, for good'
|
|
89
|
+
const period = planPeriod(plan)
|
|
90
|
+
if (period === null) return plan.periodSpec ?? ''
|
|
91
|
+
const parts: string[] = []
|
|
92
|
+
const piece = (count: number, word: string) => {
|
|
93
|
+
if (count > 0) parts.push(`${count} ${word}${count === 1 ? '' : 's'}`)
|
|
94
|
+
}
|
|
95
|
+
piece(period.years, 'year')
|
|
96
|
+
piece(period.months, 'month')
|
|
97
|
+
piece(period.weeks, 'week')
|
|
98
|
+
piece(period.days, 'day')
|
|
99
|
+
return parts.join(', ')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PlanFormInput {
|
|
103
|
+
readonly key?: string | undefined
|
|
104
|
+
readonly name?: string | undefined
|
|
105
|
+
readonly description?: string | undefined
|
|
106
|
+
readonly group?: string | undefined
|
|
107
|
+
readonly price?: string | undefined
|
|
108
|
+
readonly currency?: string | undefined
|
|
109
|
+
readonly mode?: string | undefined
|
|
110
|
+
readonly length?: string | undefined
|
|
111
|
+
readonly unit?: string | undefined
|
|
112
|
+
readonly interval?: string | undefined
|
|
113
|
+
readonly stripe_price?: string | undefined
|
|
114
|
+
readonly giftable?: string | undefined
|
|
115
|
+
readonly hidden?: string | undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type PlanParse =
|
|
119
|
+
| { readonly ok: true; readonly plan: Omit<NewPlan, 'stripePriceId' | 'stripeProductId'> }
|
|
120
|
+
| { readonly ok: false; readonly error: string }
|
|
121
|
+
|
|
122
|
+
function bad(error: string): PlanParse {
|
|
123
|
+
return { ok: false, error }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parsePlanForm(form: PlanFormInput, graceDays: number): PlanParse {
|
|
127
|
+
const key = (form.key ?? '').trim().toLowerCase()
|
|
128
|
+
if (!PLAN_KEY.test(key)) return bad('bad-key')
|
|
129
|
+
|
|
130
|
+
const name = (form.name ?? '').trim()
|
|
131
|
+
if (name === '') return bad('bad-name')
|
|
132
|
+
|
|
133
|
+
const group = (form.group ?? '').trim().toLowerCase()
|
|
134
|
+
if (!PLAN_KEY.test(group)) return bad('bad-group')
|
|
135
|
+
|
|
136
|
+
const price = Number(form.price ?? '')
|
|
137
|
+
if (!isValidMinorAmount(price)) return bad('bad-price')
|
|
138
|
+
|
|
139
|
+
const currency = (form.currency ?? '').trim().toLowerCase()
|
|
140
|
+
if (!isCurrencyCode(currency)) return bad('bad-currency')
|
|
141
|
+
|
|
142
|
+
const mode = form.mode as PlanMode
|
|
143
|
+
if (mode !== 'auto' && mode !== 'fixed' && mode !== 'lifetime') return bad('bad-mode')
|
|
144
|
+
|
|
145
|
+
let periodSpec: string | null = null
|
|
146
|
+
let billingInterval: 'month' | 'year' | null = null
|
|
147
|
+
|
|
148
|
+
if (mode === 'fixed') {
|
|
149
|
+
const length = Number(form.length ?? '')
|
|
150
|
+
const unit = form.unit ?? ''
|
|
151
|
+
const letter =
|
|
152
|
+
unit === 'days' ? 'D' : unit === 'weeks' ? 'W' : unit === 'months' ? 'M' : unit === 'years' ? 'Y' : null
|
|
153
|
+
if (letter === null || !Number.isInteger(length) || length < 1) return bad('bad-length')
|
|
154
|
+
|
|
155
|
+
periodSpec = `P${length}${letter}`
|
|
156
|
+
const parsed = parsePeriod(periodSpec)
|
|
157
|
+
if (parsed === null) return bad('bad-length')
|
|
158
|
+
if (periodCeilingDays(parsed) + graceDays > MAX_PLAN_DAYS) return bad('too-long')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (mode === 'auto') {
|
|
162
|
+
if (form.interval !== 'month' && form.interval !== 'year') return bad('bad-interval')
|
|
163
|
+
billingInterval = form.interval
|
|
164
|
+
if (form.giftable === 'on') return bad('auto-gift')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
plan: {
|
|
170
|
+
planKey: key,
|
|
171
|
+
name,
|
|
172
|
+
description: (form.description ?? '').trim() || null,
|
|
173
|
+
groupKey: group,
|
|
174
|
+
priceMinor: price,
|
|
175
|
+
currency,
|
|
176
|
+
mode,
|
|
177
|
+
periodSpec,
|
|
178
|
+
billingInterval,
|
|
179
|
+
giftable: mode === 'auto' ? false : form.giftable === 'on',
|
|
180
|
+
hidden: form.hidden === 'on',
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { PluginMigration } from '@meith/plugin-kit'
|
|
2
|
+
|
|
3
|
+
export const DUES_MIGRATIONS: readonly PluginMigration[] = [
|
|
4
|
+
{
|
|
5
|
+
id: '0001_customers_orders',
|
|
6
|
+
statements: [
|
|
7
|
+
`create table if not exists plugin_dues_customer (
|
|
8
|
+
user_id integer not null,
|
|
9
|
+
stripe_customer_id text not null,
|
|
10
|
+
created_at timestamptz not null default now(),
|
|
11
|
+
primary key (user_id)
|
|
12
|
+
)`,
|
|
13
|
+
`create unique index if not exists plugin_dues_customer_stripe_key
|
|
14
|
+
on plugin_dues_customer (stripe_customer_id)`,
|
|
15
|
+
`create table if not exists plugin_dues_order (
|
|
16
|
+
id bigint generated by default as identity primary key,
|
|
17
|
+
buyer_user_id integer not null,
|
|
18
|
+
recipient_user_id integer not null,
|
|
19
|
+
plan_key text not null,
|
|
20
|
+
plan_name text not null,
|
|
21
|
+
group_key text not null,
|
|
22
|
+
amount_minor integer not null,
|
|
23
|
+
currency text not null,
|
|
24
|
+
billing_mode text not null,
|
|
25
|
+
period_spec text,
|
|
26
|
+
status text not null default 'created',
|
|
27
|
+
needs_attention text,
|
|
28
|
+
idempotency_key text not null,
|
|
29
|
+
stripe_session_id text,
|
|
30
|
+
stripe_subscription_id text,
|
|
31
|
+
stripe_payment_intent_id text,
|
|
32
|
+
checkout_url text,
|
|
33
|
+
created_at timestamptz not null default now(),
|
|
34
|
+
settled_at timestamptz
|
|
35
|
+
)`,
|
|
36
|
+
`create unique index if not exists plugin_dues_order_idem_key
|
|
37
|
+
on plugin_dues_order (idempotency_key)`,
|
|
38
|
+
`create unique index if not exists plugin_dues_order_session_key
|
|
39
|
+
on plugin_dues_order (stripe_session_id)
|
|
40
|
+
where stripe_session_id is not null`,
|
|
41
|
+
`create index if not exists plugin_dues_order_pending_idx
|
|
42
|
+
on plugin_dues_order (created_at)
|
|
43
|
+
where status in ('created', 'pending')`,
|
|
44
|
+
`create index if not exists plugin_dues_order_buyer_idx
|
|
45
|
+
on plugin_dues_order (buyer_user_id)`,
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: '0002_memberships_events_ledger',
|
|
50
|
+
statements: [
|
|
51
|
+
`create table if not exists plugin_dues_membership (
|
|
52
|
+
id bigint generated by default as identity primary key,
|
|
53
|
+
user_id integer not null,
|
|
54
|
+
group_key text not null,
|
|
55
|
+
plan_key text not null,
|
|
56
|
+
status text not null,
|
|
57
|
+
renewal_mode text not null,
|
|
58
|
+
current_period_end timestamptz not null,
|
|
59
|
+
grace_until timestamptz not null,
|
|
60
|
+
needs_attention text,
|
|
61
|
+
stripe_subscription_id text,
|
|
62
|
+
last_order_id bigint,
|
|
63
|
+
created_at timestamptz not null default now(),
|
|
64
|
+
updated_at timestamptz not null default now()
|
|
65
|
+
)`,
|
|
66
|
+
`create unique index if not exists plugin_dues_membership_live_key
|
|
67
|
+
on plugin_dues_membership (user_id, group_key)
|
|
68
|
+
where status in ('active', 'grace', 'closing')`,
|
|
69
|
+
`create index if not exists plugin_dues_membership_subscription_idx
|
|
70
|
+
on plugin_dues_membership (stripe_subscription_id)
|
|
71
|
+
where stripe_subscription_id is not null`,
|
|
72
|
+
`create index if not exists plugin_dues_membership_due_idx
|
|
73
|
+
on plugin_dues_membership (grace_until)
|
|
74
|
+
where status in ('active', 'grace', 'closing')`,
|
|
75
|
+
`create table if not exists plugin_dues_event (
|
|
76
|
+
id bigint generated by default as identity primary key,
|
|
77
|
+
stripe_event_id text not null,
|
|
78
|
+
type text not null,
|
|
79
|
+
payload jsonb not null,
|
|
80
|
+
received_at timestamptz not null default now(),
|
|
81
|
+
processed_at timestamptz,
|
|
82
|
+
outcome text
|
|
83
|
+
)`,
|
|
84
|
+
`create unique index if not exists plugin_dues_event_stripe_key
|
|
85
|
+
on plugin_dues_event (stripe_event_id)`,
|
|
86
|
+
`create index if not exists plugin_dues_event_unprocessed_idx
|
|
87
|
+
on plugin_dues_event (received_at)
|
|
88
|
+
where processed_at is null`,
|
|
89
|
+
`create table if not exists plugin_dues_ledger (
|
|
90
|
+
id bigint generated by default as identity primary key,
|
|
91
|
+
occurred_at timestamptz not null default now(),
|
|
92
|
+
kind text not null,
|
|
93
|
+
user_id integer not null,
|
|
94
|
+
membership_id bigint,
|
|
95
|
+
order_id bigint,
|
|
96
|
+
amount_minor integer not null,
|
|
97
|
+
currency text not null,
|
|
98
|
+
stripe_ref text,
|
|
99
|
+
note text
|
|
100
|
+
)`,
|
|
101
|
+
`create index if not exists plugin_dues_ledger_time_idx
|
|
102
|
+
on plugin_dues_ledger (occurred_at)`,
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: '0003_discount_codes',
|
|
107
|
+
statements: [
|
|
108
|
+
`create table if not exists plugin_dues_code (
|
|
109
|
+
id bigint generated by default as identity primary key,
|
|
110
|
+
code text not null,
|
|
111
|
+
percent_off integer not null,
|
|
112
|
+
plan_key text,
|
|
113
|
+
max_redemptions integer,
|
|
114
|
+
redeemed_count integer not null default 0,
|
|
115
|
+
expires_at timestamptz,
|
|
116
|
+
disabled boolean not null default false,
|
|
117
|
+
created_by_user_id integer not null,
|
|
118
|
+
stripe_coupon_id text,
|
|
119
|
+
created_at timestamptz not null default now()
|
|
120
|
+
)`,
|
|
121
|
+
`create unique index if not exists plugin_dues_code_key
|
|
122
|
+
on plugin_dues_code (lower(code))`,
|
|
123
|
+
`alter table plugin_dues_order add column if not exists code_id bigint`,
|
|
124
|
+
`alter table plugin_dues_order add column if not exists discount_minor integer not null default 0`,
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: '0004_plans',
|
|
129
|
+
statements: [
|
|
130
|
+
`create table if not exists plugin_dues_plan (
|
|
131
|
+
id bigint generated by default as identity primary key,
|
|
132
|
+
plan_key text not null,
|
|
133
|
+
name text not null,
|
|
134
|
+
description text,
|
|
135
|
+
group_key text not null,
|
|
136
|
+
price_minor integer not null,
|
|
137
|
+
currency text not null,
|
|
138
|
+
mode text not null,
|
|
139
|
+
period_spec text,
|
|
140
|
+
billing_interval text,
|
|
141
|
+
stripe_price_id text,
|
|
142
|
+
stripe_product_id text,
|
|
143
|
+
giftable boolean not null default true,
|
|
144
|
+
hidden boolean not null default false,
|
|
145
|
+
archived boolean not null default false,
|
|
146
|
+
created_at timestamptz not null default now(),
|
|
147
|
+
updated_at timestamptz not null default now()
|
|
148
|
+
)`,
|
|
149
|
+
`create unique index if not exists plugin_dues_plan_key
|
|
150
|
+
on plugin_dues_plan (plan_key)`,
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
]
|