@meith/plugin-dues 0.32.0 → 0.33.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/package.json +3 -3
- package/src/definition.tsx +1 -1
- package/src/entitlement.ts +5 -2
- package/src/handlers.ts +15 -0
- package/src/schema.ts +18 -0
- package/src/store.ts +89 -3
- package/src/tasks.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/plugin-dues",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.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,8 +20,8 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@meith/plugin-kit": "^0.
|
|
24
|
-
"@meith/theme-kit": "^0.
|
|
23
|
+
"@meith/plugin-kit": "^0.33.0",
|
|
24
|
+
"@meith/theme-kit": "^0.33.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
27
|
"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.33.0',
|
|
61
61
|
description: en['dues.definition.description'].replace(
|
|
62
62
|
'{label}',
|
|
63
63
|
staticConfig.label.toLowerCase(),
|
package/src/entitlement.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { moneyMatches } from './money'
|
|
|
5
5
|
import { addBillingInterval, addDays, addPeriod, parsePeriod } from './period'
|
|
6
6
|
import { clampGrantUntil, isLifetime, LIFETIME_END } from './plans'
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
consumeCodeReservation,
|
|
9
9
|
extendMembership,
|
|
10
10
|
flagMembership,
|
|
11
11
|
insertLedger,
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
orderBySessionId,
|
|
19
19
|
type PlanRow,
|
|
20
20
|
planRowByKey,
|
|
21
|
+
releaseCodeReservation,
|
|
21
22
|
setMembershipStatus,
|
|
22
23
|
settleOrder,
|
|
23
24
|
} from './store'
|
|
@@ -157,7 +158,7 @@ export async function settlePaidOrder(
|
|
|
157
158
|
stripePaymentIntentId: info.paymentIntentId,
|
|
158
159
|
})
|
|
159
160
|
|
|
160
|
-
if (order.codeId !== null) await
|
|
161
|
+
if (order.codeId !== null) await consumeCodeReservation(deps.data, order.id, order.codeId)
|
|
161
162
|
|
|
162
163
|
if (order.buyerUserId !== order.recipientUserId && outcome === 'granted') {
|
|
163
164
|
await tryNotify(deps, {
|
|
@@ -221,6 +222,7 @@ export async function applyInternalEvent(
|
|
|
221
222
|
if (order === null) return 'unmatched-session'
|
|
222
223
|
if (order.status === 'paid') return 'already-settled'
|
|
223
224
|
await settleOrder(deps.data, order.id, { status: 'failed' })
|
|
225
|
+
if (order.codeId !== null) await releaseCodeReservation(deps.data, order.id)
|
|
224
226
|
return 'order-failed'
|
|
225
227
|
}
|
|
226
228
|
|
|
@@ -229,6 +231,7 @@ export async function applyInternalEvent(
|
|
|
229
231
|
if (order === null) return 'unmatched-session'
|
|
230
232
|
if (order.status !== 'created' && order.status !== 'pending') return 'already-settled'
|
|
231
233
|
await settleOrder(deps.data, order.id, { status: 'cancelled' })
|
|
234
|
+
if (order.codeId !== null) await releaseCodeReservation(deps.data, order.id)
|
|
232
235
|
return 'order-expired'
|
|
233
236
|
}
|
|
234
237
|
|
package/src/handlers.ts
CHANGED
|
@@ -15,9 +15,11 @@ import {
|
|
|
15
15
|
markEventProcessed,
|
|
16
16
|
membershipById,
|
|
17
17
|
recordEvent,
|
|
18
|
+
reserveCodeRedemption,
|
|
18
19
|
saveCodeCoupon,
|
|
19
20
|
saveStripeCustomer,
|
|
20
21
|
setMembershipStatus,
|
|
22
|
+
settleOrder,
|
|
21
23
|
} from './store'
|
|
22
24
|
import { createStripeClient, type StripeClient, StripeError } from './stripe/client'
|
|
23
25
|
import { parseEventEnvelope, toInternalEvent } from './stripe/events'
|
|
@@ -197,6 +199,19 @@ export async function handleCheckout(
|
|
|
197
199
|
return back({ error: 'try-again', plan: plan.key })
|
|
198
200
|
}
|
|
199
201
|
|
|
202
|
+
if (code !== null) {
|
|
203
|
+
const reserved = await reserveCodeRedemption(services.context.data, code.id, order.id)
|
|
204
|
+
if (!reserved) {
|
|
205
|
+
await settleOrder(services.context.data, order.id, { status: 'cancelled' })
|
|
206
|
+
return back({
|
|
207
|
+
error: 'code-exhausted',
|
|
208
|
+
plan: plan.key,
|
|
209
|
+
code: codeInput,
|
|
210
|
+
recipient: recipientInput,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
200
215
|
if (charge === 0 && plan.mode !== 'auto') {
|
|
201
216
|
await settlePaidOrder(entitlementDeps(services), order, {
|
|
202
217
|
amountTotal: 0,
|
package/src/schema.ts
CHANGED
|
@@ -150,4 +150,22 @@ export const DUES_MIGRATIONS: readonly PluginMigration[] = [
|
|
|
150
150
|
on plugin_dues_plan (plan_key)`,
|
|
151
151
|
],
|
|
152
152
|
},
|
|
153
|
+
{
|
|
154
|
+
id: '0005_code_reservations',
|
|
155
|
+
statements: [
|
|
156
|
+
`create table if not exists plugin_dues_code_reservation (
|
|
157
|
+
id bigint generated by default as identity primary key,
|
|
158
|
+
code_id bigint not null,
|
|
159
|
+
order_id bigint not null,
|
|
160
|
+
status text not null default 'held',
|
|
161
|
+
created_at timestamptz not null default now(),
|
|
162
|
+
updated_at timestamptz not null default now()
|
|
163
|
+
)`,
|
|
164
|
+
`create unique index if not exists plugin_dues_code_reservation_order_key
|
|
165
|
+
on plugin_dues_code_reservation (order_id)`,
|
|
166
|
+
`create index if not exists plugin_dues_code_reservation_active_idx
|
|
167
|
+
on plugin_dues_code_reservation (code_id)
|
|
168
|
+
where status in ('held', 'consumed')`,
|
|
169
|
+
],
|
|
170
|
+
},
|
|
153
171
|
]
|
package/src/store.ts
CHANGED
|
@@ -768,10 +768,96 @@ export async function saveCodeCoupon(
|
|
|
768
768
|
)
|
|
769
769
|
}
|
|
770
770
|
|
|
771
|
-
export async function
|
|
771
|
+
export async function reserveCodeRedemption(
|
|
772
|
+
data: PluginData,
|
|
773
|
+
codeId: number,
|
|
774
|
+
orderId: number,
|
|
775
|
+
): Promise<boolean> {
|
|
776
|
+
return data.tx(async (tx) => {
|
|
777
|
+
const locked = await tx.one(
|
|
778
|
+
`select max_redemptions from plugin_dues_code where id = $1 for update`,
|
|
779
|
+
[codeId],
|
|
780
|
+
)
|
|
781
|
+
if (locked === null) return false
|
|
782
|
+
|
|
783
|
+
const existing = await tx.one(
|
|
784
|
+
`select status from plugin_dues_code_reservation where order_id = $1`,
|
|
785
|
+
[orderId],
|
|
786
|
+
)
|
|
787
|
+
if (existing !== null) return String(existing.status) !== 'released'
|
|
788
|
+
|
|
789
|
+
const max =
|
|
790
|
+
locked.max_redemptions === null || locked.max_redemptions === undefined
|
|
791
|
+
? null
|
|
792
|
+
: Number(locked.max_redemptions)
|
|
793
|
+
if (max !== null) {
|
|
794
|
+
const active = await tx.one(
|
|
795
|
+
`select count(*)::int as taken from plugin_dues_code_reservation
|
|
796
|
+
where code_id = $1 and status in ('held', 'consumed')`,
|
|
797
|
+
[codeId],
|
|
798
|
+
)
|
|
799
|
+
if (active !== null && Number(active.taken) >= max) return false
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
await tx.query(
|
|
803
|
+
`insert into plugin_dues_code_reservation (code_id, order_id)
|
|
804
|
+
values ($1, $2)
|
|
805
|
+
on conflict (order_id) do nothing`,
|
|
806
|
+
[codeId, orderId],
|
|
807
|
+
)
|
|
808
|
+
return true
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function consumeCodeReservation(
|
|
813
|
+
data: PluginData,
|
|
814
|
+
orderId: number,
|
|
815
|
+
codeId: number,
|
|
816
|
+
): Promise<void> {
|
|
817
|
+
await data.tx(async (tx) => {
|
|
818
|
+
const consumed = await tx.one(
|
|
819
|
+
`update plugin_dues_code_reservation
|
|
820
|
+
set status = 'consumed', updated_at = now()
|
|
821
|
+
where order_id = $1 and status = 'held'
|
|
822
|
+
returning code_id`,
|
|
823
|
+
[orderId],
|
|
824
|
+
)
|
|
825
|
+
if (consumed !== null) {
|
|
826
|
+
await tx.query(
|
|
827
|
+
`update plugin_dues_code set redeemed_count = redeemed_count + 1 where id = $1`,
|
|
828
|
+
[Number(consumed.code_id)],
|
|
829
|
+
)
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const present = await tx.one(
|
|
834
|
+
`select id from plugin_dues_code_reservation where order_id = $1`,
|
|
835
|
+
[orderId],
|
|
836
|
+
)
|
|
837
|
+
if (present !== null) return
|
|
838
|
+
|
|
839
|
+
const recorded = await tx.one(
|
|
840
|
+
`insert into plugin_dues_code_reservation (code_id, order_id, status)
|
|
841
|
+
values ($1, $2, 'consumed')
|
|
842
|
+
on conflict (order_id) do nothing
|
|
843
|
+
returning id`,
|
|
844
|
+
[codeId, orderId],
|
|
845
|
+
)
|
|
846
|
+
if (recorded !== null) {
|
|
847
|
+
await tx.query(
|
|
848
|
+
`update plugin_dues_code set redeemed_count = redeemed_count + 1 where id = $1`,
|
|
849
|
+
[codeId],
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
})
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
export async function releaseCodeReservation(data: PluginData, orderId: number): Promise<void> {
|
|
772
856
|
await data.query(
|
|
773
|
-
`update
|
|
774
|
-
|
|
857
|
+
`update plugin_dues_code_reservation
|
|
858
|
+
set status = 'released', updated_at = now()
|
|
859
|
+
where order_id = $1 and status = 'held'`,
|
|
860
|
+
[orderId],
|
|
775
861
|
)
|
|
776
862
|
}
|
|
777
863
|
|
package/src/tasks.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
markEventProcessed,
|
|
10
10
|
membershipsPastPeriod,
|
|
11
11
|
pendingOrdersOlderThan,
|
|
12
|
+
releaseCodeReservation,
|
|
12
13
|
setMembershipStatus,
|
|
13
14
|
settleOrder,
|
|
14
15
|
unprocessedEvents,
|
|
@@ -61,6 +62,7 @@ export async function runReconcile(
|
|
|
61
62
|
for (const order of await pendingOrdersOlderThan(deps.data, PENDING_AFTER_MINUTES, BATCH)) {
|
|
62
63
|
if (order.stripeSessionId === null) {
|
|
63
64
|
await settleOrder(deps.data, order.id, { status: 'cancelled' })
|
|
65
|
+
if (order.codeId !== null) await releaseCodeReservation(deps.data, order.id)
|
|
64
66
|
ordersClosed += 1
|
|
65
67
|
continue
|
|
66
68
|
}
|
|
@@ -76,6 +78,7 @@ export async function runReconcile(
|
|
|
76
78
|
ordersSettled += 1
|
|
77
79
|
} else if (session.status === 'expired') {
|
|
78
80
|
await settleOrder(deps.data, order.id, { status: 'cancelled' })
|
|
81
|
+
if (order.codeId !== null) await releaseCodeReservation(deps.data, order.id)
|
|
79
82
|
ordersClosed += 1
|
|
80
83
|
}
|
|
81
84
|
}
|