@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/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
+ ]