@meith/plugin-dues 0.31.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/plugin-dues",
3
- "version": "0.31.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.31.0",
24
- "@meith/theme-kit": "^0.31.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"
@@ -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.31.0',
60
+ version: '0.33.0',
61
61
  description: en['dues.definition.description'].replace(
62
62
  '{label}',
63
63
  staticConfig.label.toLowerCase(),
@@ -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
- countCodeRedemption,
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 countCodeRedemption(deps.data, order.codeId)
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 countCodeRedemption(data: PluginData, id: number): Promise<void> {
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 plugin_dues_code set redeemed_count = redeemed_count + 1 where id = $1`,
774
- [id],
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
  }