@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/handlers.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PluginRequest,
|
|
3
|
+
PluginResponse,
|
|
4
|
+
PluginRuntimeContext,
|
|
5
|
+
} from '@meith/plugin-kit'
|
|
6
|
+
|
|
7
|
+
import { codeProblem, discountedPrice, normalizeCode } from './codes'
|
|
8
|
+
import type { DuesConfig } from './config'
|
|
9
|
+
import { isLifetime, sellablePlanByKey } from './plans'
|
|
10
|
+
import { applyInternalEvent, settlePaidOrder, type EntitlementDeps } from './entitlement'
|
|
11
|
+
import {
|
|
12
|
+
attachCheckoutSession,
|
|
13
|
+
codeByCode,
|
|
14
|
+
findStripeCustomer,
|
|
15
|
+
insertOrder,
|
|
16
|
+
liveMembership,
|
|
17
|
+
membershipById,
|
|
18
|
+
recordEvent,
|
|
19
|
+
markEventFailed,
|
|
20
|
+
markEventProcessed,
|
|
21
|
+
saveCodeCoupon,
|
|
22
|
+
saveStripeCustomer,
|
|
23
|
+
setMembershipStatus,
|
|
24
|
+
type CodeRow,
|
|
25
|
+
} from './store'
|
|
26
|
+
import { createStripeClient, StripeError, type StripeClient } from './stripe/client'
|
|
27
|
+
import { parseEventEnvelope, toInternalEvent } from './stripe/events'
|
|
28
|
+
import { verifyStripeSignature } from './stripe/webhook'
|
|
29
|
+
|
|
30
|
+
export interface DuesServices {
|
|
31
|
+
readonly config: DuesConfig
|
|
32
|
+
readonly context: PluginRuntimeContext
|
|
33
|
+
readonly stripe: StripeClient | null
|
|
34
|
+
readonly webhookSecret: string
|
|
35
|
+
readonly now: () => Date
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildServices(
|
|
39
|
+
config: DuesConfig,
|
|
40
|
+
context: PluginRuntimeContext,
|
|
41
|
+
overrides: { readonly stripe?: StripeClient | null; readonly now?: () => Date } = {},
|
|
42
|
+
): DuesServices {
|
|
43
|
+
const secretKey = String(context.settings.stripe_secret_key ?? '')
|
|
44
|
+
const stripe =
|
|
45
|
+
overrides.stripe !== undefined
|
|
46
|
+
? overrides.stripe
|
|
47
|
+
: secretKey === ''
|
|
48
|
+
? null
|
|
49
|
+
: createStripeClient({
|
|
50
|
+
secretKey,
|
|
51
|
+
apiVersion: String(context.settings.stripe_api_version ?? ''),
|
|
52
|
+
...(String(context.settings.stripe_api_base ?? '') === ''
|
|
53
|
+
? {}
|
|
54
|
+
: { apiBase: String(context.settings.stripe_api_base) }),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
config,
|
|
59
|
+
context,
|
|
60
|
+
stripe,
|
|
61
|
+
webhookSecret: String(context.settings.stripe_webhook_secret ?? ''),
|
|
62
|
+
now: overrides.now ?? (() => new Date()),
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function entitlementDeps(services: DuesServices): EntitlementDeps {
|
|
67
|
+
return {
|
|
68
|
+
config: services.config,
|
|
69
|
+
data: services.context.data,
|
|
70
|
+
grants: services.context.grants,
|
|
71
|
+
notify: services.context.notify,
|
|
72
|
+
log: (message, detail) => services.context.logger.warn(message, detail),
|
|
73
|
+
now: services.now,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function requestOrigin(request: PluginRequest): string {
|
|
78
|
+
if (request.boardUrl !== '') return request.boardUrl
|
|
79
|
+
const host = request.headers['x-forwarded-host'] ?? request.headers.host ?? 'localhost'
|
|
80
|
+
const loopback = host.startsWith('127.0.0.1') || host.startsWith('localhost')
|
|
81
|
+
const proto = request.headers['x-forwarded-proto'] ?? (loopback ? 'http' : 'https')
|
|
82
|
+
return `${proto}://${host}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function back(query: Record<string, string>): PluginResponse {
|
|
86
|
+
const params = new URLSearchParams(query).toString()
|
|
87
|
+
return { kind: 'redirect', to: `/plugins/dues${params === '' ? '' : `?${params}`}` }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function offsite(to: string): PluginResponse {
|
|
91
|
+
return { kind: 'redirect', to: `/plugins/dues/go?to=${encodeURIComponent(to)}` }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function backToManage(query: Record<string, string>): PluginResponse {
|
|
95
|
+
const params = new URLSearchParams(query).toString()
|
|
96
|
+
return { kind: 'redirect', to: `/plugins/dues/manage${params === '' ? '' : `?${params}`}` }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const REUSABLE_CHECKOUT_MS = 25 * 60 * 1000
|
|
100
|
+
|
|
101
|
+
export async function handleCheckout(
|
|
102
|
+
services: DuesServices,
|
|
103
|
+
request: PluginRequest,
|
|
104
|
+
): Promise<PluginResponse> {
|
|
105
|
+
const buyerId = request.viewer.userId
|
|
106
|
+
if (buyerId === null) return back({ error: 'sign-in' })
|
|
107
|
+
|
|
108
|
+
const planKey = request.form?.plan ?? ''
|
|
109
|
+
const plan = await sellablePlanByKey(services.context.data, services.config, planKey)
|
|
110
|
+
if (plan === null || plan.hidden) return back({ error: 'unknown-plan' })
|
|
111
|
+
|
|
112
|
+
if (plan.mode === 'auto' && plan.stripePriceId === null) {
|
|
113
|
+
return back({ error: 'plan-not-ready', plan: plan.key })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const recipientInput = (request.form?.recipient ?? '').trim()
|
|
117
|
+
let recipientId = buyerId
|
|
118
|
+
let recipientName: string | null = null
|
|
119
|
+
if (recipientInput !== '') {
|
|
120
|
+
const found = await services.context.users.byUsername(recipientInput)
|
|
121
|
+
if (found === null) {
|
|
122
|
+
return back({ error: 'unknown-recipient', plan: plan.key, recipient: recipientInput })
|
|
123
|
+
}
|
|
124
|
+
recipientId = found.userId
|
|
125
|
+
recipientName = found.username
|
|
126
|
+
}
|
|
127
|
+
const isGift = recipientId !== buyerId
|
|
128
|
+
|
|
129
|
+
if (isGift && !plan.giftable) return back({ error: 'gift-not-allowed', plan: plan.key })
|
|
130
|
+
|
|
131
|
+
const held = await liveMembership(services.context.data, recipientId, plan.groupKey)
|
|
132
|
+
if (held !== null) {
|
|
133
|
+
if (isLifetime(held.currentPeriodEnd)) {
|
|
134
|
+
return back({ error: 'already-forever', plan: plan.key })
|
|
135
|
+
}
|
|
136
|
+
if (plan.mode === 'auto') return back({ error: 'already-member', plan: plan.key })
|
|
137
|
+
if (plan.mode === 'lifetime' && held.renewalMode === 'auto') {
|
|
138
|
+
return back({ error: 'cancel-first', plan: plan.key })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const now = services.now()
|
|
143
|
+
|
|
144
|
+
const codeInput = normalizeCode(request.form?.code ?? '')
|
|
145
|
+
let code: CodeRow | null = null
|
|
146
|
+
if (codeInput !== '') {
|
|
147
|
+
const bounce = { plan: plan.key, code: codeInput, recipient: recipientInput }
|
|
148
|
+
code = await codeByCode(services.context.data, codeInput)
|
|
149
|
+
if (code === null) return back({ error: 'unknown-code', ...bounce })
|
|
150
|
+
const problem = codeProblem(code, plan.key, now)
|
|
151
|
+
if (problem !== null) return back({ error: `code-${problem}`, ...bounce })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const charge =
|
|
155
|
+
code === null ? plan.priceMinor : discountedPrice(plan.priceMinor, code.percentOff)
|
|
156
|
+
|
|
157
|
+
if (services.stripe === null && (plan.mode === 'auto' || charge > 0)) {
|
|
158
|
+
return back({ error: 'unconfigured' })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const minuteBucket = Math.floor(now.getTime() / 60_000)
|
|
162
|
+
const newOrder = (idempotencyKey: string) =>
|
|
163
|
+
insertOrder(services.context.data, {
|
|
164
|
+
buyerUserId: buyerId,
|
|
165
|
+
recipientUserId: recipientId,
|
|
166
|
+
planKey: plan.key,
|
|
167
|
+
planName: plan.name,
|
|
168
|
+
groupKey: plan.groupKey,
|
|
169
|
+
amountMinor: charge,
|
|
170
|
+
currency: plan.currency,
|
|
171
|
+
billingMode: plan.mode,
|
|
172
|
+
periodSpec: plan.mode === 'fixed' ? plan.periodSpec : null,
|
|
173
|
+
idempotencyKey,
|
|
174
|
+
codeId: code?.id ?? null,
|
|
175
|
+
discountMinor: plan.priceMinor - charge,
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const baseKey = `co:${buyerId}:${plan.key}:${recipientId}:${code?.id ?? 0}:${minuteBucket}`
|
|
179
|
+
let { order, created } = await newOrder(baseKey)
|
|
180
|
+
|
|
181
|
+
if (!created && order.status !== 'created' && order.status !== 'pending') {
|
|
182
|
+
;({ order, created } = await newOrder(`${baseKey}:${order.id}`))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!created) {
|
|
186
|
+
if (
|
|
187
|
+
order.checkoutUrl !== null &&
|
|
188
|
+
now.getTime() - order.createdAt.getTime() < REUSABLE_CHECKOUT_MS
|
|
189
|
+
) {
|
|
190
|
+
return offsite(order.checkoutUrl)
|
|
191
|
+
}
|
|
192
|
+
return back({ error: 'try-again', plan: plan.key })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (charge === 0 && plan.mode !== 'auto') {
|
|
196
|
+
await settlePaidOrder(entitlementDeps(services), order, {
|
|
197
|
+
amountTotal: 0,
|
|
198
|
+
currency: plan.currency,
|
|
199
|
+
subscriptionId: null,
|
|
200
|
+
paymentIntentId: null,
|
|
201
|
+
})
|
|
202
|
+
return { kind: 'redirect', to: `/plugins/dues/return?order=${order.id}` }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (services.stripe === null) return back({ error: 'unconfigured' })
|
|
206
|
+
const stripe = services.stripe
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
let customerId = await findStripeCustomer(services.context.data, buyerId)
|
|
210
|
+
if (customerId === null) {
|
|
211
|
+
const customer = await stripe.createCustomer({
|
|
212
|
+
metadata: { meith_user_id: String(buyerId) },
|
|
213
|
+
})
|
|
214
|
+
await saveStripeCustomer(services.context.data, buyerId, customer.id)
|
|
215
|
+
customerId = customer.id
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const origin = requestOrigin(request)
|
|
219
|
+
const productName =
|
|
220
|
+
recipientName === null ? plan.name : `${plan.name} — a gift for ${recipientName}`
|
|
221
|
+
|
|
222
|
+
let couponId: string | null = null
|
|
223
|
+
if (code !== null && plan.mode === 'auto') {
|
|
224
|
+
couponId = code.stripeCouponId
|
|
225
|
+
if (couponId === null) {
|
|
226
|
+
const coupon = await stripe.createCoupon({
|
|
227
|
+
percentOff: code.percentOff,
|
|
228
|
+
name: `Dues code ${code.code}`,
|
|
229
|
+
reference: String(code.id),
|
|
230
|
+
})
|
|
231
|
+
await saveCodeCoupon(services.context.data, code.id, coupon.id)
|
|
232
|
+
couponId = coupon.id
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const session = await stripe.createCheckoutSession({
|
|
237
|
+
mode: plan.mode === 'auto' ? 'subscription' : 'payment',
|
|
238
|
+
customer: customerId,
|
|
239
|
+
client_reference_id: String(order.id),
|
|
240
|
+
line_items: [
|
|
241
|
+
plan.mode === 'auto'
|
|
242
|
+
? { price: plan.stripePriceId, quantity: 1 }
|
|
243
|
+
: {
|
|
244
|
+
quantity: 1,
|
|
245
|
+
price_data: {
|
|
246
|
+
currency: plan.currency,
|
|
247
|
+
unit_amount: charge,
|
|
248
|
+
product_data: { name: productName },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
metadata: { dues_order_id: String(order.id) },
|
|
253
|
+
...(plan.mode === 'auto'
|
|
254
|
+
? { subscription_data: { metadata: { dues_order_id: String(order.id) } } }
|
|
255
|
+
: {}),
|
|
256
|
+
...(couponId === null ? {} : { discounts: [{ coupon: couponId }] }),
|
|
257
|
+
success_url: `${origin}/plugins/dues/return?order=${order.id}`,
|
|
258
|
+
cancel_url: `${origin}/plugins/dues?cancelled=1`,
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
await attachCheckoutSession(services.context.data, order.id, {
|
|
262
|
+
id: session.id,
|
|
263
|
+
url: session.url,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
if (session.url === null) return back({ error: 'stripe-error' })
|
|
267
|
+
return offsite(session.url)
|
|
268
|
+
} catch (error) {
|
|
269
|
+
services.context.logger.error('dues: could not start checkout', {
|
|
270
|
+
orderId: order.id,
|
|
271
|
+
message: error instanceof Error ? error.message : String(error),
|
|
272
|
+
...(error instanceof StripeError ? { code: error.code, status: error.status } : {}),
|
|
273
|
+
})
|
|
274
|
+
return back({ error: 'stripe-error', plan: plan.key })
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function handleWebhook(
|
|
279
|
+
services: DuesServices,
|
|
280
|
+
request: PluginRequest,
|
|
281
|
+
): Promise<PluginResponse> {
|
|
282
|
+
if (services.webhookSecret === '') {
|
|
283
|
+
return {
|
|
284
|
+
kind: 'json',
|
|
285
|
+
status: 503,
|
|
286
|
+
body: { error: 'the webhook signing secret is not configured' },
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (request.rawBody === null) {
|
|
290
|
+
return { kind: 'json', status: 400, body: { error: 'no body' } }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const verdict = verifyStripeSignature(
|
|
294
|
+
request.rawBody,
|
|
295
|
+
request.headers['stripe-signature'],
|
|
296
|
+
services.webhookSecret,
|
|
297
|
+
services.now(),
|
|
298
|
+
)
|
|
299
|
+
if (!verdict.ok) {
|
|
300
|
+
return { kind: 'json', status: 400, body: { error: `signature ${verdict.reason}` } }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let payload: unknown
|
|
304
|
+
try {
|
|
305
|
+
payload = JSON.parse(new TextDecoder().decode(request.rawBody))
|
|
306
|
+
} catch {
|
|
307
|
+
return { kind: 'json', status: 400, body: { error: 'unparseable body' } }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const envelope = parseEventEnvelope(payload)
|
|
311
|
+
if (envelope === null) {
|
|
312
|
+
return { kind: 'json', status: 400, body: { error: 'not an event envelope' } }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const recorded = await recordEvent(services.context.data, {
|
|
316
|
+
stripeEventId: envelope.id,
|
|
317
|
+
type: envelope.type,
|
|
318
|
+
payload,
|
|
319
|
+
})
|
|
320
|
+
if (!recorded.first) {
|
|
321
|
+
return { kind: 'json', status: 200, body: { received: true, outcome: 'replay' } }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const outcome = await applyInternalEvent(entitlementDeps(services), toInternalEvent(envelope))
|
|
326
|
+
await markEventProcessed(services.context.data, recorded.id, outcome)
|
|
327
|
+
return { kind: 'json', status: 200, body: { received: true, outcome } }
|
|
328
|
+
} catch (error) {
|
|
329
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
330
|
+
await markEventFailed(services.context.data, recorded.id, `failed: ${message}`)
|
|
331
|
+
services.context.logger.error('dues: webhook processing failed', {
|
|
332
|
+
eventId: envelope.id,
|
|
333
|
+
message,
|
|
334
|
+
})
|
|
335
|
+
return { kind: 'json', status: 200, body: { received: true, outcome: 'deferred' } }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export async function handlePortal(
|
|
340
|
+
services: DuesServices,
|
|
341
|
+
request: PluginRequest,
|
|
342
|
+
): Promise<PluginResponse> {
|
|
343
|
+
const userId = request.viewer.userId
|
|
344
|
+
if (userId === null) return backToManage({ error: 'sign-in' })
|
|
345
|
+
if (services.stripe === null) return backToManage({ error: 'unconfigured' })
|
|
346
|
+
|
|
347
|
+
const customerId = await findStripeCustomer(services.context.data, userId)
|
|
348
|
+
if (customerId === null) return backToManage({ error: 'no-customer' })
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const portal = await services.stripe.createBillingPortalSession({
|
|
352
|
+
customer: customerId,
|
|
353
|
+
returnUrl: `${requestOrigin(request)}/plugins/dues/manage`,
|
|
354
|
+
})
|
|
355
|
+
return offsite(portal.url)
|
|
356
|
+
} catch (error) {
|
|
357
|
+
services.context.logger.error('dues: could not open the billing portal', {
|
|
358
|
+
message: error instanceof Error ? error.message : String(error),
|
|
359
|
+
})
|
|
360
|
+
return backToManage({ error: 'stripe-error' })
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function handleCancel(
|
|
365
|
+
services: DuesServices,
|
|
366
|
+
request: PluginRequest,
|
|
367
|
+
): Promise<PluginResponse> {
|
|
368
|
+
const userId = request.viewer.userId
|
|
369
|
+
if (userId === null) return backToManage({ error: 'sign-in' })
|
|
370
|
+
|
|
371
|
+
const membershipId = Number(request.form?.membership ?? '')
|
|
372
|
+
if (!Number.isSafeInteger(membershipId) || membershipId <= 0) {
|
|
373
|
+
return backToManage({ error: 'cancel-failed' })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const membership = await membershipById(services.context.data, membershipId)
|
|
377
|
+
if (membership === null || membership.userId !== userId) {
|
|
378
|
+
return backToManage({ error: 'cancel-failed' })
|
|
379
|
+
}
|
|
380
|
+
if (membership.renewalMode !== 'auto' || membership.stripeSubscriptionId === null) {
|
|
381
|
+
return backToManage({ error: 'cancel-failed' })
|
|
382
|
+
}
|
|
383
|
+
if (services.stripe === null) return backToManage({ error: 'unconfigured' })
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
await services.stripe.setCancelAtPeriodEnd(membership.stripeSubscriptionId, true)
|
|
387
|
+
} catch (error) {
|
|
388
|
+
services.context.logger.error('dues: cancel at period end failed', {
|
|
389
|
+
membershipId,
|
|
390
|
+
message: error instanceof Error ? error.message : String(error),
|
|
391
|
+
})
|
|
392
|
+
return backToManage({ error: 'stripe-error' })
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await setMembershipStatus(services.context.data, membership.id, 'closing')
|
|
396
|
+
return backToManage({ cancelled: '1' })
|
|
397
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { dues } from './definition'
|
|
2
|
+
export type { DuesConfigInput, DuesPlanInput } from './config'
|
|
3
|
+
|
|
4
|
+
export { SUBSCRIBED_EVENT_TYPES } from './stripe/events'
|
|
5
|
+
export { signStripePayload } from './stripe/webhook'
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
DUES_DEMO_CODES,
|
|
9
|
+
DUES_DEMO_CURRENCY,
|
|
10
|
+
DUES_DEMO_GRACE_DAYS,
|
|
11
|
+
DUES_DEMO_GROUP,
|
|
12
|
+
DUES_DEMO_PLANS,
|
|
13
|
+
DUES_DEMO_PRICES,
|
|
14
|
+
seedDuesDemo,
|
|
15
|
+
type DuesDemoCast,
|
|
16
|
+
type DuesDemoDeps,
|
|
17
|
+
type DuesDemoSummary,
|
|
18
|
+
} from './demo'
|
package/src/money.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
|
|
2
|
+
const ZERO_DECIMAL = new Set([
|
|
3
|
+
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
|
|
4
|
+
'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
|
|
5
|
+
])
|
|
6
|
+
|
|
7
|
+
export function isZeroDecimal(currency: string): boolean {
|
|
8
|
+
return ZERO_DECIMAL.has(currency.toLowerCase())
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isCurrencyCode(value: string): boolean {
|
|
12
|
+
return /^[A-Za-z]{3}$/.test(value)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isValidMinorAmount(value: unknown): value is number {
|
|
16
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatMinor(amountMinor: number, currency: string): string {
|
|
20
|
+
const upper = currency.toUpperCase()
|
|
21
|
+
const amount = isZeroDecimal(currency) ? amountMinor : amountMinor / 100
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return new Intl.NumberFormat('en', {
|
|
25
|
+
style: 'currency',
|
|
26
|
+
currency: upper,
|
|
27
|
+
minimumFractionDigits: isZeroDecimal(currency) ? 0 : 2,
|
|
28
|
+
}).format(amount)
|
|
29
|
+
} catch {
|
|
30
|
+
return `${amount.toFixed(isZeroDecimal(currency) ? 0 : 2)} ${upper}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function moneyMatches(
|
|
35
|
+
expected: { readonly amountMinor: number; readonly currency: string },
|
|
36
|
+
actual: { readonly amountMinor: number | null; readonly currency: string | null },
|
|
37
|
+
): boolean {
|
|
38
|
+
return (
|
|
39
|
+
actual.amountMinor === expected.amountMinor &&
|
|
40
|
+
(actual.currency ?? '').toLowerCase() === expected.currency.toLowerCase()
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/period.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
|
|
2
|
+
export interface Period {
|
|
3
|
+
readonly years: number
|
|
4
|
+
readonly months: number
|
|
5
|
+
readonly weeks: number
|
|
6
|
+
readonly days: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const PATTERN = /^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?$/
|
|
10
|
+
|
|
11
|
+
export function parsePeriod(spec: string): Period | null {
|
|
12
|
+
const match = PATTERN.exec(spec)
|
|
13
|
+
if (match === null) return null
|
|
14
|
+
|
|
15
|
+
const [, years, months, weeks, days] = match
|
|
16
|
+
const period = {
|
|
17
|
+
years: Number(years ?? 0),
|
|
18
|
+
months: Number(months ?? 0),
|
|
19
|
+
weeks: Number(weeks ?? 0),
|
|
20
|
+
days: Number(days ?? 0),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const total = period.years + period.months + period.weeks + period.days
|
|
24
|
+
return total === 0 ? null : period
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function periodCeilingDays(period: Period): number {
|
|
28
|
+
return period.years * 366 + period.months * 31 + period.weeks * 7 + period.days
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DAY_MS = 86_400_000
|
|
32
|
+
|
|
33
|
+
export function addPeriod(from: Date, period: Period): Date {
|
|
34
|
+
let out = new Date(from.getTime())
|
|
35
|
+
|
|
36
|
+
const months = period.years * 12 + period.months
|
|
37
|
+
if (months > 0) {
|
|
38
|
+
const dayOfMonth = out.getUTCDate()
|
|
39
|
+
out.setUTCDate(1)
|
|
40
|
+
out.setUTCMonth(out.getUTCMonth() + months)
|
|
41
|
+
const lastDay = new Date(
|
|
42
|
+
Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0),
|
|
43
|
+
).getUTCDate()
|
|
44
|
+
out.setUTCDate(Math.min(dayOfMonth, lastDay))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const days = period.weeks * 7 + period.days
|
|
48
|
+
if (days > 0) out = new Date(out.getTime() + days * DAY_MS)
|
|
49
|
+
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function addBillingInterval(from: Date, interval: 'month' | 'year'): Date {
|
|
54
|
+
return addPeriod(from, {
|
|
55
|
+
years: interval === 'year' ? 1 : 0,
|
|
56
|
+
months: interval === 'month' ? 1 : 0,
|
|
57
|
+
weeks: 0,
|
|
58
|
+
days: 0,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function addDays(from: Date, days: number): Date {
|
|
63
|
+
return new Date(from.getTime() + days * DAY_MS)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function describePeriod(period: Period): string {
|
|
67
|
+
const parts: string[] = []
|
|
68
|
+
const piece = (count: number, word: string) => {
|
|
69
|
+
if (count > 0) parts.push(`${count} ${word}${count === 1 ? '' : 's'}`)
|
|
70
|
+
}
|
|
71
|
+
piece(period.years, 'year')
|
|
72
|
+
piece(period.months, 'month')
|
|
73
|
+
piece(period.weeks, 'week')
|
|
74
|
+
piece(period.days, 'day')
|
|
75
|
+
return parts.join(', ')
|
|
76
|
+
}
|